commit 05ce0da29688b547f5d086b8cc8e1efcdeb10611
Author: Matt Batchelder
Date: Tue Dec 2 10:32:59 2025 -0500
Initial Upload
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c5095b9
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,2 @@
+# Contributing
+Please refer to our parent repository's [CONTRIBUTING](https://github.com/xibosignage/xibo/blob/master/CONTRIBUTING.md).
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6cecf09
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,245 @@
+# Multi-stage build
+# Stage 1
+# Run composer
+FROM composer as composer
+COPY ./composer.json /app
+COPY ./composer.lock /app
+
+RUN composer install --no-interaction --no-dev --optimize-autoloader
+
+# Tidy up
+# remove non-required vendor files
+WORKDIR /app/vendor
+RUN find -type d -name '.git' -exec rm -r {} + && \
+ find -path ./twig/twig/lib/Twig -prune -type d -name 'Test' -exec rm -r {} + && \
+ find -type d -name 'tests' -depth -exec rm -r {} + && \
+ find -type d -name 'benchmarks' -depth -exec rm -r {} + && \
+ find -type d -name 'smoketests' -depth -exec rm -r {} + && \
+ find -type d -name 'demo' -depth -exec rm -r {} + && \
+ find -type d -name 'doc' -depth -exec rm -r {} + && \
+ find -type d -name 'docs' -depth -exec rm -r {} + && \
+ find -type d -name 'examples' -depth -exec rm -r {} + && \
+ find -type f -name 'phpunit.xml' -exec rm -r {} + && \
+ find -type f -name '*.md' -exec rm -r {} +
+
+
+# Stage 2
+# Run webpack
+FROM node:22 AS webpack
+WORKDIR /app
+
+# Copy package.json and the webpack config file
+COPY webpack.config.js .
+COPY package.json .
+COPY package-lock.json .
+
+# Install npm packages
+RUN npm install
+
+# Copy ui folder
+COPY ./ui ./ui
+
+# Copy modules source folder
+COPY ./modules/src ./modules/src
+COPY ./modules/vendor ./modules/vendor
+
+# Build webpack
+RUN npm run publish
+
+# Stage 3
+# Build the CMS container
+FROM debian:bullseye-slim
+MAINTAINER Xibo Signage
+LABEL org.opencontainers.image.authors="support@xibosignage.com"
+
+# Install apache, PHP, and supplimentary programs.
+RUN apt update && \
+ apt install -y software-properties-common lsb-release ca-certificates curl && \
+ rm -rf /var/lib/apt/lists/* && \
+ ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime
+
+# Add sury.org PHP Repository
+RUN curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg && \
+ sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list'
+
+RUN LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive apt update && apt upgrade -y && apt install -y \
+ tar \
+ bash \
+ curl \
+ apache2 \
+ libapache2-mod-xsendfile \
+ netcat \
+ iputils-ping \
+ gnupg \
+ php8.4 \
+ libapache2-mod-php8.4 \
+ php8.4-gd \
+ php8.4-dom \
+ php8.4-pdo \
+ php8.4-zip \
+ php8.4-mysql \
+ php8.4-gettext \
+ php8.4-soap \
+ php8.4-iconv \
+ php8.4-curl \
+ php8.4-ctype \
+ php8.4-fileinfo \
+ php8.4-xml \
+ php8.4-simplexml \
+ php8.4-mbstring \
+ php8.4-memcached \
+ php8.4-phar \
+ php8.4-opcache \
+ php8.4-mongodb \
+ php8.4-gnupg \
+ tzdata \
+ msmtp \
+ openssl \
+ cron \
+ default-mysql-client \
+ && dpkg-reconfigure --frontend noninteractive tzdata \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN update-alternatives --set php /usr/bin/php8.4
+
+# Enable Apache module
+RUN a2enmod rewrite \
+ && a2enmod headers \
+ && a2enmod proxy \
+ && a2enmod proxy_http \
+ && a2enmod proxy_wstunnel
+
+# Add all necessary config files in one layer
+ADD docker/ /
+
+# Update the PHP.ini file
+RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/session.gc_probability = .*$/session.gc_probability = 1/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/session.gc_divisor = .*$/session.gc_divisor = 100/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/allow_url_fopen = .*$/allow_url_fopen = Off/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/expose_php = .*$/expose_php = Off/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/session.gc_probability = .*$/session.gc_probability = 1/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/session.gc_divisor = .*$/session.gc_divisor = 100/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/allow_url_fopen = .*$/allow_url_fopen = Off/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/expose_php = .*$/expose_php = Off/" /etc/php/8.4/cli/php.ini
+
+# Capture the git commit for this build if we provide one
+ARG GIT_COMMIT=prod
+
+# Setup persistent environment variables
+ENV CMS_DEV_MODE=false \
+ INSTALL_TYPE=docker \
+ XMR_HOST=xmr \
+ CMS_SERVER_NAME=localhost \
+ MYSQL_HOST=mysql \
+ MYSQL_USER=cms \
+ MYSQL_PASSWORD=none \
+ MYSQL_PORT=3306 \
+ MYSQL_DATABASE=cms \
+ MYSQL_BACKUP_ENABLED=true \
+ MYSQL_ATTR_SSL_CA=none \
+ MYSQL_ATTR_SSL_VERIFY_SERVER_CERT=true \
+ CMS_SMTP_SERVER=smtp.gmail.com:587 \
+ CMS_SMTP_USERNAME=none \
+ CMS_SMTP_PASSWORD=none \
+ CMS_SMTP_USE_TLS=YES \
+ CMS_SMTP_USE_STARTTLS=YES \
+ CMS_SMTP_REWRITE_DOMAIN=gmail.com \
+ CMS_SMTP_HOSTNAME=none \
+ CMS_SMTP_FROM_LINE_OVERRIDE=YES \
+ CMS_SMTP_FROM=none \
+ CMS_ALIAS=none \
+ CMS_PHP_SESSION_GC_MAXLIFETIME=1440 \
+ CMS_PHP_POST_MAX_SIZE=2G \
+ CMS_PHP_UPLOAD_MAX_FILESIZE=2G \
+ CMS_PHP_MAX_EXECUTION_TIME=300 \
+ CMS_PHP_MEMORY_LIMIT=256M \
+ CMS_PHP_CLI_MAX_EXECUTION_TIME=0 \
+ CMS_PHP_CLI_MEMORY_LIMIT=256M \
+ CMS_PHP_COOKIE_SECURE=Off \
+ CMS_PHP_COOKIE_HTTP_ONLY=On \
+ CMS_PHP_COOKIE_SAMESITE=Lax \
+ CMS_APACHE_START_SERVERS=2 \
+ CMS_APACHE_MIN_SPARE_SERVERS=5 \
+ CMS_APACHE_MAX_SPARE_SERVERS=10 \
+ CMS_APACHE_MAX_REQUEST_WORKERS=60 \
+ CMS_APACHE_MAX_CONNECTIONS_PER_CHILD=300 \
+ CMS_APACHE_TIMEOUT=30 \
+ CMS_APACHE_OPTIONS_INDEXES=false \
+ CMS_QUICK_CHART_URL=http://cms-quickchart:3400 \
+ CMS_APACHE_SERVER_TOKENS=OS \
+ CMS_APACHE_LOG_REQUEST_TIME=false \
+ CMS_USE_MEMCACHED=false \
+ MEMCACHED_HOST=memcached \
+ MEMCACHED_PORT=11211 \
+ CMS_USAGE_REPORT=true \
+ XTR_ENABLED=true \
+ GIT_COMMIT=$GIT_COMMIT \
+ GNUPGHOME=/var/www/.gnupg
+
+# Expose port 80
+EXPOSE 80
+
+# Map the source files into /var/www/cms
+RUN mkdir -p /var/www/cms
+
+# Composer generated vendor files
+COPY --from=composer /app /var/www/cms
+
+# Copy dist built webpack app folder to web
+COPY --from=webpack /app/web/dist /var/www/cms/web/dist
+
+# Copy modules built webpack app folder to cms modules
+COPY --from=webpack /app/modules /var/www/cms/modules
+
+# All other files (.dockerignore excludes many things, but we tidy up the rest below)
+COPY --chown=www-data:www-data . /var/www/cms
+
+# OpenOOH specification
+RUN mkdir /var/www/cms/openooh \
+ && curl -o /var/www/cms/openooh/specification.json https://raw.githubusercontent.com/openooh/venue-taxonomy/main/specification.json
+
+# Help Links
+RUN curl -o /var/www/cms/help-links.yaml https://raw.githubusercontent.com/xibosignage/xibo-manual/master/help-links.yaml || true
+
+# Git commit fallback
+RUN echo $GIT_COMMIT > /var/www/cms/commit.sha
+
+# Tidy up
+RUN rm /var/www/cms/composer.* && \
+ rm -r /var/www/cms/docker && \
+ rm -r /var/www/cms/tests && \
+ rm /var/www/cms/.dockerignore && \
+ rm /var/www/cms/phpunit.xml && \
+ rm /var/www/cms/package.json && \
+ rm /var/www/cms/package-lock.json && \
+ rm /var/www/cms/cypress.config.js && \
+ rm -r /var/www/cms/cypress && \
+ rm -r /var/www/cms/ui && \
+ rm /var/www/cms/webpack.config.js && \
+ rm /var/www/cms/lib/routes-cypress.php
+
+# Map a volumes to this folder.
+# Our CMS files, library, cache and backups will be in here.
+RUN mkdir -p /var/www/cms/library/temp && \
+ mkdir -p /var/www/backup && \
+ mkdir -p /var/www/cms/cache && \
+ mkdir -p /var/www/cms/web/userscripts && \
+ chown -R www-data:www-data /var/www/cms && \
+ chmod +x /entrypoint.sh /usr/local/bin/httpd-foreground /usr/local/bin/wait-for-command.sh \
+ /etc/periodic/15min/cms-db-backup && \
+ mkdir -p /run/apache2 && \
+ ln -sf /usr/bin/msmtp /usr/sbin/sendmail && \
+ chmod 777 /tmp && \
+ chown -R www-data:www-data /var/www/.gnupg
+
+# Expose volume mount points
+VOLUME /var/www/cms/library
+VOLUME /var/www/cms/custom
+VOLUME /var/www/cms/web/theme/custom
+VOLUME /var/www/backup
+VOLUME /var/www/cms/web/userscripts
+VOLUME /var/www/cms/ca-certs
+
+CMD ["/entrypoint.sh"]
diff --git a/Dockerfile.ci b/Dockerfile.ci
new file mode 100644
index 0000000..dc49870
--- /dev/null
+++ b/Dockerfile.ci
@@ -0,0 +1,192 @@
+# A production style container which has the source mapped in from PWD
+# Multi-stage build
+# Stage 1
+# Run composer
+FROM composer as composer
+COPY ./composer.json /app
+COPY ./composer.lock /app
+
+RUN composer install --no-interaction
+
+# Stage 2
+# Run webpack
+FROM node:22 AS webpack
+WORKDIR /app
+
+# Copy package.json and the webpack config file
+COPY webpack.config.js .
+COPY package.json .
+COPY package-lock.json .
+
+# Install npm packages
+RUN npm install
+
+# Copy ui folder
+COPY ./ui ./ui
+
+# Copy modules source folder
+COPY ./modules/src ./modules/src
+COPY ./modules/vendor ./modules/vendor
+
+# Build webpack
+RUN npm run publish
+
+# Stage 1
+# Build the CMS container
+FROM debian:bullseye-slim
+MAINTAINER Xibo Signage
+LABEL org.opencontainers.image.authors="support@xibosignage.com"
+
+# Install apache, PHP, and supplimentary programs.
+RUN apt update && \
+ apt install -y software-properties-common lsb-release ca-certificates curl && \
+ rm -rf /var/lib/apt/lists/* && \
+ ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime
+
+# Add sury.org PHP Repository
+RUN curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg && \
+ sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list'
+
+RUN LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive apt update && apt upgrade -y && apt install -y \
+ tar \
+ bash \
+ curl \
+ apache2 \
+ libapache2-mod-xsendfile \
+ netcat \
+ iputils-ping \
+ gnupg \
+ php8.4 \
+ libapache2-mod-php8.4 \
+ php8.4-gd \
+ php8.4-dom \
+ php8.4-pdo \
+ php8.4-zip \
+ php8.4-mysql \
+ php8.4-gettext \
+ php8.4-soap \
+ php8.4-iconv \
+ php8.4-curl \
+ php8.4-ctype \
+ php8.4-fileinfo \
+ php8.4-xml \
+ php8.4-simplexml \
+ php8.4-mbstring \
+ php8.4-memcached \
+ php8.4-phar \
+ php8.4-opcache \
+ php8.4-mongodb \
+ php8.4-gnupg \
+ tzdata \
+ msmtp \
+ openssl \
+ cron \
+ default-mysql-client \
+ && dpkg-reconfigure --frontend noninteractive tzdata \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN update-alternatives --set php /usr/bin/php8.4
+
+# Enable Apache module
+RUN a2enmod rewrite \
+ && a2enmod headers \
+ && a2enmod proxy \
+ && a2enmod proxy_http \
+ && a2enmod proxy_wstunnel
+
+# Add all necessary config files in one layer
+ADD docker/ /
+
+# Adjust file permissions as appropriate
+RUN chmod +x /entrypoint.sh /usr/local/bin/httpd-foreground /usr/local/bin/wait-for-command.sh \
+ /etc/periodic/15min/cms-db-backup && \
+ chmod 777 /tmp && \
+ chown -R www-data:www-data /var/www/.gnupg
+
+# Update the PHP.ini file
+RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/session.gc_probability = .*$/session.gc_probability = 1/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/session.gc_divisor = .*$/session.gc_divisor = 100/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/allow_url_fopen = .*$/allow_url_fopen = Off/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/expose_php = .*$/expose_php = Off/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/session.gc_probability = .*$/session.gc_probability = 1/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/session.gc_divisor = .*$/session.gc_divisor = 100/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/allow_url_fopen = .*$/allow_url_fopen = Off/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/expose_php = .*$/expose_php = Off/" /etc/php/8.4/cli/php.ini
+
+# Capture the git commit for this build if we provide one
+ARG GIT_COMMIT=ci
+
+# Set some environment variables
+ENV CMS_DEV_MODE=true \
+ INSTALL_TYPE=ci \
+ MYSQL_HOST=db \
+ MYSQL_PORT=3306 \
+ MYSQL_USER=root \
+ MYSQL_PASSWORD=root \
+ MYSQL_DATABASE=cms \
+ MYSQL_BACKUP_ENABLED=false \
+ MYSQL_ATTR_SSL_CA=none \
+ MYSQL_ATTR_SSL_VERIFY_SERVER_CERT=true \
+ CMS_SERVER_NAME=localhost \
+ CMS_ALIAS=none \
+ CMS_PHP_SESSION_GC_MAXLIFETIME=1440 \
+ CMS_PHP_POST_MAX_SIZE=2G \
+ CMS_PHP_UPLOAD_MAX_FILESIZE=2G \
+ CMS_PHP_MAX_EXECUTION_TIME=300 \
+ CMS_PHP_MEMORY_LIMIT=256M \
+ CMS_PHP_CLI_MAX_EXECUTION_TIME=0 \
+ CMS_PHP_CLI_MEMORY_LIMIT=256M \
+ CMS_PHP_COOKIE_SECURE=Off \
+ CMS_PHP_COOKIE_HTTP_ONLY=On \
+ CMS_PHP_COOKIE_SAMESITE=Lax \
+ CMS_APACHE_START_SERVERS=2 \
+ CMS_APACHE_MIN_SPARE_SERVERS=5 \
+ CMS_APACHE_MAX_SPARE_SERVERS=10 \
+ CMS_APACHE_MAX_REQUEST_WORKERS=60 \
+ CMS_APACHE_MAX_CONNECTIONS_PER_CHILD=300 \
+ CMS_APACHE_TIMEOUT=30 \
+ CMS_APACHE_OPTIONS_INDEXES=false \
+ CMS_QUICK_CHART_URL=http://cms-quickchart:3400 \
+ CMS_APACHE_SERVER_TOKENS=OS \
+ CMS_USE_MEMCACHED=false \
+ MEMCACHED_HOST=memcached \
+ MEMCACHED_PORT=11211 \
+ CMS_USAGE_REPORT=false \
+ XTR_ENABLED=true \
+ GIT_COMMIT=$GIT_COMMIT \
+ GNUPGHOME=/var/www/.gnupg
+
+# Expose port 80
+EXPOSE 80
+
+# Map the source files into /var/www/cms
+# Create library and cache, because they might not exist
+# Create /var/www/backup so that we have somewhere for entrypoint to log errors.
+RUN mkdir -p /var/www/cms && \
+ mkdir -p /var/www/cms/library/temp && \
+ mkdir -p /var/www/cms/cache && \
+ mkdir -p /var/www/backup
+
+# Composer generated vendor files
+COPY --from=composer /app /var/www/cms
+
+# Copy dist built webpack app folder to web
+COPY --from=webpack /app/web/dist /var/www/cms/web/dist
+
+# Copy modules built webpack app folder to cms modules
+COPY --from=webpack /app/modules /var/www/cms/modules
+
+# All other files (.dockerignore excludes things we don't want)
+COPY --chown=www-data:www-data . /var/www/cms
+
+# OpenOOH specification
+RUN mkdir /var/www/cms/openooh \
+ && curl -o /var/www/cms/openooh/specification.json https://raw.githubusercontent.com/openooh/venue-taxonomy/main/specification.json
+
+# Help Links
+RUN curl -o help_links.yaml https://raw.githubusercontent.com/xibosignage/xibo-manual/develop/help_links.yaml || true
+
+# Run entry
+CMD ["/entrypoint.sh"]
diff --git a/Dockerfile.cypress b/Dockerfile.cypress
new file mode 100644
index 0000000..fc0728f
--- /dev/null
+++ b/Dockerfile.cypress
@@ -0,0 +1,16 @@
+FROM cypress/base:12
+
+WORKDIR /app
+
+RUN npm install har-validator
+RUN npm install --save-dev --slient cypress@10.1.0
+
+RUN $(npm bin)/cypress verify
+
+RUN mkdir /app/results
+
+# Create this file so that we can volume mount it
+RUN touch /app/cypress.config.js
+
+# Create this folder so that we can volume mount it
+RUN mkdir /app/cypress
\ No newline at end of file
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 0000000..2da4c27
--- /dev/null
+++ b/Dockerfile.dev
@@ -0,0 +1,142 @@
+# Xibo Development Container
+# This is the dev container and doesn't contain CRON or composer it maps the PWD straight through
+# Stage 1
+# Build the CMS container
+FROM debian:bullseye-slim
+LABEL org.opencontainers.image.authors="support@xibosignage.com"
+
+RUN apt update && \
+ apt install -y software-properties-common lsb-release ca-certificates curl && \
+ rm -rf /var/lib/apt/lists/* && \
+ ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime
+
+# Add sury.org PHP Repository
+RUN curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg && \
+ sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list'
+
+RUN LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive apt update && apt upgrade -y && apt install -y \
+ tar \
+ bash \
+ curl \
+ apache2 \
+ libapache2-mod-xsendfile \
+ netcat \
+ iputils-ping \
+ gnupg \
+ php8.4 \
+ libapache2-mod-php8.4 \
+ php8.4-gd \
+ php8.4-dom \
+ php8.4-pdo \
+ php8.4-zip \
+ php8.4-mysql \
+ php8.4-gettext \
+ php8.4-soap \
+ php8.4-iconv \
+ php8.4-curl \
+ php8.4-ctype \
+ php8.4-fileinfo \
+ php8.4-xml \
+ php8.4-simplexml \
+ php8.4-mbstring \
+ php8.4-memcached \
+ php8.4-phar \
+ php8.4-opcache \
+ php8.4-mongodb \
+ php8.4-gnupg \
+ tzdata \
+ msmtp \
+ openssl \
+ cron \
+ default-mysql-client \
+ && dpkg-reconfigure --frontend noninteractive tzdata \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN update-alternatives --set php /usr/bin/php8.4
+
+# Enable Apache module
+RUN a2enmod rewrite \
+ && a2enmod headers \
+ && a2enmod proxy \
+ && a2enmod proxy_http \
+ && a2enmod proxy_wstunnel
+
+# Add all necessary config files in one layer
+ADD docker/ /
+
+# Adjust file permissions as appropriate
+RUN chmod +x /entrypoint.sh /usr/local/bin/httpd-foreground /usr/local/bin/wait-for-command.sh \
+ /etc/periodic/15min/cms-db-backup && \
+ chmod 777 /tmp && \
+ chown -R www-data:www-data /var/www/.gnupg
+
+# Update the PHP.ini file
+RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/session.gc_probability = .*$/session.gc_probability = 1/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/session.gc_divisor = .*$/session.gc_divisor = 100/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/allow_url_fopen = .*$/allow_url_fopen = Off/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/expose_php = .*$/expose_php = Off/" /etc/php/8.4/apache2/php.ini && \
+ sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/session.gc_probability = .*$/session.gc_probability = 1/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/session.gc_divisor = .*$/session.gc_divisor = 100/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/allow_url_fopen = .*$/allow_url_fopen = Off/" /etc/php/8.4/cli/php.ini && \
+ sed -i "s/expose_php = .*$/expose_php = Off/" /etc/php/8.4/cli/php.ini
+
+# Capture the git commit for this build if we provide one
+ARG GIT_COMMIT=dev
+
+# Set some environment variables
+ENV CMS_DEV_MODE=true \
+ INSTALL_TYPE=dev \
+ XMR_HOST=xmr \
+ MYSQL_HOST=db \
+ MYSQL_PORT=3306 \
+ MYSQL_USER=root \
+ MYSQL_PASSWORD=root \
+ MYSQL_DATABASE=cms \
+ MYSQL_BACKUP_ENABLED=false \
+ MYSQL_ATTR_SSL_CA=none \
+ MYSQL_ATTR_SSL_VERIFY_SERVER_CERT=true \
+ CMS_SERVER_NAME=localhost \
+ CMS_ALIAS=none \
+ CMS_PHP_SESSION_GC_MAXLIFETIME=1440 \
+ CMS_PHP_POST_MAX_SIZE=2G \
+ CMS_PHP_UPLOAD_MAX_FILESIZE=2G \
+ CMS_PHP_MAX_EXECUTION_TIME=300 \
+ CMS_PHP_MEMORY_LIMIT=256M \
+ CMS_PHP_CLI_MAX_EXECUTION_TIME=0 \
+ CMS_PHP_CLI_MEMORY_LIMIT=256M \
+ CMS_PHP_COOKIE_SECURE=Off \
+ CMS_PHP_COOKIE_HTTP_ONLY=On \
+ CMS_PHP_COOKIE_SAMESITE=Lax \
+ CMS_APACHE_START_SERVERS=2 \
+ CMS_APACHE_MIN_SPARE_SERVERS=5 \
+ CMS_APACHE_MAX_SPARE_SERVERS=10 \
+ CMS_APACHE_MAX_REQUEST_WORKERS=60 \
+ CMS_APACHE_MAX_CONNECTIONS_PER_CHILD=300 \
+ CMS_APACHE_TIMEOUT=30 \
+ CMS_APACHE_OPTIONS_INDEXES=false \
+ CMS_QUICK_CHART_URL=http://quickchart:3400 \
+ CMS_APACHE_SERVER_TOKENS=OS \
+ CMS_APACHE_LOG_REQUEST_TIME=true \
+ CMS_USE_MEMCACHED=true \
+ MEMCACHED_HOST=memcached \
+ MEMCACHED_PORT=11211 \
+ CMS_USAGE_REPORT=true \
+ XTR_ENABLED=false \
+ GIT_COMMIT=$GIT_COMMIT \
+ GNUPGHOME=/var/www/.gnupg
+
+# Expose port 80
+EXPOSE 80
+
+# Map the source files into /var/www/cms
+# Create library and cache, because they might not exist
+# Create /var/www/backup so that we have somewhere for entrypoint to log errors.
+RUN mkdir -p /var/www/cms && \
+ mkdir -p /var/www/cms/library/temp && \
+ mkdir -p /var/www/cms/cache && \
+ mkdir -p /var/www/backup
+
+# Run entry
+CMD ["/entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..cebe035
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,662 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6b4dc8c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+# Cloud-CMS
+
+Cloud-CMS is a powerful **Open Source Digital Signage platform** with a web-based content management system and display player software. It enables you to manage and deliver dynamic content across multiple screens from a centralized interface.
+
+We offer commercial player options for Android, LG webOS, and Samsung Tizen, as well as CMS hosting and support services.
+
+For more information about the original project, visit [Xibo Signage](https://xibosignage.com).
+
+---
+
+## About Cloud-CMS
+
+Cloud-CMS is based on the Xibo CMS project and provides everything you need to run a digital signage network or a single screen. Our goal is to make digital signage accessible, flexible, and easy to manage.
+
+---
+
+## Features
+
+- Web-based content management
+- Layout and playlist scheduling
+- Support for multiple display types and players
+- API for integration with third-party systems
+- Docker-based deployment for easy setup
+
+---
+
+## Installation
+
+### Quick Installation Guide
+
+We recommend installing an official release via **Docker**.
+Follow these steps to quickly install **Cloud-CMS** using Docker:
+
+1. **Install Docker and Docker Compose**
+ - Download and install Docker from [https://docs.docker.com/get-docker/](https://docs.docker.com/get-docker/).
+
+2. **Create a Project Directory**
+ ```bash
+ mkdir cloud-cms
+ cd cloud-cms
+ ```
+
+3. **Download the Docker Compose File**
+ - Obtain the official `docker-compose.yml`
+
+4. **Configure Environment Variables**
+ - Create a `.env` file and set values for:
+ - `MYSQL_PASSWORD`
+ - `CMS_DB_NAME`
+ - `CMS_DB_USER`
+ - `CMS_DB_PASSWORD`
+
+5. **Start the Containers**
+ ```bash
+ docker-compose up -d
+ ```
+
+6. **Access Cloud-CMS**
+ - Open your browser and navigate to `http://localhost` (or your server IP).
+
+
+
+---
+
+## Development
+
+Please only install a development environment if you intend to make code changes to Cloud-CMS. Installing from the repository is not suitable for production installations.
+
+Cloud-CMS uses Docker to ensure all contributors have a repeatable development environment that is easy to set up.
+
+---
+
+## Support
+
+Cloud-CMS is supported by **Oribi Technology Services**.
+For assistance, please use one of the following options:
+
+- **Submit a ticket:** [https://portal.oribi-tech.com/new-ticket](https://portal.oribi-tech.com/new-ticket)
+- **Email:** [support@oribi-tech.com](mailto:support@oribi-tech.com)
+
+---
+
+## License
+
+Copyright (C) 2006-2025 Xibo Signage Ltd and Contributors.
+
+Cloud-CMS (based on Xibo CMS) is free software: you can redistribute it and/or modify it under the terms of the **GNU Affero General Public License** as published by the Free Software Foundation, either version 3 of the License, or any later version.
+
+Cloud-CMS is distributed in the hope that it will be useful, but **WITHOUT ANY WARRANTY**; without even the implied warranty of **MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE**. See the GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License along with Cloud-CMS. If not, see [http://www.gnu.org/licenses/](http://www.gnu.org/licenses/).
+
+---
+
+## Attribution
+
+Cloud-CMS is a white-label version of the Xibo CMS project. Original source code and documentation are available at [Xibo CMS GitHub Repository](https://github.com/xibosignage/xibo-cms).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..25fbf9b
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,13 @@
+# Security Policy
+
+## Supported Versions
+Xibo supports and maintains the current and prior major releases, with updates being provided to the latest minor
+release within that.
+
+A full list of support is available on our website: https://xibosignage.com/docs/setup/supported-versions-and-environments
+
+## Reporting a Vulnerability
+Please report (suspected) security vulnerabilities using the Security tab in this repository.
+
+You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as
+possible depending on complexity but historically within a few days.
\ No newline at end of file
diff --git a/bin/locale.php b/bin/locale.php
new file mode 100644
index 0000000..db6f2e9
--- /dev/null
+++ b/bin/locale.php
@@ -0,0 +1,223 @@
+.
+ */
+
+/**
+ * This is a simple script to load all twig files recursively so that we have a complete set of twig files in the
+ * /cache folder
+ * we can then reliably run xgettext over them to update our POT file
+ */
+
+use Slim\Flash\Messages;
+use Slim\Views\Twig;
+use Twig\TwigFilter;
+use Xibo\Service\ConfigService;
+use Xibo\Twig\ByteFormatterTwigExtension;
+use Xibo\Twig\DateFormatTwigExtension;
+use Xibo\Twig\TransExtension;
+use Xibo\Twig\TwigMessages;
+
+error_reporting(E_ALL);
+ini_set('display_errors', 1);
+
+define('PROJECT_ROOT', realpath(__DIR__ . '/..'));
+require_once PROJECT_ROOT . '/vendor/autoload.php';
+
+$view = Twig::create([
+ PROJECT_ROOT . '/views',
+ PROJECT_ROOT . '/modules',
+ PROJECT_ROOT . '/reports'
+], [
+ 'cache' => PROJECT_ROOT . '/cache'
+]);
+$view->addExtension(new TransExtension());
+$view->addExtension(new ByteFormatterTwigExtension());
+$view->addExtension(new DateFormatTwigExtension());
+$view->getEnvironment()->addFilter(new TwigFilter('url_decode', 'urldecode'));
+
+// Trick the flash middleware
+$storage = [];
+$view->addExtension(new TwigMessages(new Messages($storage)));
+
+foreach (glob(PROJECT_ROOT . '/views/*.twig') as $file) {
+ $view->getEnvironment()->load(str_replace(PROJECT_ROOT . '/views/', '', $file));
+}
+
+/**
+ * Mock PDO Storage Service which returns an empty array when select queried.
+ */
+class MockPdoStorageServiceForModuleFactory extends \Xibo\Storage\PdoStorageService
+{
+ public function select($sql, $params, $connection = 'default', $reconnect = false, $close = false)
+ {
+ return [];
+ }
+}
+
+// Mock Config Service
+class MockConfigService extends ConfigService
+{
+ public function getSetting($setting, $default = null, $full = false)
+ {
+ return '';
+ }
+}
+
+// Translator function
+function __($original)
+{
+ return $original;
+}
+
+// Stash
+$pool = new \Stash\Pool();
+
+// Create a new Sanitizer service
+$sanitizerService = new \Xibo\Helper\SanitizerService();
+
+// Create a new base dependency service
+$baseDependencyService = new \Xibo\Service\BaseDependenciesService();
+$baseDependencyService->setConfig(new MockConfigService());
+$baseDependencyService->setStore(new MockPdoStorageServiceForModuleFactory());
+$baseDependencyService->setSanitizer($sanitizerService);
+
+$moduleFactory = new \Xibo\Factory\ModuleFactory(
+ '',
+ $pool,
+ $view,
+ new MockConfigService(),
+);
+$moduleFactory->useBaseDependenciesService($baseDependencyService);
+// Get all module
+$modules = $moduleFactory->getAll();
+
+$moduleTemplateFactory = new \Xibo\Factory\ModuleTemplateFactory(
+ $pool,
+ $view,
+);
+$moduleTemplateFactory->useBaseDependenciesService($baseDependencyService);
+// Get all module templates
+$moduleTemplates = $moduleTemplateFactory->getAll(null, false);
+
+// --------------
+// Create translation file
+// Each line contains title or description or properties of the module/templates
+$file = PROJECT_ROOT. '/locale/moduletranslate.php';
+$content = 'name.'\');' . PHP_EOL;
+ $content .= 'echo __(\''.$module->description.'\');' . PHP_EOL;
+
+ // Settings Translation
+ foreach ($module->settings as $setting) {
+ if (!empty($setting->title)) {
+ $content .= 'echo __(\''.$setting->title.'\');' . PHP_EOL;
+ }
+ if (!empty($setting->helpText)) {
+ // replaces any single quote within the value with a backslash followed by a single quote
+ $helpText = addslashes(trim($setting->helpText));
+ $content .= 'echo __(\''.$helpText.'\');' . PHP_EOL;
+ }
+
+ if (isset($setting->options) > 0) {
+ foreach ($setting->options as $option) {
+ if (!empty($option->title)) {
+ $content .= 'echo __(\''.$option->title.'\');' . PHP_EOL;
+ }
+ }
+ }
+ }
+
+ // Properties translation
+ foreach ($module->properties as $property) {
+ if (!empty($property->title)) {
+ $content .= 'echo __(\''.addslashes(trim($property->title)).'\');' . PHP_EOL;
+ }
+ if (!empty($property->helpText)) {
+ // replaces any single quote within the value with a backslash followed by a single quote
+ $helpText = addslashes($property->helpText);
+ $content .= 'echo __(\''.$helpText.'\');' . PHP_EOL;
+ }
+
+ if (isset($property->validation) > 0) {
+ $tests = $property->validation->tests;
+ foreach ($tests as $test) {
+ // Property rule test message
+ $message = $test->message;
+ if (!empty($message)) {
+ $content .= 'echo __(\''.addslashes(trim($message)).'\');' . PHP_EOL;
+ }
+ }
+ }
+
+ if (isset($property->options) > 0) {
+ foreach ($property->options as $option) {
+ if (!empty($option->title)) {
+ $content .= 'echo __(\''.$option->title.'\');' . PHP_EOL;
+ }
+ }
+ }
+ }
+}
+
+$content .= '// Module Template translation' . PHP_EOL;
+// Template Translation
+foreach ($moduleTemplates as $moduleTemplate) {
+ $content .= 'echo __(\''.$moduleTemplate->title.'\');' . PHP_EOL;
+
+ // Properties Translation
+ foreach ($moduleTemplate->properties as $property) {
+ if (!empty($property->title)) {
+ $content .= 'echo __(\''.addslashes(trim($property->title)).'\');' . PHP_EOL;
+ }
+ if (!empty($property->helpText)) {
+ // replaces any single quote within the value with a backslash followed by a single quote
+ $helpText = addslashes(trim($property->helpText));
+ $content .= 'echo __(\''.$helpText.'\');' . PHP_EOL;
+ }
+
+ if (isset($property->validation) > 0) {
+ $tests = $property->validation->tests;
+ foreach ($tests as $test) {
+ // Property rule test message
+ $message = $test->message;
+ if (!empty($message)) {
+ $content .= 'echo __(\''.$message.'\');' . PHP_EOL;
+ }
+ }
+ }
+
+ if (isset($property->options) > 0) {
+ foreach ($property->options as $option) {
+ if (!empty($option->title)) {
+ $content .= 'echo __(\''.$option->title.'\');' . PHP_EOL;
+ }
+ }
+ }
+ }
+}
+
+file_put_contents($file, $content);
+echo 'moduletranslate.file created and data written successfully.';
diff --git a/bin/run.php b/bin/run.php
new file mode 100644
index 0000000..1359176
--- /dev/null
+++ b/bin/run.php
@@ -0,0 +1,118 @@
+.
+ */
+
+use Monolog\Logger;
+use Monolog\Processor\UidProcessor;
+use Nyholm\Psr7\ServerRequest;
+use Slim\Http\ServerRequest as Request;
+use Slim\Views\TwigMiddleware;
+use Xibo\Factory\ContainerFactory;
+
+define('XIBO', true);
+define('PROJECT_ROOT', realpath(__DIR__ . '/..'));
+
+error_reporting(0);
+ini_set('display_errors', 0);
+
+require PROJECT_ROOT . '/vendor/autoload.php';
+
+if (!file_exists(PROJECT_ROOT . '/web/settings.php')) {
+ die('Not configured');
+}
+
+// convert all the command line arguments into a URL
+$argv = $GLOBALS['argv'];
+array_shift($GLOBALS['argv']);
+$pathInfo = '/' . implode('/', $argv);
+
+// Create the container for dependency injection.
+try {
+ $container = ContainerFactory::create();
+} catch (Exception $e) {
+ die($e->getMessage());
+}
+$container->set('name', 'xtr');
+
+$container->set('logger', function () {
+ $logger = new Logger('CONSOLE');
+
+ $uidProcessor = new UidProcessor();
+ // db
+ $dbhandler = new \Xibo\Helper\DatabaseLogHandler();
+
+ $logger->pushProcessor($uidProcessor);
+ $logger->pushHandler($dbhandler);
+
+ // Optionally allow console logging
+ if (isset($_SERVER['LOG_TO_CONSOLE']) && $_SERVER['LOG_TO_CONSOLE']) {
+ $logger->pushHandler(new \Monolog\Handler\StreamHandler(STDERR, Logger::DEBUG));
+ }
+
+ return $logger;
+});
+
+$app = \DI\Bridge\Slim\Bridge::create($container);
+
+$app->setBasePath($container->get('basePath'));
+// Config
+$app->configService = $container->get('configService');
+
+// Check for upgrade after we've loaded settings to make sure the main app gets any custom settings it needs.
+if (\Xibo\Helper\Environment::migrationPending()) {
+ die('Upgrade pending');
+}
+
+// Handle additional Middleware
+\Xibo\Middleware\State::setMiddleWare($app);
+
+$twigMiddleware = TwigMiddleware::createFromContainer($app);
+
+$app->add(new \Xibo\Middleware\ConnectorMiddleware($app));
+$app->add(new \Xibo\Middleware\ListenersMiddleware($app));
+$app->add(new \Xibo\Middleware\Storage($app));
+$app->add(new \Xibo\Middleware\Xtr($app));
+$app->add(new \Xibo\Middleware\State($app));
+$app->add($twigMiddleware);
+$app->add(new \Xibo\Middleware\Log($app));
+$app->add(new \Xibo\Middleware\Xmr($app));
+
+$app->addRoutingMiddleware();
+$errorMiddleware = $app->addErrorMiddleware(
+ \Xibo\Helper\Environment::isDevMode() || \Xibo\Helper\Environment::isForceDebugging(),
+ true,
+ true
+);
+$errorMiddleware->setDefaultErrorHandler(\Xibo\Middleware\Handlers::jsonErrorHandler($container));
+
+// All routes
+$app->get('/', ['\Xibo\Controller\Task','poll']);
+$app->get('/{id}', ['\Xibo\Controller\Task','run']);
+
+// if we passed taskId in console
+if (!empty($argv)) {
+ $request = new Request(new ServerRequest('GET', $pathInfo));
+ return $app->handle($request);
+}
+
+// Run app
+$app->run();
+
diff --git a/bin/xtr.php b/bin/xtr.php
new file mode 100644
index 0000000..e20156e
--- /dev/null
+++ b/bin/xtr.php
@@ -0,0 +1,9 @@
+=8.1",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-gd": "*",
+ "ext-gnupg": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mongodb": "*",
+ "ext-openssl": "*",
+ "ext-pdo": "*",
+ "ext-session": "*",
+ "ext-soap": "*",
+ "ext-sockets": "*",
+ "ext-simplexml": "*",
+ "ext-zip": "*",
+ "akrabat/ip-address-middleware" : "2.6.*",
+ "apereo/phpcas": "dev-master#6ef19a1e636303bda2f05f46b08035d53a8b602d",
+ "dragonmantank/cron-expression": "^3.4",
+ "erusev/parsedown": "dev-master#26cfde9dbffa43a77bfd45c87b0394df0dfcba85",
+ "flynsarmy/slim-monolog": "~1.0",
+ "gettext/gettext": "~4.0",
+ "guzzlehttp/guzzle": "7.9.*",
+ "guzzlehttp/psr7": "2.7.*",
+ "illuminate/support": "v10.48.*",
+ "infostars/picofeed": "dev-westphal/php8",
+ "intervention/image": "2.7.2",
+ "james-heinrich/getid3": "1.9.*",
+ "jmikola/geojson": "^1.0",
+ "johngrogg/ics-parser": "^3.1",
+ "league/oauth2-server": "^8.0",
+ "lcobucci/clock": "3.0.*",
+ "lcobucci/jwt": "5.3.*",
+ "mjaschen/phpgeo": "^5.0",
+ "mongodb/mongodb": "2.0.*",
+ "monolog/monolog": "2.10.*",
+ "mpdf/mpdf": "v8.1.*",
+ "nesbot/carbon": "^2.73.0",
+ "nyholm/psr7": "^1.8",
+ "onelogin/php-saml": "4.1.*",
+ "phenx/php-font-lib": "^0.5.0",
+ "php-di/php-di": "7.0.*",
+ "php-di/slim-bridge": "3.4.*",
+ "phpmailer/phpmailer": "6.5.*",
+ "psr/container": "2.0.*",
+ "psr/http-message": "1.1.*",
+ "psr/http-server-handler": "1.0.*",
+ "psr/http-server-middleware": "1.0.*",
+ "psr/log": "1.1.*",
+ "ralouphie/mimey": "^1.0",
+ "respect/validation": "2.2.*",
+ "robmorgan/phinx": "0.13.*",
+ "robthree/twofactorauth": "1.8.2",
+ "slim/flash": "^0.4",
+ "slim/http": "^1.2",
+ "slim/slim": "^4.14",
+ "slim/twig-view": "3.3.0",
+ "symfony/event-dispatcher": "^4.1",
+ "symfony/event-dispatcher-contracts": "^1.10",
+ "symfony/filesystem": "v6.4.*",
+ "symfony/html-sanitizer": "v6.4.*",
+ "symfony/yaml": "5.4.*",
+ "tedivm/stash": "v1.2.*",
+ "twig/twig": "3.11.*",
+ "xibosignage/support": "dev-php8"
+ },
+ "require-dev": {
+ "exussum12/coverage-checker": "^0.11.2",
+ "doctrine/instantiator": "1.5.0",
+ "doctrine/annotations": "^1",
+ "league/oauth2-client": "^2.4",
+ "micheh/phpcs-gitlab": "^1.1",
+ "phpunit/phpunit": "10.5.*",
+ "shipmonk/composer-dependency-analyser": "^1.8",
+ "squizlabs/php_codesniffer": "3.*",
+ "xibosignage/oauth2-xibo-cms": "dev-feature/3.0",
+ "zircote/swagger-php": "^2"
+ },
+ "autoload": {
+ "psr-4": { "Xibo\\" : "lib/", "Xibo\\Custom\\": "custom/" }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Xibo\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "phpcs": "phpcs --standard=vendor/xibosignage/support/src/Standards/xibo_ruleset.xml"
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..228f0b7
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,9210 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "66b8c6f75806d19cb4967aaf3d04f8f9",
+ "packages": [
+ {
+ "name": "akrabat/ip-address-middleware",
+ "version": "2.6.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/akrabat/ip-address-middleware.git",
+ "reference": "ff2a118f6c0214fe006156d5e28f1a4195ca6caf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/akrabat/ip-address-middleware/zipball/ff2a118f6c0214fe006156d5e28f1a4195ca6caf",
+ "reference": "ff2a118f6c0214fe006156d5e28f1a4195ca6caf",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0",
+ "psr/container": "^1.0 || ^2.0",
+ "psr/http-message": "^1.0 || ^2.0",
+ "psr/http-server-middleware": "^1.0"
+ },
+ "replace": {
+ "akrabat/rka-ip-address-middleware": "*"
+ },
+ "require-dev": {
+ "laminas/laminas-diactoros": "^2.4 || ^3.0",
+ "phpunit/phpunit": "^8.5.8 || ^9.4",
+ "squizlabs/php_codesniffer": "^3.2"
+ },
+ "type": "library",
+ "extra": {
+ "laminas": {
+ "config-provider": "RKA\\Middleware\\Mezzio\\ConfigProvider"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "RKA\\Middleware\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Rob Allen",
+ "email": "rob@akrabat.com",
+ "homepage": "http://akrabat.com"
+ }
+ ],
+ "description": "PSR-15 middleware that determines the client IP address and stores it as a ServerRequest attribute",
+ "homepage": "http://github.com/akrabat/rka-ip-address-middleware",
+ "keywords": [
+ "IP",
+ "middleware",
+ "psr7"
+ ],
+ "support": {
+ "issues": "https://github.com/akrabat/ip-address-middleware/issues",
+ "source": "https://github.com/akrabat/ip-address-middleware/tree/2.6.1"
+ },
+ "time": "2025-01-18T13:31:13+00:00"
+ },
+ {
+ "name": "apereo/phpcas",
+ "version": "dev-master",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/apereo/phpCAS.git",
+ "reference": "6ef19a1e636303bda2f05f46b08035d53a8b602d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/apereo/phpCAS/zipball/6ef19a1e636303bda2f05f46b08035d53a8b602d",
+ "reference": "6ef19a1e636303bda2f05f46b08035d53a8b602d",
+ "shasum": ""
+ },
+ "require": {
+ "ext-curl": "*",
+ "ext-dom": "*",
+ "php": ">=7.1.0",
+ "psr/log": "^1.0 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "monolog/monolog": "^1.0.0 || ^2.0.0",
+ "phpstan/phpstan": "^1.5",
+ "phpunit/phpunit": ">=7.5"
+ },
+ "default-branch": true,
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.3.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "source/CAS.php"
+ ],
+ "classmap": [
+ "source/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Joachim Fritschi",
+ "email": "jfritschi@freenet.de",
+ "homepage": "https://github.com/jfritschi"
+ },
+ {
+ "name": "Adam Franco",
+ "homepage": "https://github.com/adamfranco"
+ },
+ {
+ "name": "Henry Pan",
+ "homepage": "https://github.com/phy25"
+ }
+ ],
+ "description": "Provides a simple API for authenticating users against a CAS server",
+ "homepage": "https://wiki.jasig.org/display/CASC/phpCAS",
+ "keywords": [
+ "apereo",
+ "cas",
+ "jasig"
+ ],
+ "support": {
+ "issues": "https://github.com/apereo/phpCAS/issues",
+ "source": "https://github.com/apereo/phpCAS/tree/master"
+ },
+ "time": "2024-06-29T15:51:32+00:00"
+ },
+ {
+ "name": "cakephp/cache",
+ "version": "3.7.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/cache.git",
+ "reference": "16f113ede9ce4df77361dec0f942aaf871647d9a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/cache/zipball/16f113ede9ce4df77361dec0f942aaf871647d9a",
+ "reference": "16f113ede9ce4df77361dec0f942aaf871647d9a",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^3.6.0",
+ "php": ">=5.6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cake\\Cache\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/cache/graphs/contributors"
+ }
+ ],
+ "description": "Easy to use Caching library with support for multiple caching backends",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cache",
+ "caching",
+ "cakephp"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/cache"
+ },
+ "time": "2019-02-17T17:30:29+00:00"
+ },
+ {
+ "name": "cakephp/core",
+ "version": "3.9.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/core.git",
+ "reference": "4b45635d6be8a98be175fea9c9f575de29d515b3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/core/zipball/4b45635d6be8a98be175fea9c9f575de29d515b3",
+ "reference": "4b45635d6be8a98be175fea9c9f575de29d515b3",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/utility": "^3.6.0",
+ "php": ">=5.6.0"
+ },
+ "suggest": {
+ "cakephp/cache": "To use Configure::store() and restore().",
+ "cakephp/event": "To use PluginApplicationInterface or plugin applications."
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "functions.php"
+ ],
+ "psr-4": {
+ "Cake\\Core\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/core/graphs/contributors"
+ }
+ ],
+ "description": "CakePHP Framework Core classes",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "core",
+ "framework"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/core"
+ },
+ "time": "2020-10-21T21:21:05+00:00"
+ },
+ {
+ "name": "cakephp/database",
+ "version": "4.0.0-alpha1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/database.git",
+ "reference": "952f3da1ed67c2edc4b7c2a99ef95361d9ef9131"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/database/zipball/952f3da1ed67c2edc4b7c2a99ef95361d9ef9131",
+ "reference": "952f3da1ed67c2edc4b7c2a99ef95361d9ef9131",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/cache": "^3.6.0",
+ "cakephp/core": "^3.6.0",
+ "cakephp/datasource": "^3.6.0",
+ "cakephp/log": "^3.6.0",
+ "php": ">=5.6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cake\\Database\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/database/graphs/contributors"
+ }
+ ],
+ "description": "Flexible and powerful Database abstraction library with a familiar PDO-like API",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "abstraction",
+ "cakephp",
+ "database",
+ "database abstraction",
+ "pdo"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/database"
+ },
+ "time": "2019-03-18T06:12:38+00:00"
+ },
+ {
+ "name": "cakephp/datasource",
+ "version": "3.9.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/datasource.git",
+ "reference": "c1c5e06c38dc61b997703d38fc704917f39b4dd8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/datasource/zipball/c1c5e06c38dc61b997703d38fc704917f39b4dd8",
+ "reference": "c1c5e06c38dc61b997703d38fc704917f39b4dd8",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^3.6.0",
+ "php": ">=5.6.0"
+ },
+ "suggest": {
+ "cakephp/cache": "If you decide to use Query caching.",
+ "cakephp/collection": "If you decide to use ResultSetInterface.",
+ "cakephp/utility": "If you decide to use EntityTrait."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cake\\Datasource\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/datasource/graphs/contributors"
+ }
+ ],
+ "description": "Provides connection managing and traits for Entities and Queries that can be reused for different datastores",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "connection management",
+ "datasource",
+ "entity",
+ "query"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/datasource"
+ },
+ "time": "2020-10-20T12:53:41+00:00"
+ },
+ {
+ "name": "cakephp/log",
+ "version": "3.9.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/log.git",
+ "reference": "b5b97154b8e63f41e206021901e49397e2f4ca90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/log/zipball/b5b97154b8e63f41e206021901e49397e2f4ca90",
+ "reference": "b5b97154b8e63f41e206021901e49397e2f4ca90",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^3.6.0",
+ "php": ">=5.6.0",
+ "psr/log": "^1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cake\\Log\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/log/graphs/contributors"
+ }
+ ],
+ "description": "CakePHP logging library with support for multiple different streams",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "Streams",
+ "cakephp",
+ "log",
+ "logging"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/log"
+ },
+ "time": "2020-10-20T12:53:41+00:00"
+ },
+ {
+ "name": "cakephp/utility",
+ "version": "3.9.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/utility.git",
+ "reference": "e655b399b7492e517caef52fb87af9db10543112"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/utility/zipball/e655b399b7492e517caef52fb87af9db10543112",
+ "reference": "e655b399b7492e517caef52fb87af9db10543112",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^3.6.0",
+ "php": ">=5.6.0"
+ },
+ "suggest": {
+ "ext-intl": "To use Text::transliterate() or Text::slug()",
+ "lib-ICU": "To use Text::transliterate() or Text::slug()"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Cake\\Utility\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/utility/graphs/contributors"
+ }
+ ],
+ "description": "CakePHP Utility classes such as Inflector, String, Hash, and Security",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "hash",
+ "inflector",
+ "security",
+ "string",
+ "utility"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/utility"
+ },
+ "time": "2020-08-18T13:55:20+00:00"
+ },
+ {
+ "name": "carbonphp/carbon-doctrine-types",
+ "version": "3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
+ "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
+ "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "conflict": {
+ "doctrine/dbal": "<4.0.0 || >=5.0.0"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^4.0.0",
+ "nesbot/carbon": "^2.71.0 || ^3.0.0",
+ "phpunit/phpunit": "^10.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "KyleKatarn",
+ "email": "kylekatarnls@gmail.com"
+ }
+ ],
+ "description": "Types to use Carbon in Doctrine",
+ "keywords": [
+ "carbon",
+ "date",
+ "datetime",
+ "doctrine",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
+ "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/kylekatarnls",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/Carbon",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-02-09T16:56:22+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": "doctrine/inflector",
+ "version": "2.0.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/inflector.git",
+ "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+ "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^11.0",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/phpstan-strict-rules": "^1.3",
+ "phpunit/phpunit": "^8.5 || ^9.5",
+ "vimeo/psalm": "^4.25 || ^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Inflector\\": "lib/Doctrine/Inflector"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Guilherme Blanco",
+ "email": "guilhermeblanco@gmail.com"
+ },
+ {
+ "name": "Roman Borschel",
+ "email": "roman@code-factory.org"
+ },
+ {
+ "name": "Benjamin Eberlei",
+ "email": "kontakt@beberlei.de"
+ },
+ {
+ "name": "Jonathan Wage",
+ "email": "jonwage@gmail.com"
+ },
+ {
+ "name": "Johannes Schmitt",
+ "email": "schmittjoh@gmail.com"
+ }
+ ],
+ "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.",
+ "homepage": "https://www.doctrine-project.org/projects/inflector.html",
+ "keywords": [
+ "inflection",
+ "inflector",
+ "lowercase",
+ "manipulation",
+ "php",
+ "plural",
+ "singular",
+ "strings",
+ "uppercase",
+ "words"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/inflector/issues",
+ "source": "https://github.com/doctrine/inflector/tree/2.0.10"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-02-18T20:23:39+00:00"
+ },
+ {
+ "name": "dragonmantank/cron-expression",
+ "version": "v3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dragonmantank/cron-expression.git",
+ "reference": "8c784d071debd117328803d86b2097615b457500"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
+ "reference": "8c784d071debd117328803d86b2097615b457500",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0",
+ "webmozart/assert": "^1.0"
+ },
+ "replace": {
+ "mtdowling/cron-expression": "^1.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^1.0",
+ "phpunit/phpunit": "^7.0|^8.0|^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Cron\\": "src/Cron/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Chris Tankersley",
+ "email": "chris@ctankersley.com",
+ "homepage": "https://github.com/dragonmantank"
+ }
+ ],
+ "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due",
+ "keywords": [
+ "cron",
+ "schedule"
+ ],
+ "support": {
+ "issues": "https://github.com/dragonmantank/cron-expression/issues",
+ "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/dragonmantank",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-09T13:47:03+00:00"
+ },
+ {
+ "name": "erusev/parsedown",
+ "version": "dev-master",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/erusev/parsedown.git",
+ "reference": "26cfde9dbffa43a77bfd45c87b0394df0dfcba85"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/erusev/parsedown/zipball/26cfde9dbffa43a77bfd45c87b0394df0dfcba85",
+ "reference": "26cfde9dbffa43a77bfd45c87b0394df0dfcba85",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5|^8.5|^9.6"
+ },
+ "default-branch": true,
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Parsedown": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Emanuil Rusev",
+ "email": "hello@erusev.com",
+ "homepage": "http://erusev.com"
+ }
+ ],
+ "description": "Parser for Markdown.",
+ "homepage": "http://parsedown.org",
+ "keywords": [
+ "markdown",
+ "parser"
+ ],
+ "support": {
+ "issues": "https://github.com/erusev/parsedown/issues",
+ "source": "https://github.com/erusev/parsedown/tree/master"
+ },
+ "time": "2025-08-31T07:57:44+00:00"
+ },
+ {
+ "name": "flynsarmy/slim-monolog",
+ "version": "v1.0.1",
+ "target-dir": "Flynsarmy/SlimMonolog",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Flynsarmy/Slim-Monolog.git",
+ "reference": "2a3a20671cc14372424085d563991c90ba7818e8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Flynsarmy/Slim-Monolog/zipball/2a3a20671cc14372424085d563991c90ba7818e8",
+ "reference": "2a3a20671cc14372424085d563991c90ba7818e8",
+ "shasum": ""
+ },
+ "require": {
+ "monolog/monolog": ">=1.6.0",
+ "php": ">=5.3.0",
+ "slim/slim": ">=2.3.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Flynsarmy\\SlimMonolog": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Flyn San",
+ "email": "flynsarmy@gmail.com",
+ "homepage": "http://www.flynsarmy.com/"
+ }
+ ],
+ "description": "Monolog logging support Slim Framework",
+ "homepage": "http://github.com/flynsarmy/Slim-Monolog",
+ "keywords": [
+ "extensions",
+ "logging",
+ "middleware"
+ ],
+ "support": {
+ "issues": "https://github.com/Flynsarmy/Slim-Monolog/issues",
+ "source": "https://github.com/Flynsarmy/Slim-Monolog/tree/master"
+ },
+ "time": "2015-07-15T22:14:44+00:00"
+ },
+ {
+ "name": "gettext/gettext",
+ "version": "v4.8.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-gettext/Gettext.git",
+ "reference": "11af89ee6c087db3cf09ce2111a150bca5c46e12"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/11af89ee6c087db3cf09ce2111a150bca5c46e12",
+ "reference": "11af89ee6c087db3cf09ce2111a150bca5c46e12",
+ "shasum": ""
+ },
+ "require": {
+ "gettext/languages": "^2.3",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "illuminate/view": "^5.0.x-dev",
+ "phpunit/phpunit": "^4.8|^5.7|^6.5",
+ "squizlabs/php_codesniffer": "^3.0",
+ "symfony/yaml": "~2",
+ "twig/extensions": "*",
+ "twig/twig": "^1.31|^2.0"
+ },
+ "suggest": {
+ "illuminate/view": "Is necessary if you want to use the Blade extractor",
+ "symfony/yaml": "Is necessary if you want to use the Yaml extractor/generator",
+ "twig/extensions": "Is necessary if you want to use the Twig extractor",
+ "twig/twig": "Is necessary if you want to use the Twig extractor"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Gettext\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Oscar Otero",
+ "email": "oom@oscarotero.com",
+ "homepage": "http://oscarotero.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "PHP gettext manager",
+ "homepage": "https://github.com/oscarotero/Gettext",
+ "keywords": [
+ "JS",
+ "gettext",
+ "i18n",
+ "mo",
+ "po",
+ "translation"
+ ],
+ "support": {
+ "email": "oom@oscarotero.com",
+ "issues": "https://github.com/oscarotero/Gettext/issues",
+ "source": "https://github.com/php-gettext/Gettext/tree/v4.8.12"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/oscarotero",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/oscarotero",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/misteroom",
+ "type": "patreon"
+ }
+ ],
+ "time": "2024-05-18T10:25:07+00:00"
+ },
+ {
+ "name": "gettext/languages",
+ "version": "2.12.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-gettext/Languages.git",
+ "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-gettext/Languages/zipball/0b0b0851c55168e1dfb14305735c64019732b5f1",
+ "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4"
+ },
+ "bin": [
+ "bin/export-plural-rules",
+ "bin/import-cldr-data"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Gettext\\Languages\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Michele Locati",
+ "email": "mlocati@gmail.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "gettext languages with plural rules",
+ "homepage": "https://github.com/php-gettext/Languages",
+ "keywords": [
+ "cldr",
+ "i18n",
+ "internationalization",
+ "l10n",
+ "language",
+ "languages",
+ "localization",
+ "php",
+ "plural",
+ "plural rules",
+ "plurals",
+ "translate",
+ "translations",
+ "unicode"
+ ],
+ "support": {
+ "issues": "https://github.com/php-gettext/Languages/issues",
+ "source": "https://github.com/php-gettext/Languages/tree/2.12.1"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/mlocati",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/mlocati",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-19T11:14:02+00:00"
+ },
+ {
+ "name": "guzzlehttp/guzzle",
+ "version": "7.9.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/guzzle.git",
+ "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
+ "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/promises": "^1.5.3 || ^2.0.3",
+ "guzzlehttp/psr7": "^2.7.0",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "ext-curl": "*",
+ "guzzle/client-integration-tests": "3.0.2",
+ "php-http/message-factory": "^1.1",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+ "psr/log": "Required for using the Log middleware"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\": "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"
+ },
+ {
+ "name": "Jeremy Lindblom",
+ "email": "jeremeamia@gmail.com",
+ "homepage": "https://github.com/jeremeamia"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle is a PHP HTTP client library",
+ "keywords": [
+ "client",
+ "curl",
+ "framework",
+ "http",
+ "http client",
+ "psr-18",
+ "psr-7",
+ "rest",
+ "web service"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.9.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-03-27T13:37:11+00:00"
+ },
+ {
+ "name": "guzzlehttp/promises",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/promises.git",
+ "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
+ "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Promise\\": "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"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle promises library",
+ "keywords": [
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/2.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-03-27T13:27:01+00:00"
+ },
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
+ "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "http-interop/http-factory-tests": "0.9.0",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "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"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-03-27T12:30:47+00:00"
+ },
+ {
+ "name": "illuminate/collections",
+ "version": "v10.48.28",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/illuminate/collections.git",
+ "reference": "48de3d6bc6aa779112ddcb608a3a96fc975d89d8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/illuminate/collections/zipball/48de3d6bc6aa779112ddcb608a3a96fc975d89d8",
+ "reference": "48de3d6bc6aa779112ddcb608a3a96fc975d89d8",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/conditionable": "^10.0",
+ "illuminate/contracts": "^10.0",
+ "illuminate/macroable": "^10.0",
+ "php": "^8.1"
+ },
+ "suggest": {
+ "symfony/var-dumper": "Required to use the dump method (^6.2)."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "10.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "helpers.php"
+ ],
+ "psr-4": {
+ "Illuminate\\Support\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The Illuminate Collections package.",
+ "homepage": "https://laravel.com",
+ "support": {
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
+ },
+ "time": "2024-11-21T14:02:44+00:00"
+ },
+ {
+ "name": "illuminate/conditionable",
+ "version": "v10.48.28",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/illuminate/conditionable.git",
+ "reference": "3ee34ac306fafc2a6f19cd7cd68c9af389e432a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/illuminate/conditionable/zipball/3ee34ac306fafc2a6f19cd7cd68c9af389e432a5",
+ "reference": "3ee34ac306fafc2a6f19cd7cd68c9af389e432a5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "10.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Illuminate\\Support\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The Illuminate Conditionable package.",
+ "homepage": "https://laravel.com",
+ "support": {
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
+ },
+ "time": "2024-11-21T14:02:44+00:00"
+ },
+ {
+ "name": "illuminate/contracts",
+ "version": "v10.48.28",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/illuminate/contracts.git",
+ "reference": "f90663a69f926105a70b78060a31f3c64e2d1c74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/illuminate/contracts/zipball/f90663a69f926105a70b78060a31f3c64e2d1c74",
+ "reference": "f90663a69f926105a70b78060a31f3c64e2d1c74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1",
+ "psr/container": "^1.1.1|^2.0.1",
+ "psr/simple-cache": "^1.0|^2.0|^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "10.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Illuminate\\Contracts\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The Illuminate Contracts package.",
+ "homepage": "https://laravel.com",
+ "support": {
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
+ },
+ "time": "2024-11-21T14:02:44+00:00"
+ },
+ {
+ "name": "illuminate/macroable",
+ "version": "v10.48.28",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/illuminate/macroable.git",
+ "reference": "dff667a46ac37b634dcf68909d9d41e94dc97c27"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/illuminate/macroable/zipball/dff667a46ac37b634dcf68909d9d41e94dc97c27",
+ "reference": "dff667a46ac37b634dcf68909d9d41e94dc97c27",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "10.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Illuminate\\Support\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The Illuminate Macroable package.",
+ "homepage": "https://laravel.com",
+ "support": {
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
+ },
+ "time": "2023-06-05T12:46:42+00:00"
+ },
+ {
+ "name": "illuminate/support",
+ "version": "v10.48.28",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/illuminate/support.git",
+ "reference": "6d09b480d34846245d9288f4dcefb17a73ce6e6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/illuminate/support/zipball/6d09b480d34846245d9288f4dcefb17a73ce6e6a",
+ "reference": "6d09b480d34846245d9288f4dcefb17a73ce6e6a",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/inflector": "^2.0",
+ "ext-ctype": "*",
+ "ext-filter": "*",
+ "ext-mbstring": "*",
+ "illuminate/collections": "^10.0",
+ "illuminate/conditionable": "^10.0",
+ "illuminate/contracts": "^10.0",
+ "illuminate/macroable": "^10.0",
+ "nesbot/carbon": "^2.67",
+ "php": "^8.1",
+ "voku/portable-ascii": "^2.0"
+ },
+ "conflict": {
+ "tightenco/collect": "<5.5.33"
+ },
+ "suggest": {
+ "illuminate/filesystem": "Required to use the composer class (^10.0).",
+ "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.6).",
+ "ramsey/uuid": "Required to use Str::uuid() (^4.7).",
+ "symfony/process": "Required to use the composer class (^6.2).",
+ "symfony/uid": "Required to use Str::ulid() (^6.2).",
+ "symfony/var-dumper": "Required to use the dd function (^6.2).",
+ "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.4.1)."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "10.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "helpers.php"
+ ],
+ "psr-4": {
+ "Illuminate\\Support\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The Illuminate Support package.",
+ "homepage": "https://laravel.com",
+ "support": {
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
+ },
+ "time": "2024-12-10T14:47:55+00:00"
+ },
+ {
+ "name": "infostars/picofeed",
+ "version": "dev-westphal/php8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dasgarner/picofeed.git",
+ "reference": "74b7abb2cf71fac7d84710ab49e5733f758946ad"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dasgarner/picofeed/zipball/74b7abb2cf71fac7d84710ab49e5733f758946ad",
+ "reference": "74b7abb2cf71fac7d84710ab49e5733f758946ad",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-iconv": "*",
+ "ext-libxml": "*",
+ "ext-simplexml": "*",
+ "ext-xml": "*",
+ "laminas/laminas-xml": "^1.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpdocumentor/reflection-docblock": "2.0.4",
+ "phpunit/phpunit": "4.8.26",
+ "symfony/yaml": "2.8.7"
+ },
+ "suggest": {
+ "ext-curl": "PicoFeed will use cURL if present"
+ },
+ "bin": [
+ "picofeed"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "PicoFeed": "lib/"
+ }
+ },
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Frédéric Guillot"
+ }
+ ],
+ "description": "Modern library to handle RSS/Atom feeds",
+ "homepage": "https://github.com/infostars/picofeed",
+ "support": {
+ "source": "https://github.com/dasgarner/picofeed/tree/westphal/php8"
+ },
+ "time": "2025-07-30T08:56:47+00:00"
+ },
+ {
+ "name": "intervention/image",
+ "version": "2.7.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Intervention/image.git",
+ "reference": "04be355f8d6734c826045d02a1079ad658322dad"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
+ "reference": "04be355f8d6734c826045d02a1079ad658322dad",
+ "shasum": ""
+ },
+ "require": {
+ "ext-fileinfo": "*",
+ "guzzlehttp/psr7": "~1.1 || ^2.0",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "~0.9.2",
+ "phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
+ },
+ "suggest": {
+ "ext-gd": "to use GD library based image processing.",
+ "ext-imagick": "to use Imagick based image processing.",
+ "intervention/imagecache": "Caching extension for the Intervention Image library"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Image": "Intervention\\Image\\Facades\\Image"
+ },
+ "providers": [
+ "Intervention\\Image\\ImageServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "2.4-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Intervention\\Image\\": "src/Intervention/Image"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Oliver Vogel",
+ "email": "oliver@intervention.io",
+ "homepage": "https://intervention.io/"
+ }
+ ],
+ "description": "Image handling and manipulation library with support for Laravel integration",
+ "homepage": "http://image.intervention.io/",
+ "keywords": [
+ "gd",
+ "image",
+ "imagick",
+ "laravel",
+ "thumbnail",
+ "watermark"
+ ],
+ "support": {
+ "issues": "https://github.com/Intervention/image/issues",
+ "source": "https://github.com/Intervention/image/tree/2.7.2"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/interventionio",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/Intervention",
+ "type": "github"
+ }
+ ],
+ "time": "2022-05-21T17:30:32+00:00"
+ },
+ {
+ "name": "james-heinrich/getid3",
+ "version": "v1.9.23",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/JamesHeinrich/getID3.git",
+ "reference": "06c7482532ff2b3f9111b011d880ca6699c8542b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/JamesHeinrich/getID3/zipball/06c7482532ff2b3f9111b011d880ca6699c8542b",
+ "reference": "06c7482532ff2b3f9111b011d880ca6699c8542b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.0"
+ },
+ "suggest": {
+ "ext-SimpleXML": "SimpleXML extension is required to analyze RIFF/WAV/BWF audio files (also requires `ext-libxml`).",
+ "ext-com_dotnet": "COM extension is required when loading files larger than 2GB on Windows.",
+ "ext-ctype": "ctype extension is required when loading files larger than 2GB on 32-bit PHP (also on 64-bit PHP on Windows) or executing `getid3_lib::CopyTagsToComments`.",
+ "ext-dba": "DBA extension is required to use the DBA database as a cache storage.",
+ "ext-exif": "EXIF extension is required for graphic modules.",
+ "ext-iconv": "iconv extension is required to work with different character sets (when `ext-mbstring` is not available).",
+ "ext-json": "JSON extension is required to analyze Apple Quicktime videos.",
+ "ext-libxml": "libxml extension is required to analyze RIFF/WAV/BWF audio files.",
+ "ext-mbstring": "mbstring extension is required to work with different character sets.",
+ "ext-mysql": "MySQL extension is required to use the MySQL database as a cache storage (deprecated in PHP 5.5, removed in PHP >= 7.0, use `ext-mysqli` instead).",
+ "ext-mysqli": "MySQLi extension is required to use the MySQL database as a cache storage.",
+ "ext-rar": "RAR extension is required for RAR archive module.",
+ "ext-sqlite3": "SQLite3 extension is required to use the SQLite3 database as a cache storage.",
+ "ext-xml": "XML extension is required for graphic modules to analyze the XML metadata.",
+ "ext-zlib": "Zlib extension is required for archive modules and compressed metadata."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.9.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "getid3/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-1.0-or-later",
+ "LGPL-3.0-only",
+ "MPL-2.0"
+ ],
+ "description": "PHP script that extracts useful information from popular multimedia file formats",
+ "homepage": "https://www.getid3.org/",
+ "keywords": [
+ "codecs",
+ "php",
+ "tags"
+ ],
+ "support": {
+ "issues": "https://github.com/JamesHeinrich/getID3/issues",
+ "source": "https://github.com/JamesHeinrich/getID3/tree/v1.9.23"
+ },
+ "time": "2023-10-19T13:18:49+00:00"
+ },
+ {
+ "name": "jmikola/geojson",
+ "version": "1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/jmikola/geojson.git",
+ "reference": "e28f3855bb61a91aab32b74c176d76dd0b5658d7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/jmikola/geojson/zipball/e28f3855bb61a91aab32b74c176d76dd0b5658d7",
+ "reference": "e28f3855bb61a91aab32b74c176d76dd0b5658d7",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "php": "^7.4 || ^8.0",
+ "symfony/polyfill-php80": "^1.25"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "scrutinizer/ocular": "^1.8.1",
+ "slevomat/coding-standard": "^8.0",
+ "squizlabs/php_codesniffer": "^3.6"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GeoJson\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jeremy Mikola",
+ "email": "jmikola@gmail.com"
+ }
+ ],
+ "description": "GeoJSON implementation for PHP",
+ "homepage": "https://github.com/jmikola/geojson",
+ "keywords": [
+ "geo",
+ "geojson",
+ "geospatial"
+ ],
+ "support": {
+ "issues": "https://github.com/jmikola/geojson/issues",
+ "source": "https://github.com/jmikola/geojson/tree/1.2.0"
+ },
+ "time": "2023-12-04T17:19:43+00:00"
+ },
+ {
+ "name": "johngrogg/ics-parser",
+ "version": "v3.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/u01jmg3/ics-parser.git",
+ "reference": "abb41a4a46256389aa4e6f582bad76f0d4cb3ebc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/u01jmg3/ics-parser/zipball/abb41a4a46256389aa4e6f582bad76f0d4cb3ebc",
+ "reference": "abb41a4a46256389aa4e6f582bad76f0d4cb3ebc",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=5.6.40"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^5|^9|^10"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "ICal": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jonathan Goode",
+ "role": "Developer/Owner"
+ },
+ {
+ "name": "John Grogg",
+ "email": "john.grogg@gmail.com",
+ "role": "Developer/Prior Owner"
+ }
+ ],
+ "description": "ICS Parser",
+ "homepage": "https://github.com/u01jmg3/ics-parser",
+ "keywords": [
+ "iCalendar",
+ "ical",
+ "ical-parser",
+ "ics",
+ "ics-parser",
+ "ifb"
+ ],
+ "support": {
+ "issues": "https://github.com/u01jmg3/ics-parser/issues",
+ "source": "https://github.com/u01jmg3/ics-parser/tree/v3.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/u01jmg3",
+ "type": "github"
+ }
+ ],
+ "time": "2024-06-26T08:18:40+00:00"
+ },
+ {
+ "name": "laminas/laminas-xml",
+ "version": "1.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laminas/laminas-xml.git",
+ "reference": "3a7850dec668a89807accfa4826a2ff11497fe74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laminas/laminas-xml/zipball/3a7850dec668a89807accfa4826a2ff11497fe74",
+ "reference": "3a7850dec668a89807accfa4826a2ff11497fe74",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-simplexml": "*",
+ "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
+ },
+ "conflict": {
+ "zendframework/zendxml": "*"
+ },
+ "require-dev": {
+ "ext-iconv": "*",
+ "laminas/laminas-coding-standard": "~1.0.0",
+ "phpunit/phpunit": "^10.5.35 || ^11.4",
+ "squizlabs/php_codesniffer": "3.10.3 as 2.9999999.9999999"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Laminas\\Xml\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "Utility library for XML usage, best practices, and security in PHP",
+ "homepage": "https://laminas.dev",
+ "keywords": [
+ "laminas",
+ "security",
+ "xml"
+ ],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-xml/issues",
+ "rss": "https://github.com/laminas/laminas-xml/releases.atom",
+ "source": "https://github.com/laminas/laminas-xml"
+ },
+ "funding": [
+ {
+ "url": "https://funding.communitybridge.org/projects/laminas-project",
+ "type": "community_bridge"
+ }
+ ],
+ "time": "2024-10-11T08:45:59+00:00"
+ },
+ {
+ "name": "laravel/serializable-closure",
+ "version": "v2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/serializable-closure.git",
+ "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
+ "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "nesbot/carbon": "^2.67|^3.0",
+ "pestphp/pest": "^2.36|^3.0",
+ "phpstan/phpstan": "^2.0",
+ "symfony/var-dumper": "^6.2.0|^7.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\SerializableClosure\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "nuno@laravel.com"
+ }
+ ],
+ "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
+ "keywords": [
+ "closure",
+ "laravel",
+ "serializable"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/serializable-closure/issues",
+ "source": "https://github.com/laravel/serializable-closure"
+ },
+ "time": "2025-03-19T13:51:03+00:00"
+ },
+ {
+ "name": "lcobucci/clock",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/lcobucci/clock.git",
+ "reference": "039ef98c6b57b101d10bd11d8fdfda12cbd996dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/lcobucci/clock/zipball/039ef98c6b57b101d10bd11d8fdfda12cbd996dc",
+ "reference": "039ef98c6b57b101d10bd11d8fdfda12cbd996dc",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~8.1.0 || ~8.2.0",
+ "psr/clock": "^1.0"
+ },
+ "provide": {
+ "psr/clock-implementation": "1.0"
+ },
+ "require-dev": {
+ "infection/infection": "^0.26",
+ "lcobucci/coding-standard": "^9.0",
+ "phpstan/extension-installer": "^1.2",
+ "phpstan/phpstan": "^1.9.4",
+ "phpstan/phpstan-deprecation-rules": "^1.1.1",
+ "phpstan/phpstan-phpunit": "^1.3.2",
+ "phpstan/phpstan-strict-rules": "^1.4.4",
+ "phpunit/phpunit": "^9.5.27"
+ },
+ "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.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/lcobucci",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/lcobucci",
+ "type": "patreon"
+ }
+ ],
+ "time": "2022-12-19T15:00:24+00:00"
+ },
+ {
+ "name": "lcobucci/jwt",
+ "version": "5.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/lcobucci/jwt.git",
+ "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83",
+ "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "ext-sodium": "*",
+ "php": "~8.1.0 || ~8.2.0 || ~8.3.0",
+ "psr/clock": "^1.0"
+ },
+ "require-dev": {
+ "infection/infection": "^0.27.0",
+ "lcobucci/clock": "^3.0",
+ "lcobucci/coding-standard": "^11.0",
+ "phpbench/phpbench": "^1.2.9",
+ "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": "^10.2.6"
+ },
+ "suggest": {
+ "lcobucci/clock": ">= 3.0"
+ },
+ "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.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/lcobucci",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/lcobucci",
+ "type": "patreon"
+ }
+ ],
+ "time": "2024-04-11T23:07:54+00:00"
+ },
+ {
+ "name": "league/event",
+ "version": "2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/event.git",
+ "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/event/zipball/062ebb450efbe9a09bc2478e89b7c933875b0935",
+ "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.0"
+ },
+ "require-dev": {
+ "henrikbjorn/phpspec-code-coverage": "~1.0.1",
+ "phpspec/phpspec": "^2.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.2-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/2.3.0"
+ },
+ "time": "2025-03-14T19:51:10+00:00"
+ },
+ {
+ "name": "league/oauth2-server",
+ "version": "8.5.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/oauth2-server.git",
+ "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/cc8778350f905667e796b3c2364a9d3bd7a73518",
+ "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518",
+ "shasum": ""
+ },
+ "require": {
+ "defuse/php-encryption": "^2.3",
+ "ext-openssl": "*",
+ "lcobucci/clock": "^2.2 || ^3.0",
+ "lcobucci/jwt": "^4.3 || ^5.0",
+ "league/event": "^2.2",
+ "league/uri": "^6.7 || ^7.0",
+ "php": "^8.0",
+ "psr/http-message": "^1.0.1 || ^2.0"
+ },
+ "replace": {
+ "league/oauth2server": "*",
+ "lncd/oauth2": "*"
+ },
+ "require-dev": {
+ "laminas/laminas-diactoros": "^3.0.0",
+ "phpstan/phpstan": "^0.12.57",
+ "phpstan/phpstan-phpunit": "^0.12.16",
+ "phpunit/phpunit": "^9.6.6",
+ "roave/security-advisories": "dev-master"
+ },
+ "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/8.5.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sephster",
+ "type": "github"
+ }
+ ],
+ "time": "2024-12-20T23:06:10+00:00"
+ },
+ {
+ "name": "league/uri",
+ "version": "7.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri.git",
+ "reference": "81fb5145d2644324614cc532b28efd0215bda430"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430",
+ "reference": "81fb5145d2644324614cc532b28efd0215bda430",
+ "shasum": ""
+ },
+ "require": {
+ "league/uri-interfaces": "^7.5",
+ "php": "^8.1"
+ },
+ "conflict": {
+ "league/uri-schemes": "^1.0"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-fileinfo": "to create Data URI from file contennts",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
+ "league/uri-components": "Needed to easily manipulate URI objects components",
+ "php-64bit": "to improve IPV4 host parsing",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "URI manipulation library",
+ "homepage": "https://uri.thephpleague.com",
+ "keywords": [
+ "data-uri",
+ "file-uri",
+ "ftp",
+ "hostname",
+ "http",
+ "https",
+ "middleware",
+ "parse_str",
+ "parse_url",
+ "psr-7",
+ "query-string",
+ "querystring",
+ "rfc3986",
+ "rfc3987",
+ "rfc6570",
+ "uri",
+ "uri-template",
+ "url",
+ "ws"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri/tree/7.5.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2024-12-08T08:40:02+00:00"
+ },
+ {
+ "name": "league/uri-interfaces",
+ "version": "7.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri-interfaces.git",
+ "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
+ "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
+ "shasum": ""
+ },
+ "require": {
+ "ext-filter": "*",
+ "php": "^8.1",
+ "psr/http-factory": "^1",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "php-64bit": "to improve IPV4 host parsing",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "Common interfaces and classes for URI representation and interaction",
+ "homepage": "https://uri.thephpleague.com",
+ "keywords": [
+ "data-uri",
+ "file-uri",
+ "ftp",
+ "hostname",
+ "http",
+ "https",
+ "parse_str",
+ "parse_url",
+ "psr-7",
+ "query-string",
+ "querystring",
+ "rfc3986",
+ "rfc3987",
+ "rfc6570",
+ "uri",
+ "url",
+ "ws"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2024-12-08T08:18:47+00:00"
+ },
+ {
+ "name": "masterminds/html5",
+ "version": "2.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Masterminds/html5-php.git",
+ "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6",
+ "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Masterminds\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Matt Butcher",
+ "email": "technosophos@gmail.com"
+ },
+ {
+ "name": "Matt Farina",
+ "email": "matt@mattfarina.com"
+ },
+ {
+ "name": "Asmir Mustafic",
+ "email": "goetas@gmail.com"
+ }
+ ],
+ "description": "An HTML5 parser and serializer.",
+ "homepage": "http://masterminds.github.io/html5-php",
+ "keywords": [
+ "HTML5",
+ "dom",
+ "html",
+ "parser",
+ "querypath",
+ "serializer",
+ "xml"
+ ],
+ "support": {
+ "issues": "https://github.com/Masterminds/html5-php/issues",
+ "source": "https://github.com/Masterminds/html5-php/tree/2.9.0"
+ },
+ "time": "2024-03-31T07:05:07+00:00"
+ },
+ {
+ "name": "mjaschen/phpgeo",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mjaschen/phpgeo.git",
+ "reference": "20524a47c8edc76364c7373911224a710aeb1cae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mjaschen/phpgeo/zipball/20524a47c8edc76364c7373911224a710aeb1cae",
+ "reference": "20524a47c8edc76364c7373911224a710aeb1cae",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0",
+ "squizlabs/php_codesniffer": "^3.7",
+ "vimeo/psalm": "^5.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Location\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marcus Jaschen",
+ "email": "mjaschen@gmail.com",
+ "homepage": "https://www.marcusjaschen.de/"
+ }
+ ],
+ "description": "Simple Yet Powerful Geo Library",
+ "homepage": "https://phpgeo.marcusjaschen.de/",
+ "keywords": [
+ "Polygon",
+ "area",
+ "bearing",
+ "bounds",
+ "calculation",
+ "coordinate",
+ "distance",
+ "earth",
+ "ellipsoid",
+ "geo",
+ "geofence",
+ "gis",
+ "gps",
+ "haversine",
+ "length",
+ "perpendicular",
+ "point",
+ "polyline",
+ "projection",
+ "simplify",
+ "track",
+ "vincenty"
+ ],
+ "support": {
+ "docs": "https://phpgeo.marcusjaschen.de/Installation.html",
+ "email": "mjaschen@gmail.com",
+ "issues": "https://github.com/mjaschen/phpgeo/issues",
+ "source": "https://github.com/mjaschen/phpgeo/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/mjaschen",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/mjaschen",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-07T09:53:11+00:00"
+ },
+ {
+ "name": "mongodb/mongodb",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mongodb/mongo-php-library.git",
+ "reference": "04cd7edc6a28950e3fab6eccb2869d72a0541232"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/04cd7edc6a28950e3fab6eccb2869d72a0541232",
+ "reference": "04cd7edc6a28950e3fab6eccb2869d72a0541232",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.0",
+ "ext-mongodb": "^2.0",
+ "php": "^8.1",
+ "psr/log": "^1.1.4|^2|^3"
+ },
+ "replace": {
+ "mongodb/builder": "*"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^12.0",
+ "phpunit/phpunit": "^10.5.35",
+ "rector/rector": "^1.2",
+ "squizlabs/php_codesniffer": "^3.7",
+ "vimeo/psalm": "6.5.*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "MongoDB\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Andreas Braun",
+ "email": "andreas.braun@mongodb.com"
+ },
+ {
+ "name": "Jeremy Mikola",
+ "email": "jmikola@gmail.com"
+ },
+ {
+ "name": "Jérôme Tamarelle",
+ "email": "jerome.tamarelle@mongodb.com"
+ }
+ ],
+ "description": "MongoDB driver library",
+ "homepage": "https://jira.mongodb.org/browse/PHPLIB",
+ "keywords": [
+ "database",
+ "driver",
+ "mongodb",
+ "persistence"
+ ],
+ "support": {
+ "issues": "https://github.com/mongodb/mongo-php-library/issues",
+ "source": "https://github.com/mongodb/mongo-php-library/tree/2.0.0"
+ },
+ "time": "2025-04-10T08:34:11+00:00"
+ },
+ {
+ "name": "monolog/monolog",
+ "version": "2.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "5cf826f2991858b54d5c3809bee745560a1042a7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5cf826f2991858b54d5c3809bee745560a1042a7",
+ "reference": "5cf826f2991858b54d5c3809bee745560a1042a7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2",
+ "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^2.4.9 || ^3.0",
+ "doctrine/couchdb": "~1.0@dev",
+ "elasticsearch/elasticsearch": "^7 || ^8",
+ "ext-json": "*",
+ "graylog2/gelf-php": "^1.4.2 || ^2@dev",
+ "guzzlehttp/guzzle": "^7.4",
+ "guzzlehttp/psr7": "^2.2",
+ "mongodb/mongodb": "^1.8",
+ "php-amqplib/php-amqplib": "~2.4 || ^3",
+ "phpspec/prophecy": "^1.15",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^8.5.38 || ^9.6.19",
+ "predis/predis": "^1.1 || ^2.0",
+ "rollbar/rollbar": "^1.3 || ^2 || ^3",
+ "ruflin/elastica": "^7",
+ "swiftmailer/swiftmailer": "^5.3|^6.0",
+ "symfony/mailer": "^5.4 || ^6",
+ "symfony/mime": "^5.4 || ^6"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+ "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+ "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+ "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+ "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+ "ext-mbstring": "Allow to work properly with unicode symbols",
+ "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+ "ext-openssl": "Required to send log messages using SSL",
+ "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+ "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+ "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+ "rollbar/rollbar": "Allow sending log messages to Rollbar",
+ "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Monolog\\": "src/Monolog"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "https://seld.be"
+ }
+ ],
+ "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/2.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Seldaek",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T12:43:37+00:00"
+ },
+ {
+ "name": "mpdf/mpdf",
+ "version": "v8.1.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mpdf/mpdf.git",
+ "reference": "146c7c1dfd21c826b9d5bbfe3c15e52fd933c90f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mpdf/mpdf/zipball/146c7c1dfd21c826b9d5bbfe3c15e52fd933c90f",
+ "reference": "146c7c1dfd21c826b9d5bbfe3c15e52fd933c90f",
+ "shasum": ""
+ },
+ "require": {
+ "ext-gd": "*",
+ "ext-mbstring": "*",
+ "mpdf/psr-log-aware-trait": "^2.0 || ^3.0",
+ "myclabs/deep-copy": "^1.7",
+ "paragonie/random_compat": "^1.4|^2.0|^9.99.99",
+ "php": "^5.6 || ^7.0 || ~8.0.0 || ~8.1.0 || ~8.2.0",
+ "psr/http-message": "^1.0",
+ "psr/log": "^1.0 || ^2.0 || ^3.0",
+ "setasign/fpdi": "^2.1"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.3.0",
+ "mpdf/qrcode": "^1.1.0",
+ "squizlabs/php_codesniffer": "^3.5.0",
+ "tracy/tracy": "~2.5",
+ "yoast/phpunit-polyfills": "^1.0"
+ },
+ "suggest": {
+ "ext-bcmath": "Needed for generation of some types of barcodes",
+ "ext-xml": "Needed mainly for SVG manipulation",
+ "ext-zlib": "Needed for compression of embedded resources, such as fonts"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mpdf\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Matěj Humpál",
+ "role": "Developer, maintainer"
+ },
+ {
+ "name": "Ian Back",
+ "role": "Developer (retired)"
+ }
+ ],
+ "description": "PHP library generating PDF files from UTF-8 encoded HTML",
+ "homepage": "https://mpdf.github.io",
+ "keywords": [
+ "pdf",
+ "php",
+ "utf-8"
+ ],
+ "support": {
+ "docs": "http://mpdf.github.io",
+ "issues": "https://github.com/mpdf/mpdf/issues",
+ "source": "https://github.com/mpdf/mpdf"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.me/mpdf",
+ "type": "custom"
+ }
+ ],
+ "time": "2023-05-03T19:36:43+00:00"
+ },
+ {
+ "name": "mpdf/psr-log-aware-trait",
+ "version": "v2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mpdf/psr-log-aware-trait.git",
+ "reference": "7a077416e8f39eb626dee4246e0af99dd9ace275"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mpdf/psr-log-aware-trait/zipball/7a077416e8f39eb626dee4246e0af99dd9ace275",
+ "reference": "7a077416e8f39eb626dee4246e0af99dd9ace275",
+ "shasum": ""
+ },
+ "require": {
+ "psr/log": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mpdf\\PsrLogAwareTrait\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Dorison",
+ "email": "mark@chromatichq.com"
+ },
+ {
+ "name": "Kristofer Widholm",
+ "email": "kristofer@chromatichq.com"
+ }
+ ],
+ "description": "Trait to allow support of different psr/log versions.",
+ "support": {
+ "issues": "https://github.com/mpdf/psr-log-aware-trait/issues",
+ "source": "https://github.com/mpdf/psr-log-aware-trait/tree/v2.0.0"
+ },
+ "time": "2023-05-03T06:18:28+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "faed855a7b5f4d4637717c2b3863e277116beb36"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36",
+ "reference": "faed855a7b5f4d4637717c2b3863e277116beb36",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-05T12:25:42+00:00"
+ },
+ {
+ "name": "nesbot/carbon",
+ "version": "2.73.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/CarbonPHP/carbon.git",
+ "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/9228ce90e1035ff2f0db84b40ec2e023ed802075",
+ "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075",
+ "shasum": ""
+ },
+ "require": {
+ "carbonphp/carbon-doctrine-types": "*",
+ "ext-json": "*",
+ "php": "^7.1.8 || ^8.0",
+ "psr/clock": "^1.0",
+ "symfony/polyfill-mbstring": "^1.0",
+ "symfony/polyfill-php80": "^1.16",
+ "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0"
+ },
+ "provide": {
+ "psr/clock-implementation": "1.0"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0",
+ "doctrine/orm": "^2.7 || ^3.0",
+ "friendsofphp/php-cs-fixer": "^3.0",
+ "kylekatarnls/multi-tester": "^2.0",
+ "ondrejmirtes/better-reflection": "<6",
+ "phpmd/phpmd": "^2.9",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^0.12.99 || ^1.7.14",
+ "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20",
+ "squizlabs/php_codesniffer": "^3.4"
+ },
+ "bin": [
+ "bin/carbon"
+ ],
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Carbon\\Laravel\\ServiceProvider"
+ ]
+ },
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-2.x": "2.x-dev",
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Carbon\\": "src/Carbon/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Brian Nesbitt",
+ "email": "brian@nesbot.com",
+ "homepage": "https://markido.com"
+ },
+ {
+ "name": "kylekatarnls",
+ "homepage": "https://github.com/kylekatarnls"
+ }
+ ],
+ "description": "An API extension for DateTime that supports 281 different languages.",
+ "homepage": "https://carbon.nesbot.com",
+ "keywords": [
+ "date",
+ "datetime",
+ "time"
+ ],
+ "support": {
+ "docs": "https://carbon.nesbot.com/docs",
+ "issues": "https://github.com/briannesbitt/Carbon/issues",
+ "source": "https://github.com/briannesbitt/Carbon"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/kylekatarnls",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/Carbon#sponsor",
+ "type": "opencollective"
+ },
+ {
+ "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-01-08T20:10:23+00:00"
+ },
+ {
+ "name": "nikic/fast-route",
+ "version": "v1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/FastRoute.git",
+ "reference": "181d480e08d9476e61381e04a71b34dc0432e812"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812",
+ "reference": "181d480e08d9476e61381e04a71b34dc0432e812",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35|~5.7"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "FastRoute\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov",
+ "email": "nikic@php.net"
+ }
+ ],
+ "description": "Fast request router for PHP",
+ "keywords": [
+ "router",
+ "routing"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/FastRoute/issues",
+ "source": "https://github.com/nikic/FastRoute/tree/master"
+ },
+ "time": "2018-02-13T20:26:39+00:00"
+ },
+ {
+ "name": "nyholm/psr7",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nyholm/psr7.git",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "php-http/message-factory-implementation": "1.0",
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "http-interop/http-factory-tests": "^0.9",
+ "php-http/message-factory": "^1.0",
+ "php-http/psr7-integration-tests": "^1.0",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
+ "symfony/error-handler": "^4.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.8-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Nyholm\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ },
+ {
+ "name": "Martijn van der Ven",
+ "email": "martijn@vanderven.se"
+ }
+ ],
+ "description": "A fast PHP7 implementation of PSR-7",
+ "homepage": "https://tnyholm.se",
+ "keywords": [
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "issues": "https://github.com/Nyholm/psr7/issues",
+ "source": "https://github.com/Nyholm/psr7/tree/1.8.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Zegnat",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nyholm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-09-09T07:06:30+00:00"
+ },
+ {
+ "name": "onelogin/php-saml",
+ "version": "4.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/onelogin/php-saml.git",
+ "reference": "b22a57ebd13e838b90df5d3346090bc37056409d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/onelogin/php-saml/zipball/b22a57ebd13e838b90df5d3346090bc37056409d",
+ "reference": "b22a57ebd13e838b90df5d3346090bc37056409d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "robrichards/xmlseclibs": ">=3.1.1"
+ },
+ "require-dev": {
+ "pdepend/pdepend": "^2.8.0",
+ "php-coveralls/php-coveralls": "^2.0",
+ "phploc/phploc": "^4.0 || ^5.0 || ^6.0 || ^7.0",
+ "phpunit/phpunit": "^9.5",
+ "sebastian/phpcpd": "^4.0 || ^5.0 || ^6.0 ",
+ "squizlabs/php_codesniffer": "^3.5.8"
+ },
+ "suggest": {
+ "ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs",
+ "ext-dom": "Install xml lib",
+ "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)",
+ "ext-zlib": "Install zlib"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "OneLogin\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "OneLogin PHP SAML Toolkit",
+ "homepage": "https://developers.onelogin.com/saml/php",
+ "keywords": [
+ "SAML2",
+ "onelogin",
+ "saml"
+ ],
+ "support": {
+ "email": "sixto.garcia@onelogin.com",
+ "issues": "https://github.com/onelogin/php-saml/issues",
+ "source": "https://github.com/onelogin/php-saml/"
+ },
+ "time": "2022-07-15T20:44:36+00:00"
+ },
+ {
+ "name": "paragonie/random_compat",
+ "version": "v9.99.100",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paragonie/random_compat.git",
+ "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">= 7"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "4.*|5.*",
+ "vimeo/psalm": "^1"
+ },
+ "suggest": {
+ "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com"
+ }
+ ],
+ "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
+ "keywords": [
+ "csprng",
+ "polyfill",
+ "pseudorandom",
+ "random"
+ ],
+ "support": {
+ "email": "info@paragonie.com",
+ "issues": "https://github.com/paragonie/random_compat/issues",
+ "source": "https://github.com/paragonie/random_compat"
+ },
+ "time": "2020-10-15T08:29:30+00:00"
+ },
+ {
+ "name": "phenx/php-font-lib",
+ "version": "0.5.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/php-font-lib.git",
+ "reference": "a1681e9793040740a405ac5b189275059e2a9863"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a1681e9793040740a405ac5b189275059e2a9863",
+ "reference": "a1681e9793040740a405ac5b189275059e2a9863",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*"
+ },
+ "require-dev": {
+ "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "FontLib\\": "src/FontLib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Ménager",
+ "email": "fabien.menager@gmail.com"
+ }
+ ],
+ "description": "A library to read, parse, export and make subsets of different types of font files.",
+ "homepage": "https://github.com/PhenX/php-font-lib",
+ "support": {
+ "issues": "https://github.com/dompdf/php-font-lib/issues",
+ "source": "https://github.com/dompdf/php-font-lib/tree/0.5.6"
+ },
+ "time": "2024-01-29T14:45:26+00:00"
+ },
+ {
+ "name": "php-di/invoker",
+ "version": "2.3.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHP-DI/Invoker.git",
+ "reference": "59f15608528d8a8838d69b422a919fd6b16aa576"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/59f15608528d8a8838d69b422a919fd6b16aa576",
+ "reference": "59f15608528d8a8838d69b422a919fd6b16aa576",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "psr/container": "^1.0|^2.0"
+ },
+ "require-dev": {
+ "athletic/athletic": "~0.1.8",
+ "mnapoli/hard-mode": "~0.3.0",
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Invoker\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Generic and extensible callable invoker",
+ "homepage": "https://github.com/PHP-DI/Invoker",
+ "keywords": [
+ "callable",
+ "dependency",
+ "dependency-injection",
+ "injection",
+ "invoke",
+ "invoker"
+ ],
+ "support": {
+ "issues": "https://github.com/PHP-DI/Invoker/issues",
+ "source": "https://github.com/PHP-DI/Invoker/tree/2.3.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ }
+ ],
+ "time": "2025-01-17T12:49:27+00:00"
+ },
+ {
+ "name": "php-di/php-di",
+ "version": "7.0.11",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHP-DI/PHP-DI.git",
+ "reference": "32f111a6d214564520a57831d397263e8946c1d2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/32f111a6d214564520a57831d397263e8946c1d2",
+ "reference": "32f111a6d214564520a57831d397263e8946c1d2",
+ "shasum": ""
+ },
+ "require": {
+ "laravel/serializable-closure": "^1.0 || ^2.0",
+ "php": ">=8.0",
+ "php-di/invoker": "^2.0",
+ "psr/container": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "psr/container-implementation": "^1.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3",
+ "friendsofphp/proxy-manager-lts": "^1",
+ "mnapoli/phpunit-easymock": "^1.3",
+ "phpunit/phpunit": "^9.6 || ^10 || ^11",
+ "vimeo/psalm": "^5|^6"
+ },
+ "suggest": {
+ "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "DI\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The dependency injection container for humans",
+ "homepage": "https://php-di.org/",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interop",
+ "dependency injection",
+ "di",
+ "ioc",
+ "psr11"
+ ],
+ "support": {
+ "issues": "https://github.com/PHP-DI/PHP-DI/issues",
+ "source": "https://github.com/PHP-DI/PHP-DI/tree/7.0.11"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-03T07:45:57+00:00"
+ },
+ {
+ "name": "php-di/slim-bridge",
+ "version": "3.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHP-DI/Slim-Bridge.git",
+ "reference": "02ab0274a19d104d74561164f8915b62d93f3cf0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHP-DI/Slim-Bridge/zipball/02ab0274a19d104d74561164f8915b62d93f3cf0",
+ "reference": "02ab0274a19d104d74561164f8915b62d93f3cf0",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "php-di/invoker": "^2.0.0",
+ "php-di/php-di": "^6.0|^7.0",
+ "slim/slim": "^4.2.0"
+ },
+ "require-dev": {
+ "laminas/laminas-diactoros": "^2.1",
+ "mnapoli/hard-mode": "^0.3.0",
+ "phpunit/phpunit": ">= 7.0 < 10"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "DI\\Bridge\\Slim\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHP-DI integration in Slim",
+ "support": {
+ "issues": "https://github.com/PHP-DI/Slim-Bridge/issues",
+ "source": "https://github.com/PHP-DI/Slim-Bridge/tree/3.4.1"
+ },
+ "time": "2024-06-19T15:47:45+00:00"
+ },
+ {
+ "name": "phpmailer/phpmailer",
+ "version": "v6.5.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPMailer/PHPMailer.git",
+ "reference": "c0d9f7dd3c2aa247ca44791e9209233829d82285"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/c0d9f7dd3c2aa247ca44791e9209233829d82285",
+ "reference": "c0d9f7dd3c2aa247ca44791e9209233829d82285",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-filter": "*",
+ "ext-hash": "*",
+ "php": ">=5.5.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
+ "doctrine/annotations": "^1.2",
+ "php-parallel-lint/php-console-highlighter": "^0.5.0",
+ "php-parallel-lint/php-parallel-lint": "^1.3.1",
+ "phpcompatibility/php-compatibility": "^9.3.5",
+ "roave/security-advisories": "dev-latest",
+ "squizlabs/php_codesniffer": "^3.6.2",
+ "yoast/phpunit-polyfills": "^1.0.0"
+ },
+ "suggest": {
+ "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
+ "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
+ "league/oauth2-google": "Needed for Google XOAUTH2 authentication",
+ "psr/log": "For optional PSR-3 debug logging",
+ "stevenmaguire/oauth2-microsoft": "Needed for Microsoft XOAUTH2 authentication",
+ "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPMailer\\PHPMailer\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-only"
+ ],
+ "authors": [
+ {
+ "name": "Marcus Bointon",
+ "email": "phpmailer@synchromedia.co.uk"
+ },
+ {
+ "name": "Jim Jagielski",
+ "email": "jimjag@gmail.com"
+ },
+ {
+ "name": "Andy Prevost",
+ "email": "codeworxtech@users.sourceforge.net"
+ },
+ {
+ "name": "Brent R. Matzelle"
+ }
+ ],
+ "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
+ "support": {
+ "issues": "https://github.com/PHPMailer/PHPMailer/issues",
+ "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.5.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Synchro",
+ "type": "github"
+ }
+ ],
+ "time": "2022-02-17T08:19:04+00:00"
+ },
+ {
+ "name": "psr/cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/cache.git",
+ "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+ "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Cache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for caching libraries",
+ "keywords": [
+ "cache",
+ "psr",
+ "psr-6"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/cache/tree/3.0.0"
+ },
+ "time": "2021-02-03T23:26:27+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",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
+ "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/1.1"
+ },
+ "time": "2023-04-04T09:50:52+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": "1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "Psr/Log/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.4"
+ },
+ "time": "2021-05-03T11:20:27+00:00"
+ },
+ {
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\SimpleCache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for simple caching",
+ "keywords": [
+ "cache",
+ "caching",
+ "psr",
+ "psr-16",
+ "simple-cache"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ },
+ "time": "2021-10-29T13:26:27+00:00"
+ },
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
+ },
+ {
+ "name": "ralouphie/mimey",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/mimey.git",
+ "reference": "2a0e997c733b7c2f9f8b61cafb006fd5fb9fa15a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/mimey/zipball/2a0e997c733b7c2f9f8b61cafb006fd5fb9fa15a",
+ "reference": "2a0e997c733b7c2f9f8b61cafb006fd5fb9fa15a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~3.7.0",
+ "satooshi/php-coveralls": ">=1.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mimey\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "PHP package for converting file extensions to MIME types and vice versa.",
+ "support": {
+ "issues": "https://github.com/ralouphie/mimey/issues",
+ "source": "https://github.com/ralouphie/mimey/tree/master"
+ },
+ "time": "2016-09-28T03:36:23+00:00"
+ },
+ {
+ "name": "respect/stringifier",
+ "version": "0.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Respect/Stringifier.git",
+ "reference": "e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Respect/Stringifier/zipball/e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59",
+ "reference": "e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^2.8",
+ "malukenho/docheader": "^0.1.7",
+ "phpunit/phpunit": "^6.4"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/stringify.php"
+ ],
+ "psr-4": {
+ "Respect\\Stringifier\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Respect/Stringifier Contributors",
+ "homepage": "https://github.com/Respect/Stringifier/graphs/contributors"
+ }
+ ],
+ "description": "Converts any value to a string",
+ "homepage": "http://respect.github.io/Stringifier/",
+ "keywords": [
+ "respect",
+ "stringifier",
+ "stringify"
+ ],
+ "support": {
+ "issues": "https://github.com/Respect/Stringifier/issues",
+ "source": "https://github.com/Respect/Stringifier/tree/0.2.0"
+ },
+ "time": "2017-12-29T19:39:25+00:00"
+ },
+ {
+ "name": "respect/validation",
+ "version": "2.2.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Respect/Validation.git",
+ "reference": "d304ace5325efd7180daffb1f8627bb0affd4e3a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Respect/Validation/zipball/d304ace5325efd7180daffb1f8627bb0affd4e3a",
+ "reference": "d304ace5325efd7180daffb1f8627bb0affd4e3a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0 || ^8.1 || ^8.2",
+ "respect/stringifier": "^0.2.0",
+ "symfony/polyfill-mbstring": "^1.2"
+ },
+ "require-dev": {
+ "egulias/email-validator": "^3.0",
+ "malukenho/docheader": "^0.1",
+ "mikey179/vfsstream": "^1.6",
+ "phpstan/phpstan": "^1.9",
+ "phpstan/phpstan-deprecation-rules": "^1.1",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^9.6",
+ "psr/http-message": "^1.0",
+ "respect/coding-standard": "^3.0",
+ "squizlabs/php_codesniffer": "^3.7",
+ "symfony/validator": "^3.0||^4.0"
+ },
+ "suggest": {
+ "egulias/email-validator": "Strict (RFC compliant) email validation",
+ "ext-bcmath": "Arbitrary Precision Mathematics",
+ "ext-fileinfo": "File Information",
+ "ext-mbstring": "Multibyte String Functions"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Respect\\Validation\\": "library/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Respect/Validation Contributors",
+ "homepage": "https://github.com/Respect/Validation/graphs/contributors"
+ }
+ ],
+ "description": "The most awesome validation engine ever created for PHP",
+ "homepage": "http://respect.github.io/Validation/",
+ "keywords": [
+ "respect",
+ "validation",
+ "validator"
+ ],
+ "support": {
+ "issues": "https://github.com/Respect/Validation/issues",
+ "source": "https://github.com/Respect/Validation/tree/2.2.4"
+ },
+ "time": "2023-02-15T01:05:24+00:00"
+ },
+ {
+ "name": "robmorgan/phinx",
+ "version": "0.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/phinx.git",
+ "reference": "18e06e4a2b18947663438afd2f467e17c62e867d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/phinx/zipball/18e06e4a2b18947663438afd2f467e17c62e867d",
+ "reference": "18e06e4a2b18947663438afd2f467e17c62e867d",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/database": "^4.0",
+ "php": ">=7.2",
+ "psr/container": "^1.0 || ^2.0",
+ "symfony/config": "^3.4|^4.0|^5.0|^6.0",
+ "symfony/console": "^3.4|^4.0|^5.0|^6.0"
+ },
+ "require-dev": {
+ "cakephp/cakephp-codesniffer": "^4.0",
+ "ext-json": "*",
+ "ext-pdo": "*",
+ "phpunit/phpunit": "^8.5|^9.3",
+ "sebastian/comparator": ">=1.2.3",
+ "symfony/yaml": "^3.4|^4.0|^5.0"
+ },
+ "suggest": {
+ "ext-json": "Install if using JSON configuration format",
+ "ext-pdo": "PDO extension is needed",
+ "symfony/yaml": "Install if using YAML configuration format"
+ },
+ "bin": [
+ "bin/phinx"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Phinx\\": "src/Phinx/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Rob Morgan",
+ "email": "robbym@gmail.com",
+ "homepage": "https://robmorgan.id.au",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com",
+ "homepage": "https://shadowhand.me",
+ "role": "Developer"
+ },
+ {
+ "name": "Richard Quadling",
+ "email": "rquadling@gmail.com",
+ "role": "Developer"
+ },
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/phinx/graphs/contributors",
+ "role": "Developer"
+ }
+ ],
+ "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.",
+ "homepage": "https://phinx.org",
+ "keywords": [
+ "database",
+ "database migrations",
+ "db",
+ "migrations",
+ "phinx"
+ ],
+ "support": {
+ "issues": "https://github.com/cakephp/phinx/issues",
+ "source": "https://github.com/cakephp/phinx/tree/0.13.4"
+ },
+ "time": "2023-01-07T00:42:55+00:00"
+ },
+ {
+ "name": "robrichards/xmlseclibs",
+ "version": "3.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/robrichards/xmlseclibs.git",
+ "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07",
+ "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "php": ">= 5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "RobRichards\\XMLSecLibs\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "A PHP library for XML Security",
+ "homepage": "https://github.com/robrichards/xmlseclibs",
+ "keywords": [
+ "security",
+ "signature",
+ "xml",
+ "xmldsig"
+ ],
+ "support": {
+ "issues": "https://github.com/robrichards/xmlseclibs/issues",
+ "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3"
+ },
+ "time": "2024-11-20T21:13:56+00:00"
+ },
+ {
+ "name": "robthree/twofactorauth",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/RobThree/TwoFactorAuth.git",
+ "reference": "65681de5a324eae05140ac58b08648a60212afc0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/65681de5a324eae05140ac58b08648a60212afc0",
+ "reference": "65681de5a324eae05140ac58b08648a60212afc0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6.0"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpunit/phpunit": "@stable"
+ },
+ "suggest": {
+ "bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider",
+ "endroid/qr-code": "Needed for EndroidQrCodeProvider"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "RobThree\\Auth\\": "lib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Rob Janssen",
+ "homepage": "http://robiii.me",
+ "role": "Developer"
+ }
+ ],
+ "description": "Two Factor Authentication",
+ "homepage": "https://github.com/RobThree/TwoFactorAuth",
+ "keywords": [
+ "Authentication",
+ "MFA",
+ "Multi Factor Authentication",
+ "Two Factor Authentication",
+ "authenticator",
+ "authy",
+ "php",
+ "tfa"
+ ],
+ "support": {
+ "issues": "https://github.com/RobThree/TwoFactorAuth/issues",
+ "source": "https://github.com/RobThree/TwoFactorAuth"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/robiii",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/RobThree",
+ "type": "github"
+ }
+ ],
+ "time": "2022-03-22T16:11:07+00:00"
+ },
+ {
+ "name": "setasign/fpdi",
+ "version": "v2.6.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Setasign/FPDI.git",
+ "reference": "4b53852fde2734ec6a07e458a085db627c60eada"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada",
+ "reference": "4b53852fde2734ec6a07e458a085db627c60eada",
+ "shasum": ""
+ },
+ "require": {
+ "ext-zlib": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "setasign/tfpdf": "<1.31"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7",
+ "setasign/fpdf": "~1.8.6",
+ "setasign/tfpdf": "~1.33",
+ "squizlabs/php_codesniffer": "^3.5",
+ "tecnickcom/tcpdf": "^6.8"
+ },
+ "suggest": {
+ "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "setasign\\Fpdi\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jan Slabon",
+ "email": "jan.slabon@setasign.com",
+ "homepage": "https://www.setasign.com"
+ },
+ {
+ "name": "Maximilian Kresse",
+ "email": "maximilian.kresse@setasign.com",
+ "homepage": "https://www.setasign.com"
+ }
+ ],
+ "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
+ "homepage": "https://www.setasign.com/fpdi",
+ "keywords": [
+ "fpdf",
+ "fpdi",
+ "pdf"
+ ],
+ "support": {
+ "issues": "https://github.com/Setasign/FPDI/issues",
+ "source": "https://github.com/Setasign/FPDI/tree/v2.6.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-05T09:57:14+00:00"
+ },
+ {
+ "name": "slim/flash",
+ "version": "0.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/slimphp/Slim-Flash.git",
+ "reference": "9aaff5fded3b54f4e519ec3d4ac74d3d1f2cbbbc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/slimphp/Slim-Flash/zipball/9aaff5fded3b54f4e519ec3d4ac74d3d1f2cbbbc",
+ "reference": "9aaff5fded3b54f4e519ec3d4ac74d3d1f2cbbbc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Slim\\Flash\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Josh Lockhart",
+ "email": "hello@joshlockhart.com",
+ "homepage": "http://joshlockhart.com"
+ }
+ ],
+ "description": "Slim Framework Flash message service provider",
+ "homepage": "http://slimframework.com",
+ "keywords": [
+ "flash",
+ "framework",
+ "message",
+ "provider",
+ "slim"
+ ],
+ "support": {
+ "issues": "https://github.com/slimphp/Slim-Flash/issues",
+ "source": "https://github.com/slimphp/Slim-Flash/tree/master"
+ },
+ "time": "2017-10-22T10:35:05+00:00"
+ },
+ {
+ "name": "slim/http",
+ "version": "1.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/slimphp/Slim-Http.git",
+ "reference": "a8def7b8e9eabd0cdc21654ad4a82606942e066a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/slimphp/Slim-Http/zipball/a8def7b8e9eabd0cdc21654ad4a82606942e066a",
+ "reference": "a8def7b8e9eabd0cdc21654ad4a82606942e066a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-fileinfo": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-simplexml": "*",
+ "php": "^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "require-dev": {
+ "adriansuter/php-autoload-override": "^1.4",
+ "doctrine/instantiator": "^1.3.1",
+ "laminas/laminas-diactoros": "^3.1.0",
+ "nyholm/psr7": "^1.8.1",
+ "php-http/psr7-integration-tests": "^1.3.0",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^9.6",
+ "squizlabs/php_codesniffer": "^3.9"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Slim\\Http\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Josh Lockhart",
+ "email": "hello@joshlockhart.com",
+ "homepage": "http://joshlockhart.com"
+ },
+ {
+ "name": "Andrew Smith",
+ "email": "a.smith@silentworks.co.uk",
+ "homepage": "http://silentworks.co.uk"
+ },
+ {
+ "name": "Rob Allen",
+ "email": "rob@akrabat.com",
+ "homepage": "http://akrabat.com"
+ },
+ {
+ "name": "Pierre Berube",
+ "email": "pierre@lgse.com",
+ "homepage": "http://www.lgse.com"
+ }
+ ],
+ "description": "Slim PSR-7 Object Decorators",
+ "homepage": "http://slimframework.com",
+ "keywords": [
+ "http",
+ "psr-7",
+ "psr7"
+ ],
+ "support": {
+ "issues": "https://github.com/slimphp/Slim-Http/issues",
+ "source": "https://github.com/slimphp/Slim-Http/tree/1.4.0"
+ },
+ "time": "2024-06-24T18:27:41+00:00"
+ },
+ {
+ "name": "slim/slim",
+ "version": "4.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/slimphp/Slim.git",
+ "reference": "5943393b88716eb9e82c4161caa956af63423913"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/slimphp/Slim/zipball/5943393b88716eb9e82c4161caa956af63423913",
+ "reference": "5943393b88716eb9e82c4161caa956af63423913",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "nikic/fast-route": "^1.3",
+ "php": "^7.4 || ^8.0",
+ "psr/container": "^1.0 || ^2.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^1.1 || ^2.0",
+ "psr/http-server-handler": "^1.0",
+ "psr/http-server-middleware": "^1.0",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "adriansuter/php-autoload-override": "^1.4",
+ "ext-simplexml": "*",
+ "guzzlehttp/psr7": "^2.6",
+ "httpsoft/http-message": "^1.1",
+ "httpsoft/http-server-request": "^1.1",
+ "laminas/laminas-diactoros": "^2.17 || ^3",
+ "nyholm/psr7": "^1.8",
+ "nyholm/psr7-server": "^1.1",
+ "phpspec/prophecy": "^1.19",
+ "phpspec/prophecy-phpunit": "^2.1",
+ "phpstan/phpstan": "^1.11",
+ "phpunit/phpunit": "^9.6",
+ "slim/http": "^1.3",
+ "slim/psr7": "^1.6",
+ "squizlabs/php_codesniffer": "^3.10",
+ "vimeo/psalm": "^5.24"
+ },
+ "suggest": {
+ "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware",
+ "ext-xml": "Needed to support XML format in BodyParsingMiddleware",
+ "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim",
+ "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Slim\\": "Slim"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Josh Lockhart",
+ "email": "hello@joshlockhart.com",
+ "homepage": "https://joshlockhart.com"
+ },
+ {
+ "name": "Andrew Smith",
+ "email": "a.smith@silentworks.co.uk",
+ "homepage": "http://silentworks.co.uk"
+ },
+ {
+ "name": "Rob Allen",
+ "email": "rob@akrabat.com",
+ "homepage": "http://akrabat.com"
+ },
+ {
+ "name": "Pierre Berube",
+ "email": "pierre@lgse.com",
+ "homepage": "http://www.lgse.com"
+ },
+ {
+ "name": "Gabriel Manricks",
+ "email": "gmanricks@me.com",
+ "homepage": "http://gabrielmanricks.com"
+ }
+ ],
+ "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs",
+ "homepage": "https://www.slimframework.com",
+ "keywords": [
+ "api",
+ "framework",
+ "micro",
+ "router"
+ ],
+ "support": {
+ "docs": "https://www.slimframework.com/docs/v4/",
+ "forum": "https://discourse.slimframework.com/",
+ "irc": "irc://irc.freenode.net:6667/slimphp",
+ "issues": "https://github.com/slimphp/Slim/issues",
+ "rss": "https://www.slimframework.com/blog/feed.rss",
+ "slack": "https://slimphp.slack.com/",
+ "source": "https://github.com/slimphp/Slim",
+ "wiki": "https://github.com/slimphp/Slim/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/slimphp",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/slim/slim",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-06-13T08:54:48+00:00"
+ },
+ {
+ "name": "slim/twig-view",
+ "version": "3.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/slimphp/Twig-View.git",
+ "reference": "df6dd6af6bbe28041be49c9fb8470c2e9b70cd98"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/slimphp/Twig-View/zipball/df6dd6af6bbe28041be49c9fb8470c2e9b70cd98",
+ "reference": "df6dd6af6bbe28041be49c9fb8470c2e9b70cd98",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "psr/http-message": "^1.0",
+ "slim/slim": "^4.9",
+ "symfony/polyfill-php81": "^1.23",
+ "twig/twig": "^3.3"
+ },
+ "require-dev": {
+ "phpspec/prophecy-phpunit": "^2.0",
+ "phpstan/phpstan": "^1.3.0",
+ "phpunit/phpunit": "^9.5",
+ "psr/http-factory": "^1.0",
+ "squizlabs/php_codesniffer": "^3.6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Slim\\Views\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Josh Lockhart",
+ "email": "hello@joshlockhart.com",
+ "homepage": "http://joshlockhart.com"
+ },
+ {
+ "name": "Pierre Berube",
+ "email": "pierre@lgse.com",
+ "homepage": "http://www.lgse.com"
+ }
+ ],
+ "description": "Slim Framework 4 view helper built on top of the Twig 3 templating component",
+ "homepage": "https://www.slimframework.com",
+ "keywords": [
+ "framework",
+ "slim",
+ "template",
+ "twig",
+ "view"
+ ],
+ "support": {
+ "issues": "https://github.com/slimphp/Twig-View/issues",
+ "source": "https://github.com/slimphp/Twig-View/tree/3.3.0"
+ },
+ "time": "2022-01-02T05:14:45+00:00"
+ },
+ {
+ "name": "symfony/config",
+ "version": "v6.4.22",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/config.git",
+ "reference": "af5917a3b1571f54689e56677a3f06440d2fe4c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/config/zipball/af5917a3b1571f54689e56677a3f06440d2fe4c7",
+ "reference": "af5917a3b1571f54689e56677a3f06440d2fe4c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/filesystem": "^5.4|^6.0|^7.0",
+ "symfony/polyfill-ctype": "~1.8"
+ },
+ "conflict": {
+ "symfony/finder": "<5.4",
+ "symfony/service-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/event-dispatcher": "^5.4|^6.0|^7.0",
+ "symfony/finder": "^5.4|^6.0|^7.0",
+ "symfony/messenger": "^5.4|^6.0|^7.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/yaml": "^5.4|^6.0|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Config\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "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/v6.4.22"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-05-14T06:00:01+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v5.4.47",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+ "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php73": "^1.9",
+ "symfony/polyfill-php80": "^1.16",
+ "symfony/service-contracts": "^1.1|^2|^3",
+ "symfony/string": "^5.1|^6.0"
+ },
+ "conflict": {
+ "psr/log": ">=3",
+ "symfony/dependency-injection": "<4.4",
+ "symfony/dotenv": "<5.1",
+ "symfony/event-dispatcher": "<4.4",
+ "symfony/lock": "<4.4",
+ "symfony/process": "<4.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2",
+ "symfony/config": "^4.4|^5.0|^6.0",
+ "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+ "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
+ "symfony/lock": "^4.4|^5.0|^6.0",
+ "symfony/process": "^4.4|^5.0|^6.0",
+ "symfony/var-dumper": "^4.4|^5.0|^6.0"
+ },
+ "suggest": {
+ "psr/log": "For using the console logger",
+ "symfony/event-dispatcher": "",
+ "symfony/lock": "",
+ "symfony/process": ""
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.4.47"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-06T11:30:55+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher",
+ "version": "v4.4.44",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher.git",
+ "reference": "1e866e9e5c1b22168e0ce5f0b467f19bba61266a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1e866e9e5c1b22168e0ce5f0b467f19bba61266a",
+ "reference": "1e866e9e5c1b22168e0ce5f0b467f19bba61266a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.3",
+ "symfony/event-dispatcher-contracts": "^1.1",
+ "symfony/polyfill-php80": "^1.16"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<3.4"
+ },
+ "provide": {
+ "psr/event-dispatcher-implementation": "1.0",
+ "symfony/event-dispatcher-implementation": "1.1"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^3.4|^4.0|^5.0",
+ "symfony/dependency-injection": "^3.4|^4.0|^5.0",
+ "symfony/error-handler": "~3.4|~4.4",
+ "symfony/expression-language": "^3.4|^4.0|^5.0",
+ "symfony/http-foundation": "^3.4|^4.0|^5.0",
+ "symfony/service-contracts": "^1.1|^2",
+ "symfony/stopwatch": "^3.4|^4.0|^5.0"
+ },
+ "suggest": {
+ "symfony/dependency-injection": "",
+ "symfony/http-kernel": ""
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\EventDispatcher\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "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/v4.4.44"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-07-20T09:59:04+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher-contracts",
+ "version": "v1.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+ "reference": "761c8b8387cfe5f8026594a75fdf0a4e83ba6974"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/761c8b8387cfe5f8026594a75fdf0a4e83ba6974",
+ "reference": "761c8b8387cfe5f8026594a75fdf0a4e83ba6974",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.3"
+ },
+ "suggest": {
+ "psr/event-dispatcher": "",
+ "symfony/event-dispatcher-implementation": ""
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "1.1-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\EventDispatcher\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to dispatching event",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v1.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-07-20T09:59:04+00:00"
+ },
+ {
+ "name": "symfony/filesystem",
+ "version": "v6.4.13",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/4856c9cf585d5a0313d8d35afd681a526f038dd3",
+ "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
+ },
+ "require-dev": {
+ "symfony/process": "^5.4|^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides basic utilities for the filesystem",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v6.4.13"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-10-25T15:07:50+00:00"
+ },
+ {
+ "name": "symfony/html-sanitizer",
+ "version": "v6.4.21",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/html-sanitizer.git",
+ "reference": "f66d6585c6ece946239317c339f8b2860dfdf2db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f66d6585c6ece946239317c339f8b2860dfdf2db",
+ "reference": "f66d6585c6ece946239317c339f8b2860dfdf2db",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "league/uri": "^6.5|^7.0",
+ "masterminds/html5": "^2.7.2",
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HtmlSanitizer\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Titouan Galopin",
+ "email": "galopintitouan@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "Purifier",
+ "html",
+ "sanitizer"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.21"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-03-31T07:29:45+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
+ "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-12-23T08:48:59+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php73",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php73.git",
+ "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+ "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+ "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\\Php73\\": ""
+ },
+ "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 7.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php80",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php80.git",
+ "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "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\\Php80\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ion Bazan",
+ "email": "ion.bazan@gmail.com"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-01-02T08:10:11+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php81",
+ "version": "v1.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php81.git",
+ "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "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\\Php81\\": ""
+ },
+ "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.1+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
+ "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-04-25T09:37:31+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v6.4.21",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "73e2c6966a5aef1d4892873ed5322245295370c6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/73e2c6966a5aef1d4892873ed5322245295370c6",
+ "reference": "73e2c6966a5aef1d4892873ed5322245295370c6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/error-handler": "^5.4|^6.0|^7.0",
+ "symfony/http-client": "^5.4|^6.0|^7.0",
+ "symfony/intl": "^6.2|^7.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^5.4|^6.0|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v6.4.21"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-04-18T15:23:29+00:00"
+ },
+ {
+ "name": "symfony/translation",
+ "version": "v6.4.23",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/translation.git",
+ "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/de8afa521e04a5220e9e58a1dc99971ab7cac643",
+ "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/translation-contracts": "^2.5|^3.0"
+ },
+ "conflict": {
+ "symfony/config": "<5.4",
+ "symfony/console": "<5.4",
+ "symfony/dependency-injection": "<5.4",
+ "symfony/http-client-contracts": "<2.5",
+ "symfony/http-kernel": "<5.4",
+ "symfony/service-contracts": "<2.5",
+ "symfony/twig-bundle": "<5.4",
+ "symfony/yaml": "<5.4"
+ },
+ "provide": {
+ "symfony/translation-implementation": "2.3|3.0"
+ },
+ "require-dev": {
+ "nikic/php-parser": "^4.18|^5.0",
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^5.4|^6.0|^7.0",
+ "symfony/console": "^5.4|^6.0|^7.0",
+ "symfony/dependency-injection": "^5.4|^6.0|^7.0",
+ "symfony/finder": "^5.4|^6.0|^7.0",
+ "symfony/http-client-contracts": "^2.5|^3.0",
+ "symfony/http-kernel": "^5.4|^6.0|^7.0",
+ "symfony/intl": "^5.4|^6.0|^7.0",
+ "symfony/polyfill-intl-icu": "^1.21",
+ "symfony/routing": "^5.4|^6.0|^7.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/yaml": "^5.4|^6.0|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\Translation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools to internationalize your application",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/translation/tree/v6.4.23"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-26T21:24:02+00:00"
+ },
+ {
+ "name": "symfony/translation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/translation-contracts.git",
+ "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
+ "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Translation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to translation",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-27T08:32:26+00:00"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v5.4.45",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/yaml.git",
+ "reference": "a454d47278cc16a5db371fe73ae66a78a633371e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/a454d47278cc16a5db371fe73ae66a78a633371e",
+ "reference": "a454d47278cc16a5db371fe73ae66a78a633371e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1|^3",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "symfony/console": "<5.3"
+ },
+ "require-dev": {
+ "symfony/console": "^5.3|^6.0"
+ },
+ "suggest": {
+ "symfony/console": "For validating YAML files using the lint command"
+ },
+ "bin": [
+ "Resources/bin/yaml-lint"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Yaml\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Loads and dumps YAML files",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v5.4.45"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:11:13+00:00"
+ },
+ {
+ "name": "tedivm/stash",
+ "version": "v1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/tedious/Stash.git",
+ "reference": "a0beaf566d7acf9da1da9c0d30efc0f4b5ea4042"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/tedious/Stash/zipball/a0beaf566d7acf9da1da9c0d30efc0f4b5ea4042",
+ "reference": "a0beaf566d7acf9da1da9c0d30efc0f4b5ea4042",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0",
+ "psr/cache": "^2|^3"
+ },
+ "provide": {
+ "psr/cache-implementation": "2.0|3.0"
+ },
+ "require-dev": {
+ "dms/phpunit-arraysubset-asserts": "^0.5.0",
+ "friendsofphp/php-cs-fixer": "^2.8",
+ "php-coveralls/php-coveralls": "^2.0",
+ "phpunit/phpunit": "^9.0|^10"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Stash\\": "src/Stash/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Robert Hafner",
+ "email": "tedivm@tedivm.com"
+ },
+ {
+ "name": "Josh Hall-Bachner",
+ "email": "charlequin@gmail.com"
+ }
+ ],
+ "description": "The place to keep your cache.",
+ "homepage": "http://github.com/tedious/Stash",
+ "keywords": [
+ "apc",
+ "cache",
+ "caching",
+ "memcached",
+ "psr-6",
+ "psr6",
+ "redis",
+ "sessions"
+ ],
+ "support": {
+ "issues": "https://github.com/tedious/Stash/issues",
+ "source": "https://github.com/tedious/Stash/tree/v1.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/tedivm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/tedivm/stash",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-05-27T13:41:29+00:00"
+ },
+ {
+ "name": "twig/twig",
+ "version": "v3.11.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/twigphp/Twig.git",
+ "reference": "3b06600ff3abefaf8ff55d5c336cd1c4253f8c7e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/3b06600ff3abefaf8ff55d5c336cd1c4253f8c7e",
+ "reference": "3b06600ff3abefaf8ff55d5c336cd1c4253f8c7e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-mbstring": "^1.3",
+ "symfony/polyfill-php80": "^1.22",
+ "symfony/polyfill-php81": "^1.29"
+ },
+ "require-dev": {
+ "psr/container": "^1.0|^2.0",
+ "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Resources/core.php",
+ "src/Resources/debug.php",
+ "src/Resources/escaper.php",
+ "src/Resources/string_loader.php"
+ ],
+ "psr-4": {
+ "Twig\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com",
+ "homepage": "http://fabien.potencier.org",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Twig Team",
+ "role": "Contributors"
+ },
+ {
+ "name": "Armin Ronacher",
+ "email": "armin.ronacher@active-4.com",
+ "role": "Project Founder"
+ }
+ ],
+ "description": "Twig, the flexible, fast, and secure template language for PHP",
+ "homepage": "https://twig.symfony.com",
+ "keywords": [
+ "templating"
+ ],
+ "support": {
+ "issues": "https://github.com/twigphp/Twig/issues",
+ "source": "https://github.com/twigphp/Twig/tree/v3.11.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/twig/twig",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-07T12:34:41+00:00"
+ },
+ {
+ "name": "voku/portable-ascii",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/voku/portable-ascii.git",
+ "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+ "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
+ },
+ "suggest": {
+ "ext-intl": "Use Intl for transliterator_transliterate() support"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "voku\\": "src/voku/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Lars Moelleken",
+ "homepage": "https://www.moelleken.org/"
+ }
+ ],
+ "description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
+ "homepage": "https://github.com/voku/portable-ascii",
+ "keywords": [
+ "ascii",
+ "clean",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/voku/portable-ascii/issues",
+ "source": "https://github.com/voku/portable-ascii/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.me/moelleken",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/voku",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/portable-ascii",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://www.patreon.com/voku",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-21T01:49:47+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.11.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
+ "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<0.12.20",
+ "vimeo/psalm": "<4.6.1 || 4.6.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5.13"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.10-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/1.11.0"
+ },
+ "time": "2022-06-03T18:03:27+00:00"
+ },
+ {
+ "name": "xibosignage/support",
+ "version": "dev-php8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/xibosignage/support.git",
+ "reference": "371a680cdf26de37efbf454a0bf4a85c2c2d3833"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/xibosignage/support/zipball/371a680cdf26de37efbf454a0bf4a85c2c2d3833",
+ "reference": "371a680cdf26de37efbf454a0bf4a85c2c2d3833",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/support": "v10.*",
+ "php": ">=8.1",
+ "psr/http-message": "^1.0",
+ "psr/log": "^1.1",
+ "symfony/html-sanitizer": "^6.3"
+ },
+ "require-dev": {
+ "exussum12/coverage-checker": "^0.11.2",
+ "respect/validation": "2.2.*",
+ "squizlabs/php_codesniffer": "3.*"
+ },
+ "suggest": {
+ "monolog/monolog": "If the Monolog Handlers/Processors are to be used",
+ "nesbot/carbon": "If the RespectSanitizer is to be used.",
+ "respect/validation": "If the RespectSanitizer is to be used."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Xibo\\": "src/Xibo"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Xibo Signage Ltd",
+ "homepage": "https://xibosignage.com"
+ }
+ ],
+ "description": "Support functions used throughout the Xibo Signage Platform",
+ "homepage": "https://xibosignage.com",
+ "support": {
+ "issues": "https://github.com/xibosignage/support/issues",
+ "source": "https://github.com/xibosignage/support/tree/php8"
+ },
+ "time": "2025-05-23T07:38:48+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "doctrine/annotations",
+ "version": "1.14.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/annotations.git",
+ "reference": "253dca476f70808a5aeed3a47cc2cc88c5cab915"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/annotations/zipball/253dca476f70808a5aeed3a47cc2cc88c5cab915",
+ "reference": "253dca476f70808a5aeed3a47cc2cc88c5cab915",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/lexer": "^1 || ^2",
+ "ext-tokenizer": "*",
+ "php": "^7.1 || ^8.0",
+ "psr/cache": "^1 || ^2 || ^3"
+ },
+ "require-dev": {
+ "doctrine/cache": "^1.11 || ^2.0",
+ "doctrine/coding-standard": "^9 || ^12",
+ "phpstan/phpstan": "~1.4.10 || ^1.10.28",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7",
+ "vimeo/psalm": "^4.30 || ^5.14"
+ },
+ "suggest": {
+ "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Guilherme Blanco",
+ "email": "guilhermeblanco@gmail.com"
+ },
+ {
+ "name": "Roman Borschel",
+ "email": "roman@code-factory.org"
+ },
+ {
+ "name": "Benjamin Eberlei",
+ "email": "kontakt@beberlei.de"
+ },
+ {
+ "name": "Jonathan Wage",
+ "email": "jonwage@gmail.com"
+ },
+ {
+ "name": "Johannes Schmitt",
+ "email": "schmittjoh@gmail.com"
+ }
+ ],
+ "description": "Docblock Annotations Parser",
+ "homepage": "https://www.doctrine-project.org/projects/annotations.html",
+ "keywords": [
+ "annotations",
+ "docblock",
+ "parser"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/annotations/issues",
+ "source": "https://github.com/doctrine/annotations/tree/1.14.4"
+ },
+ "time": "2024-09-05T10:15:52+00:00"
+ },
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=13"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12 || ^13",
+ "phpstan/phpstan": "1.4.10 || 2.1.11",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.5"
+ },
+ "time": "2025-04-07T20:06:18+00:00"
+ },
+ {
+ "name": "doctrine/instantiator",
+ "version": "1.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^11",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^0.16 || ^1",
+ "phpstan/phpstan": "^1.4",
+ "phpstan/phpstan-phpunit": "^1",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "vimeo/psalm": "^4.30 || ^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/1.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-12-30T00:15:36+00:00"
+ },
+ {
+ "name": "doctrine/lexer",
+ "version": "2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/lexer.git",
+ "reference": "861c870e8b75f7c8f69c146c7f89cc1c0f1b49b6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/lexer/zipball/861c870e8b75f7c8f69c146c7f89cc1c0f1b49b6",
+ "reference": "861c870e8b75f7c8f69c146c7f89cc1c0f1b49b6",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12",
+ "phpstan/phpstan": "^1.3",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6",
+ "psalm/plugin-phpunit": "^0.18.3",
+ "vimeo/psalm": "^4.11 || ^5.21"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Common\\Lexer\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Guilherme Blanco",
+ "email": "guilhermeblanco@gmail.com"
+ },
+ {
+ "name": "Roman Borschel",
+ "email": "roman@code-factory.org"
+ },
+ {
+ "name": "Johannes Schmitt",
+ "email": "schmittjoh@gmail.com"
+ }
+ ],
+ "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.",
+ "homepage": "https://www.doctrine-project.org/projects/lexer.html",
+ "keywords": [
+ "annotations",
+ "docblock",
+ "lexer",
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/lexer/issues",
+ "source": "https://github.com/doctrine/lexer/tree/2.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-02-05T11:35:39+00:00"
+ },
+ {
+ "name": "exussum12/coverage-checker",
+ "version": "0.11.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/exussum12/coverageChecker.git",
+ "reference": "1bc2bddce1a531b16da3606142dd72107207b4ce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/exussum12/coverageChecker/zipball/1bc2bddce1a531b16da3606142dd72107207b4ce",
+ "reference": "1bc2bddce1a531b16da3606142dd72107207b4ce",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^3.1||^4.0",
+ "php": ">=5.5"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^5.7"
+ },
+ "bin": [
+ "bin/phpunitDiffFilter",
+ "bin/phpcsDiffFilter",
+ "bin/phpmdDiffFilter",
+ "bin/diffFilter"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "exussum12\\CoverageChecker\\": "src/",
+ "exussum12\\CoverageChecker\\tests\\": "tests/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Scott Dutton",
+ "email": "scott@exussum.co.uk"
+ }
+ ],
+ "description": "Allows checking the code coverage of a single pull request",
+ "support": {
+ "issues": "https://github.com/exussum12/coverageChecker/issues",
+ "source": "https://github.com/exussum12/coverageChecker/tree/0.11.3"
+ },
+ "time": "2022-01-19T14:41:07+00:00"
+ },
+ {
+ "name": "league/oauth2-client",
+ "version": "2.8.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/oauth2-client.git",
+ "reference": "9df2924ca644736c835fc60466a3a60390d334f9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/9df2924ca644736c835fc60466a3a60390d334f9",
+ "reference": "9df2924ca644736c835fc60466a3a60390d334f9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
+ "php": "^7.1 || >=8.0.0 <8.5.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.3.5",
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "^3.11"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "League\\OAuth2\\Client\\": "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": "Woody Gilk",
+ "homepage": "https://github.com/shadowhand",
+ "role": "Contributor"
+ }
+ ],
+ "description": "OAuth 2.0 Client Library",
+ "keywords": [
+ "Authentication",
+ "SSO",
+ "authorization",
+ "identity",
+ "idp",
+ "oauth",
+ "oauth2",
+ "single sign on"
+ ],
+ "support": {
+ "issues": "https://github.com/thephpleague/oauth2-client/issues",
+ "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.1"
+ },
+ "time": "2025-02-26T04:37:30+00:00"
+ },
+ {
+ "name": "micheh/phpcs-gitlab",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/micheh/phpcs-gitlab.git",
+ "reference": "fd64e6579d9e30a82abba616fabcb9a2c837c7a8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/micheh/phpcs-gitlab/zipball/fd64e6579d9e30a82abba616fabcb9a2c837c7a8",
+ "reference": "fd64e6579d9e30a82abba616fabcb9a2c837c7a8",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.0 || ^9.0",
+ "squizlabs/php_codesniffer": "^3.3.1",
+ "vimeo/psalm": "^4.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Micheh\\PhpCodeSniffer\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Michel Hunziker",
+ "email": "info@michelhunziker.com"
+ }
+ ],
+ "description": "Gitlab Report for PHP_CodeSniffer (display the violations in the Gitlab CI/CD Code Quality Report)",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "code quality",
+ "gitlab",
+ "phpcs",
+ "report"
+ ],
+ "support": {
+ "issues": "https://github.com/micheh/phpcs-gitlab/issues",
+ "source": "https://github.com/micheh/phpcs-gitlab/tree/1.1.0"
+ },
+ "time": "2020-12-20T09:39:07+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v4.19.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2",
+ "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.9-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4"
+ },
+ "time": "2024-09-29T15:01:53+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "10.1.16",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "7e308268858ed6baedc8704a304727d20bc07c77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77",
+ "reference": "7e308268858ed6baedc8704a304727d20bc07c77",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
+ "php": ">=8.1",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-text-template": "^3.0.1",
+ "sebastian/code-unit-reverse-lookup": "^3.0.0",
+ "sebastian/complexity": "^3.2.0",
+ "sebastian/environment": "^6.1.0",
+ "sebastian/lines-of-code": "^2.0.2",
+ "sebastian/version": "^4.0.1",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.1"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "10.1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:31:57+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "4.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-31T06:24:48+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^10.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:56:09+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-31T14:07:24+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:57:52+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "10.5.48",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "6e0a2bc39f6fae7617989d690d76c48e6d2eb541"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6e0a2bc39f6fae7617989d690d76c48e6d2eb541",
+ "reference": "6e0a2bc39f6fae7617989d690d76c48e6d2eb541",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.3",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.1",
+ "phpunit/php-code-coverage": "^10.1.16",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-invoker": "^4.0.0",
+ "phpunit/php-text-template": "^3.0.1",
+ "phpunit/php-timer": "^6.0.0",
+ "sebastian/cli-parser": "^2.0.1",
+ "sebastian/code-unit": "^2.0.0",
+ "sebastian/comparator": "^5.0.3",
+ "sebastian/diff": "^5.1.1",
+ "sebastian/environment": "^6.1.0",
+ "sebastian/exporter": "^5.1.2",
+ "sebastian/global-state": "^6.0.2",
+ "sebastian/object-enumerator": "^5.0.0",
+ "sebastian/recursion-context": "^5.0.0",
+ "sebastian/type": "^4.0.0",
+ "sebastian/version": "^4.0.1"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "10.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.48"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-11T04:07:17+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:12:49+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "a81fee9eef0b7a76af11d121767abc44c104e503"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503",
+ "reference": "a81fee9eef0b7a76af11d121767abc44c104e503",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:58:43+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:59:15+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e",
+ "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "sebastian/diff": "^5.0",
+ "sebastian/exporter": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-18T14:56:07+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "68ff824baeae169ec9f2137158ee529584553799"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799",
+ "reference": "68ff824baeae169ec9f2137158ee529584553799",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-21T08:37:17+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "5.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0",
+ "symfony/process": "^6.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:15:17+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "6.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "8074dbcd93529b357029f5cc5058fd3e43666984"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984",
+ "reference": "8074dbcd93529b357029f5cc5058fd3e43666984",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-23T08:47:14+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "5.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "955288482d97c19a372d3f31006ab3f37da47adf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf",
+ "reference": "955288482d97c19a372d3f31006ab3f37da47adf",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:17:12+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "6.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "sebastian/object-reflector": "^3.0",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:19:19+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-21T08:38:20+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "sebastian/object-reflector": "^3.0",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:08:32+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "24ed13d98130f0e7122df55d06c5c4942a577957"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957",
+ "reference": "24ed13d98130f0e7122df55d06c5c4942a577957",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:06:18+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "05909fb5bc7df4c52992396d0116aed689f93712"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712",
+ "reference": "05909fb5bc7df4c52992396d0116aed689f93712",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:05:40+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "462699a16464c3944eefc02ebdd77882bd3925bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf",
+ "reference": "462699a16464c3944eefc02ebdd77882bd3925bf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:10:45+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-07T11:34:05+00:00"
+ },
+ {
+ "name": "shipmonk/composer-dependency-analyser",
+ "version": "1.8.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/shipmonk-rnd/composer-dependency-analyser.git",
+ "reference": "ca6b2725cd4854d97c1ce08e6954a74fbdd25372"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/shipmonk-rnd/composer-dependency-analyser/zipball/ca6b2725cd4854d97c1ce08e6954a74fbdd25372",
+ "reference": "ca6b2725cd4854d97c1ce08e6954a74fbdd25372",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "editorconfig-checker/editorconfig-checker": "^10.6.0",
+ "ergebnis/composer-normalize": "^2.19.0",
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "phpcompatibility/php-compatibility": "^9.3.5",
+ "phpstan/phpstan": "^1.12.3",
+ "phpstan/phpstan-phpunit": "^1.4.0",
+ "phpstan/phpstan-strict-rules": "^1.6.0",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "shipmonk/name-collision-detector": "^2.1.1",
+ "slevomat/coding-standard": "^8.15.0"
+ },
+ "bin": [
+ "bin/composer-dependency-analyser"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ShipMonk\\ComposerDependencyAnalyser\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Fast detection of composer dependency issues (dead dependencies, shadow dependencies, misplaced dependencies)",
+ "keywords": [
+ "analyser",
+ "composer",
+ "composer dependency",
+ "dead code",
+ "dead dependency",
+ "detector",
+ "dev",
+ "misplaced dependency",
+ "shadow dependency",
+ "static analysis",
+ "unused code",
+ "unused dependency"
+ ],
+ "support": {
+ "issues": "https://github.com/shipmonk-rnd/composer-dependency-analyser/issues",
+ "source": "https://github.com/shipmonk-rnd/composer-dependency-analyser/tree/1.8.3"
+ },
+ "time": "2025-02-10T13:31:57+00:00"
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "3.13.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "5b5e3821314f947dd040c70f7992a64eac89025c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c",
+ "reference": "5b5e3821314f947dd040c70f7992a64eac89025c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ },
+ "bin": [
+ "bin/phpcbf",
+ "bin/phpcs"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Greg Sherwood",
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-06-17T22:17:01+00:00"
+ },
+ {
+ "name": "symfony/finder",
+ "version": "v6.4.17",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/finder.git",
+ "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7",
+ "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "symfony/filesystem": "^6.0|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Finder\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Finds files and directories via an intuitive fluent interface",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v6.4.17"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-12-29T13:51:37+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:36:25+00:00"
+ },
+ {
+ "name": "xibosignage/oauth2-xibo-cms",
+ "version": "dev-feature/3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/xibosignage/oauth2-xibo-cms.git",
+ "reference": "e0819d4f77f3e86b752ef050c636f99c17b3060e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/xibosignage/oauth2-xibo-cms/zipball/e0819d4f77f3e86b752ef050c636f99c17b3060e",
+ "reference": "e0819d4f77f3e86b752ef050c636f99c17b3060e",
+ "shasum": ""
+ },
+ "require": {
+ "league/oauth2-client": "^2.4",
+ "php": ">=5.6.0",
+ "psr/log": "1.1.*"
+ },
+ "require-dev": {
+ "monolog/monolog": "^1.22"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Xibo\\OAuth2\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Xibo Signage Ltd",
+ "homepage": "https://xibo.org.uk"
+ }
+ ],
+ "description": "A Xibo CMS provider for league/oauth2-client",
+ "homepage": "http://xibo.org.uk",
+ "keywords": [
+ "Authentication",
+ "SSO",
+ "authorization",
+ "client",
+ "identity",
+ "idp",
+ "oauth",
+ "oauth2",
+ "single sign on",
+ "xibo",
+ "xibo cms"
+ ],
+ "support": {
+ "source": "https://github.com/xibosignage/oauth2-xibo-cms/tree/feature/3.0"
+ },
+ "time": "2022-04-26T11:41:58+00:00"
+ },
+ {
+ "name": "zircote/swagger-php",
+ "version": "2.1.13",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/zircote/swagger-php.git",
+ "reference": "cc88b23d2dfb9fa38b989cd479477860235d0591"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/zircote/swagger-php/zipball/cc88b23d2dfb9fa38b989cd479477860235d0591",
+ "reference": "cc88b23d2dfb9fa38b989cd479477860235d0591",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/annotations": "^1.7",
+ "php": ">=7.2",
+ "symfony/finder": ">=3.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5.21 || ^9",
+ "squizlabs/php_codesniffer": ">=2.7"
+ },
+ "bin": [
+ "bin/swagger"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Swagger\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Robert Allen",
+ "email": "zircote@gmail.com",
+ "homepage": "http://www.zircote.com"
+ },
+ {
+ "name": "Bob Fanger",
+ "email": "bfanger@gmail.com",
+ "homepage": "http://bfanger.nl"
+ }
+ ],
+ "description": "Swagger-PHP - Generate interactive documentation for your RESTful API using phpdoc annotations",
+ "homepage": "https://github.com/zircote/swagger-php/",
+ "keywords": [
+ "api",
+ "json",
+ "rest",
+ "service discovery"
+ ],
+ "support": {
+ "issues": "https://github.com/zircote/swagger-php/issues",
+ "source": "https://github.com/zircote/swagger-php/tree/2.1.13"
+ },
+ "time": "2023-09-12T22:12:26+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": {
+ "apereo/phpcas": 20,
+ "erusev/parsedown": 20,
+ "infostars/picofeed": 20,
+ "xibosignage/oauth2-xibo-cms": 20,
+ "xibosignage/support": 20
+ },
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.1",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-gd": "*",
+ "ext-gnupg": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mongodb": "*",
+ "ext-openssl": "*",
+ "ext-pdo": "*",
+ "ext-session": "*",
+ "ext-soap": "*",
+ "ext-sockets": "*",
+ "ext-simplexml": "*",
+ "ext-zip": "*"
+ },
+ "platform-dev": {},
+ "platform-overrides": {
+ "php": "8.1",
+ "ext-gd": "1",
+ "ext-dom": "1",
+ "ext-gnupg": "1",
+ "ext-json": "1",
+ "ext-mongodb": "2.0.0",
+ "ext-pdo": "1",
+ "ext-soap": "1",
+ "ext-sockets": "1",
+ "ext-zip": "1"
+ },
+ "plugin-api-version": "2.6.0"
+}
diff --git a/custom/README.md b/custom/README.md
new file mode 100644
index 0000000..565668b
--- /dev/null
+++ b/custom/README.md
@@ -0,0 +1,30 @@
+# Custom Modules
+This folder is provided as a reasonable place to copy/develop custom modules. This folder is auto-loaded based on the
+`Xibo\Custom` namespace.
+
+This folder is also monitored by the Modules Page for `.json` files describing modules available to be installed, the
+structure of such a file is:
+
+``` json
+{
+ "title": "Module Title",
+ "author": "Module Author",
+ "description": "Module Description",
+ "name": "code-name",
+ "class": "Xibo\\Custom\\ClassName"
+}
+```
+
+The module class must `extend Xibo\Widget\ModuleWidget` and implement the installOrUpdate method.
+
+We recommend that modules put their Twig Views in a sub-folder of this one, named as their module name. This should be
+set in `installOrUpdate` like `$module->viewPath = '../custom/{name}';`.
+
+
+## Web Accessible Resources
+All web accessible resources must placed in the `/web/modules` folder and be installed to the library in `installFiles`.
+
+
+# Theme Views
+This location can also be used for theme views - we recommend a sub-folder for each theme. The theme `config.php` file
+should set its `view_path` to `PROJECT_ROOT . '/custom/folder-name`.
\ No newline at end of file
diff --git a/cypress.config.js b/cypress.config.js
new file mode 100644
index 0000000..a01cd77
--- /dev/null
+++ b/cypress.config.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022-2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+const { defineConfig } = require('cypress')
+
+module.exports = defineConfig({
+ viewportWidth: 1366,
+ viewportHeight: 768,
+ numTestsKeptInMemory: 5,
+ defaultCommandTimeout: 10000,
+ requestTimeout: 10000,
+ env: {
+ client_id: "MrGPc7e3IL1hA6w13l7Ru5giygxmNiafGNhFv89d",
+ client_secret: "Pk6DdDgu2HzSoepcMHRabY60lDEvQ9ucTejYvc5dOgNVSNaOJirCUM83oAzlwe0KBiGR2Nhi6ltclyNC1rmcq0CiJZXzE42KfeatQ4j9npr6nMIQAzMal8O8RiYrIoono306CfyvSSJRfVfKExIjj0ZyE4TUrtPezJbKmvkVDzh8aj3kbanDKatirhwpfqfVdfgsqVNjzIM9ZgKHnbrTX7nNULL3BtxxNGgDMuCuvKiJFrLSyIIz1F4SNrHwHz"
+ },
+ e2e: {
+ experimentalSessionAndOrigin: true,
+ // We've imported your old cypress plugins here.
+ // You may want to clean this up later by importing these.
+ setupNodeEvents(on, config) {
+ return require('./cypress/plugins/index.js')(on, config)
+ },
+ baseUrl: 'http://localhost',
+ },
+})
diff --git a/cypress/assets/audioSample.mp3 b/cypress/assets/audioSample.mp3
new file mode 100644
index 0000000..f0dea31
Binary files /dev/null and b/cypress/assets/audioSample.mp3 differ
diff --git a/cypress/assets/export_test_layout.zip b/cypress/assets/export_test_layout.zip
new file mode 100644
index 0000000..1a5e930
Binary files /dev/null and b/cypress/assets/export_test_layout.zip differ
diff --git a/cypress/assets/imageSample.png b/cypress/assets/imageSample.png
new file mode 100644
index 0000000..9699018
Binary files /dev/null and b/cypress/assets/imageSample.png differ
diff --git a/cypress/e2e/Administration/applications.cy.js b/cypress/e2e/Administration/applications.cy.js
new file mode 100644
index 0000000..f3e46b9
--- /dev/null
+++ b/cypress/e2e/Administration/applications.cy.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Applications', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add edit an application', function() {
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/application/*',
+ }).as('putRequest');
+
+ cy.visit('/application/view');
+
+ // Click on the Add Application button
+ cy.contains('Add Application').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Application ' + testRun);
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if application is added in toast message
+ cy.contains('Edit Application');
+
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Application Edited ' + testRun);
+
+ // edit test application
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "application" value
+ expect(responseData.name).to.eq('Cypress Test Application Edited ' + testRun);
+ // Return appKey as a Cypress.Promise to ensure proper scoping
+ return Cypress.Promise.resolve(responseData.key);
+ }).then((appKey) => {
+ if (appKey) {
+ // TODO cannot be deleted via cypress
+ // Delete the application and assert success
+ // cy.deleteApplication(appKey).then((res) => {
+ // expect(res.status).to.equal(200);
+ // });
+ }
+ });
+ });
+});
diff --git a/cypress/e2e/Administration/folder.cy.js b/cypress/e2e/Administration/folder.cy.js
new file mode 100644
index 0000000..602bfd4
--- /dev/null
+++ b/cypress/e2e/Administration/folder.cy.js
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Folders', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('creating a new folder and rename it', () => {
+ cy.visit('/folders/view');
+ cy.contains('Root Folder').rightclick();
+ cy.contains('Create').should('be.visible').click();
+
+ cy.visit('/folders/view');
+ cy.contains('New Folder').should('be.visible').rightclick();
+ cy.contains('Rename').type('Folder123{enter}');
+ });
+
+ it('Moving an image from Root Folder to another folder', () => {
+ cy.intercept({
+ url: '/library?*',
+ query: {media: 'child_folder_media'},
+ }).as('mediaGridLoadAfterSearch');
+
+ // Go to library
+ cy.visit('/library/view');
+
+ cy.get('#media').type('child_folder_media');
+
+ // Wait for the search to complete
+ cy.wait('@mediaGridLoadAfterSearch');
+
+ cy.get('#libraryItems tbody tr').should('have.length', 1);
+ cy.get('#datatable-container').should('contain', 'child_folder_media');
+
+ // Click the dropdown menu and choose a folder to move the image to
+ cy.get('#libraryItems tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#libraryItems tr:first-child .library_button_selectfolder').click({force: true});
+
+ // Expand the folder tree and select ChildFolder
+ cy.get('#container-folder-form-tree>ul>li>i').click();
+ cy.get('#container-folder-form-tree>ul>li:not(.jstree-loading)>i').click();
+ cy.contains('ChildFolder').click();
+
+ // Click the save button
+ cy.get('.save-button').click();
+ });
+
+ it('Sharing', () => {
+ // Create and alias for load user permissions for folders
+ cy.intercept({
+ url: '/user/permissions/Folder/*',
+ query: {name: 'folder_user'},
+ }).as('permissionsFoldersAfterSearch');
+
+ cy.visit('/folders/view');
+
+ cy.contains('ShareFolder').should('be.visible').rightclick();
+ cy.get('ul.jstree-contextmenu >li:nth-child(6) > a').click(); // Click on Share Link
+ cy.get('#name').type('folder_user');
+
+ cy.wait('@permissionsFoldersAfterSearch');
+
+ cy.get('#permissionsTable tbody tr').should('have.length', 1);
+ cy.get('#permissionsTable tbody tr:nth-child(1) td:nth-child(1)').contains('folder_user');
+ cy.get('#permissionsTable tbody tr:nth-child(1) td:nth-child(2)> input').click();
+ cy.get('.save-button').click();
+ });
+
+ it('Set Home Folders for a user', () => {
+ // Create and alias for load users
+ cy.intercept({
+ url: '/user*',
+ query: {userName: 'folder_user'},
+ }).as('userGridLoadAfterSearch');
+
+ cy.visit('/user/view');
+ cy.get('#userName').type('folder_user');
+
+ cy.wait('@userGridLoadAfterSearch');
+ cy.get('#users tbody tr').should('have.length', 1);
+ cy.get('#users tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#users tr:first-child .user_button_set_home').click({force: true});
+ cy.get('#home-folder').should('be.visible');
+ cy.get('.jstree-anchor:contains(\'FolderHome\')').should('be.visible').click();
+ cy.get('.save-button').click();
+
+ // Check
+ cy.visit('/user/view');
+ cy.get('#userName').clear();
+ cy.get('#userName').type('folder_user');
+ cy.wait('@userGridLoadAfterSearch');
+
+ cy.get('#users tbody tr').should('have.length', 1);
+ cy.get('#users tbody tr:nth-child(1) td:nth-child(1)').contains('folder_user');
+ cy.get('#users tbody tr:nth-child(1) td:nth-child(3)').contains('FolderHome');
+ });
+
+ it('Remove an empty folder', () => {
+ cy.visit('/folders/view');
+ // Find the EmptyFolder element and right-click on it
+ cy.get('.jstree-anchor:contains(\'EmptyFolder\')')
+ .rightclick()
+ .should('have.class', 'jstree-hovered'); // Ensure the right-click effect
+
+ // Find the context menu item with "Remove" text and click on it
+ cy.contains('Remove').click();
+
+ // Validate
+ cy.visit('/folders/view');
+ cy.get('.jstree-anchor:contains(\'EmptyFolder\')').should('not.exist');
+ });
+
+ it('cannot remove a folder with content', () => {
+ cy.visit('/folders/view');
+ cy.get('.jstree-anchor:contains(\'FolderWithContent\')')
+ .rightclick();
+
+ // Find the context menu item with "Remove" text and click on it
+ cy.contains('Remove').click();
+
+ // Check folder still exists
+ cy.visit('/folders/view');
+ cy.get('.jstree-anchor:contains(\'FolderWithContent\')').should('exist');
+ });
+
+ it('search a media in a folder', () => {
+ // Go to library
+ cy.visit('/library/view');
+ cy.get('.jstree-anchor:contains(\'Root Folder\')')
+ .should('be.visible') // Ensure the element is visible
+ .parent()
+ .find('.jstree-icon.jstree-ocl')
+ .click();
+
+ cy.get('.jstree-anchor:contains(\'FolderWithImage\')').click();
+ cy.get('#libraryItems tbody tr').should('have.length', 1);
+ cy.get('#libraryItems tbody').contains('media_for_search_in_folder')
+ .should('be.visible');
+ });
+
+ it('Hide Folder tree', () => {
+ // Go to library
+ cy.visit('/library/view');
+ // The Folder tree is open by default on a grid
+ cy.get('#folder-tree-select-folder-button').click();
+ // clicking on the folder icon hides it
+ cy.get('#grid-folder-filter').should('have.css', 'display', 'none');
+ });
+
+ it('Move folders and Merge', () => {
+ // Go to folders
+ cy.visit('/folders/view');
+ cy.get('.jstree-anchor:contains(\'MoveFromFolder\')').rightclick();
+ cy.contains('Move Folder').click();
+ cy.get('#container-folder-form-tree').within(() => {
+ // Find the "MoveToFolder" link and click it
+ cy.contains('MoveToFolder').click();
+ });
+ cy.get('#merge').should('be.visible').check();
+ cy.get('.save-button').click();
+
+ // Validate test34 image exist in MoveToFolder
+ cy.visit('/folders/view');
+ cy.get('.jstree-anchor:contains(\'MoveFromFolder\')').should('not.exist');
+
+ // Validate test34 image exist in MoveToFolder
+ // Go to library
+ cy.visit('/library/view');
+
+ cy.get('.jstree-anchor:contains(\'Root Folder\')')
+ .should('be.visible') // Ensure the element is visible
+ .parent()
+ .find('.jstree-icon.jstree-ocl')
+ .click();
+ cy.get('.jstree-anchor:contains(\'MoveToFolder\')').click();
+ cy.get('#libraryItems tbody').contains('test34');
+ });
+});
diff --git a/cypress/e2e/Administration/modules.cy.js b/cypress/e2e/Administration/modules.cy.js
new file mode 100644
index 0000000..64c11cf
--- /dev/null
+++ b/cypress/e2e/Administration/modules.cy.js
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Modules Page', function () {
+ beforeEach(function () {
+ cy.login();
+ });
+
+ it.skip('should load the modules page and show a complete table of modules', function () {
+
+ cy.visit('/module/view');
+
+ cy.contains('Modules');
+
+ // Click on the first page of the pagination
+ cy.get('.pagination > :nth-child(2) > a').click();
+
+ cy.contains('Showing 1 to');
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Administration/tags.cy.js b/cypress/e2e/Administration/tags.cy.js
new file mode 100644
index 0000000..f0f41fd
--- /dev/null
+++ b/cypress/e2e/Administration/tags.cy.js
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Tags', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a tag', function() {
+ cy.visit('/tag/view');
+
+ // Click on the Add Tag button
+ cy.contains('Add Tag').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Tag ' + testRun + '_1');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if tag is added in toast message
+ cy.contains('Added Cypress Test Tag ' + testRun + '_1');
+ });
+
+ it('searches and edit existing tag', function() {
+ // Create a new tag and then search for it and delete it
+ cy.createTag('Cypress Test Tag ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/tag?*',
+ query: {tag: 'Cypress Test Tag ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/tag/*',
+ }).as('putRequest');
+
+ cy.visit('/tag/view');
+
+ // Filter for the created tag
+ cy.get('#Filter input[name="tag"]')
+ .type('Cypress Test Tag ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#tags tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#tags tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#tags tr:first-child .tag_button_edit').click({force: true});
+
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Tag Edited ' + testRun);
+
+ // edit test tag
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+ const tag = responseData.tag;
+
+ // assertion on the "tag" value
+ expect(tag).to.eq('Cypress Test Tag Edited ' + testRun);
+ });
+ });
+ });
+
+ it('searches and delete existing tag', function() {
+ // Create a new tag and then search for it and delete it
+ cy.createTag('Cypress Test Tag ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/tag?*',
+ query: {tag: 'Cypress Test Tag ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/tag/view');
+
+ // Filter for the created tag
+ cy.get('#Filter input[name="tag"]')
+ .type('Cypress Test Tag ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#tags tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#tags tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#tags tr:first-child .tag_button_delete').click({force: true});
+
+ // Delete test tag
+ cy.get('.bootbox .save-button').click();
+
+ // Check if tag is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Tag');
+ });
+ });
+
+ it('selects multiple tags and delete them', function() {
+ // Create a new tag and then search for it and delete it
+ cy.createTag('Cypress Test Tag ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/tag?*',
+ query: {tag: 'Cypress Test Tag'},
+ }).as('loadGridAfterSearch');
+
+ // Delete all test tags
+ cy.visit('/tag/view');
+
+ // Clear filter
+ cy.get('.clear-filter-btn').click();
+ cy.get('#Filter input[name="tag"]')
+ .type('Cypress Test Tag');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+
+ // Select all
+ cy.get('button[data-toggle="selectAll"]').click();
+
+ // Delete all
+ cy.get('.dataTables_info button[data-toggle="dropdown"]').click();
+ cy.get('.dataTables_info a[data-button-id="tag_button_delete"]').click();
+
+ cy.get('button.save-button').click();
+
+ // Modal should contain one successful delete at least
+ cy.get('.modal-body').contains(': Success');
+ });
+ });
+});
diff --git a/cypress/e2e/Administration/tasks.cy.js b/cypress/e2e/Administration/tasks.cy.js
new file mode 100644
index 0000000..508cb9d
--- /dev/null
+++ b/cypress/e2e/Administration/tasks.cy.js
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Tasks', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should edit a task', function() {
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/task/*',
+ }).as('putRequest');
+
+ cy.visit('/task/view');
+
+ // Click on the first row element to open the delete modal
+ cy.get('#tasks tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#tasks tr:first-child .task_button_edit').click({force: true});
+
+ // Assuming you have an input field with the id 'myInputField'
+ cy.get('.modal input#name').invoke('val').then((value) => {
+ return Cypress.Promise.resolve(value);
+ }).then((value) => {
+ if (value) {
+ cy.get('.modal input#name').clear()
+ .type(value + ' Edited');
+
+ // edit test tag
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "task" value
+ expect(responseData.name).to.eq(value + ' Edited');
+ });
+ }
+ });
+ });
+});
diff --git a/cypress/e2e/Administration/transitions.cy.js b/cypress/e2e/Administration/transitions.cy.js
new file mode 100644
index 0000000..27cb515
--- /dev/null
+++ b/cypress/e2e/Administration/transitions.cy.js
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Transitions', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should edit an transition', function() {
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/transition/*',
+ }).as('putRequest');
+
+ cy.visit('/transition/view');
+ cy.get('#transitions tbody tr').should('have.length', 3);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#transitions tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#transitions tr:first-child .transition_button_edit').click({force: true});
+
+ cy.get('.modal #availableAsIn').then(($checkbox) => {
+ const isChecked = $checkbox.prop('checked');
+ cy.get('#availableAsIn').should('be.visible').click(); // Click to check/uncheck
+
+ // edit
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "task" value
+ if (isChecked) {
+ expect(responseData.availableAsIn).to.eq(0);
+ } else {
+ expect(responseData.availableAsIn).to.eq(1);
+ }
+ });
+ });
+ });
+});
diff --git a/cypress/e2e/Administration/usergroups.cy.js b/cypress/e2e/Administration/usergroups.cy.js
new file mode 100644
index 0000000..4f9464f
--- /dev/null
+++ b/cypress/e2e/Administration/usergroups.cy.js
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Usergroups', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a usergroup', function() {
+ cy.visit('/group/view');
+
+ // Click on the Add Usergroup button
+ cy.contains('Add User Group').click();
+
+ cy.get('.modal input#group')
+ .type('Cypress Test Usergroup ' + testRun + '_1');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if usergroup is added in toast message
+ cy.contains('Added Cypress Test Usergroup');
+ });
+
+ it('searches and edit existing usergroup', function() {
+ // Create a new usergroup and then search for it and delete it
+ cy.createUsergroup('Cypress Test Usergroup ' + testRun).then((groupId) => {
+ cy.intercept({
+ url: '/group?*',
+ query: {userGroup: 'Cypress Test Usergroup ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/group/*',
+ }).as('putRequest');
+
+ cy.visit('/group/view');
+
+ // Filter for the created usergroup
+ cy.get('#Filter input[name="userGroup"]')
+ .type('Cypress Test Usergroup ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#userGroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#userGroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#userGroups tr:first-child .usergroup_button_edit').click({force: true});
+
+ cy.get('.modal input#group').clear()
+ .type('Cypress Test Usergroup Edited ' + testRun);
+
+ // edit test usergroup
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "usergroup" value
+ expect(responseData.group).to.eq('Cypress Test Usergroup Edited ' + testRun);
+
+ // Delete the usergroup and assert success
+ cy.deleteUsergroup(groupId).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+ });
+ });
+ });
+
+ it('searches and delete existing usergroup', function() {
+ // Create a new usergroup and then search for it and delete it
+ cy.createUsergroup('Cypress Test Usergroup ' + testRun).then((groupId) => {
+ cy.intercept({
+ url: '/group?*',
+ query: {userGroup: 'Cypress Test Usergroup ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/group/view');
+
+ // Filter for the created usergroup
+ cy.get('#Filter input[name="userGroup"]')
+ .type('Cypress Test Usergroup ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#userGroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#userGroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#userGroups tr:first-child .usergroup_button_delete').click({force: true});
+
+ // Delete test usergroup
+ cy.get('.bootbox .save-button').click();
+
+ // Check if usergroup is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Usergroup');
+ });
+ });
+});
diff --git a/cypress/e2e/Administration/users.cy.js b/cypress/e2e/Administration/users.cy.js
new file mode 100644
index 0000000..a482557
--- /dev/null
+++ b/cypress/e2e/Administration/users.cy.js
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Users', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a user', function() {
+ cy.intercept({
+ url: '/user/form/homepages?groupId=1&userTypeId=3*',
+ query: {},
+ }).as('loadHomepageAfterSearch');
+
+ cy.visit('/user/view');
+
+ // Click on the Add User button
+ cy.contains('Add User').click();
+ cy.get('.radio input[value="manual"]').click();
+
+ cy.get('#onboarding-steper-next-button').click();
+
+ cy.get('.modal input#userName')
+ .type('CypressTestUser' + testRun);
+
+ cy.get('.modal input#password')
+ .type('cypress');
+
+ // Error checking - for incorrect email format
+ cy.get('.modal input#email').type('cypress');
+
+ cy.get('.select2-container--bootstrap').eq(1).click();
+ cy.log('Before waiting for Icon Dashboard element');
+ cy.wait('@loadHomepageAfterSearch');
+ cy.get('.select2-results__option')
+ .should('contain', 'Icon Dashboard')
+ .click();
+
+ // Try saving
+ cy.get('.modal .save-button').click();
+
+ cy.contains('Please enter a valid email address.');
+ cy.get('.modal input#email').clear().type('cypress@test.com');
+
+ // Save
+ cy.get('.modal .save-button').click();
+
+ // Check if user is added in toast message
+ cy.contains('Added CypressTestUser');
+ });
+
+ it('searches and edit existing user', function() {
+ // Create a new user and then search for it and delete it
+ cy.createUser('CypressTestUser' + testRun, 'password', 3, 1).then((id) => {
+ cy.intercept({
+ url: '/user?*',
+ query: {userName: 'CypressTestUser' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/user/*',
+ }).as('putRequest');
+
+ cy.visit('/user/view');
+
+ // Filter for the created user
+ cy.get('#Filter input[name="userName"]')
+ .type('CypressTestUser' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#users tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#users tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#users tr:first-child .user_button_edit').click({force: true});
+
+ cy.get('.modal input#userName').clear()
+ .type('CypressTestUserEdited' + testRun);
+
+ cy.get('.modal input#newPassword').clear().type('newPassword');
+ cy.get('.modal input#retypeNewPassword').clear().type('wrongPassword');
+
+ // edit test user
+ cy.get('.bootbox .save-button').click();
+ cy.wait('@putRequest')
+
+ // Error checking - for password mismatch
+ cy.contains('Passwords do not match');
+ cy.get('.modal input#retypeNewPassword').clear().type('newPassword');
+
+ // edit test user
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "user" value
+ expect(responseData.userName).to.eq('CypressTestUserEdited' + testRun);
+ });
+
+ // Delete the user and assert success
+ cy.deleteUser(id).then((res) => {
+ expect(res.status).to.equal(200);
+ });
+ });
+ });
+
+ it('searches and delete existing user', function() {
+ // Create a new user and then search for it and delete it
+ cy.createUser('CypressTestUser' + testRun, 'password', 3, 1).then((id) => {
+ cy.intercept({
+ url: '/user?*',
+ query: {userName: 'CypressTestUser' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/user/view');
+
+ // Filter for the created user
+ cy.get('#Filter input[name="userName"]')
+ .type('CypressTestUser' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#users tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#users tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#users tr:first-child .user_button_delete').click({force: true});
+
+ // Delete test User
+ cy.get('.bootbox .save-button').click();
+
+ // Check if User is deleted in toast message
+ cy.get('.toast').contains('Deleted CypressTestUser');
+ });
+ });
+});
diff --git a/cypress/e2e/Campaign/campaigns.cy.js b/cypress/e2e/Campaign/campaigns.cy.js
new file mode 100644
index 0000000..b158ef8
--- /dev/null
+++ b/cypress/e2e/Campaign/campaigns.cy.js
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Campaigns', function() {
+ const testRun = Cypress._.random(0, 1e9);
+
+ beforeEach(function() {
+ cy.login();
+ });
+
+ // Create a list campaign
+ // Assign layout to it
+ // and add the id to the session
+ it('should add a campaign and assign a layout', function() {
+ cy.intercept('/campaign?draw=4&*').as('campaignGridLoad');
+
+ cy.intercept({
+ url: '/campaign?*',
+ query: {name: 'Cypress Test Campaign ' + testRun},
+ }).as('campaignGridLoadAfterSearch');
+
+ cy.intercept({
+ url: '/layout?*',
+ query: {layout: 'List Campaign Layout'},
+ }).as('layoutLoadAfterSearch');
+
+ // Intercept the POST request to get the campaign Id
+ cy.intercept('/campaign').as('postCampaign');
+ cy.intercept('/campaign/form/add?*').as('campaignFormAdd');
+
+ cy.visit('/campaign/view');
+
+ // Click on the Add Campaign button
+ cy.contains('Add Campaign').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Campaign ' + testRun);
+
+ cy.get('.modal .save-button').click();
+
+ // Wait for the edit form to pop open
+ cy.contains('.modal .modal-title', testRun);
+
+ // Wait for the intercepted POST request to complete and the response to be received
+ cy.wait('@postCampaign').then((interception) => {
+ // Access the response body and extract the ID
+ const id = interception.response.body.id;
+ // Save the ID to the Cypress.env object
+ Cypress.env('sessionCampaignId', id);
+ });
+
+ // Switch to the layouts tab.
+ cy.contains('.modal .nav-tabs .nav-link', 'Layouts').click();
+
+ // Should have no layouts assigned
+ cy.get('.modal #LayoutAssignSortable').children()
+ .should('have.length', 0);
+
+ // Search for 2 layouts names 'List Campaign Layout 1' and 'List Campaign Layout 2'
+ cy.get('.form-inline input[name="layout"]')
+ .type('List Campaign Layout').blur();
+
+ // Wait for the intercepted request and check the URL for the desired query parameter value
+ cy.wait('@layoutLoadAfterSearch').then((interception) => {
+ // Perform your desired actions or assertions here
+ cy.log('Layout Loading');
+
+ cy.get('#layoutAssignments tbody tr').should('have.length', 2);
+
+ // Assign a layout
+ cy.get('#layoutAssignments tr:nth-child(1) a.assignItem').click();
+ cy.get('#layoutAssignments tr:nth-child(2) a.assignItem').click();
+
+ // Save
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for 4th campaign grid reload
+ cy.wait('@campaignGridLoad');
+
+ // Filter for the created campaign
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Campaign ' + testRun);
+
+ cy.wait('@campaignGridLoadAfterSearch');
+
+ // Should have 2 layouts assigned
+ cy.get('#campaigns tbody tr').should('have.length', 1);
+ cy.get('#campaigns tbody tr:nth-child(1) td:nth-child(5)').contains('2');
+ });
+ });
+
+ it('should schedule a campaign and should set display status to green', function() {
+ // At this point we know the campaignId
+ const displayName = 'List Campaign Display 1';
+ const sessionCampaignId = Cypress.env('sessionCampaignId');
+
+ // Schedule the campaign
+ cy.scheduleCampaign(sessionCampaignId, displayName).then((res) => {
+ cy.displaySetStatus(displayName, 1);
+
+ // Go to display grid
+ cy.intercept('/display?draw=3&*').as('displayGridLoad');
+
+ cy.visit('/display/view');
+
+ // Filter for the created campaign
+ cy.get('.FilterDiv input[name="display"]')
+ .type(displayName);
+
+ // Should have the display
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Check the display status is green
+ cy.get('#displays tbody tr:nth-child(1)').should('have.class', 'table-success'); // For class "table-success"
+ cy.get('#displays tbody tr:nth-child(1)').should('have.class', 'odd'); // For class "odd"
+ });
+ });
+
+ it('delete a campaign and check if the display status is pending', function() {
+ cy.intercept('/campaign?draw=2&*').as('campaignGridLoad');
+ cy.intercept('DELETE', '/campaign/*', (req) => {
+ }).as('deleteCampaign');
+ cy.visit('/campaign/view');
+
+ // Filter for the created campaign
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Campaign ' + testRun);
+
+ // Wait for 2nd campaign grid reload
+ cy.wait('@campaignGridLoad');
+
+ cy.get('#campaigns tbody tr').should('have.length', 1);
+
+ cy.get('#campaigns tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#campaigns tr:first-child .campaign_button_delete').click({force: true});
+
+ // Delete the campaign
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted DELETE request to complete with status 200
+ cy.wait('@deleteCampaign').its('response.statusCode').should('eq', 200);
+
+ // check the display status
+ cy.displayStatusEquals('List Campaign Display 1', 3).then((res) => {
+ expect(res.body).to.be.true;
+ });
+ });
+});
diff --git a/cypress/e2e/Display/displaygroups.cy.js b/cypress/e2e/Display/displaygroups.cy.js
new file mode 100644
index 0000000..c86545a
--- /dev/null
+++ b/cypress/e2e/Display/displaygroups.cy.js
@@ -0,0 +1,318 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Display Groups', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add one empty and one filled display groups', function() {
+ cy.visit('/displaygroup/view');
+
+ // Click on the Add Displaygroup button
+ cy.contains('Add Display Group').click();
+
+ cy.get('.modal input#displayGroup')
+ .type('Cypress Test Displaygroup ' + testRun + '_1');
+
+ // Add first by clicking next
+ cy.get('.modal').contains('Next').click();
+
+ // Check if displaygroup is added in toast message
+ cy.contains('Added Cypress Test Displaygroup ' + testRun + '_1');
+
+ cy.get('.modal input#displayGroup')
+ .type('Cypress Test Displaygroup ' + testRun + '_2');
+
+ cy.get('.modal input#description')
+ .type('Description');
+
+ cy.get('.modal input#isDynamic').check();
+
+ cy.get('.modal input#dynamicCriteria')
+ .type('testLayoutId');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if displaygroup is added in toast message
+ cy.contains('Added Cypress Test Displaygroup ' + testRun + '_2');
+ });
+
+ it('copy an existing displaygroup', function() {
+ // Create a new displaygroup and then search for it and delete it
+ cy.createDisplaygroup('Cypress Test Displaygroup ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: 'Cypress Test Displaygroup ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the POST request
+ cy.intercept({
+ method: 'POST',
+ url: /\/displaygroup\/\d+\/copy$/,
+ }).as('postRequest');
+
+ cy.visit('/displaygroup/view');
+
+ // Filter for the created displaygroup
+ cy.get('#Filter input[name="displayGroup"]')
+ .type('Cypress Test Displaygroup ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displaygroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displaygroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displaygroups tr:first-child .displaygroup_button_copy').click({force: true});
+
+ // Delete test displaygroup
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted POST request and check the form data
+ cy.wait('@postRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+ expect(responseData.displayGroup).to.include('Cypress Test Displaygroup ' + testRun + ' 2');
+ });
+ });
+ });
+
+ it('searches and delete existing displaygroup', function() {
+ // Create a new displaygroup and then search for it and delete it
+ cy.createDisplaygroup('Cypress Test Displaygroup ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: 'Cypress Test Displaygroup ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/displaygroup/view');
+
+ // Filter for the created displaygroup
+ cy.get('#Filter input[name="displayGroup"]')
+ .type('Cypress Test Displaygroup ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displaygroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displaygroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displaygroups tr:first-child .displaygroup_button_delete').click({force: true});
+
+ // Delete test displaygroup
+ cy.get('.bootbox .save-button').click();
+
+ // Check if displaygroup is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Displaygroup');
+ });
+ });
+
+ // Seeded displays: dispgrp_disp1, dispgrp_disp2
+ it('manage membership for a displaygroup', function() {
+ cy.createDisplaygroup('Cypress Test Displaygroup ' + testRun).then((res) => {
+ // assign displays to display group
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: 'Cypress Test Displaygroup ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'POST',
+ url: /\/displaygroup\/\d+\/display\/assign$/,
+ }).as('postRequest');
+
+ cy.intercept({
+ url: '/display*',
+ query: {display: 'dispgrp_disp1'},
+ }).as('loadDisplayAfterSearch');
+
+ cy.visit('/displaygroup/view');
+
+ // Filter for the created displaygroup
+ cy.get('#Filter input[name="displayGroup"]')
+ .type('Cypress Test Displaygroup ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displaygroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displaygroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displaygroups tr:first-child .displaygroup_button_group_members').click({force: true});
+
+ cy.get('.modal #display').type('dispgrp_disp1');
+
+ cy.wait('@loadDisplayAfterSearch');
+ cy.get('#displaysMembersTable').within(() => {
+ // count the rows within table
+ cy.get('tbody').find('tr').should('have.length', 1);
+ cy.get('tbody tr:first-child input[type="checkbox"]').check();
+ });
+
+ // Save assignments
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted POST request and check the form data
+ cy.wait('@postRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const body = response.body;
+ expect(body.success).to.eq(true);
+ });
+ });
+ });
+
+ // -------
+ // Seeded displays: dispgrp_disp_dynamic1, dispgrp_disp_dynamic2
+ it('should add a dynamic display group', function() {
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dynamic'},
+ }).as('loadDisplayGridAfterSearch');
+
+ cy.visit('/displaygroup/view');
+
+ // Click on the Add Displaygroup button
+ cy.contains('Add Display Group').click();
+
+ cy.get('.modal input#displayGroup')
+ .type('Cypress Test Displaygroup ' + testRun);
+
+ // Add first by clicking next
+ cy.get('.modal #isDynamic').check();
+ // Type "dynamic" into the input field with the name "dynamicCriteria"
+ cy.get('.modal input[name="dynamicCriteria"]').type('dynamic');
+ cy.wait('@loadDisplayGridAfterSearch');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if displaygroup is added in toast message
+ cy.contains('Added Cypress Test Displaygroup ' + testRun);
+ });
+
+ it('should edit the criteria of a dynamic display group', function() {
+ // Create a new displaygroup with dynamic criteria
+ cy.createDisplaygroup('Cypress Test Displaygroup Dynamic ' + testRun, true, 'dynamic').then((res) => {
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: 'Cypress Test Displaygroup Dynamic ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/displaygroup/*',
+ }).as('putRequest');
+
+ cy.visit('/displaygroup/view');
+
+ // Filter for the created displaygroup
+ cy.get('#Filter input[name="displayGroup"]')
+ .type('Cypress Test Displaygroup Dynamic ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displaygroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displaygroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displaygroups tr:first-child .displaygroup_button_edit').click({force: true});
+
+ cy.get('.modal input[name="dynamicCriteria"]').clear().type('dynamic_edited');
+
+ // Delete test displaygroup
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "display" value
+ expect(responseData.dynamicCriteria).to.eq('dynamic_edited');
+ });
+ });
+ });
+
+ // -------
+ // -- Delete Many
+ it('selects multiple display groups and delete them', function() {
+ // Create a new displaygroup and then search for it and delete it
+ cy.createDisplaygroup('Cypress Test Displaygroup ' + testRun).then((res) => {
+ cy.intercept('GET', '/displaygroup?draw=2&*').as('displaygroupGridLoad');
+
+ // Delete all test displaygroups
+ cy.visit('/displaygroup/view');
+
+ // Clear filter
+ cy.get('#Filter input[name="displayGroup"]')
+ .clear()
+ .type('Cypress Test Displaygroup');
+
+ // Wait for the grid reload
+ cy.wait('@displaygroupGridLoad');
+
+ // Select all
+ cy.get('button[data-toggle="selectAll"]').click();
+
+ // Delete all
+ cy.get('.dataTables_info button[data-toggle="dropdown"]').click();
+ cy.get('.dataTables_info a[data-button-id="displaygroup_button_delete"]').click();
+
+ cy.get('input#checkbox-confirmDelete').check();
+ cy.get('button.save-button').click();
+
+ // Modal should contain one successful delete at least
+ cy.get('.modal-body').contains(': Success');
+ });
+ });
+
+ // ---------
+ // Tests - Error handling
+ it('should not add a displaygroup without dynamic criteria', function() {
+ cy.visit('/displaygroup/view');
+
+ // Click on the Add Displaygroup button
+ cy.contains('Add Display Group').click();
+
+ cy.get('.modal input#displayGroup')
+ .type('Cypress Test Displaygroup ' + testRun + '_1');
+
+ cy.get('.modal input#isDynamic').check();
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check toast message
+ cy.contains('Dynamic Display Groups must have at least one Criteria specified.');
+ });
+});
diff --git a/cypress/e2e/Display/displays.cy.js b/cypress/e2e/Display/displays.cy.js
new file mode 100644
index 0000000..da4f4bc
--- /dev/null
+++ b/cypress/e2e/Display/displays.cy.js
@@ -0,0 +1,334 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Displays', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ // Seeded displays: disp1, disp2, disp3, disp4, disp5
+ // Seeded display Groups: disp5_dispgrp
+ // Seeded layouts: disp4_default_layout
+ it('searches and edit existing display', function() {
+ // search for a display disp1 and edit
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dis_disp1'},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/display/*',
+ }).as('putRequest');
+
+ cy.visit('/display/view');
+
+ // Filter for the created display
+ cy.get('#Filter input[name="display"]')
+ .type('dis_disp1');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displays tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displays tr:first-child .display_button_edit').click({force: true});
+
+ cy.get('.modal input#display').clear()
+ .type('dis_disp1 Edited');
+
+ cy.get('.modal input#license').clear()
+ .type('dis_disp1_license');
+
+ cy.get('.modal input#description').clear()
+ .type('description');
+
+ // edit test display
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "display" value
+ expect(responseData.display).to.eq('dis_disp1 Edited');
+ expect(responseData.description).to.eq('description');
+ expect(responseData.license).to.eq('dis_disp1_license');
+ });
+ });
+
+ // Display: disp2
+ it('searches and delete existing display', function() {
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dis_disp2'},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/display/view');
+
+ // Filter for the created display
+ cy.get('#Filter input[name="display"]')
+ .type('dis_disp2');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displays tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displays tr:first-child .display_button_delete').click({force: true});
+
+ // Delete test display
+ cy.get('.bootbox .save-button').click();
+
+ // Check if display is deleted in toast message
+ cy.get('.toast').contains('Deleted dis_disp2');
+ });
+
+ // Display: disp3
+ it('searches and authorise an unauthorised display', function() {
+ // search for a display disp1 and edit
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dis_disp3'},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/display/authorise/*',
+ }).as('putRequest');
+
+ cy.visit('/display/view');
+
+ // Filter for the created display
+ cy.get('#Filter input[name="display"]')
+ .type('dis_disp3');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displays tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displays tr:first-child .display_button_authorise').click({force: true});
+
+ // edit test display
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ // assertion
+ expect(response.body.message).to.eq('Authorised set to 1 for dis_disp3');
+ });
+ });
+
+ // Display: disp4
+ it('set a default layout', function() {
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dis_disp4'},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/display/defaultlayout/*',
+ }).as('putRequest');
+
+ cy.intercept({
+ url: '/layout*',
+ query: {
+ layout: 'disp4_default_layout',
+ },
+ }).as('loadLayoutAfterSearch');
+
+ cy.visit('/display/view');
+
+ // Filter for the created display
+ cy.get('#Filter input[name="display"]')
+ .type('dis_disp4');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displays tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displays tr:first-child .display_button_defaultlayout').click({force: true});
+
+ // Set the default layout
+ cy.get('.modal .select2-container--bootstrap').click();
+ cy.get('.select2-search__field').type('disp4_default_layout');
+
+ cy.wait('@loadLayoutAfterSearch');
+ cy.get('.select2-results__option').contains('disp4_default_layout').click();
+
+ // edit test display
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const body = response.body;
+ expect(body.success).to.eq(true);
+ });
+ });
+
+ // Display: disp5
+ it('manage membership for disp5', function() {
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dis_disp5'},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'POST',
+ url: /\/display\/\d+\/displaygroup\/assign$/,
+ }).as('postRequest');
+
+ cy.intercept({
+ url: '/displaygroup*',
+ query: {
+ displayGroup: 'disp5_dispgrp',
+ },
+ }).as('loadDisplaypGroupAfterSearch');
+
+ cy.visit('/display/view');
+
+ // Filter for the created display
+ cy.get('#Filter input[name="display"]')
+ .type('dis_disp5');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displays tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displays tr:first-child .display_button_group_membership').click({force: true});
+
+ cy.get('.modal #displayGroup').type('disp5_dispgrp');
+
+ cy.wait('@loadDisplaypGroupAfterSearch');
+ cy.get('#displaysGroupsMembersTable').within(() => {
+ // count the rows within table
+ cy.get('tbody').find('tr')
+ .should('have.length', 1)
+ .and('contain', 'disp5_dispgrp');
+ cy.get('tbody tr:first-child input[type="checkbox"]')
+ .should('not.be.checked')
+ .check();
+ });
+
+ // Save assignments
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted POST request and check the form data
+ cy.wait('@postRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const body = response.body;
+ expect(body.success).to.eq(true);
+ });
+ });
+
+ it('should display map and revert back to table', function() {
+ cy.intercept('GET', '/user/pref?preference=displayGrid').as('displayPrefsLoad');
+ cy.intercept('GET', '/display?draw=2*').as('displayLoad');
+ cy.intercept('POST', '/user/pref').as('userPrefPost');
+
+ cy.visit('/display/view');
+
+ cy.wait('@displayPrefsLoad');
+ cy.wait('@displayLoad');
+ cy.wait('@userPrefPost');
+
+ cy.get('#map_button').click();
+
+ cy.get('#display-map.leaflet-container').should('be.visible');
+
+ cy.get('#list_button').click();
+
+ cy.get('#displays_wrapper.dataTables_wrapper').should('be.visible');
+ });
+
+ // ---------
+ // Tests - Error handling
+ it('should not be able to save while editing existing display with incorrect latitude/longitude', function() {
+ // search for a display disp1 and edit
+ cy.intercept({
+ url: '/display?*',
+ query: {display: 'dis_disp1'},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/display/*',
+ }).as('putRequest');
+
+ cy.visit('/display/view');
+
+ // Filter for the created display
+ cy.get('#Filter input[name="display"]')
+ .type('dis_disp1');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displays tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displays tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displays tr:first-child .display_button_edit').click({force: true});
+ cy.contains('Details').click();
+
+ cy.get('.modal input#latitude').type('1234');
+
+ // edit test display
+ cy.get('.bootbox .save-button').click();
+
+ // Check error message
+ cy.contains('The latitude entered is not valid.');
+
+ cy.get('.modal input#latitude').clear();
+ cy.get('.modal input#longitude').type('1234');
+
+ // edit test display
+ cy.get('.bootbox .save-button').click();
+
+ // Check error message
+ cy.contains('The longitude entered is not valid.');
+ });
+});
diff --git a/cypress/e2e/Display/displaysettings.cy.js b/cypress/e2e/Display/displaysettings.cy.js
new file mode 100644
index 0000000..fcee1a2
--- /dev/null
+++ b/cypress/e2e/Display/displaysettings.cy.js
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Display Settings', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should and edit a display setting', function() {
+ // Intercept the POST request
+ cy.intercept({
+ method: 'POST',
+ url: '/displayprofile',
+ }).as('postRequest');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/displayprofile/*',
+ }).as('putRequest');
+
+ cy.visit('/displayprofile/view');
+
+ // Click on the Add Display Setting button
+ cy.contains('Add Profile').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Display Setting ' + testRun);
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@postRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "tag" value
+ expect(responseData.name).to.eq('Cypress Test Display Setting ' + testRun);
+
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Display Setting Edited ' + testRun);
+
+ // Select the option with the value "10 minutes"
+ cy.get('.modal #collectInterval').select('600');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "tag" value
+ expect(responseData.name).to.eq('Cypress Test Display Setting Edited ' + testRun);
+ });
+ });
+ });
+
+ it('searches and edit existing display setting', function() {
+ // Create a new tag and then search for it and delete it
+ cy.createDisplayProfile('Cypress Test Display Setting ' + testRun, 'android').then((id) => {
+ cy.intercept({
+ url: '/displayprofile?*',
+ query: {displayProfile: 'Cypress Test Display Setting ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/displayprofile/*',
+ }).as('putRequest');
+
+ cy.visit('/displayprofile/view');
+
+ // Filter for the created tag
+ cy.get('#Filter input[name="displayProfile"]')
+ .type('Cypress Test Display Setting ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displayProfiles tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displayProfiles tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displayProfiles tr:first-child .displayprofile_button_edit').click({force: true});
+
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Display Setting Edited ' + testRun);
+
+ // edit test tag
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "tag" value
+ expect(responseData.name).to.eq('Cypress Test Display Setting Edited ' + testRun);
+ });
+
+ // Delete the user and assert success
+ cy.deleteDisplayProfile(id).then((res) => {
+ expect(res.status).to.equal(204);
+ });
+ });
+ });
+
+ it('searches and delete existing display setting', function() {
+ // Create a new tag and then search for it and delete it
+ cy.createDisplayProfile('Cypress Test Display Setting ' + testRun, 'android').then((id) => {
+ cy.intercept({
+ url: '/displayprofile?*',
+ query: {displayProfile: 'Cypress Test Display Setting ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/displayprofile/view');
+
+ // Filter for the created tag
+ cy.get('#Filter input[name="displayProfile"]')
+ .type('Cypress Test Display Setting ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#displayProfiles tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#displayProfiles tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#displayProfiles tr:first-child .displayprofile_button_delete').click({force: true});
+
+ // Delete test tag
+ cy.get('.bootbox .save-button').click();
+
+ // Check if tag is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Display Setting');
+ });
+ });
+});
diff --git a/cypress/e2e/Display/syncgroups.cy.js b/cypress/e2e/Display/syncgroups.cy.js
new file mode 100644
index 0000000..980473c
--- /dev/null
+++ b/cypress/e2e/Display/syncgroups.cy.js
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Sync Groups', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add one empty syncgroups', function() {
+ cy.visit('/syncgroup/view');
+
+ // Click on the Add Sync Group button
+ cy.contains('Add Sync Group').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Sync Group ' + testRun);
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if syncgroup is added in toast message
+ cy.contains('Added Cypress Test Sync Group ' + testRun);
+ });
+
+ it('searches and delete existing syncgroup', function() {
+ // Create a new syncgroup and then search for it and delete it
+ cy.createSyncGroup('Cypress Test Sync Group ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/syncgroup?*',
+ query: {name: 'Cypress Test Sync Group ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/syncgroup/view');
+
+ // Filter for the created syncgroup
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Sync Group ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#syncgroups tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#syncgroups tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#syncgroups tr:first-child .syncgroup_button_group_delete').click({force: true});
+
+ // Delete test syncgroup
+ cy.get('.bootbox .save-button').click();
+
+ // Check if syncgroup is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Sync Group');
+ });
+ });
+
+ // ---------
+ // Tests - Error handling
+ it.only('should not add a syncgroup without publisher port', function() {
+ cy.visit('/syncgroup/view');
+
+ // Click on the Add Sync Group button
+ cy.contains('Add Sync Group').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Sync Group ' + testRun);
+
+ cy.get('#syncPublisherPort').clear();
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if syncgroup is added in toast message
+ cy.contains('Sync Publisher Port cannot be empty');
+ });
+});
diff --git a/cypress/e2e/Layout/Editor/layout-action-menu.cy.js b/cypress/e2e/Layout/Editor/layout-action-menu.cy.js
new file mode 100644
index 0000000..9429a0d
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout-action-menu.cy.js
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Editor Toolbar (Back button, Interactive Mode, Layout jump list)', () => {
+ beforeEach(function() {
+ cy.login();
+
+ cy.intercept('GET', '/user/pref?preference=toolbar').as('toolbarPrefsLoad');
+ cy.intercept('GET', '/user/pref?preference=editor').as('editorPrefsLoad');
+
+ cy.visit('/layout/view');
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ cy.wait('@toolbarPrefsLoad');
+ cy.wait('@editorPrefsLoad');
+ });
+
+ it('Back button should be present and navigate correctly', () => {
+ cy.get('#backBtn')
+ .should('have.class', 'btn btn-lg')
+ .and('have.attr', 'href', '/layout/view')
+ .click({force: true});
+ cy.url().should('include', '/layout/view');
+ });
+
+ it('should display Interactive Mode with OFF status initially', () => { // done
+ cy.get('li.interactive-control')
+ .should('have.attr', 'data-status', 'off')
+ .within(() => {
+ cy.contains('.interactive-control-label', 'Interactive Mode');
+ cy.get('.interactive-control-status-off').should('be.visible').and('contain.text', 'OFF');
+ cy.get('.interactive-control-status-on').should('not.be.visible');
+ });
+ });
+
+ it('should toggle Interactive Mode status on click', () => { // done
+ cy.get('li.nav-item.interactive-control[data-status="off"]')
+ .should(($el) => {
+ expect($el).to.be.visible;
+ })
+ .click({force: true});
+ cy.get('.interactive-control-status-off').should('not.be.visible');
+ });
+
+ it.only('should open and close the layout jump list dropdown safely', () => {
+ cy.intercept('GET', '/layout?onlyMyLayouts=*').as('onlyMyLayouts');
+
+ const layoutName = 'Audio-Video-PDF';
+
+ cy.get('#select2-layoutJumpList-container')
+ .should('be.visible');
+
+ // Force click because the element intermittently detaches in CI environment
+ cy.get('#layoutJumpListContainer .select2-selection')
+ .should('be.visible')
+ .click({force: true});
+
+ // Check for status
+ cy.wait('@onlyMyLayouts').then((interception) => {
+ const result = interception.response.body.data[0];
+ cy.log('result:', result.layoutId);
+ });
+
+ // Type into the search input
+ cy.get('.select2-search__field')
+ .should('be.visible')
+ .clear()
+ .type(layoutName, {delay: 100});
+
+ // Click the matching option
+ cy.get('.select2-results__option')
+ .contains(layoutName)
+ .click();
+ });
+
+ it('Options dropdown menu toggles and contains expected items', () => {
+ cy.get('#optionsContainerTop').should('be.visible');
+ cy.get('#optionsContainerTop').click({force: true});
+
+ cy.get('.navbar-submenu-options-container')
+ .should('be.visible')
+ .within(() => {
+ cy.get('#publishLayout').should('be.visible');
+ cy.get('#checkoutLayout').should('have.class', 'd-none');
+ cy.get('#discardLayout').should('be.visible');
+ cy.get('#newLayout').should('be.visible');
+ cy.get('#deleteLayout').should('have.class', 'd-none');
+ cy.get('#saveTemplate').should('have.class', 'd-none');
+ cy.get('#scheduleLayout').should('have.class', 'd-none');
+ cy.get('#clearLayout').should('be.visible');
+ cy.get('#displayTooltips').should('be.checked');
+ cy.get('#deleteConfirmation').should('be.checked');
+ });
+ });
+
+ it('Tooltips and popovers appear on hover', () => {
+ // Tooltip
+ cy.get('.layout-info-name')
+ .should('be.visible')
+ .trigger('mouseover');
+ cy.get('.tooltip').should('be.visible');
+ cy.get('.layout-info-name')
+ .should('be.visible')
+ .trigger('mouseout');
+
+ // Popover
+ cy.get('#layout-info-status')
+ .should('be.visible')
+ .trigger('mouseover');
+ cy.get('.popover').should('be.visible');
+ cy.get('#layout-info-status')
+ .should('be.visible')
+ .trigger('mouseout');
+ });
+});
diff --git a/cypress/e2e/Layout/Editor/layout_editor_background.cy.js b/cypress/e2e/Layout/Editor/layout_editor_background.cy.js
new file mode 100644
index 0000000..f797976
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_background.cy.js
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Editor Background', function() {
+ const SELECTORS = {
+ layoutAddButton: 'button.layout-add-button',
+ layoutViewer: '#layout-viewer',
+ propertiesPanel: '#properties-panel',
+ colorPickerTrigger: '.input-group-prepend',
+ colorPickerSaturation: '.colorpicker-saturation',
+ backgroundColorInput: '#input_backgroundColor',
+ backgroundzIndex: '#input_backgroundzIndex',
+ resolutionDropdown: '#input_resolutionId',
+ select2Selection: '.select2-selection',
+ select2SearchInput: '.select2-container--open input[type="search"]',
+ layoutInfoDimensions: '.layout-info-dimensions span',
+ };
+
+ beforeEach(function() {
+ cy.login();
+ cy.visit('/layout/view');
+ cy.get(SELECTORS.layoutAddButton).click();
+ cy.get(SELECTORS.layoutViewer).should('be.visible'); // Assert that the URL has changed to the layout editor
+ });
+
+ it('should update the background according to the colour set via colour picker', function() {
+ cy.get(SELECTORS.propertiesPanel).should('be.visible'); // Verify properties panel is present
+ cy.get(SELECTORS.colorPickerTrigger).click(); // Open colour picker
+ cy.get(SELECTORS.colorPickerSaturation).click(68, 28); // Select on a specific saturation
+ cy.get(SELECTORS.propertiesPanel).click(30, 60); // Click outside color picker to close
+
+ // Verify the selected color is applied to the background
+ cy.get(SELECTORS.layoutViewer).should('have.css', 'background-color', 'rgb(243, 248, 255)');
+ });
+
+ it('should update the background according to the colour set via hex input', function() {
+ cy.get(SELECTORS.propertiesPanel).should('be.visible');
+ cy.get(SELECTORS.backgroundColorInput).clear().type('#b53939{enter}');
+
+ // Verify the selected color is applied to the background
+ cy.get(SELECTORS.layoutViewer).should('have.css', 'background-color', 'rgb(243, 248, 255)');
+ });
+
+ it('should update the layer according to the input', function() {
+ cy.get(SELECTORS.propertiesPanel).should('be.visible');
+ cy.get(SELECTORS.backgroundzIndex).clear().type('1{enter}');
+
+ // Verify the selected number is applied to the layer
+ cy.get(SELECTORS.backgroundzIndex).should('have.value', '1');
+ });
+
+ // This is failing and a bug reported
+ it.skip('should update the layout resolution', function() {
+ cy.get(SELECTORS.propertiesPanel).should('be.visible');
+ const resName = 'cinema';
+
+ cy.get(SELECTORS.resolutionDropdown).parent().find(SELECTORS.select2Selection).click();
+ cy.get(SELECTORS.select2SearchInput).type(resName);
+ cy.selectOption(resName);
+
+ cy.get(SELECTORS.layoutInfoDimensions)
+ .should('be.visible')
+ .and('contain', '4096x2304');
+ });
+});
+
diff --git a/cypress/e2e/Layout/Editor/layout_editor_empty.cy.js b/cypress/e2e/Layout/Editor/layout_editor_empty.cy.js
new file mode 100644
index 0000000..acce6ac
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_empty.cy.js
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Designer (Empty)', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ context('Unexisting Layout', function() {
+ it('show layout not found if layout does not exist', function() {
+ // Use a huge id to test a layout not found
+ cy.visit({
+ url: '/layout/designer/111111111111',
+ failOnStatusCode: false,
+ });
+
+ // See page not found message
+ cy.contains('Layout not found');
+ });
+ });
+
+ context('Empty layout (published)', function() {
+ const layoutTempName = '';
+
+ beforeEach(function() {
+ // Import a layout and go to the Layout's designer page - we need a Layout in a Published state
+ cy.importLayout('../assets/export_test_layout.zip').as('testLayoutId').then((res) => {
+ cy.goToLayoutAndLoadPrefs(res);
+ });
+ });
+
+ it.skip('goes into draft mode when checked out', function() {
+ // Get the done button from the checkout modal
+ cy.get('[data-test="welcomeModal"] button.btn-bb-checkout').click();
+
+ // Check if campaign is deleted in toast message
+ cy.contains('Checked out ' + layoutTempName);
+ });
+
+ it.skip('should prevent a layout edit action, and show a toast message', function() {
+ // Should contain widget options form
+ cy.get('#properties-panel-form-container').contains('Edit Layout');
+
+ // The save button should not be visible
+ cy.get('#properties-panel-form-container [data-action="save"]').should('not.exist');
+ });
+ });
+
+ context('Empty layout (draft)', function() {
+ beforeEach(function() {
+ // Create random name
+ const uuid = Cypress._.random(0, 1e9);
+
+ // Create a new layout and go to the layout's designer page, then load toolbar prefs
+ cy.createLayout(uuid).as('testLayoutId').then((res) => {
+ cy.goToLayoutAndLoadPrefs(res);
+ });
+ });
+
+ it.skip('should create a new region from within the navigator edit', () => {
+ // Open navigator edit
+ cy.get('.editor-bottom-bar #navigator-edit-btn').click();
+
+ // Click on add region button
+ cy.get('.editor-bottom-bar #add-btn').click();
+
+ // Check if there are 2 regions in the timeline ( there was 1 by default )
+ cy.get('#layout-timeline [data-type="region"]').should('have.length', 2);
+ });
+
+ it.skip('should delete a region using the toolbar bin', () => {
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+
+ // Open navigator edit
+ cy.get('.editor-bottom-bar #navigator-edit-btn').click();
+
+ // Select a region from the navigator
+ cy.get('#layout-navigator-content [data-type="region"]:first-child').click().then(($el) => {
+ const regionId = $el.attr('id');
+
+ // Click trash container
+ cy.get('.editor-bottom-bar #delete-btn').click();
+
+ // Confirm delete on modal
+ cy.get('[data-test="deleteObjectModal"] button.btn-bb-confirm').click();
+
+ // Check toast message
+ cy.get('.toast-success').contains('Deleted');
+
+ // Wait for the layout to reload
+ cy.wait('@reloadLayout');
+
+ // Check that region is not on timeline
+ cy.get('#layout-timeline [data-type="region"]#' + regionId).should('not.exist');
+ });
+ });
+
+ it.skip('creates a new widget by selecting a searched media from the toolbar to layout-navigator region', () => {
+ cy.populateLibraryWithMedia();
+
+ // Create and alias for reload Layout
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+ cy.intercept('GET', '/library/search?*').as('mediaLoad');
+
+ // Open library search tab
+ cy.get('.editor-main-toolbar #btn-menu-0').should('be.visible').click({force: true});
+ cy.get('.editor-main-toolbar #btn-menu-1').should('be.visible').click({force: true});
+
+ cy.wait('@mediaLoad');
+
+ cy.get('.editor-bottom-bar #navigator-edit-btn').click({force: true});
+
+ cy.get('.editor-main-toolbar #media-content-1 .toolbar-card:nth-of-type(2)').find('img').should('be.visible');
+
+ // Get a table row, select it and add to the region
+ cy.get('.editor-main-toolbar #media-content-1 .toolbar-card:nth-of-type(2) .select-button').click({force: true}).then(() => {
+ cy.get('#layout-navigator [data-type="region"]:first-child').click().then(() => {
+ // Wait for the layout to reload
+ cy.wait('@reloadLayout');
+
+ // Check if there is just one widget in the timeline
+ cy.get('#layout-timeline [data-type="region"] [data-type="widget"]').then(($widgets) => {
+ expect($widgets.length).to.eq(1);
+ });
+ });
+ });
+ });
+
+ it.skip('shows the file upload form by adding a uploadable media from the toolbar to layout-navigator region', () => {
+ cy.populateLibraryWithMedia();
+
+ // Open toolbar Widgets tab
+ cy.get('.editor-main-toolbar #btn-menu-1').should('be.visible').click({force: true});
+ cy.get('.editor-main-toolbar #btn-menu-2').should('be.visible').click({force: true});
+
+ cy.get('.editor-bottom-bar #navigator-edit-btn').click();
+
+ cy.get('.editor-main-toolbar #content-2 .toolbar-pane-content .toolbar-card.upload-card').should('be.visible').then(() => {
+ cy.get('.editor-main-toolbar #content-2 .toolbar-pane-content .toolbar-card.upload-card .select-upload').click({force: true});
+ cy.get('#layout-navigator [data-type="region"]:first-child').click({force: true});
+ cy.get('[data-test="uploadFormModal"]').contains('Upload media');
+ });
+ });
+ });
+});
diff --git a/cypress/e2e/Layout/Editor/layout_editor_options.cy.js b/cypress/e2e/Layout/Editor/layout_editor_options.cy.js
new file mode 100644
index 0000000..f09436c
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_options.cy.js
@@ -0,0 +1,348 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Editor Options', function() {
+ beforeEach(function() {
+ cy.login();
+ cy.visit('/layout/view');
+ });
+
+ it.skip('should be able to publish, checkout and discard layout', function() {
+ let layoutName;
+
+ cy.intercept('GET', '/layout?layoutId=*').as('layoutStatus');
+ cy.intercept('PUT', '/layout/discard/*').as('discardLayout');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+
+ // Publish layout
+ cy.openOptionsMenu();
+ cy.get('#publishLayout').click();
+ cy.get('button.btn-bb-Publish').click();
+
+ cy.wait('@layoutStatus').then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ // Check if the publishedStatus is "Published"
+ const layoutData = interception.response.body.data[0];
+ expect(layoutData).to.have.property('publishedStatus', 'Published');
+ });
+
+ // Checkout published layout
+ cy.openOptionsMenu();
+ cy.get('#checkoutLayout').click();
+
+ cy.wait('@layoutStatus').then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ // Check if the publishedStatus is back to "Draft"
+ const layoutData = interception.response.body.data[0];
+ expect(layoutData).to.have.property('publishedStatus', 'Draft');
+ });
+
+ // Capture layout name before discarding draft layout
+ cy.get('.layout-info-name span')
+ .invoke('text')
+ .then((name) => {
+ layoutName = name.trim().replace(/^"|"$/g, ''); // Remove double quotes
+ cy.log(`Layout Name: ${layoutName}`);
+
+ cy.openOptionsMenu();
+ cy.get('#discardLayout').click();
+ cy.get('button.btn-bb-Discard').click();
+
+ // Verify that the layout has been discarded
+ cy.wait('@discardLayout').then((interception) => {
+ expect(interception.response.statusCode).to.equal(200);
+ });
+
+ // Check if the user is redirected to the layouts page
+ cy.url().should('include', '/layout/view');
+
+ // Search for the layout name
+ cy.get('input[name="layout"]').clear().type(`${layoutName}{enter}`);
+
+ // Check status of the layout with matching layout name
+ cy.get('#layouts tbody')
+ .find('tr')
+ .should('contain', layoutName)
+ .should('contain', 'Published');
+ });
+ });
+
+ it.skip('should display an error when publishing an invalid layout', function() {
+ cy.intercept('GET', '/playlist/widget/form/edit/*').as('addElement');
+ cy.intercept('PUT', '/layout/publish/*').as('publishLayout');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+
+ // Open widgets toolbox
+ cy.openToolbarMenu(0, false);
+ cy.get('[data-sub-type="ics-calendar"]').click();
+ cy.get('[data-template-id="daily_light"]').click();
+ cy.get('.viewer-object').click();
+
+ // Wait for element to be loaded on layout
+ cy.wait('@addElement').then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ });
+
+ // Publish layout
+ cy.openOptionsMenu();
+ cy.get('#publishLayout').click();
+ cy.get('button.btn-bb-Publish').click();
+
+ // Verify response
+ cy.wait('@publishLayout').then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ expect(interception.response.body).to.have.property('message', 'There is an error with this Layout: Missing required property Feed URL');
+ });
+
+ // Verify that a toast message is displayed
+ cy.get('.toast-message')
+ .should('be.visible')
+ .and('contain.text', 'There is an error with this Layout');
+ });
+
+ it.skip('should be able to create new layout', function() {
+ cy.intercept('GET', '/layout?layoutId=*').as('newLayout');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+
+ // Capture the layout ID of the initial layout loaded
+ cy.get('#layout-editor')
+ .invoke('attr', 'data-layout-id')
+ .then((initialLayoutId) => {
+ // Create new layout
+ cy.wait(1000);
+ cy.openOptionsMenu();
+ cy.get('#newLayout').click();
+
+ cy.wait('@newLayout').then((interception) => {
+ expect(interception.response.statusCode).to.eq(200); // Check if the request was successful
+
+ // Get the new layout ID
+ cy.get('#layout-editor')
+ .invoke('attr', 'data-layout-id')
+ .then((newLayoutId) => {
+ // Assert that the new layout ID is different from the initial layout ID
+ expect(newLayoutId).to.not.eq(initialLayoutId);
+ });
+ });
+ });
+ });
+
+ it.skip('should be able to unlock layout', function() {
+ let layoutName;
+
+ cy.intercept('GET', '/layout?layoutId=*').as('checkLockStatus');
+ cy.intercept('GET', '/playlist/widget/form/edit/*').as('addElement');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+
+ // Capture layout name to navigate back to it after unlocking
+ cy.get('.layout-info-name span')
+ .invoke('text')
+ .then((name) => {
+ layoutName = name.trim().replace(/^"|"$/g, '');
+ cy.log(`Layout Name: ${layoutName}`);
+
+ // Open global elements toolbox
+ cy.openToolbarMenu(1, false);
+ cy.get('[data-template-id="text"]').click();
+ cy.get('.viewer-object').click();
+
+ // Wait for element to be loaded on layout
+ cy.wait('@addElement').then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ });
+
+ // Check for lock status
+ cy.wait('@checkLockStatus').then((interception) => {
+ const isLocked = interception.response.body.data[0].isLocked;
+ expect(isLocked).to.not.be.empty;
+ cy.log('isLocked:', isLocked);
+ });
+
+ cy.intercept('PUT', '/layout/lock/release/*').as('unlock');
+
+ // Unlock layout
+ cy.wait(1000);
+ cy.openOptionsMenu();
+ cy.get('#unlockLayout').should('be.visible').click();
+ cy.get('button.btn-bb-unlock').click();
+
+ // Wait for the release lock request to complete
+ cy.wait('@unlock').then((interception) => {
+ expect(interception.response.statusCode).to.equal(200);
+ });
+
+ // Check if the user is redirected to the /layout/view page
+ cy.url().should('include', '/layout/view');
+
+ // Search for the layout name
+ cy.get('input[name="layout"]').clear().type(`${layoutName}{enter}`);
+ cy.get('#layouts tbody tr').should('contain.text', layoutName);
+ cy.get('#layouts tbody tr').should('have.length', 1);
+
+ cy.openRowMenu();
+ cy.get('#layout_button_design').click();
+ cy.get('#layout-viewer').should('be.visible');
+
+ // Check for lock status
+ cy.wait('@checkLockStatus').then((interception) => {
+ const isLocked = interception.response.body.data[0].isLocked;
+ expect(isLocked).be.empty;
+ cy.log('isLocked:', isLocked);
+ });
+ });
+ });
+
+ it.skip('should enable tooltips', function() {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ cy.openOptionsMenu();
+
+ // Enable tooltips
+ // Check the current state of the tooltips checkbox
+ cy.get('#displayTooltips').then(($checkbox) => {
+ if (!$checkbox.is(':checked')) {
+ // Check the checkbox if it is currently unchecked
+ cy.wrap($checkbox).click();
+ cy.wait('@updatePreferences');
+
+ // Confirm the checkbox is checked
+ cy.get('#displayTooltips').should('be.checked');
+ }
+ });
+
+ // Verify that tooltips are present
+ cy.get('.navbar-nav .btn-menu-option[data-toggle="tooltip"]').each(($element) => {
+ // Trigger hover to show tooltip
+ cy.wrap($element).trigger('mouseover');
+
+ // Check that the tooltip is visible for each button
+ cy.get('.tooltip').should('be.visible'); // Expect tooltip to be present
+ });
+ });
+
+ it.skip('should disable tooltips', function() {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ cy.openOptionsMenu();
+
+ // Disable tooltips
+ // Check the current state of the tooltips checkbox
+ cy.get('#displayTooltips').then(($checkbox) => {
+ if ($checkbox.is(':checked')) {
+ // Uncheck the checkbox if it is currently checked
+ cy.wrap($checkbox).click();
+ cy.wait('@updatePreferences');
+
+ // Confirm the checkbox is now unchecked
+ cy.get('#displayTooltips').should('not.be.checked');
+ }
+ });
+
+ // Verify that tooltips are gone
+ cy.get('.navbar-nav .btn-menu-option[data-toggle="tooltip"]').each(($element) => {
+ cy.wrap($element).trigger('mouseover'); // Trigger hover to show tooltip
+ cy.get('.tooltip').should('not.exist'); // Check if tooltip is gone for each button on the toolbox
+ });
+ });
+
+ it.skip('should enable delete confirmation', function() {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ cy.openOptionsMenu();
+
+ // Check the current state of the delete confirmation checkbox
+ cy.get('#deleteConfirmation').then(($checkbox) => {
+ if (!$checkbox.is(':checked')) {
+ // Check the checkbox if it is currently unchecked
+ cy.wrap($checkbox).click();
+ cy.wait('@updatePreferences');
+
+ // Confirm the checkbox is checked
+ cy.get('#deleteConfirmation').should('be.checked');
+ }
+ });
+
+ // Add an element then attempt to delete
+ cy.openToolbarMenu(0, false);
+ cy.get('[data-sub-type="clock"]').click();
+ cy.get('[data-sub-type="clock-analogue"]').click();
+ cy.get('.viewer-object').click();
+ cy.get('#delete-btn').click();
+
+ // Verify that delete confirmation modal appears
+ cy.get('.modal-content')
+ .should('be.visible')
+ .and('contain.text', 'Delete Widget');
+ });
+
+ it.skip('should disable delete confirmation', function() {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ cy.openOptionsMenu();
+
+ // Check the current state of the delete confirmation checkbox
+ cy.get('#deleteConfirmation').then(($checkbox) => {
+ if ($checkbox.is(':checked')) {
+ // Uncheck the checkbox if it is currently checked
+ cy.wrap($checkbox).click();
+ cy.wait('@updatePreferences');
+
+ // Confirm the checkbox is now unchecked
+ cy.get('#displayTooltips').should('not.be.checked');
+ }
+ });
+
+ cy.intercept('DELETE', '/region/*').as('deleteElement');
+
+ // Add an element then attempt to delete
+ cy.openToolbarMenu(0, false);
+ cy.get('[data-sub-type="clock"]').click();
+ cy.get('[data-sub-type="clock-analogue"]').click();
+ cy.get('.viewer-object').click();
+ cy.get('#delete-btn').click();
+
+ // Verify that the widget is immediately deleted without confirmation
+ cy.wait('@deleteElement').then((interception) => {
+ expect(interception.response.statusCode).to.equal(200);
+ });
+
+ cy.get('.viewer-object').within(() => {
+ cy.get('[data-type="region"]').should('not.exist');
+ });
+ });
+});
diff --git a/cypress/e2e/Layout/Editor/layout_editor_populated.cy.js b/cypress/e2e/Layout/Editor/layout_editor_populated.cy.js
new file mode 100644
index 0000000..5ebd57c
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_populated.cy.js
@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Designer (Populated)', function() {
+ beforeEach(function() {
+ cy.login();
+
+ // Import existing
+ cy.importLayout('../assets/export_test_layout.zip').as('testLayoutId').then((res) => {
+ cy.checkoutLayout(res);
+
+ cy.goToLayoutAndLoadPrefs(res);
+ });
+ });
+
+ // Open widget form, change the name and duration, save, and see the name change result
+ it.skip('changes and saves widget properties', () => {
+ // Create and alias for reload widget
+ cy.intercept('GET', '/playlist/widget/form/edit/*').as('reloadWidget');
+
+ // Select the first widget from the first region on timeline ( image )
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').click();
+
+ // Type the new name in the input
+ cy.get('#properties-panel input[name="name"]').clear().type('newName');
+
+ // Set a duration
+ cy.get('#properties-panel #useDuration').check();
+ cy.get('#properties-panel input[name="duration"]').clear().type(12);
+
+ // Save form
+ cy.get('#properties-panel button[data-action="save"]').click();
+
+ // Should show a notification for the name change
+ cy.get('.toast-success').contains('newName');
+
+ // Check if the values are the same entered after reload
+ cy.wait('@reloadWidget').then(() => {
+ cy.get('#properties-panel input[name="name"]').should('have.attr', 'value').and('equal', 'newName');
+ cy.get('#properties-panel input[name="duration"]').should('have.attr', 'value').and('equal', '12');
+ });
+ });
+
+ // On layout edit form, change background color and layer, save and check the changes
+ it.skip('changes and saves layout properties', () => {
+ // Create and alias for reload layout
+
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+
+ // Change background color
+ cy.get('#properties-panel input[name="backgroundColor"]').clear().type('#ccc');
+
+ // Change layer
+ cy.get('#properties-panel input[name="backgroundzIndex"]').clear().type(1);
+
+ // Save form
+ cy.get('#properties-panel button[data-action="save"]').click();
+
+ // Should show a notification for the successful save
+ cy.get('.toast-success').contains('Edited');
+
+ // Check if the values are the same entered after reload
+ cy.wait('@reloadLayout').then(() => {
+ cy.get('#properties-panel input[name="backgroundColor"]').should('have.attr', 'value').and('equal', '#cccccc');
+ cy.get('#properties-panel input[name="backgroundzIndex"]').should('have.value', '1');
+ });
+ });
+
+ // On layout edit form, change background image check the changes
+ it.skip('should change layout´s background image', () => {
+ // Create and alias for reload layout
+
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+ cy.intercept('GET', '/library/search?*').as('mediaLoad');
+
+ cy.get('#properties-panel #backgroundRemoveButton').click();
+
+ // Open library search tab
+ cy.get('.editor-main-toolbar #btn-menu-0').click();
+ cy.get('.editor-main-toolbar #btn-menu-1').click();
+
+ cy.wait('@mediaLoad');
+
+ cy.get('.editor-bottom-bar #navigator-edit-btn').click();
+
+ cy.get('.editor-main-toolbar #media-content-1 .toolbar-card:nth-of-type(2)').find('img').should('be.visible');
+
+ // Get a table row, select it and add to the region
+ cy.get('.editor-main-toolbar #media-content-1 .toolbar-card:nth-of-type(2) .select-button').click({force: true}).then(() => {
+ cy.get('#properties-panel-form-container .background-image-drop').click().then(() => {
+ // Save form
+ cy.get('#properties-panel button[data-action="save"]').click();
+
+ // Should show a notification for the successful save
+ cy.get('.toast-success').contains('Edited');
+
+ // Check if the background field has an image
+ cy.get('#properties-panel .background-image-add img#bg_image_image').should('be.visible');
+ });
+ });
+ });
+
+ // Navigator
+ it.skip('should change and save the region´s position', () => {
+ // Create and alias for position save and reload layout
+
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+ cy.intercept('GET', '/region/form/edit/*').as('reloadRegion');
+ cy.intercept('GET', '**/region/preview/*').as('regionPreview');
+
+ // Open navigator edit
+ cy.get('.editor-bottom-bar #navigator-edit-btn').click();
+
+ // Wait for the region to preview
+ cy.wait('@regionPreview');
+
+ cy.get('#layout-navigator [data-type="region"]:first').then(($originalRegion) => {
+ const regionId = $originalRegion.attr('id');
+
+ // Select region
+ cy.get('#layout-navigator-content #' + regionId).click();
+
+ // Move region 50px for each dimension
+ cy.get('#layout-navigator-content #' + regionId).then(($movedRegion) => {
+ const regionOriginalPosition = {
+ top: Math.round($movedRegion.position().top),
+ left: Math.round($movedRegion.position().left),
+ };
+
+ const offsetToAdd = 50;
+
+ // Move the region
+ cy.get('#layout-navigator-content #' + regionId)
+ .trigger('mousedown', {
+ which: 1,
+ })
+ .trigger('mousemove', {
+ which: 1,
+ pageX: $movedRegion.width() / 2 + $movedRegion.offset().left + offsetToAdd,
+ pageY: $movedRegion.height() / 2 + $movedRegion.offset().top + offsetToAdd,
+ })
+ .trigger('mouseup');
+
+ // Close the navigator edit
+ cy.wait('@reloadRegion');
+
+ // Save
+ cy.get('#properties-panel button#save').click();
+
+ // Wait for the layout to reload
+ cy.wait('@reloadLayout');
+
+ // Check if the region´s position are not the original
+ cy.get('#layout-navigator-content #' + regionId).then(($changedRegion) => {
+ expect(Math.round($changedRegion.position().top)).to.not.eq(regionOriginalPosition.top);
+ expect(Math.round($changedRegion.position().left)).to.not.eq(regionOriginalPosition.left);
+ });
+ });
+ });
+ });
+
+ it.skip('should delete a widget using the toolbar bin', () => {
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+ cy.intercept('GET', '/region/preview/*').as('regionPreview');
+
+ // Select a widget from the timeline
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').click().then(($el) => {
+ const widgetId = $el.attr('id');
+
+ // Wait for the widget to be loaded
+ cy.wait('@regionPreview');
+
+ // Click trash container
+ cy.get('.editor-bottom-bar button#delete-btn').click({force: true});
+
+ // Confirm delete on modal
+ cy.get('[data-test="deleteObjectModal"] button.btn-bb-confirm').click();
+
+ // Check toast message
+ cy.get('.toast-success').contains('Deleted');
+
+ // Wait for the layout to reload
+ cy.wait('@reloadLayout');
+
+ // Check that widget is not on timeline
+ cy.get('#layout-timeline [data-type="widget"]#' + widgetId).should('not.exist');
+ });
+ });
+
+ it.skip('saves the widgets order when sorting by dragging', () => {
+ cy.intercept('GET', 'POST', '**/playlist/order/*').as('saveOrder');
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').then(($oldWidget) => {
+ const offsetX = 50;
+
+ // Move to the second widget position ( plus offset )
+ cy.wrap($oldWidget)
+ .trigger('mousedown', {
+ which: 1,
+ })
+ .trigger('mousemove', {
+ which: 1,
+ pageX: $oldWidget.offset().left + $oldWidget.width() * 1.5 + offsetX,
+ })
+ .trigger('mouseup', {force: true});
+
+ cy.wait('@saveOrder');
+
+ // Should show a notification for the order change
+ cy.get('.toast-success').contains('Order Changed');
+
+ // Reload layout and check if the new first widget has a different Id
+ cy.wait('@reloadLayout');
+
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').then(($newWidget) => {
+ expect($oldWidget.attr('id')).not.to.eq($newWidget.attr('id'));
+ });
+ });
+ });
+});
diff --git a/cypress/e2e/Layout/Editor/layout_editor_status_bar.cy.js b/cypress/e2e/Layout/Editor/layout_editor_status_bar.cy.js
new file mode 100644
index 0000000..23a942f
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_status_bar.cy.js
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Editor Status Bar', function() {
+ const layoutStatusSelector = '#layout-info-status';
+ const layoutNameSelector = '.layout-info-name span';
+ const layoutDurationSelector = '.layout-info-duration .layout-info-duration-value';
+ const layoutDimensionsSelector = '.layout-info-dimensions span';
+ const tooltipSelector = '.popover';
+
+ beforeEach(function() {
+ cy.login();
+ cy.visit('/layout/view');
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ });
+
+ it('should display the correct Layout status icon and tooltip', function() {
+ cy.get(layoutStatusSelector)
+ .should('be.visible')
+ .and('have.class', 'badge-danger')
+ .trigger('mouseover');
+
+ cy.get(tooltipSelector)
+ .should('be.visible')
+ .and('contain', 'This Layout is invalid');
+
+ cy.get(layoutStatusSelector).trigger('mouseout');
+ });
+
+ it('should display the correct Layout name', () => {
+ // Verify the Layout name text
+ cy.get(layoutNameSelector)
+ .should('be.visible')
+ .and('contain', 'Untitled');
+ });
+
+ it('should display the correct Layout duration', () => {
+ // Verify the duration is correctly displayed
+ cy.get(layoutDurationSelector)
+ .should('be.visible')
+ .and('contain', '00:00');
+ });
+
+ it('should display the correct Layout dimensions', () => {
+ // Verify the dimensions are correctly displayed
+ cy.get(layoutDimensionsSelector)
+ .should('be.visible')
+ .and('contain', '1920x1080');
+ });
+});
diff --git a/cypress/e2e/Layout/Editor/layout_editor_toolbar.cy.js b/cypress/e2e/Layout/Editor/layout_editor_toolbar.cy.js
new file mode 100644
index 0000000..3b61042
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_toolbar.cy.js
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout Editor Toolbar', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it.skip('should expand and close the toolbox', function() {
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ cy.openToolbarMenu(0);
+ cy.get('.close-content').filter(':visible').click();
+ });
+
+ const setZoomLevel = (level) => {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+
+ cy.openToolbarMenu(0);
+
+ cy.get('.toolbar-level-control-menu').click();
+ cy.get('nav.navbar').then(($toolbar) => {
+ if ($toolbar.hasClass(`toolbar-level-${level}`)) return;
+ cy.get(`i[data-level="${level}"]`).click();
+ cy.wait('@updatePreferences');
+ });
+ cy.get('nav.navbar').should('have.class', `toolbar-level-${level}`);
+ };
+
+ it.skip('should be able to set zoom level to 1', function() {
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+ setZoomLevel(1);
+ });
+
+ it.skip('should be able to set zoom level to 2', function() {
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+ setZoomLevel(2);
+ });
+
+ function searchAndAddElement(tabIndex, keyword, elementSelector, subTypeSelector, paneSelector) {
+ cy.intercept('POST', '/region/*').as('addRegion');
+
+ // Search for the element
+ cy.toolbarSearch(keyword);
+ cy.get(paneSelector + '.active')
+ .find('.toolbar-pane-content')
+ .find('.toolbar-card')
+ .should('have.length.greaterThan', 0)
+ .each(($card) => {
+ cy.wrap($card)
+ .find('.card-title')
+ .should('include.text', keyword);
+ });
+
+ // Add the widget to layout
+ cy.get(elementSelector).click();
+ if (subTypeSelector) {
+ cy.get(subTypeSelector).click();
+ }
+ cy.get('.viewer-object').click();
+ cy.wait('@addRegion').then((interception) => { // todo: error here
+ expect(interception.response.statusCode).to.eq(200);
+ });
+ }
+
+ it.skip('should navigate to Widgets tab, search and add a widget', function() {
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ // Open the respective toolbar tab
+ cy.openToolbarMenu(0);
+
+ searchAndAddElement(0, 'Clock', '[data-sub-type="clock"]', '[data-sub-type="clock-analogue"]', '.toolbar-widgets-pane');
+ });
+
+ it.skip('should navigate to Global Elements tab, search and add an element', function() {
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ searchAndAddElement(1, 'Text', '[data-template-id="text"]', '', '.toolbar-global-pane');
+ });
+
+ function testLibrarySearchAndAddMedia(mediaType, tabIndex, keyword, folderName, mediaTitle) {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+ cy.intercept('GET', '/folders?start=0&length=10').as('loadFolders');
+ cy.intercept('GET', '/library/search*').as('librarySearch');
+ cy.intercept('POST', '/region/*').as('addRegion');
+
+ cy.openToolbarMenu(tabIndex);
+
+ // Conditionally filter media by Folder if folderName is provided
+ if (folderName) {
+ cy.get(`.toolbar-pane.toolbar-${mediaType}-pane.active`)
+ .find('#input-folder')
+ .parent()
+ .find('.select2-selection')
+ .click();
+ cy.wait('@loadFolders');
+ cy.get('.select2-container--open')
+ .contains(folderName)
+ .click();
+ cy.wait('@updatePreferences');
+ cy.wait('@librarySearch');
+ }
+
+ // Search for a media
+ cy.toolbarSearchWithActiveFilter(keyword);
+ cy.get(`.toolbar-pane.toolbar-${mediaType}-pane.active`)
+ .find('.toolbar-pane-content')
+ .find('.toolbar-card[data-type="media"]')
+ .each(($card) => {
+ cy.wrap($card)
+ .find('span.media-title')
+ .should('include.text', keyword);
+ });
+
+ cy.wait('@librarySearch');
+ cy.wait('@updatePreferences');
+
+ // Add media to layout
+ cy.get(`.toolbar-pane.toolbar-${mediaType}-pane.active`)
+ .find(`[data-card-title="${mediaTitle}"]`)
+ .should('exist')
+ .click();
+ cy.get('.viewer-object').click();
+ cy.wait('@addRegion');
+ }
+
+ // Test cases
+ it.skip('should navigate to Library Image Search tab, filter, search and add media', function() {
+ testLibrarySearchAndAddMedia('image', 2, 'media_for_search', 'FolderWithImage', 'media_for_search_in_folder');
+ });
+
+ it.skip('should navigate to Library Audio Search tab, filter, search and add media', function() {
+ testLibrarySearchAndAddMedia('audio', 3, 'test-audio', null, 'test-audio.mp3');
+ });
+
+ it.skip('should navigate to Library Video Search tab, filter, search and add media', function() {
+ testLibrarySearchAndAddMedia('video', 4, 'test-video', null, 'test-video.mp4');
+ });
+
+ it.skip('should navigate to Interactive Actions tab and search for actions', function() {
+ const keyword = 'Next';
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ cy.openToolbarMenu(7, false);
+ cy.toolbarSearch(keyword);
+ cy.get('.toolbar-pane.toolbar-actions-pane.active')
+ .find('.toolbar-pane-content .toolbar-card')
+ .each(($card) => {
+ cy.wrap($card).find('.card-title').should('include.text', keyword);
+ });
+ });
+});
+
diff --git a/cypress/e2e/Layout/Editor/layout_editor_unchanged.cy.js b/cypress/e2e/Layout/Editor/layout_editor_unchanged.cy.js
new file mode 100644
index 0000000..6667878
--- /dev/null
+++ b/cypress/e2e/Layout/Editor/layout_editor_unchanged.cy.js
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Layout Designer (Populated/Unchanged)', function() {
+ before(function() {
+ // Import existing
+ // cy.importLayout('../assets/export_test_layout.zip').as('testLayoutId').then((res) => {
+ // cy.checkoutLayout(res);
+ // });
+ });
+
+ beforeEach(function() {
+ cy.login();
+ // cy.goToLayoutAndLoadPrefs(this.testLayoutId);
+ });
+
+ it.skip('should load all the layout designer elements', function() {
+ // Check if the basic elements of the designer loaded
+ cy.get('#layout-editor').should('be.visible');
+ cy.get('.timeline-panel').should('be.visible');
+ cy.get('#layout-viewer-container').should('be.visible');
+ cy.get('#properties-panel').should('be.visible');
+ });
+
+ it.skip('shows widget properties in the properties panel when clicking on a widget in the timeline', function() {
+ // Select the first widget from the first region on timeline ( image )
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').click();
+
+ // Check if the properties panel title is Edit Image
+ cy.get('#properties-panel').contains('Edit Image');
+ });
+
+ it.skip('should open the playlist editor and be able to show modals', function() {
+ cy.intercept('GET', '/playlist/widget/form/edit/*').as('reloadWidget');
+
+ // Open the playlist editor
+ cy.get('#layout-timeline .designer-region-info:first .open-playlist-editor').click();
+
+ // Wait for the widget to load
+ cy.wait('@reloadWidget');
+
+ // Right click on the first widget in the playlist editor
+ cy.get('.editor-modal #timeline-container .playlist-widget:first').rightclick();
+
+ // Open the delete modal for the first widget
+ cy.get('.context-menu-overlay .context-menu-widget .deleteBtn').should('be.visible').click();
+
+ // Modal should be visible
+ cy.get('[data-test="deleteObjectModal"]').should('be.visible');
+ });
+
+ it.skip('should revert a saved form to a previous state', () => {
+ let oldName;
+
+ // Create and alias for reload widget
+
+ cy.intercept('GET', '/playlist/widget/form/edit/*').as('reloadWidget');
+ cy.intercept('PUT', '/playlist/widget/*').as('saveWidget');
+
+ // Select the first widget on timeline ( image )
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').click();
+
+ // Wait for the widget to load
+ cy.wait('@reloadWidget');
+
+ // Get the input field
+ cy.get('#properties-panel input[name="name"]').then(($input) => {
+ // Save old name
+ oldName = $input.val();
+
+ // Type the new name in the input
+ cy.get('#properties-panel input[name="name"]').clear().type('newName');
+
+ // Save form
+ cy.get('#properties-panel button[data-action="save"]').click();
+
+ // Should show a notification for the name change
+ cy.get('.toast-success');
+
+ // Wait for the widget to save
+ cy.wait('@reloadWidget');
+
+ // Click the revert button
+ cy.get('.editor-bottom-bar #undo-btn').click();
+
+ // Wait for the widget to save
+ cy.wait('@saveWidget');
+
+ // Test if the revert made the name go back to the old name
+ cy.get('#properties-panel input[name="name"]').should('have.attr', 'value').and('equal', oldName);
+ });
+ });
+
+ it.skip('should revert the widgets order when using the undo feature', () => {
+ cy.intercept('POST', '**/playlist/order/*').as('saveOrder');
+ cy.intercept('GET', '/layout?layoutId=*').as('reloadLayout');
+
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').then(($oldWidget) => {
+ const offsetX = 50;
+
+ // Move to the second widget position ( plus offset )
+ cy.wrap($oldWidget)
+ .trigger('mousedown', {
+ which: 1,
+ })
+ .trigger('mousemove', {
+ which: 1,
+ pageX: $oldWidget.offset().left + $oldWidget.width() * 1.5 + offsetX,
+ })
+ .trigger('mouseup', {force: true});
+
+ cy.wait('@saveOrder');
+
+ // Should show a notification for the order change
+ cy.get('.toast-success').contains('Order Changed');
+
+ // Reload layout and check if the new first widget has a different Id
+ cy.wait('@reloadLayout');
+
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').then(($newWidget) => {
+ expect($oldWidget.attr('id')).not.to.eq($newWidget.attr('id'));
+ });
+
+ // Click the revert button
+ cy.get('.editor-bottom-bar #undo-btn').click();
+
+ // Wait for the order to save
+ cy.wait('@saveOrder');
+ cy.wait('@reloadLayout');
+
+ // Test if the revert made the name go back to the first widget
+ cy.get('#layout-timeline .designer-region:first [data-type="widget"]:first-child').then(($newWidget) => {
+ expect($oldWidget.attr('id')).to.eq($newWidget.attr('id'));
+ });
+ });
+ });
+
+ it.skip('should play a preview in the viewer', () => {
+ cy.intercept('GET', '**/region/preview/*').as('loadRegion');
+ // Wait for the viewer and region to load
+ cy.get('#layout-viewer-container .viewer-object.layout-player').should('be.visible');
+ cy.wait('@loadRegion');
+
+ // Click play
+ cy.get('.editor-bottom-bar #play-btn').click();
+
+ // Check if the fullscreen iframe has loaded
+ cy.get('#layout-viewer-container #layout-viewer .viewer-object > iframe').should('be.visible');
+ });
+});
diff --git a/cypress/e2e/Layout/IA/toggle_mode_on_off.cy.js b/cypress/e2e/Layout/IA/toggle_mode_on_off.cy.js
new file mode 100644
index 0000000..a41cc43
--- /dev/null
+++ b/cypress/e2e/Layout/IA/toggle_mode_on_off.cy.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+
+describe('Test IA: Toggle Mode ON/OFF', () => {
+
+ beforeEach(() => {
+ cy.login();
+
+ // Navigate to Layouts page
+ cy.visit('/layout/view');
+
+ //Click the Add Layout button
+ cy.get('button.layout-add-button').click();
+ cy.get('#layout-viewer').should('be.visible');
+ });
+
+ it('should verify default status = OFF and checks the status of IA Mode when toggled to ON or OFF', () => {
+
+ //check default IA Mode = OFF
+ cy.get('li.nav-item.interactive-control')
+ .should('have.attr', 'data-status', 'off')
+ .then(($el) => {
+ cy.wrap($el).click({ force: true })
+ })
+
+ //Toggle Mode = ON
+ cy.get('li.nav-item.interactive-control')
+ .should('have.attr', 'data-status', 'on')
+ .and('contain.text', 'ON')
+
+ //Toggle OFF back to Layout Editor
+ cy.get('li.nav-item.interactive-control').click({ force: true })
+ cy.get('li.nav-item.interactive-control')
+ .should('have.attr', 'data-status', 'off')
+ .and('contain.text', 'OFF')
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Layout/Widget/layout_editor_clock.cy.js b/cypress/e2e/Layout/Widget/layout_editor_clock.cy.js
new file mode 100644
index 0000000..fd9f36e
--- /dev/null
+++ b/cypress/e2e/Layout/Widget/layout_editor_clock.cy.js
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Clock Analogue Widget', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should create a new layout and be redirected to the layout designer, add/delete analogue clock', function() {
+ cy.intercept('/playlist/widget/*').as('saveWidget');
+ cy.intercept('DELETE', '**/region/**').as('deleteWidget');
+ cy.intercept('POST', '/user/pref').as('userPref');
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ // Open widget menu
+ cy.openToolbarMenu(0);
+
+ cy.get('[data-sub-type="clock"]')
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('[data-sub-type="clock-analogue"] > .toolbar-card-thumb')
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('.viewer-object.layout.ui-droppable-active')
+ .should('be.visible')
+ .click();
+
+ // Check if the widget is in the viewer
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_clock-analogue"]').should('exist');
+
+ cy.get('[name="themeId"]').select('Dark', {force: true});
+ cy.get('[name="offset"]').clear().type('1').trigger('change');
+ cy.wait('@saveWidget');
+
+ cy.get('.widget-form .nav-link[href="#advancedTab"]').click();
+
+ // Type the new name in the input
+ cy.get('#advancedTab input[name="name"]').clear().type('newName');
+ cy.wait('@saveWidget');
+
+ // Set a duration
+ cy.get('#advancedTab input[name="useDuration"]').check();
+ cy.wait('@saveWidget');
+ cy.get('#advancedTab input[name="duration"]').clear().type('12').trigger('change');
+ cy.wait('@saveWidget');
+
+ // Change the background of the layout
+ cy.get('.viewer-object').click({force: true});
+ cy.get('[name="backgroundColor"]').clear().type('#ffffff').trigger('change');
+
+ // Validate background color changed wo white
+ cy.get('.viewer-object').should('have.css', 'background-color', 'rgb(255, 255, 255)');
+
+ // Check if the name and duration values are the same entered
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_clock-analogue"]').parents('.designer-region').click();
+ cy.get('.widget-form .nav-link[href="#advancedTab"]').click();
+ cy.get('#advancedTab input[name="name"]').should('have.attr', 'value').and('equal', 'newName');
+ cy.get('#advancedTab input[name="duration"]').should('have.attr', 'value').and('equal', '12');
+
+ // Delete
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_clock-analogue"]')
+ .parents('.designer-region')
+ .rightclick();
+
+ // todo -investigate further why this is not working in ci/cdk mode
+ // cy.get('[data-title="Delete"]').click().then(() => {
+ // cy.wait('@deleteWidget').its('response.statusCode').should('eq', 200);
+ // cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_clock-analogue"]')
+ // .should('not.exist');
+ // });
+ });
+});
diff --git a/cypress/e2e/Layout/Widget/layout_editor_dataset.cy.js b/cypress/e2e/Layout/Widget/layout_editor_dataset.cy.js
new file mode 100644
index 0000000..cfc5e57
--- /dev/null
+++ b/cypress/e2e/Layout/Widget/layout_editor_dataset.cy.js
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Dataset', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should create a new layout, add/delete dataset widget', function() {
+ cy.intercept('/dataset?start=*').as('loadDatasets');
+ cy.intercept('DELETE', '**/region/**').as('deleteWidget');
+ cy.intercept('POST', '/user/pref').as('userPref');
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ // Open widget menu and add dataset widget
+ cy.openToolbarMenu(0);
+
+ cy.get('[data-sub-type="dataset"]')
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('[data-template-id="dataset_table_1"]')
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('.viewer-object.layout.ui-droppable-active')
+ .should('be.visible')
+ .click();
+
+ // Verify widget exists in the layout viewer
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_dataset"]').should('exist');
+
+ // Select and configure the dataset
+ cy.get('#configureTab .select2-selection').click();
+ cy.wait('@loadDatasets');
+ cy.get('.select2-container--open input[type="search"]').type('8 items');
+ cy.get('.select2-container--open').contains('8 items').click();
+
+ cy.get('[name="lowerLimit"]').clear().type('1');
+ cy.get('[name="upperLimit"]').clear().type('10');
+ cy.get('.order-clause-row > :nth-child(2) > .form-control').select('Col1', {force: true});
+ cy.get('.order-clause-row > .btn').click();
+ cy.get(':nth-child(2) > :nth-child(2) > .form-control').select('Col2', {force: true});
+
+ // Open Appearance Tab
+ cy.get('.nav-link[href="#appearanceTab"]').click();
+
+ // Ensure dataset has exactly two columns
+ cy.get('#columnsOut li').should('have.length', 2);
+
+ // Move columns to "Columns Selected"
+ cy.get('#columnsOut li:first').trigger('mousedown', {which: 1}).trigger('mousemove', {which: 1, pageX: 583, pageY: 440});
+ cy.get('#columnsIn').click();
+ cy.get('#columnsOut li:first').trigger('mousedown', {which: 1}).trigger('mousemove', {which: 1, pageX: 583, pageY: 440});
+ cy.get('#columnsIn').click();
+
+ // Customize appearance settings
+ cy.get('[name="showHeadings"]').check();
+ cy.get('[name="rowsPerPage"]').clear().type('5');
+ cy.get('[name="fontSize"]').clear().type('48');
+ cy.get('[name="backgroundColor"]').clear().type('#333333');
+
+ // Delete widget
+ // The .moveable-control-box overlay obstructing the right-click interaction on the designer region, causing the test to fail.
+ // By invoking .hide(), we remove the overlay temporarily to allow uninterrupted interaction with the underlying elements.
+ cy.get('.moveable-control-box').invoke('hide');
+
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_dataset"]')
+ .parents('.designer-region')
+ .scrollIntoView()
+ .should('be.visible')
+ .rightclick();
+ // Wait until the widget has been deleted
+ // cy.get('[data-title="Delete"]').click().then(() => {
+ // cy.wait('@deleteWidget').its('response.statusCode').should('eq', 200);
+ // cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_dataset"]')
+ // .should('not.exist');
+ // });
+ });
+});
diff --git a/cypress/e2e/Layout/Widget/layout_editor_mastodon.cy.js b/cypress/e2e/Layout/Widget/layout_editor_mastodon.cy.js
new file mode 100644
index 0000000..af8099a
--- /dev/null
+++ b/cypress/e2e/Layout/Widget/layout_editor_mastodon.cy.js
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Mastodon', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should create a new layout and be redirected to the layout designer, add/delete Mastodon widget', function() {
+ cy.intercept('DELETE', '**/region/**').as('deleteWidget');
+ cy.intercept('POST', '/user/pref').as('userPref');
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ // Open widget menu
+ cy.openToolbarMenu(0);
+
+ cy.get('[data-sub-type="mastodon"]')
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('[data-template-id="social_media_static_1"] > .toolbar-card-thumb')
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('.viewer-object.layout.ui-droppable-active')
+ .should('be.visible')
+ .click();
+
+ // Check if the widget is in the viewer
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_mastodon"]')
+ .should('exist');
+
+ cy.get('[name="hashtag"]').clear();
+ cy.get('[name="hashtag"]').type('#cat');
+ cy.get('[name="searchOn"]').select('local', {force: true});
+ cy.get('[name="numItems"]').clear().type('10').trigger('change');
+ cy.get('[name="onlyMedia"]').check();
+
+ // Click on Appearance Tab
+ cy.get('.nav-link[href="#appearanceTab"]').click();
+ cy.get('[name="itemsPerPage"]').clear().type('2').trigger('change');
+
+ // Vertical/Fade/100/Right/Bottom
+ cy.get('[name="displayDirection"]').select('Vertical', {force: true});
+ cy.get('[name="effect"]').select('Fade', {force: true});
+ cy.get('[name="speed"]').clear().type('100').trigger('change');
+ cy.get('[name="alignmentH"]').select('Right', {force: true});
+ cy.get('[name="alignmentV"]').select('Bottom', {force: true});
+
+ // Delete widget
+ // The .moveable-control-box overlay obstructing the right-click interaction on the designer region, causing the test to fail.
+ // By invoking .hide(), we remove the overlay temporarily to allow uninterrupted interaction with the underlying elements.
+ cy.get('.moveable-control-box').invoke('hide');
+
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_mastodon"]')
+ .parents('.designer-region')
+ .scrollIntoView()
+ .should('be.visible')
+ .rightclick();
+
+ // Wait until the widget has been deleted
+ // cy.get('[data-title="Delete"]').click().then(() => {
+ // cy.wait('@deleteWidget').its('response.statusCode').should('eq', 200);
+ // cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_mastodon"]')
+ // .should('not.exist');
+ // });
+ });
+});
diff --git a/cypress/e2e/Layout/Widget/layout_editor_rss_ticker.cy.js b/cypress/e2e/Layout/Widget/layout_editor_rss_ticker.cy.js
new file mode 100644
index 0000000..19e907e
--- /dev/null
+++ b/cypress/e2e/Layout/Widget/layout_editor_rss_ticker.cy.js
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('RSS Ticker', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should create a new layout and be redirected to the layout designer, add/delete RSS ticker widget', function() {
+ cy.intercept('DELETE', '**/region/**').as('deleteWidget');
+ cy.intercept('POST', '/user/pref').as('userPref');
+
+ cy.visit('/layout/view');
+ cy.get('button[href="/layout"]').click();
+
+ // Open widget menu
+ cy.openToolbarMenu(0);
+
+ cy.get('[data-sub-type="rss-ticker"]')
+ .scrollIntoView()
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('[data-template-id="article_image_only"] > .toolbar-card-thumb')
+ .scrollIntoView()
+ .should('be.visible')
+ .click();
+ cy.wait('@userPref');
+
+ cy.get('.viewer-object.layout.ui-droppable-active')
+ .should('be.visible')
+ .click();
+
+ // Check if the widget is in the viewer
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_rss-ticker"]').should('exist');
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_rss-ticker"]').parents('.designer-region').click();
+
+ // Validate if uri is not provide we show an error message
+ cy.get('[name="numItems"]').clear().type('10').trigger('change');
+ cy.get('.form-container').contains('Missing required property Feed URL');
+
+ cy.get('[name="uri"]').clear();
+ cy.get('[name="uri"]').type('http://xibo.org.uk/feed');
+ cy.get('[name="numItems"]').clear().type('10').trigger('change');
+ cy.get('[name="durationIsPerItem"]').check();
+ cy.get('[name="takeItemsFrom"]').select('End of the Feed', {force: true});
+ cy.get('[name="reverseOrder"]').check();
+ cy.get('[name="randomiseItems"]').check();
+
+ cy.get('[name="userAgent"]').clear().type('Mozilla/5.0');
+ cy.get('[name="updateInterval"]').clear().type('10').trigger('change');
+
+ // Click on Appearance Tab
+ cy.get('.nav-link[href="#appearanceTab"]').click();
+ cy.get('[name="backgroundColor"]').clear().type('#dddddd');
+ cy.get('[name="itemImageFit"]').select('Fill', {force: true});
+ cy.get('[name="effect"]').select('Fade', {force: true});
+ cy.get('[name="speed"]').clear().type('500');
+ // Update CKEditor value
+ cy.updateCKEditor('noDataMessage', 'No data to show');
+ cy.get('[name="copyright"]').clear().type('Xibo').trigger('change');
+
+ // Delete widget
+ // The .moveable-control-box overlay obstructing the right-click interaction on the designer region, causing the test to fail.
+ // By invoking .hide(), we remove the overlay temporarily to allow uninterrupted interaction with the underlying elements.
+ cy.get('.moveable-control-box').invoke('hide');
+
+ cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_rss-ticker"]')
+ .parents('.designer-region')
+ .scrollIntoView()
+ .should('be.visible')
+ .rightclick();
+
+ // Wait until the widget has been deleted
+ // cy.get('[data-title="Delete"]').click().then(() => {
+ // cy.wait('@deleteWidget').its('response.statusCode').should('eq', 200);
+ // cy.get('#layout-viewer .designer-region .widget-preview[data-type="widget_rss-ticker"]')
+ // .should('not.exist');
+ // });
+ });
+});
diff --git a/cypress/e2e/Layout/layout_view.cy.js b/cypress/e2e/Layout/layout_view.cy.js
new file mode 100644
index 0000000..ab09415
--- /dev/null
+++ b/cypress/e2e/Layout/layout_view.cy.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Layout View', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('searches and delete existing layout', function() {
+ // Create random name
+ const uuid = Cypress._.random(0, 1e10);
+
+ // Create a new layout and go to the layout's designer page, then load toolbar prefs
+ cy.createLayout(uuid).as('testLayoutId').then((res) => {
+ cy.intercept('GET', '/layout?draw=2&*').as('layoutGridLoad');
+
+ cy.visit('/layout/view');
+
+ // Filter for the created layout
+ cy.get('#Filter input[name="layout"]')
+ .type(uuid);
+
+ // Wait for the layout grid reload
+ cy.wait('@layoutGridLoad');
+
+ // Click on the first row element to open the designer
+ cy.get('#layouts tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#layouts tr:first-child .layout_button_delete').click({force: true});
+
+ // Delete test layout
+ cy.get('.bootbox .save-button').click();
+
+ // Check if layout is deleted in toast message
+ cy.get('.toast').contains('Deleted ' + uuid);
+ });
+ });
+});
diff --git a/cypress/e2e/Library/datasets.cy.js b/cypress/e2e/Library/datasets.cy.js
new file mode 100644
index 0000000..d8ba7f2
--- /dev/null
+++ b/cypress/e2e/Library/datasets.cy.js
@@ -0,0 +1,322 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Datasets', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add one empty dataset', function() {
+ cy.visit('/dataset/view');
+
+ // Click on the Add Dataset button
+ cy.contains('Add DataSet').click();
+
+ cy.get('.modal input#dataSet')
+ .type('Cypress Test Dataset ' + testRun + '_1');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if dataset is added in toast message
+ cy.contains('Added Cypress Test Dataset ' + testRun + '_1');
+ });
+
+ it('should be able to cancel creating dataset', function() {
+ cy.visit('/dataset/view');
+
+ // Click on the Add Dataset button
+ cy.contains('Add DataSet').click();
+
+ cy.get('.modal input#dataSet')
+ .type('Cypress Test Dataset ' + testRun + '_1');
+
+ // Click cancel
+ cy.get('.modal #dialog_btn_1').click();
+
+ // Check if you are back to the view page
+ cy.url().should('include', '/dataset/view');
+ });
+
+ it('searches and edit existing dataset', function() {
+ // Create a new dataset and then search for it and delete it
+ cy.createDataset('Cypress Test Dataset ' + testRun).then((id) => {
+ cy.intercept({
+ url: '/dataset?*',
+ query: {dataSet: 'Cypress Test Dataset ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/dataset/*',
+ }).as('putRequest');
+
+ cy.visit('/dataset/view');
+
+ // Filter for the created dataset
+ cy.get('#Filter input[name="dataSet"]')
+ .type('Cypress Test Dataset ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#datasets tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#datasets tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#datasets tr:first-child .dataset_button_edit').click({force: true});
+
+ cy.get('.modal input#dataSet').clear()
+ .type('Cypress Test Dataset Edited ' + testRun);
+
+ // edit test dataset
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "dataset" value
+ expect(responseData.dataSet).to.eq('Cypress Test Dataset Edited ' + testRun);
+ });
+
+ // Delete the dataset and assert success
+ cy.deleteDataset(id).then((res) => {
+ expect(res.status).to.equal(204);
+ });
+ });
+ });
+
+ it('add row/column to an existing dataset', function() {
+ // Create a new dataset and then search for it and delete it
+ cy.createDataset('Cypress Test Dataset ' + testRun).then((id) => {
+ cy.intercept({
+ url: '/dataset?*',
+ query: {dataSet: 'Cypress Test Dataset ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'POST',
+ url: /\/dataset\/\d+\/column$/,
+ }).as('postRequestAddColumn');
+
+ cy.intercept({
+ method: 'POST',
+ url: /\/dataset\/data\/\d+/,
+ }).as('postRequestAddRow');
+
+ cy.visit('/dataset/view');
+
+ // Filter for the created dataset
+ cy.get('#Filter input[name="dataSet"]')
+ .type('Cypress Test Dataset ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#datasets tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the View data
+ cy.get('#datasets tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#datasets tr:first-child .dataset_button_viewcolumns').click({force: true});
+
+ cy.get('#datasets').contains('No data available in table');
+
+ // Add data row to dataset
+ cy.contains('Add Column').click();
+ cy.get('.modal input#heading').type('Col1');
+
+ // Save
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@postRequestAddColumn').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "dataset" value
+ expect(responseData.heading).to.eq('Col1');
+
+ cy.contains('View Data').click();
+ cy.get('#datasets').contains('No data available in table');
+
+ // Add data row to dataset
+ cy.contains('Add Row').click();
+ cy.get('#dataSetDataAdd').within(() => {
+ cy.get('input:first').type('Your text goes here');
+ });
+
+ // Save
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted request and check data
+ cy.wait('@postRequestAddRow').then((interception) => {
+ cy.contains('Added Row');
+ });
+ });
+
+ // Now try to delete the dataset
+ cy.visit('/dataset/view');
+
+ // Filter for the created dataset
+ cy.get('#Filter input[name="dataSet"]')
+ .type('Cypress Test Dataset ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#datasets tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the View data
+ cy.get('#datasets tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#datasets tr:first-child .dataset_button_delete').click({force: true});
+ });
+ });
+
+ it('copy an existing dataset', function() {
+ // Create a new dataset and then search for it and copy it
+ cy.createDataset('Cypress Test Dataset ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/dataset?*',
+ query: {dataSet: 'Cypress Test Dataset ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the POST request
+ cy.intercept({
+ method: 'POST',
+ url: /\/dataset\/copy\/\d+/,
+ }).as('postRequest');
+
+ cy.visit('/dataset/view');
+
+ // Filter for the created dataset
+ cy.get('#Filter input[name="dataSet"]')
+ .type('Cypress Test Dataset ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#datasets tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#datasets tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#datasets tr:first-child .dataset_button_copy').click({force: true});
+
+ // save
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted POST request and check the form data
+ cy.wait('@postRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+ expect(responseData.dataSet).to.include('Cypress Test Dataset ' + testRun + ' 2');
+ });
+ });
+ });
+
+ it('searches and delete existing dataset', function() {
+ // Create a new dataset and then search for it and delete it
+ cy.createDataset('Cypress Test Dataset ' + testRun).then((res) => {
+ cy.intercept('GET', '/dataset?draw=2&*').as('datasetGridLoad');
+
+ cy.visit('/dataset/view');
+
+ // Filter for the created dataset
+ cy.get('#Filter input[name="dataSet"]')
+ .type('Cypress Test Dataset ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@datasetGridLoad');
+ cy.get('#datasets tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#datasets tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#datasets tr:first-child .dataset_button_delete').click({force: true});
+
+ // Delete test dataset
+ cy.get('.bootbox .save-button').click();
+
+ // Check if dataset is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Dataset');
+ });
+ });
+
+ it('selects multiple datasets and delete them', function() {
+ // Create a new dataset and then search for it and delete it
+ cy.createDataset('Cypress Test Dataset ' + testRun).then((res) => {
+ cy.intercept('GET', '/dataset?draw=2&*').as('datasetGridLoad');
+
+ // Delete all test datasets
+ cy.visit('/dataset/view');
+
+ // Clear filter
+ cy.get('#Filter input[name="dataSet"]')
+ .clear()
+ .type('Cypress Test Dataset');
+
+ // Wait for the grid reload
+ cy.wait('@datasetGridLoad');
+
+ // Select all
+ cy.get('button[data-toggle="selectAll"]').click();
+
+ // Delete all
+ cy.get('.dataTables_info button[data-toggle="dropdown"]').click();
+ cy.get('.dataTables_info a[data-button-id="dataset_button_delete"]').click();
+
+ cy.get('input#deleteData').check();
+ cy.get('button.save-button').click();
+
+ // Modal should contain one successful delete at least
+ cy.get('.modal-body').contains(': Success');
+ });
+ });
+
+ // ---------
+ // Tests - Error handling
+ it('should not add a remote dataset without URI', function() {
+ cy.visit('/dataset/view');
+
+ // Click on the Add Dataset button
+ cy.contains('Add DataSet').click();
+
+ cy.get('.modal input#dataSet')
+ .type('Cypress Test Dataset ' + testRun);
+
+ cy.get('.modal input#isRemote').check();
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Click on the "Remote" tab
+ cy.get(':nth-child(2) > .nav-link').should('be.visible').click();
+
+ // Check that the error message is displayed for the missing URI field
+ cy.get('#uri-error').should('have.text', 'This field is required.');
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Library/media.cy.js b/cypress/e2e/Library/media.cy.js
new file mode 100644
index 0000000..7e5cb8c
--- /dev/null
+++ b/cypress/e2e/Library/media.cy.js
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Media Admin', function() {
+ let testRun;
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a media via url', function() {
+ cy.visit('/library/view');
+
+ // Click on the Add Playlist button
+ cy.contains('Add media (URL)').click();
+
+ cy.get('#url')
+ .type('https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4');
+
+ cy.get('#optionalName')
+ .type('Cypress Test Media ' + testRun);
+
+ cy.get('.modal .save-button').click();
+ cy.wait(24000);
+
+ // Filter for the created playlist
+ cy.get('#media')
+ .type('Cypress Test Media ' + testRun);
+
+ // Should have the added playlist
+ cy.get('#libraryItems tbody tr').should('have.length', 1);
+ cy.get('#libraryItems tbody tr:nth-child(1) td:nth-child(2)').contains('Cypress Test Media ' + testRun);
+ });
+
+ it('should cancel adding a media', function() {
+ cy.visit('/library/view');
+
+ // Click on the Add Playlist button
+ cy.contains('Add media (URL)').click();
+
+ cy.get('#url')
+ .type('https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4');
+
+ cy.get('#optionalName')
+ .type('Cypress Test Media ' + testRun);
+
+ // Click cancel
+ cy.get('#dialog_btn_1').click();
+
+ // Check if you are back to the view page
+ cy.url().should('include', '/library/view');
+ });
+
+ it('should show a list of Media', function() {
+ // Wait for playlist grid reload
+ cy.intercept('/library?draw=1&*').as('mediaGridLoad');
+
+ cy.visit('/library/view').then(function() {
+ cy.wait('@mediaGridLoad');
+ cy.get('#libraryItems');
+ });
+ });
+
+ it.skip('selects media and delete them', function() {
+ // Create a new playlist and then search for it and delete it
+ cy.intercept('/library?draw=1&*').as('mediaGridLoad');
+
+ // Delete all test playlists
+ cy.visit('/library/view');
+
+ // Clear filter and search for text playlists
+ cy.get('#media')
+ .clear()
+ .type('Cypress Test Media');
+
+ // Wait for 1st playlist grid reload
+ cy.wait('@mediaGridLoad');
+
+ // Select first entry
+ cy.get('table#libraryItems').contains('Cypress Test Media').parents('tr.odd').should('be.visible').click();
+ cy.get('button[data-toggle="dropdown"]').first().click();
+
+ // Click Delete
+ cy.contains('Delete').click();
+ cy.get('button.save-button').click();
+
+ // Modal should contain one successful delete at least
+ cy.get('div[class="toast-message"]').should('contain', 'Deleted');
+ });
+ });
\ No newline at end of file
diff --git a/cypress/e2e/Library/menuboards.cy.js b/cypress/e2e/Library/menuboards.cy.js
new file mode 100644
index 0000000..00c6b7b
--- /dev/null
+++ b/cypress/e2e/Library/menuboards.cy.js
@@ -0,0 +1,439 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Menuboards', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a menuboard', function() {
+ cy.visit('/menuboard/view');
+
+ // Click on the Add Menuboard button
+ cy.contains('Add Menu Board').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Menuboard ' + testRun + '_1');
+ cy.get('.modal input#code')
+ .type('MENUBOARD');
+ cy.get('.modal textarea#description')
+ .type('Menuboard Description');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if menuboard is added in toast message
+ cy.contains('Added Menu Board');
+ });
+
+ it('searches and edit existing menuboard', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/menuboard?*',
+ query: {name: 'Cypress Test Menuboard ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/menuboard/*',
+ }).as('putRequest');
+
+ cy.visit('/menuboard/view');
+
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Menuboard ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoards tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoards tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoards tr:first-child .menuBoard_edit_button').click({force: true});
+
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Menuboard Edited ' + testRun);
+
+ // edit test menuboard
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "menuboard" value
+ expect(responseData.name).to.eq('Cypress Test Menuboard Edited ' + testRun);
+ });
+ });
+ });
+
+ it('searches and delete existing menuboard', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/menuboard?*',
+ query: {name: 'Cypress Test Menuboard ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/menuboard/view');
+
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Menuboard ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoards tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoards tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoards tr:first-child .menuBoard_delete_button').click({force: true});
+
+ // Delete test menuboard
+ cy.get('.bootbox .save-button').click();
+
+ // Check if menuboard is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Menuboard');
+ });
+ });
+
+ // -------------------
+ it('should add categories and products to a menuboard', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ cy.intercept({
+ url: '/menuboard?*',
+ query: {name: 'Cypress Test Menuboard ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/menuboard/view');
+
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Menuboard ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoards tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoards tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoards tr:first-child .menuBoard_button_viewcategories').click({force: true});
+
+ // Click on the Add Category button
+ cy.contains('Add Category').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Category ' + testRun + '_1');
+ cy.get('.modal input#code')
+ .type('MENUBOARDCAT');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if menuboard is added in toast message
+ cy.contains('Added Menu Board Category');
+
+ // Wait for the grid reload
+ // cy.wait('@loadCategoryGridAfterSearch');
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoardCategories tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoardCategories tr:first-child .menuBoardCategory_button_viewproducts').click({force: true});
+
+ // Click on the Add Product button
+ cy.contains('Add Product').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Product ' + testRun + '_1');
+ cy.get('.modal input#code')
+ .type('MENUBOARDPROD');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if menuboard is added in toast message
+ cy.contains('Added Menu Board Product');
+ });
+ });
+
+ // -------------------
+ // Categories
+ it('should add a category', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ // GO to products page
+ cy.visit('/menuboard/' + menuId + '/categories/view');
+ // Click on the Add Category button
+ cy.contains('Add Category').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Category ' + testRun + '_1');
+ cy.get('.modal input#code')
+ .type('MENUBOARDCAT');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check toast message
+ cy.contains('Added Menu Board Category');
+
+ // Delete the menuboard and assert success
+ cy.deleteMenuboard(menuId).then((response) => {
+ expect(response.status).to.equal(204);
+ });
+ });
+ });
+
+ it('searches and edit existing category', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ cy.createMenuboardCat('Cypress Test Category ' + testRun, menuId).then((menuCatId) => {
+ cy.intercept({
+ url: '/menuboard/' + menuId + '/categories?*',
+ query: {name: 'Cypress Test Category ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/menuboard/' + menuCatId + '/category',
+ }).as('putRequest');
+
+ // GO to products page
+ cy.visit('/menuboard/' + menuId + '/categories/view');
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Category ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoardCategories tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoardCategories tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoardCategories tr:first-child .menuBoardCategory_edit_button').click({force: true});
+
+ // EDIT
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Category Edited ' + testRun);
+
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "menuboard" value
+ expect(responseData.name).to.eq('Cypress Test Category Edited ' + testRun);
+ });
+
+ // Delete the menuboard and assert success
+ cy.deleteMenuboard(menuId).then((response) => {
+ expect(response.status).to.equal(204);
+ });
+ });
+ });
+ });
+
+ it('searches and delete existing category', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ cy.createMenuboardCat('Cypress Test Category ' + testRun, menuId).then((menuCatId) => {
+ cy.intercept({
+ url: '/menuboard/' + menuId + '/categories?*',
+ query: {name: 'Cypress Test Category ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/menuboard/' + menuCatId + '/category',
+ }).as('putRequest');
+
+ // GO to products page
+ cy.visit('/menuboard/' + menuId + '/categories/view');
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Category ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoardCategories tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoardCategories tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoardCategories tr:first-child .menuBoardCategory_delete_button').click({force: true});
+
+ // Delete test category
+ cy.get('.bootbox .save-button').click();
+
+ // Check toast message
+ cy.get('.toast').contains('Deleted Cypress Test Category');
+
+ // Delete the menuboard and assert success
+ cy.deleteMenuboard(menuId).then((response) => {
+ expect(response.status).to.equal(204);
+ });
+ });
+ });
+ });
+
+ // -------------------
+ // Products
+ it('should add a product', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ cy.createMenuboardCat('Cypress Test Category ' + testRun, menuId).then((menuCatId) => {
+ // GO to products page
+ cy.visit('/menuboard/' + menuCatId + '/products/view');
+ // Click on the Add Product button
+ cy.contains('Add Product').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Product ' + testRun);
+ cy.get('.modal input#code')
+ .type('MENUBOARDPROD');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if menuboard is added in toast message
+ cy.contains('Added Menu Board Product');
+ });
+ });
+ });
+
+ it('searches and edit existing product', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ cy.log(menuId);
+ cy.createMenuboardCat('Cypress Test Category ' + testRun, menuId).then((menuCatId) => {
+ cy.log(menuCatId);
+ cy.createMenuboardCatProd('Cypress Test Product ' + testRun, menuCatId).then((menuProdId) => {
+ cy.log(menuProdId);
+ cy.intercept({
+ url: '/menuboard/' + menuCatId + '/products?*',
+ query: {name: 'Cypress Test Product ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/menuboard/' + menuProdId + '/product',
+ }).as('putRequest');
+
+ // GO to products page
+ cy.visit('/menuboard/' + menuCatId + '/products/view');
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Product ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoardProducts tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoardProducts tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoardProducts tr:first-child .menuBoardProduct_edit_button').click({force: true});
+
+ // EDIT
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Product Edited ' + testRun);
+
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "menuboard" value
+ expect(responseData.name).to.eq('Cypress Test Product Edited ' + testRun);
+ });
+
+ // Delete the menuboard and assert success
+ cy.deleteMenuboard(menuId).then((response) => {
+ expect(response.status).to.equal(204);
+ });
+ });
+ });
+ });
+ });
+
+ it('searches and delete existing product', function() {
+ // Create a new menuboard and then search for it and delete it
+ cy.createMenuboard('Cypress Test Menuboard ' + testRun).then((menuId) => {
+ cy.createMenuboardCat('Cypress Test Category ' + testRun, menuId).then((menuCatId) => {
+ cy.createMenuboardCatProd('Cypress Test Product ' + testRun, menuCatId).then((menuProdId) => {
+ cy.intercept({
+ url: '/menuboard/' + menuCatId + '/products?*',
+ query: {name: 'Cypress Test Product ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/menuboard/' + menuProdId + '/product',
+ }).as('putRequest');
+
+ // GO to products page
+ cy.visit('/menuboard/' + menuCatId + '/products/view');
+ // Filter for the created menuboard
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Product ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#menuBoardProducts tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#menuBoardProducts tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#menuBoardProducts tr:first-child .menuBoardProduct_delete_button').click({force: true});
+
+ // Delete test menuboard
+ cy.get('.bootbox .save-button').click();
+
+ // Check toast message
+ cy.get('.toast').contains('Deleted Cypress Test Product');
+
+ // Delete the menuboard and assert success
+ cy.deleteMenuboard(menuId).then((response) => {
+ expect(response.status).to.equal(204);
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/cypress/e2e/Library/playlist_editor_empty.cy.js b/cypress/e2e/Library/playlist_editor_empty.cy.js
new file mode 100644
index 0000000..4a19896
--- /dev/null
+++ b/cypress/e2e/Library/playlist_editor_empty.cy.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Playlist Editor (Empty)', function() {
+
+ beforeEach(function() {
+ cy.login();
+
+ // Create random name
+ let uuid = Cypress._.random(0, 1e9);
+
+ // Create a new layout and go to the layout's designer page
+ cy.createNonDynamicPlaylist(uuid).as('testPlaylistId').then((res) => {
+ cy.openPlaylistEditorAndLoadPrefs(res);
+ });
+ });
+
+ it('should show the droppable zone and toolbar', function() {
+
+ cy.get('#playlist-editor-container').should('be.visible');
+ cy.get('div[class="container-toolbar container-fluid flex-column flex-column justify-content-between"]').should('be.visible');
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Library/playlist_editor_populated.cy.js b/cypress/e2e/Library/playlist_editor_populated.cy.js
new file mode 100644
index 0000000..7870bc0
--- /dev/null
+++ b/cypress/e2e/Library/playlist_editor_populated.cy.js
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Playlist Editor (Populated)', function() {
+
+ beforeEach(function() {
+ cy.login();
+
+ // Create random name
+ let uuid = Cypress._.random(0, 1e9);
+
+ // Create a new layout and go to the layout's designer page
+ cy.createNonDynamicPlaylist(uuid).as('testPlaylistId').then((res) => {
+
+ // Populate playlist with some widgets and media
+ cy.addWidgetToPlaylist(res, 'embedded', {
+ name: 'Embedded Widget'
+ });
+
+ cy.addMediaToLibrary("file/example.zip");
+
+ cy.addWidgetToPlaylist(res, 'clock', {
+ name: 'Clock Widget'
+ });
+
+ cy.openPlaylistEditorAndLoadPrefs(res);
+ });
+ });
+
+ it('changes and saves widget properties', () => {
+ // Create and alias for reload widget
+ // cy.intercept('GET','/playlist/widget/form/edit/*').as('reloadWidget');
+
+ // Select the first widget on timeline ( image )
+ cy.get('#timeline-container [data-type="widget"]').first().click();
+
+ // Wait for the widget to load
+ // cy.wait('@reloadWidget');
+
+ // Type the new name in the input
+ cy.get('a[href="#advancedTab"]').click();
+ cy.get('#properties-panel-form-container input[name="name"]').clear().type('newName');
+
+ // Set a duration
+ cy.get('#properties-panel-form-container input[name="useDuration"]').check();
+ cy.get('#properties-panel-form-container input[name="duration"]').clear().type(12);
+
+ // Save form
+ cy.get('#properties-panel-form-container button[data-action="save"]').click();
+
+ // Should show a notification for the name change
+ // cy.get('.toast-success');
+
+ // Wait for the widget to reload
+ // cy.wait('@reloadWidget');
+
+ // Check if the values are the same entered after reload
+ cy.get('#properties-panel-form-container input[name="name"]').should('have.prop', 'value').and('equal', 'newName');
+ cy.get('#properties-panel-form-container input[name="duration"]').should('have.prop', 'value').and('equal', '12');
+
+ });
+
+ it.skip('should revert a saved form to a previous state', () => {
+
+ let oldName;
+
+ // Create and alias for reload widget
+ // cy.intercept('GET', '/playlist/widget/form/edit/*').as('reloadWidget');
+ // cy.intercept('PUT', '/playlist/widget/*').as('saveWidget');
+
+ // Select the first widget on timeline ( image )
+ cy.get('#timeline-container [data-type="widget"]').first().click();
+
+ // Wait for the widget to load
+ // cy.wait('@reloadWidget');
+
+ // Get the input field
+ cy.get('a[href="#advancedTab"]').click();
+ cy.get('#properties-panel-form-container input[name="name"]').then(($input) => {
+
+ // Save old name
+ oldName = $input.val();
+
+ //Type the new name in the input
+ cy.get('#properties-panel-form-container input[name="name"]').clear().type('newName');
+
+ // Save form
+ cy.get('#properties-panel-form-container button[data-action="save"]').click();
+
+ // Should show a notification for the name change
+ // cy.get('.toast-success');
+
+ // Wait for the widget to save
+ // cy.wait('@reloadWidget');
+
+ // Click the revert button
+ cy.get('#playlist-editor-toolbar #undoContainer').click();
+
+ // Wait for the widget to save
+ // cy.wait('@saveWidget');
+
+ // Test if the revert made the name go back to the old name
+ cy.get('#properties-panel-form-container input[name="name"]').should('have.prop', 'value').and('equal', oldName);
+ });
+ });
+
+ it('should delete a widget using the toolbar bin', () => {
+ // cy.intercept('/playlist?playlistId=*').as('reloadPlaylist');
+
+ // Select a widget from the navigator
+ cy.get('#playlist-timeline [data-type="widget"]').first().click().then(($el) => {
+
+ const widgetId = $el.attr('id');
+
+ // Click trash container
+ cy.get('div[class="widgetDelete"]').first().click({force: true});
+
+ // Confirm delete on modal
+ cy.get('button[class*="btn-bb-confirm"]').click();
+
+ // Check toast message
+ // cy.get('.toast-success').contains('Deleted');
+
+ // Wait for the layout to reload
+ // cy.wait('@reloadPlaylist');
+
+ // Check that widget is not on timeline
+ cy.get('#playlist-timeline [data-type="widget"]#' + widgetId).should('not.exist');
+ });
+ });
+
+ it.skip('should add an audio clip to a widget by the context menu, and adds a link to open the form in the timeline', () => {
+
+ cy.populateLibraryWithMedia();
+
+ // Create and alias for reload playlist
+ cy.intercept('/playlist?playlistId=*').as('reloadPlaylist');
+
+ // Right click to open the context menu and select add audio
+ cy.get('#timeline-container [data-type="widget"]').first().should('be.visible').rightclick();
+ cy.get('.context-menu-btn[data-property="Audio"]').should('be.visible').click();
+
+ // Select the 1st option
+ cy.get('[data-test="widgetPropertiesForm"] #mediaId > option').eq(1).then(($el) => {
+ cy.get('[data-test="widgetPropertiesForm"] #mediaId').select($el.val());
+ });
+
+ // Save and close the form
+ cy.get('[data-test="widgetPropertiesForm"] .btn-bb-done').click();
+
+ // Check if the widget has the audio icon
+ // cy.wait('@reloadPlaylist');
+ cy.get('#timeline-container [data-type="widget"]:first-child')
+ .find('i[data-property="Audio"]').should('exist').click({force: true});
+
+ cy.get('[data-test="widgetPropertiesForm"]').contains('Audio for');
+ });
+
+ // Skip test for now ( it's failing in the test suite and being tested already in layout designer spec )
+ it.skip('attaches expiry dates to a widget by the context menu, and adds a link to open the form in the timeline', () => {
+ // Create and alias for reload playlist
+ // cy.intercept('/playlist?playlistId=*').as('reloadPlaylist');
+
+ // Right click to open the context menu and select add audio
+ cy.get('#timeline-container [data-type="widget"]').first().should('be.visible').rightclick();
+ cy.get('.context-menu-btn[data-property="Expiry"]').should('be.visible').click();
+
+ // Add dates
+ cy.get('[data-test="widgetPropertiesForm"] .starttime-control .date-clear-button').click();
+ // cy.get('[data-test="widgetPropertiesForm"] #fromDt').find('input[class="datePickerHelper form-control dateControl dateTime active"]').click();
+ cy.get('div[class="flatpickr-wrapper"]').first().click();
+ cy.get('.flatpickr-calendar.open .dayContainer .flatpickr-day:first').click();
+
+ cy.get('[data-test="widgetPropertiesForm"] .endtime-control .date-clear-button').click();
+ // cy.get('[data-test="widgetPropertiesForm"] #toDt').find('input[class="datePickerHelper form-control dateControl dateTime active"]').click();
+ cy.get('div[class="flatpickr-wrapper"]').last().click();
+ cy.get('.flatpickr-calendar.open .dayContainer .flatpickr-day:first').click();
+
+
+ // Save and close the form
+ cy.get('[data-test="widgetPropertiesForm"] .btn-bb-done').click();
+
+ // Check if the widget has the expiry dates icon
+ // cy.wait('@reloadPlaylist');
+ cy.get('#timeline-container [data-type="widget"]:first-child')
+ .find('i[data-property="Expiry"]').should('exist').click({force: true});
+
+ cy.get('[data-test="widgetPropertiesForm"]').contains('Expiry for');
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Library/playlist_editor_populated_unchanged.cy.js b/cypress/e2e/Library/playlist_editor_populated_unchanged.cy.js
new file mode 100644
index 0000000..fd6d000
--- /dev/null
+++ b/cypress/e2e/Library/playlist_editor_populated_unchanged.cy.js
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Playlist Editor (Populated/Unchanged)', function() {
+
+ before(function() {
+ cy.login();
+
+ // Create random name
+ let uuid = Cypress._.random(0, 1e9);
+
+ // Create a new layout and go to the layout's designer page
+ cy.createNonDynamicPlaylist(uuid).as('testPlaylistId').then((res) => {
+
+ // Populate playlist with some widgets and media
+ cy.addWidgetToPlaylist(res, 'embedded', {
+ name: 'Embedded Widget'
+ });
+
+ // TODO skip so that the test success
+ // cy.addRandomMediaToPlaylist(res);
+
+ cy.addWidgetToPlaylist(res, 'clock', {
+ name: 'Clock Widget'
+ });
+ });
+ });
+
+ beforeEach(function() {
+ cy.login();
+ cy.openPlaylistEditorAndLoadPrefs(this.testPlaylistId);
+ });
+
+ it('opens a media tab in the toolbar and searches for items', () => {
+
+ // cy.intercept('/library/search?*').as('mediaLoad');
+
+ cy.populateLibraryWithMedia();
+
+ // Open audio tool tab
+ cy.get('a[id="btn-menu-3"]').should('be.visible').click();
+
+ // cy.wait('@mediaLoad');
+
+ // Check if there are audio items in the search content
+ cy.get('div[class="toolbar-card-preview"]').last().should('be.visible');
+ });
+
+ it('creates a new widget by selecting a searched media from the toolbar to the editor, and then reverts the change', () => {
+ cy.populateLibraryWithMedia();
+
+ // Create and alias for reload playlist
+ // cy.intercept('/playlist?playlistId=*').as('reloadPlaylist');
+ // cy.intercept('DELETE', '/playlist/widget/*').as('deleteWidget');
+ // cy.intercept('/library/search?*').as('mediaLoad');
+
+ // Open library search tab
+ cy.get('a[id="btn-menu-0"]').should('be.visible').click();
+
+ // cy.wait('@mediaLoad');
+ cy.wait(1000);
+
+ // Get a table row, select it and add to the dropzone
+ cy.get('div[class="toolbar-card ui-draggable ui-draggable-handle"]').eq(2).should('be.visible').click({force: true}).then(() => {
+ cy.get('#timeline-overlay-container').click({force: true}).then(() => {
+
+ // Wait for the layout to reload
+ // cy.wait('@reloadPlaylist');
+ cy.wait(3000);
+
+ // Check if there is just one widget in the timeline
+ cy.get('#timeline-container [data-type="widget"]').then(($widgets) => {
+ expect($widgets.length).to.eq(3);
+ });
+
+ // Click the revert button
+ cy.get('#timeline-container [id^="widget_"]').last().click();
+ cy.get('button[data-action="undo"]').click({force: true});
+
+ // Wait for the widget to be deleted and for the playlist to reload
+ // cy.wait('@deleteWidget');
+ // cy.wait('@reloadPlaylist');
+ cy.wait(3000);
+
+ // Check if there is just one widget in the timeline
+ cy.get('#timeline-container [data-type="widget"]').then(($widgets) => {
+ expect($widgets.length).to.eq(2);
+ });
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Library/playlists.cy.js b/cypress/e2e/Library/playlists.cy.js
new file mode 100644
index 0000000..2d4c7c4
--- /dev/null
+++ b/cypress/e2e/Library/playlists.cy.js
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Playlists Admin', function() {
+ let testRun;
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a non-dynamic playlist', function() {
+ cy.visit('/playlist/view');
+
+ // Click on the Add Playlist button
+ cy.contains('Add Playlist').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Playlist ' + testRun);
+
+ cy.get('.modal .save-button').click();
+
+ // Filter for the created playlist
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Playlist ' + testRun);
+
+ // Should have the added playlist
+ cy.get('#playlists tbody tr').should('have.length', 1);
+ cy.get('#playlists tbody tr:nth-child(1) td:nth-child(2)').contains('Cypress Test Playlist ' + testRun);
+ });
+
+ it('should cancel adding a non-dynamic playlist', function() {
+ cy.visit('/playlist/view');
+
+ // Click on the Add Playlist button
+ cy.contains('Add Playlist').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Playlist ' + testRun);
+
+ // Click cancel
+ cy.get('#dialog_btn_1').click();
+
+ // Check if you are back to the view page
+ cy.url().should('include', '/playlist/view');
+ });
+
+ it('should show a list of Playlists', function() {
+ // Wait for playlist grid reload
+ cy.intercept('/playlist?draw=1&*').as('playlistGridLoad');
+
+ cy.visit('/playlist/view').then(function() {
+ cy.wait('@playlistGridLoad');
+ cy.get('#playlists');
+ });
+ });
+
+ it('selects multiple playlists and delete them', function() {
+ // Create a new playlist and then search for it and delete it
+ cy.createNonDynamicPlaylist('Cypress Test Playlist ' + testRun).then(() => {
+ cy.intercept('/playlist?draw=2&*').as('playlistGridLoad');
+
+ // Delete all test playlists
+ cy.visit('/playlist/view');
+
+ // Clear filter and search for text playlists
+ cy.get('#Filter input[name="name"]')
+ .clear()
+ .type('Cypress Test Playlist');
+
+ // Wait for 2nd playlist grid reload
+ cy.wait('@playlistGridLoad');
+
+ // Select all
+ cy.get('button[data-toggle="selectAll"]').click();
+
+ // Delete all
+ cy.get('.dataTables_info button[data-toggle="dropdown"]').click({force: true});
+ cy.get('.dataTables_info a[data-button-id="playlist_button_delete"]').click({force: true});
+
+ cy.get('button.save-button').click();
+
+ // Modal should contain one successful delete at least
+ cy.get('.modal-body').contains(': Success');
+ });
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/Reporting/report_bandwidth.cy.js b/cypress/e2e/Reporting/report_bandwidth.cy.js
new file mode 100644
index 0000000..ed031d1
--- /dev/null
+++ b/cypress/e2e/Reporting/report_bandwidth.cy.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Bandwidth', function() {
+ const display1 = 'POP Display 1';
+
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should load tabular data and charts', () => {
+ // Create and alias for load Display
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept('/report/data/bandwidth?*').as('reportData');
+
+ cy.visit('/report/form/bandwidth');
+
+ // Click on the select2 selection
+ cy.get('#displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+ // Click on the Apply button
+ cy.contains('Apply').should('be.visible').click();
+
+ cy.get('.chart-container').should('be.visible');
+
+ // Click on Tabular
+ cy.contains('Tabular').should('be.visible').click();
+ cy.wait('@reportData');
+
+ // Should have media stats
+ cy.get('#bandwidthTbl tbody tr:nth-child(1) td:nth-child(1)').contains('Submit Stats');
+ cy.get('#bandwidthTbl tbody tr:nth-child(1) td:nth-child(2)').contains(200); // Bandwidth
+ cy.get('#bandwidthTbl tbody tr:nth-child(1) td:nth-child(3)').contains('bytes'); // Unit
+ });
+});
diff --git a/cypress/e2e/Reporting/report_distribution.cy.js b/cypress/e2e/Reporting/report_distribution.cy.js
new file mode 100644
index 0000000..9c9fa44
--- /dev/null
+++ b/cypress/e2e/Reporting/report_distribution.cy.js
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Distribution by Layout, Media or Event', function() {
+ const display1 = 'POP Display 1';
+ const layout1 = 'POP Layout 1';
+
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('Range: Today, Checks duration and count of a layout stat', () => {
+ // Create and alias for load layout
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/layout?start=*',
+ query: {layout: layout1},
+ }).as('loadLayoutAfterSearch');
+
+ cy.intercept('/report/data/distributionReport?*').as('reportData');
+
+ cy.visit('/report/form/distributionReport');
+
+ // Click on the select2 selection
+ cy.get('#displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+ // Click on the select2 selection
+ cy.get('#layoutId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(layout1);
+ cy.wait('@loadLayoutAfterSearch');
+ cy.selectOption(layout1);
+
+ // Click on the Apply button
+ cy.contains('Apply').should('be.visible').click();
+
+ cy.get('.chart-container').should('be.visible');
+
+ // Click on Tabular
+ cy.contains('Tabular').should('be.visible').click();
+ cy.contains('Next').should('be.visible').click();
+ cy.wait('@reportData');
+
+ // Should have media stats
+ cy.get('#distributionTbl tbody tr:nth-child(3) td:nth-child(1)').contains('12:00 PM'); // Period
+ cy.get('#distributionTbl tbody tr:nth-child(3) td:nth-child(2)').contains(60); // Duration
+ cy.get('#distributionTbl tbody tr:nth-child(3) td:nth-child(3)').contains(1); // Count
+ });
+
+ it.skip('Create/Delete a Daily Distribution Report Schedule', () => {
+ const reportschedule = 'Daily Distribution by Layout 1 and Display 1';
+
+ // Create and alias for load layout
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/layout?start=*',
+ query: {layout: layout1},
+ }).as('loadLayoutAfterSearch');
+
+ cy.intercept({
+ url: '/report/reportschedule?*',
+ query: {name: reportschedule},
+ }).as('loadReportScheduleAfterSearch');
+
+ cy.visit('/report/form/distributionReport');
+
+ // Click on the select2 selection
+ cy.get('#layoutId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(layout1);
+ cy.wait('@loadLayoutAfterSearch');
+ cy.selectOption(layout1);
+
+ // ------
+ // ------
+ // Create a Daily Distribution Report Schedule
+ cy.get('#reportAddBtn').click();
+ cy.get('#reportScheduleAddForm #name ').type(reportschedule);
+
+ // Click on the select2 selection
+ cy.get('#reportScheduleAddForm #displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+ cy.get('#dialog_btn_2').should('be.visible').click();
+
+ cy.visit('/report/reportschedule/view');
+ cy.get('#name').type(reportschedule);
+ cy.wait('@loadReportScheduleAfterSearch');
+
+ // Click on the first row element to open the designer
+ cy.get('#reportschedules_wrapper tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#reportschedules_wrapper tr:first-child .reportschedule_button_delete').click({force: true});
+
+ // Delete test campaign
+ cy.get('.bootbox .save-button').click();
+
+ // Check if layout is deleted in toast message
+ cy.get('.toast').contains('Deleted ' + reportschedule);
+ });
+});
diff --git a/cypress/e2e/Reporting/report_library_usage.cy.js b/cypress/e2e/Reporting/report_library_usage.cy.js
new file mode 100644
index 0000000..98e9651
--- /dev/null
+++ b/cypress/e2e/Reporting/report_library_usage.cy.js
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Library Usage', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should load tabular data and charts', () => {
+ cy.visit('/report/form/libraryusage');
+
+ cy.get('#libraryUsage_wrapper').should('be.visible');
+ cy.get('#libraryChart').should('be.visible');
+ cy.get('#userChart').should('be.visible');
+ });
+});
diff --git a/cypress/e2e/Reporting/report_proofofplay.cy.js b/cypress/e2e/Reporting/report_proofofplay.cy.js
new file mode 100644
index 0000000..e4632c1
--- /dev/null
+++ b/cypress/e2e/Reporting/report_proofofplay.cy.js
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Proof of Play', function() {
+ const display1 = 'POP Display 1';
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('Range: Test export', function() {
+ // Create and alias for load displays
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.visit('/report/view');
+ cy.contains('Export').click();
+
+ cy.get(':nth-child(1) > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper').click();
+ cy.get('.open > .flatpickr-innerContainer > .flatpickr-rContainer > .flatpickr-days > .dayContainer > .today').click();
+ cy.get(':nth-child(2) > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper').click();
+ cy.get('.open > .flatpickr-innerContainer > .flatpickr-rContainer > .flatpickr-days > .dayContainer > .today').next().click();
+
+ // Click on the select2 selection
+ cy.get('#displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+ cy.get('.total-stat').contains('Total number of records to be exported 5');
+ });
+
+ it('Range: Today - Test layout/media stats for a layout and a display', function() {
+ cy.intercept('/report/data/proofofplayReport?*').as('reportData');
+
+ cy.visit('/report/form/proofofplayReport');
+
+ cy.get('#type').select('media');
+
+ // Click on the Apply button
+ cy.contains('Apply').click();
+
+ // Wait for
+ cy.wait('@reportData');
+ cy.get('#stats tbody').contains('media');
+
+ // Should have media stats - Test media stats for a layout and a display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(1)').contains('media'); // stat type
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(3)').contains('POP Display 1'); // display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(6)').contains('POP Layout 1'); // layout
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(8)').contains('child_folder_media'); // media
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(10)').contains(2); // number of plays
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(12)').contains(120); // total duration
+
+
+ cy.get('#type').select('layout');
+
+ // Click on the Apply button
+ cy.contains('Apply').click();
+
+ // Wait for
+ cy.wait('@reportData');
+ cy.contains('Tabular').should('be.visible').click();
+
+ cy.get('#stats tbody').contains('layout');
+
+ // Should have layout stat - Test a layout stat for an ad campaign, a layout and a display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(1)').contains('layout'); // stat type
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(3)').contains('POP Display 1'); // display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(4)').contains('POP Ad Campaign 1'); // ad campaign
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(6)').contains('POP Layout 1'); // layout
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(10)').contains(1); // number of plays
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(12)').contains(60); // total duration
+ });
+
+ it('Range: Lastweek - Test media stats for a layout and a display', function() {
+ cy.intercept('/report/data/proofofplayReport?*').as('reportData');
+
+ cy.visit('/report/form/proofofplayReport');
+
+ // Range: Lastweek
+ cy.get('#reportFilter').select('lastweek');
+
+ cy.get('#type').select('media');
+
+ // Click on the Apply button
+ cy.contains('Apply').click();
+
+ // Wait for
+ cy.wait('@reportData');
+
+ cy.get('#stats').within(() => {
+ // Check if the "No data available in table" message is not present
+ cy.contains('No data available in table').should('not.exist');
+ cy.get('tbody tr').should('have.length', 1);
+ // Should have media stats
+ cy.get('tbody td').eq(0).should('contain', 'media'); // stat type
+ cy.get('tbody td').eq(2).contains('POP Display 1'); // display
+ cy.get('tbody td').eq(5).contains('POP Layout 1'); // layout
+ cy.get('tbody td').eq(7).contains('child_folder_media'); // media
+ cy.get('tbody td').eq(9).contains(2); // number of plays
+ cy.get('tbody td').eq(11).contains(120); // total duration
+ });
+
+ cy.get('#type').select('layout');
+
+ // Click on the Apply button
+ cy.contains('Apply').click();
+
+ // Wait for
+ cy.wait('@reportData');
+
+ cy.get('#stats').within(() => {
+ // Check if the "No data available in table" message is not present
+ cy.contains('No data available in table').should('not.exist');
+ cy.get('tbody tr').should('have.length', 1);
+ // Should have layout stat - Test a layout stat for an ad campaign, a layout and a display
+ cy.get('tbody td').eq(0).contains('layout'); // stat type
+ cy.get('tbody td').eq(2).contains('POP Display 1'); // display
+ cy.get('tbody td').eq(3).contains('POP Ad Campaign 1'); // ad campaign
+ cy.get('tbody td').eq(5).contains('POP Layout 1'); // layout
+ cy.get('tbody td').eq(9).contains(1); // number of plays
+ cy.get('tbody td').eq(11).contains(60); // total duration
+ });
+ });
+
+ it('Range: Today - Test event/widget stats for a layout and a display', function() {
+ cy.intercept('/report/data/proofofplayReport?*').as('reportData');
+
+ cy.visit('/report/form/proofofplayReport');
+
+ cy.get('#type').select('event');
+
+ // Click on the Apply button
+ cy.contains('Apply').click();
+
+ // Wait for
+ cy.wait('@reportData');
+
+ // Should have media stats - Test media stats for a layout and a display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(1)').contains('event'); // stat type
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(3)').contains('POP Display 1'); // display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(9)').contains('Event123'); // tag/eventname
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(10)').contains(1); // number of plays
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(12)').contains(60); // total duration
+
+ cy.get('#type').select('widget');
+
+ // Click on the Apply button
+ cy.contains('Apply').click();
+
+ // Wait for
+ cy.wait('@reportData');
+ cy.contains('Tabular').should('be.visible').click();
+
+ // Should have layout stat - Test a layout stat for an ad campaign, a layout and a display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(1)').contains('widget'); // stat type
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(3)').contains('POP Display 1'); // display
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(6)').contains('POP Layout 1'); // layout
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(10)').contains(1); // number of plays
+ cy.get('#stats tbody tr:nth-child(1) td:nth-child(12)').contains(60); // total duration
+ });
+});
diff --git a/cypress/e2e/Reporting/report_summary.cy.js b/cypress/e2e/Reporting/report_summary.cy.js
new file mode 100644
index 0000000..5a588e6
--- /dev/null
+++ b/cypress/e2e/Reporting/report_summary.cy.js
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Summary by Layout, Media or Event', function() {
+ const display1 = 'POP Display 1';
+ const layout1 = 'POP Layout 1';
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('Range: Today, Checks duration and count of a layout stat', () => {
+ // Create alias
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/layout?start=*',
+ query: {layout: layout1},
+ }).as('loadLayoutAfterSearch');
+
+ cy.intercept('/report/data/summaryReport?*').as('reportData');
+
+ cy.visit('/report/form/summaryReport');
+
+ // Click on the select2 selection
+ cy.get('#displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+ // Click on the select2 selection
+ cy.get('#layoutId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(layout1);
+ cy.wait('@loadLayoutAfterSearch');
+ cy.selectOption(layout1);
+
+ // Click on the Apply button
+ cy.contains('Apply').should('be.visible').click();
+ // Wait for report data
+ cy.wait('@reportData');
+
+ cy.get('.chart-container').should('be.visible');
+
+ // Click on Tabular
+ cy.contains('Tabular').should('be.visible').click();
+ cy.contains('Next').should('be.visible').click();
+
+ // Should have media stats
+ cy.get('#summaryTbl tbody tr:nth-child(3) td:nth-child(1)').contains('12:00 PM'); // Period
+ cy.get('#summaryTbl tbody tr:nth-child(3) td:nth-child(2)').contains(60); // Duration
+ cy.get('#summaryTbl tbody tr:nth-child(3) td:nth-child(3)').contains(1); // Count
+ });
+
+ it('Create/Delete a Daily Summary Report Schedule', () => {
+ const reportschedule = 'Daily Summary by Layout 1 and Display 1';
+
+ // Create and alias for load layout
+ cy.intercept('/report/reportschedule/form/add*').as('reportScheduleAddForm');
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/layout?start=*',
+ query: {layout: layout1},
+ }).as('loadLayoutAfterSearch');
+
+ cy.intercept({
+ url: '/report/reportschedule?*',
+ query: {name: reportschedule},
+ }).as('loadReportScheduleAfterSearch');
+
+ cy.visit('/report/form/summaryReport');
+
+ // Click on the select2 selection
+ cy.get('#layoutId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(layout1);
+ cy.wait('@loadLayoutAfterSearch');
+ cy.selectOption(layout1);
+
+ // ------
+ // ------
+ // Create a Daily Summary Report Schedule
+ cy.get('#reportAddBtn').click();
+ cy.wait('@reportScheduleAddForm');
+ cy.get('#reportScheduleAddForm #name ').type(reportschedule);
+
+ // Click on the select2 selection
+ cy.get('#reportScheduleAddForm #displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+
+ cy.get('#dialog_btn_2').should('be.visible').click();
+
+ cy.visit('/report/reportschedule/view');
+ cy.get('#name').type(reportschedule);
+ cy.wait('@loadReportScheduleAfterSearch');
+
+ // Click on the first row element to open the designer
+ cy.get('#reportschedules_wrapper tr:first-child .dropdown-toggle').click({force: true});
+
+ cy.get('#reportschedules_wrapper tr:first-child .reportschedule_button_delete').click({force: true});
+
+ // Delete test campaign
+ cy.get('.bootbox .save-button').click();
+
+ // Check if layout is deleted in toast message
+ cy.get('.toast').contains('Deleted ' + reportschedule);
+ });
+});
diff --git a/cypress/e2e/Reporting/report_timeconnected.cy.js b/cypress/e2e/Reporting/report_timeconnected.cy.js
new file mode 100644
index 0000000..e7f814f
--- /dev/null
+++ b/cypress/e2e/Reporting/report_timeconnected.cy.js
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Time Connected', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should load time connected data of displays', () => {
+ cy.visit('/report/form/timeconnected');
+
+ // Click on the select2 selection
+ cy.get('.select2-search__field').click();
+
+ // Type the display name
+ cy.get('.select2-container--open textarea[type="search"]').type('POP Display Group');
+ cy.get('.select2-container--open .select2-results > ul').contains('POP Display Group').click();
+
+ // Click on the Apply button
+ cy.contains('Apply').should('be.visible').click();
+
+ // Should have media stats
+ cy.get('#records_table tr:nth-child(1) th:nth-child(1)').contains('POP Display 1');
+ cy.get('#records_table tr:nth-child(2) td:nth-child(2)').contains('100%');
+ });
+});
diff --git a/cypress/e2e/Reporting/report_timeconnectedsummary.cy.js b/cypress/e2e/Reporting/report_timeconnectedsummary.cy.js
new file mode 100644
index 0000000..8d1bcbf
--- /dev/null
+++ b/cypress/e2e/Reporting/report_timeconnectedsummary.cy.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Time Connected', function() {
+ const display1 = 'POP Display 1';
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should load time connected data of displays', () => {
+ // Create and alias for load display
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.visit('/report/form/timedisconnectedsummary');
+
+ // Click on the select2 selection
+ cy.get('#displayId + span .select2-selection').click();
+ cy.get('.select2-container--open input[type="search"]').type(display1);
+ cy.wait('@loadDisplayAfterSearch');
+ cy.selectOption(display1);
+
+ // Select "Yesterday" from the dropdown
+ cy.get('#reportFilter').select('yesterday');
+
+ // Click on the Apply button
+ cy.contains('Apply').should('be.visible').click();
+
+ cy.get('.chart-container').should('be.visible');
+
+ // Click on Tabular
+ cy.contains('Tabular').should('be.visible').click();
+
+ // Should have media stats
+ cy.get('#timeDisconnectedTbl tr:nth-child(1) td:nth-child(2)').contains('POP Display 1');
+ cy.get('#timeDisconnectedTbl tr:nth-child(1) td:nth-child(3)').contains('10');
+ });
+});
diff --git a/cypress/e2e/Schedule/dayparts.cy.js b/cypress/e2e/Schedule/dayparts.cy.js
new file mode 100644
index 0000000..f71be57
--- /dev/null
+++ b/cypress/e2e/Schedule/dayparts.cy.js
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Dayparts', function() {
+ let testRun = '';
+
+ beforeEach(function() {
+ cy.login();
+
+ testRun = Cypress._.random(0, 1e9);
+ });
+
+ it('should add a daypart', function() {
+ cy.visit('/daypart/view');
+
+ // Click on the Add Daypart button
+ cy.contains('Add Daypart').click();
+
+ cy.get('.modal input#name')
+ .type('Cypress Test Daypart ' + testRun + '_1');
+
+ cy.get(':nth-child(3) > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper').click();
+ // cy.get('.open > .flatpickr-time > :nth-child(1) > .arrowUp').click();
+ cy.get('.open > .flatpickr-time > :nth-child(1) > .numInput').type('8');
+ cy.get(':nth-child(4) > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper').click();
+ cy.get('.open > .flatpickr-time > :nth-child(1) > .numInput').type('17');
+
+ // Add first by clicking next
+ cy.get('.modal .save-button').click();
+
+ // Check if daypart is added in toast message
+ cy.contains('Added Cypress Test Daypart ' + testRun + '_1');
+ });
+
+ it('searches and edit existing daypart', function() {
+ // Create a new daypart and then search for it and edit it
+ cy.createDayPart('Cypress Test Daypart ' + testRun).then((id) => {
+ cy.intercept({
+ url: '/daypart?*',
+ query: {name: 'Cypress Test Daypart ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ // Intercept the PUT request
+ cy.intercept({
+ method: 'PUT',
+ url: '/daypart/*',
+ }).as('putRequest');
+
+ cy.visit('/daypart/view');
+
+ // Filter for the created daypart
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Daypart ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#dayparts tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#dayparts tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#dayparts tr:first-child .daypart_button_edit').click({force: true});
+
+ cy.get('.modal input#name').clear()
+ .type('Cypress Test Daypart Edited ' + testRun);
+
+ // edit test daypart
+ cy.get('.bootbox .save-button').click();
+
+ // Wait for the intercepted PUT request and check the form data
+ cy.wait('@putRequest').then((interception) => {
+ // Get the request body (form data)
+ const response = interception.response;
+ const responseData = response.body.data;
+
+ // assertion on the "daypart" value
+ expect(responseData.name).to.eq('Cypress Test Daypart Edited ' + testRun);
+ });
+
+ // Delete the daypart and assert success
+ cy.deleteDayPart(id).then((res) => {
+ expect(res.status).to.equal(204);
+ });
+ });
+ });
+
+ it('searches and delete existing daypart', function() {
+ // Create a new daypart and then search for it and delete it
+ cy.createDayPart('Cypress Test Daypart ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/daypart?*',
+ query: {name: 'Cypress Test Daypart ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.visit('/daypart/view');
+
+ // Filter for the created daypart
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Daypart ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#dayparts tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#dayparts tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#dayparts tr:first-child .daypart_button_delete').click({force: true});
+
+ // Delete test daypart
+ cy.get('.bootbox .save-button').click();
+
+ // Check if daypart is deleted in toast message
+ cy.get('.toast').contains('Deleted Cypress Test Daypart');
+ });
+ });
+
+ it('searches and share existing daypart', function() {
+ // Create a new daypart and then search for it and share it
+ cy.createDayPart('Cypress Test Daypart ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/daypart?*',
+ query: {name: 'Cypress Test Daypart ' + testRun},
+ }).as('loadGridAfterSearch');
+
+ cy.intercept({
+ query: {name: 'Everyone'},
+ url: /\/user\/permissions\/DayPart\/\d+\?draw=2/,
+ }).as('draw2');
+
+ cy.intercept({
+ query: {name: 'Everyone'},
+ url: /\/user\/permissions\/DayPart\/\d+\?draw=3/,
+ }).as('draw3');
+
+ cy.visit('/daypart/view');
+
+ // Filter for the created daypart
+ cy.get('#Filter input[name="name"]')
+ .type('Cypress Test Daypart ' + testRun);
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+ cy.get('#dayparts tbody tr').should('have.length', 1);
+
+ // Click on the first row element to open the delete modal
+ cy.get('#dayparts tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#dayparts tr:first-child .daypart_button_permissions').click({force: true});
+
+ cy.get('.modal #name').type('Everyone');
+ cy.wait('@draw2');
+ cy.get('#permissionsTable tbody tr').should('have.length', 1);
+
+ cy.get('#permissionsTable').within(() => {
+ cy.get('input[type="checkbox"][data-permission="view"]').should('be.visible').check();
+
+ // DOM is refreshed at this point, so wait for it
+ cy.wait('@draw3');
+ // We have no other option but to put {force: true} here
+ cy.get('input[type="checkbox"][data-permission="edit"]').check();
+ });
+
+ // Save
+ cy.get('.bootbox .save-button').click();
+
+ // Check if daypart is deleted in toast message
+ cy.get('.toast').contains('Share option Updated');
+ });
+ });
+
+ it('selects multiple dayparts and delete them', function() {
+ // Create a new daypart and then search for it and delete it
+ cy.createDayPart('Cypress Test Daypart ' + testRun).then((res) => {
+ cy.intercept({
+ url: '/daypart?*',
+ query: {name: 'Cypress Test Daypart'},
+ }).as('loadGridAfterSearch');
+
+ // Delete all test dayparts
+ cy.visit('/daypart/view');
+
+ // Clear filter
+ cy.get('#Filter input[name="name"]')
+ .clear()
+ .type('Cypress Test Daypart');
+
+ // Wait for the grid reload
+ cy.wait('@loadGridAfterSearch');
+
+ // Select all
+ cy.get('button[data-toggle="selectAll"]').click();
+
+ // Delete all
+ cy.get('.dataTables_info button[data-toggle="dropdown"]').click();
+ cy.get('.dataTables_info a[data-button-id="daypart_button_delete"]').click();
+
+ cy.get('button.save-button').click();
+
+ // Modal should contain one successful delete at least
+ cy.get('.modal-body').contains(': Success');
+ });
+ });
+});
diff --git a/cypress/e2e/Schedule/schedule.cy.js b/cypress/e2e/Schedule/schedule.cy.js
new file mode 100644
index 0000000..e0887ac
--- /dev/null
+++ b/cypress/e2e/Schedule/schedule.cy.js
@@ -0,0 +1,392 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable max-len */
+describe('Schedule Events', function() {
+ // Seeded Data
+ const campaignSchedule1 = 'Campaign for Schedule 1';
+ const layoutSchedule1 = 'Layout for Schedule 1';
+
+ const display1 = 'List Campaign Display 1';
+ const display2 = 'List Campaign Display 2';
+ const command1 = 'Set Timezone';
+
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should list all scheduled events', function() {
+ // Make a GET request to the API endpoint '/schedule/data/events'??
+ cy.request({
+ method: 'GET',
+ url: '/schedule/data/events',
+ }).then((response) => {
+ // Assertions on the response
+ expect(response.status).to.equal(200);
+ expect(response.body).to.have.property('result');
+ });
+ });
+
+ // TC-01
+ it('should schedule an event layout that has no priority, no recurrence', function() {
+ cy.intercept('GET', '/schedule/form/add?*').as('scheduleAddForm');
+
+ // Set up intercepts with aliases
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: display1},
+ }).as('loadDisplaygroupAfterSearch');
+
+ cy.intercept({
+ url: '/campaign?type=list*',
+ query: {name: layoutSchedule1},
+ }).as('loadListCampaignsAfterSearch');
+
+ // Click on the Add Event button
+ cy.visit('/schedule/view');
+
+ cy.contains('Clear Filters').should('be.visible').click();
+ cy.contains('Add Event').click();
+
+ cy.get('.bootbox.modal')
+ .should('be.visible') // essential: Ensure the modal is visible
+ .then(() => {
+ cy.get('.modal-content #eventTypeId').select('Layout');
+ // Select layout
+ cy.selectFromDropdown('.layout-control .select2-selection', layoutSchedule1, layoutSchedule1, '@loadListCampaignsAfterSearch');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select display
+ cy.selectFromDropdown('.display-group-control .select2-selection', display1, display1, '@loadDisplaygroupAfterSearch', 1);
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select day part and set name
+ cy.get('.modal-content [name="dayPartId"]').select('Always');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+ cy.get('.modal-content [name="name"]').type('Always - Layout Event');
+
+ cy.get('.modal .modal-footer').contains('Finish').click();
+
+ cy.contains('Added Event');
+ });
+
+ // Validate - schedule creation should be successful
+ cy.visit('/schedule/view');
+ cy.contains('Clear Filters').should('be.visible').click();
+
+ cy.get('#DisplayList + span .select2-selection').click();
+
+ // Type the display name
+ cy.get('.select2-container--open textarea[type="search"]').type(display1);
+
+ // Wait for Display to load
+ cy.wait('@loadDisplayAfterSearch');
+ cy.get('.select2-container--open').contains(display1);
+ cy.get('.select2-container--open .select2-results > ul > li').should('have.length', 1);
+
+ // Select the display from the dropdown
+ cy.get('.select2-container--open .select2-results > ul > li:first').contains(display1).click();
+
+ // Verify that the schedule is successfully created and listed in the grid
+ cy.get('#schedule-grid').contains(layoutSchedule1);
+
+ // Should have 1
+ cy.get('#schedule-grid tbody tr').should('have.length', 1);
+ });
+
+ // relies on TC-01
+ it('should edit a scheduled event', function() {
+ cy.intercept('GET', '/schedule/form/add?*').as('scheduleAddForm');
+
+ // Set up intercepts with aliases
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: display2},
+ }).as('loadDisplaygroupAfterSearch');
+
+ cy.intercept({
+ url: '/campaign?type=list*',
+ query: {name: layoutSchedule1},
+ }).as('loadListCampaignsAfterSearch');
+
+ // Click on the Add Event button
+ cy.visit('/schedule/view');
+
+ cy.contains('Clear Filters').should('be.visible').click();
+
+ cy.get('#DisplayList + span .select2-selection').click();
+
+ // Type the display name
+ cy.get('.select2-container--open textarea[type="search"]').type(display1);
+
+ // Wait for Display to load
+ cy.wait('@loadDisplayAfterSearch');
+ cy.get('.select2-container--open').contains(display1);
+ cy.get('.select2-container--open .select2-results > ul > li').should('have.length', 1);
+
+ // Select the display from the dropdown
+ cy.get('.select2-container--open .select2-results > ul > li:first').contains(display1).click();
+
+ // Verify that the schedule is successfully created and listed in the grid
+ cy.get('#schedule-grid').contains(layoutSchedule1);
+
+ // Should have 1
+ cy.get('#schedule-grid tbody tr').should('have.length', 1);
+ cy.get('#schedule-grid tr:first-child .dropdown-toggle').click({force: true});
+ cy.get('#schedule-grid tr:first-child .schedule_button_edit').click({force: true});
+
+ cy.contains('.stepwizard-step', 'Displays')
+ .find('a')
+ .click();
+
+ // Select display
+ cy.get('.display-group-control > .col-sm-10 > .select2 > .selection > .select2-selection').type(display2);
+ // Wait for the display group to load after search
+ cy.wait('@loadDisplaygroupAfterSearch');
+ cy.get('.select2-container--open .select2-dropdown .select2-results > ul')
+ .should('contain', display2);
+ cy.get('.select2-container--open .select2-dropdown .select2-results > ul > li')
+ .should('have.length', 2)
+ .last()
+ .click();
+
+ cy.contains('.stepwizard-step', 'Optional')
+ .find('a')
+ .click();
+
+ cy.get('.modal-content [name="name"]').clear().type('Always - Layout Event Edited');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Save').click();
+ cy.contains('Edited Event');
+ });
+
+ it('should schedule an event campaign that has no priority, no recurrence', function() {
+ cy.intercept('GET', '/schedule/form/add?*').as('scheduleAddForm');
+
+ // Set up intercepts with aliases
+ cy.intercept({
+ url: '/display?start=*',
+ query: {display: display1},
+ }).as('loadDisplayAfterSearch');
+
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: display1},
+ }).as('loadDisplaygroupAfterSearch');
+
+ cy.intercept({
+ url: '/campaign?type=list*',
+ query: {name: campaignSchedule1},
+ }).as('loadListCampaignsAfterSearch');
+
+ // Visit the page and click on the Add Event button
+ cy.visit('/schedule/view');
+
+ cy.contains('Clear Filters').should('be.visible').click();
+ cy.contains('Add Event').click();
+
+ cy.get('.bootbox.modal')
+ .should('be.visible') // essential: Ensure the modal is visible
+ .then(() => {
+ cy.get('.modal-content #eventTypeId').select('Campaign');
+ // Select campaign
+ cy.selectFromDropdown('.layout-control .select2-selection', campaignSchedule1, campaignSchedule1, '@loadListCampaignsAfterSearch');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select display
+ cy.selectFromDropdown('.display-group-control .select2-selection', display1, display1, '@loadDisplaygroupAfterSearch', 1);
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select day part and campaign
+ cy.get('.modal-content [name="dayPartId"]').select('Always');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+ cy.get('.modal-content [name="name"]').type('Always - Campaign Event');
+
+ cy.get('.modal .modal-footer').contains('Finish').click();
+
+ cy.contains('Added Event');
+ });
+
+ // Validate - schedule creation should be successful
+ cy.visit('/schedule/view');
+ cy.contains('Clear Filters').should('be.visible').click();
+
+ cy.get('#DisplayList + span .select2-selection').click();
+
+ // Type the display name
+ cy.get('.select2-container--open textarea[type="search"]').type(display1);
+
+ // Wait for Display to load
+ cy.wait('@loadDisplayAfterSearch');
+ cy.get('.select2-container--open').contains(display1);
+ cy.get('.select2-container--open .select2-results > ul > li').should('have.length', 1);
+
+ // Select the display from the dropdown
+ cy.get('.select2-container--open .select2-results > ul > li:first').contains(display1).click();
+
+ // Verify that the schedule is successfully created and listed in the grid
+ cy.get('#schedule-grid').contains(campaignSchedule1);
+ });
+
+ it('should schedule an event command layout that has no priority, no recurrence', function() {
+ cy.intercept('GET', '/schedule/form/add?*').as('scheduleAddForm');
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: display1},
+ }).as('loadDisplaygroupAfterSearch');
+
+ cy.intercept({
+ url: '/command?*',
+ query: {command: command1},
+ }).as('loadCommandAfterSearch');
+
+ // Click on the Add Event button
+ cy.visit('/schedule/view');
+
+ cy.contains('Clear Filters').should('be.visible').click();
+ cy.contains('Add Event').click();
+
+ cy.get('.bootbox.modal')
+ .should('be.visible') // essential: Ensure the modal is visible
+ .then(() => {
+ cy.get('.modal-content #eventTypeId').select('Command');
+ // Select command
+ cy.selectFromDropdown('.command-control .select2-selection', command1, command1, '@loadCommandAfterSearch');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select display
+ cy.selectFromDropdown('.display-group-control .select2-selection', display1, display1, '@loadDisplaygroupAfterSearch', 1);
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ cy.get('.starttime-control > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper').click();
+ cy.get('.open > .flatpickr-innerContainer > .flatpickr-rContainer > .flatpickr-days > .dayContainer > .today').click();
+ cy.get('.open > .flatpickr-time > :nth-child(3) > .arrowUp').click();
+
+ cy.get('.modal .modal-footer').contains('Next').click();
+ cy.get('.modal-content [name="name"]').type('Custom - Command Event');
+
+ cy.get('.modal .modal-footer').contains('Finish').click();
+ });
+ });
+
+ it('should schedule an event overlay layout that has no priority, no recurrence', function() {
+ cy.intercept('GET', '/schedule/form/add?*').as('scheduleAddForm');
+ cy.intercept({
+ url: '/displaygroup?*',
+ query: {displayGroup: display1},
+ }).as('loadDisplaygroupAfterSearch');
+
+ cy.intercept({
+ url: '/campaign?type=list*',
+ query: {name: layoutSchedule1},
+ }).as('loadListCampaignsAfterSearch');
+
+ // Click on the Add Event button
+ cy.visit('/schedule/view');
+
+ cy.contains('Clear Filters').should('be.visible').click();
+ cy.contains('Add Event').click();
+
+ cy.get('.bootbox.modal')
+ .should('be.visible') // essential: Ensure the modal is visible
+ .then(() => {
+ cy.get('.modal-content #eventTypeId').select('Overlay Layout');
+ // Select layout
+ cy.selectFromDropdown('.layout-control .select2-selection', layoutSchedule1, layoutSchedule1, '@loadListCampaignsAfterSearch');
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select display
+ cy.selectFromDropdown('.display-group-control .select2-selection', display1, display1, '@loadDisplaygroupAfterSearch', 1);
+
+ // Click Next and check toast message
+ cy.get('.modal .modal-footer').contains('Next').click();
+
+ // Select daypart - custom
+ cy.get('#dayPartId').select('Custom');
+
+
+ cy.get('.starttime-control > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper')
+ .click() // Open the picker
+ .then(() => {
+ // Select today's date
+ cy.get('.flatpickr-calendar.open .flatpickr-days .dayContainer .today')
+ .click();
+
+ // Increment the hour (adjust time)
+ cy.get('.flatpickr-calendar.open .flatpickr-time :nth-child(3) .arrowUp')
+ .click();
+
+ // Close the picker by clicking outside
+ cy.get('body').click(0, 0);
+ });
+
+ cy.get('.endtime-control > .col-sm-10 > .input-group > .flatpickr-wrapper > .datePickerHelper')
+ .click() // Open the picker
+ .then(() => {
+ // Select today's date
+ cy.get('.flatpickr-calendar.open .flatpickr-days .dayContainer .today')
+ .click();
+
+ // Increment the hour (adjust time)
+ cy.get('.flatpickr-calendar.open .flatpickr-time :nth-child(3) .arrowUp')
+ .click()
+ .click();
+
+ // Close the picker by clicking outside
+ cy.get('body').click(0, 0);
+ });
+
+ cy.get('.modal .modal-footer').contains('Next').click();
+ cy.get('.modal-content [name="name"]').type('Custom - Overlay Event');
+
+ cy.get('.modal .modal-footer').contains('Finish').click();
+ });
+ });
+});
diff --git a/cypress/e2e/Templates/templates.cy.js b/cypress/e2e/Templates/templates.cy.js
new file mode 100644
index 0000000..759694c
--- /dev/null
+++ b/cypress/e2e/Templates/templates.cy.js
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Template Test Suite', function () {
+
+ let templateName = '';
+
+ // create template flow
+ function createTemplate(name) {
+ cy.visit('/template/view');
+ cy.contains('Add Template').click();
+ cy.get('#name').clear().type(name);
+ cy.get('#dialog_btn_2').should('be.visible').click();
+ cy.get('#layout-editor').should('be.visible');
+ cy.get('#backBtn').click({ force: true });
+ }
+
+ // delete template flow
+ function deleteATemplate(name) {
+ cy.get('div[title="Row Menu"] button.dropdown-toggle').click({ force: true });
+ cy.get('a.layout_button_delete[data-commit-method="delete"]').click({ force: true });
+
+ cy.get('#layoutDeleteForm').should('be.visible');
+ cy.contains('p', 'Are you sure you want to delete this item?').should('be.visible');
+ }
+
+ beforeEach(function () {
+ cy.login();
+ templateName = 'Template No. ' + Cypress._.random(0, 1e9);
+ });
+
+ // Display Template List
+ it('should display the template list', function () {
+ cy.intercept('GET', '**/template*').as('templateList');
+ cy.visit('/template/view');
+ cy.wait('@templateList').its('response.statusCode').should('eq', 200);
+ });
+
+ // Save Incomplete Form
+ it('should prevent saving incomplete template', function () {
+ cy.visit('/template/view');
+ cy.contains('Add Template').click();
+ cy.get('#dialog_btn_2').should('be.visible').click();
+ cy.contains('Layout Name must be between 1 and 100 characters').should('be.visible');
+ });
+
+ // Create a Template
+ it('should create a template', function () {
+ createTemplate(templateName);
+ cy.contains('td', templateName).should('be.visible');
+ });
+
+ // Duplicate Template
+ it('should not allow duplicate template name', function () {
+ createTemplate(templateName);
+
+ cy.contains('Add Template').click();
+ cy.get('#name').clear().type(templateName);
+ cy.get('#dialog_btn_2').should('be.visible').click();
+
+ cy.get('.modal-footer .form-error')
+ .contains(`You already own a Layout called '${templateName}'. Please choose another name.`)
+ .should('be.visible');
+ });
+
+ // Search and Delete a template
+ it('should search template and delete it', function () {
+ cy.intercept({
+ url: '/template?*',
+ query: { template: templateName },
+ }).as('displayTemplateAfterSearch');
+
+ createTemplate(templateName);
+
+ cy.get('#template').clear().type(templateName);
+ cy.wait('@displayTemplateAfterSearch');
+ cy.get('table tbody tr').should('have.length', 1);
+ cy.get('#templates tbody tr:nth-child(1) td:nth-child(1)').contains(templateName);
+
+ cy.get('#templates tbody tr')
+ .should('have.length', 1)
+ .first()
+ .should('contain.text', templateName);
+
+ // delete template = no
+ deleteATemplate(templateName);
+ cy.get('#dialog_btn_2').click({ force: true });
+ cy.contains(templateName).should('be.visible');
+
+ // delete template = yes
+ deleteATemplate(templateName);
+ cy.get('#dialog_btn_3').click({ force: true });
+ cy.get('#toast-container .toast-message').contains(`Deleted ${templateName}`).should('be.visible');
+ cy.contains(templateName).should('not.exist');
+ });
+
+ // Multiple deleting of templates
+ it('should delete multiple templates', function () {
+ createTemplate(templateName);
+
+ cy.get('button[data-toggle="selectAll"]').click();
+ cy.get('.dataTables_info button[data-toggle="dropdown"]').click();
+ cy.get('a[data-button-id="layout_button_delete"]').click();
+
+ cy.get('.modal-footer').contains('Save').click();
+ cy.get('.modal-body').contains(': Success');
+ cy.get('.modal-footer').contains('Close').click();
+ cy.contains('.dataTables_empty', 'No data available in table').should('be.visible');
+ });
+
+ // Search for non-existing template
+ it('should not return any entry for non-existing template', function () {
+ cy.visit('/template/view');
+ cy.get('#template').clear().type('This is a hardcoded template name just to make sure it doesnt exist in the record');
+ cy.contains('.dataTables_empty', 'No data available in table').should('be.visible');
+ });
+
+});
diff --git a/cypress/e2e/UserAccount/user_account.cy.js b/cypress/e2e/UserAccount/user_account.cy.js
new file mode 100644
index 0000000..8edc7b5
--- /dev/null
+++ b/cypress/e2e/UserAccount/user_account.cy.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('User Account Test Suite', function() {
+
+ beforeEach(function () {
+ cy.login();
+ cy.visit('/statusdashboard');
+ });
+
+ it('navigates to edit profile', function() {
+ cy.url().should('include', 'dashboard');
+ cy.get('img.nav-avatar').should('be.visible');
+ cy.get('#navbarUserMenu').click();
+ cy.get('div[aria-labelledby="navbarUserMenu"]')
+ .should('be.visible')
+ .contains('Edit Profile');
+ });
+
+ it('verifies all menu items are present and in order', function() {
+ cy.get('#navbarUserMenu').click();
+ cy.get('div[aria-labelledby="navbarUserMenu"] a')
+ .should('have.length', 6)
+ .then($items => {
+ const texts = [...$items].map(el => el.innerText.trim());
+ expect(texts).to.deep.equal([
+ 'Preferences',
+ 'Edit Profile',
+ 'My Applications',
+ 'Reshow welcome',
+ 'About',
+ 'Logout'
+ ]);
+ });
+ });
+
+ it('validates edit profile', function() {
+ cy.get('#navbarUserMenu').click();
+ cy.get('div[aria-labelledby="navbarUserMenu"]')
+ .contains('Edit Profile')
+ .click();
+
+ cy.get('.modal-content').should('be.visible');
+ cy.contains('label', 'User Name').should('be.visible');
+ cy.contains('label', 'Password').should('be.visible');
+ cy.contains('label', 'New Password').should('be.visible');
+ cy.contains('label', 'Retype New Password').should('be.visible');
+ cy.contains('label', 'Email').should('be.visible');
+ cy.contains('label', 'Two Factor Authentication').should('be.visible');
+
+ // Ensure 2FA defaults to Off
+ cy.get('#twoFactorTypeId')
+ .should('be.visible')
+ .find('option:selected')
+ .should('have.text', 'Off');
+ });
+
+});
\ No newline at end of file
diff --git a/cypress/e2e/User_Account/user_account.cy.js b/cypress/e2e/User_Account/user_account.cy.js
new file mode 100644
index 0000000..8edc7b5
--- /dev/null
+++ b/cypress/e2e/User_Account/user_account.cy.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2025 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('User Account Test Suite', function() {
+
+ beforeEach(function () {
+ cy.login();
+ cy.visit('/statusdashboard');
+ });
+
+ it('navigates to edit profile', function() {
+ cy.url().should('include', 'dashboard');
+ cy.get('img.nav-avatar').should('be.visible');
+ cy.get('#navbarUserMenu').click();
+ cy.get('div[aria-labelledby="navbarUserMenu"]')
+ .should('be.visible')
+ .contains('Edit Profile');
+ });
+
+ it('verifies all menu items are present and in order', function() {
+ cy.get('#navbarUserMenu').click();
+ cy.get('div[aria-labelledby="navbarUserMenu"] a')
+ .should('have.length', 6)
+ .then($items => {
+ const texts = [...$items].map(el => el.innerText.trim());
+ expect(texts).to.deep.equal([
+ 'Preferences',
+ 'Edit Profile',
+ 'My Applications',
+ 'Reshow welcome',
+ 'About',
+ 'Logout'
+ ]);
+ });
+ });
+
+ it('validates edit profile', function() {
+ cy.get('#navbarUserMenu').click();
+ cy.get('div[aria-labelledby="navbarUserMenu"]')
+ .contains('Edit Profile')
+ .click();
+
+ cy.get('.modal-content').should('be.visible');
+ cy.contains('label', 'User Name').should('be.visible');
+ cy.contains('label', 'Password').should('be.visible');
+ cy.contains('label', 'New Password').should('be.visible');
+ cy.contains('label', 'Retype New Password').should('be.visible');
+ cy.contains('label', 'Email').should('be.visible');
+ cy.contains('label', 'Two Factor Authentication').should('be.visible');
+
+ // Ensure 2FA defaults to Off
+ cy.get('#twoFactorTypeId')
+ .should('be.visible')
+ .find('option:selected')
+ .should('have.text', 'Off');
+ });
+
+});
\ No newline at end of file
diff --git a/cypress/e2e/dashboard.cy.js b/cypress/e2e/dashboard.cy.js
new file mode 100644
index 0000000..5d73376
--- /dev/null
+++ b/cypress/e2e/dashboard.cy.js
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+describe('Dashboard', function() {
+ beforeEach(function() {
+ cy.login();
+ });
+
+ it('should be at the dashboard page', function() {
+ cy.visit('/statusdashboard');
+
+
+ cy.url().should('include', 'dashboard');
+
+ // Check for the dashboard elements
+ cy.contains('Bandwidth Usage');
+ cy.contains('Library Usage');
+ cy.contains('Display Activity');
+ cy.contains('Latest News');
+ });
+});
diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js
new file mode 100644
index 0000000..5cd8adc
--- /dev/null
+++ b/cypress/e2e/login.cy.js
@@ -0,0 +1,37 @@
+describe('Login', function() {
+
+ it('should be able to login the default user', function () {
+
+ cy.visit('/login').then(() => {
+
+ cy.get('input#username')
+ .type('xibo_admin');
+
+ cy.get('input#password')
+ .type('password');
+
+ cy.get('button[type=submit]')
+ .click();
+
+ cy.url().should('include', 'dashboard');
+
+ cy.contains('xibo_admin');
+ });
+ });
+
+ it('should fail to login an invalid user', function () {
+
+ cy.visit('/login').then(() => {
+ cy.get('input#username')
+ .type('xibo_admin');
+
+ cy.get('input#password')
+ .type('wrongpassword');
+
+ cy.get('button[type=submit]')
+ .click();
+
+ cy.contains('Username or Password incorrect');
+ });
+ });
+});
\ No newline at end of file
diff --git a/cypress/e2e/unauthed.cy.js b/cypress/e2e/unauthed.cy.js
new file mode 100644
index 0000000..f882398
--- /dev/null
+++ b/cypress/e2e/unauthed.cy.js
@@ -0,0 +1,19 @@
+describe('Unauthenticated CMS access', function () {
+ it('should visit the login page and check the version', function () {
+
+ cy.visit('/login').then(() => {
+
+ cy.url().should('include', '/login');
+
+ cy.contains('Version 4.');
+ });
+ });
+
+ it('should redirect to login when an authenticated page is requested', function() {
+ cy.visit('/logout').then(() => {
+ cy.visit('/layout/view').then(() => {
+ cy.url().should('include', '/login');
+ });
+ });
+ });
+});
diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json
new file mode 100644
index 0000000..da18d93
--- /dev/null
+++ b/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
\ No newline at end of file
diff --git a/cypress/fixtures/file/example.zip b/cypress/fixtures/file/example.zip
new file mode 100644
index 0000000..ab770b3
Binary files /dev/null and b/cypress/fixtures/file/example.zip differ
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
new file mode 100644
index 0000000..fd170fb
--- /dev/null
+++ b/cypress/plugins/index.js
@@ -0,0 +1,17 @@
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+module.exports = (on, config) => {
+ // `on` is used to hook into various events Cypress emits
+ // `config` is the resolved Cypress config
+}
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
new file mode 100644
index 0000000..6810262
--- /dev/null
+++ b/cypress/support/commands.js
@@ -0,0 +1,455 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+Cypress.Commands.add('login', function(callbackRoute = '/login') {
+ cy.session('saveSession', () => {
+ cy.visit(callbackRoute);
+ cy.request({
+ method: 'POST',
+ url: '/login',
+ form: true,
+ body: {
+ username: 'xibo_admin',
+ password: 'password',
+ },
+ }).then((res) => {
+ // Get access token and save it as a environment variable
+ cy.getAccessToken().then(function() {
+ cy.getCookie('PHPSESSID').should('exist');
+ });
+ });
+ });
+});
+
+Cypress.Commands.add('getAccessToken', function() {
+ cy.request({
+ method: 'POST',
+ url: '/api/authorize/access_token',
+ form: true,
+ body: {
+ client_id: Cypress.env('client_id'),
+ client_secret: Cypress.env('client_secret'),
+ grant_type: 'client_credentials',
+ },
+ }).then((res) => {
+ Cypress.env('accessToken', res.body.access_token);
+ });
+});
+
+Cypress.Commands.add('tutorialClose', function() {
+ const csrf_token = Cypress.$('meta[name="token"]').attr('content');
+
+ // Make the ajax request to hide the user welcome tutorial
+ Cypress.$.ajax({
+ url: '/user/welcome',
+ type: 'PUT',
+ headers: {
+ 'X-XSRF-TOKEN': csrf_token,
+ },
+ });
+});
+
+Cypress.Commands.add('formRequest', (method, url, formData) => {
+ return new Promise(function(resolve, reject) {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open(method, url);
+ xhr.setRequestHeader('Authorization', 'Bearer ' + Cypress.env('accessToken'));
+
+ xhr.onload = function() {
+ if (this.status >= 200 && this.status < 300) {
+ resolve(xhr.response);
+ } else {
+ reject({
+ status: this.status,
+ statusText: xhr.statusText,
+ });
+ }
+ };
+ xhr.onerror = function() {
+ reject({
+ status: this.status,
+ statusText: xhr.statusText,
+ });
+ };
+
+ xhr.send(formData);
+ });
+});
+
+Cypress.Commands.add('addMediaToLibrary', (fileName) => {
+ // Declarations
+ const method = 'POST';
+ const url = '/api/library';
+ const fileType = '*/*';
+
+ // Get file from fixtures as binary
+ return cy.fixture(fileName, 'binary').then((zipBin) => {
+ // File in binary format gets converted to blob so it can be sent as Form data
+ const fileBlob = Cypress.Blob.binaryStringToBlob(zipBin, fileType);
+
+ // Build up the form
+ const formData = new FormData();
+
+ formData.set('files[]', fileBlob, fileName); // adding a file to the form
+
+ // Perform the request
+ return cy.formRequest(method, url, formData).then((response) => {
+ const { files } = JSON.parse(response);
+
+ // Return id
+ return files[0].name;
+ });
+ });
+});
+
+// Campaign
+Cypress.Commands.add('createCampaign', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/campaign',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ },
+ }).then((res) => {
+ return res.body.campaignId;
+ });
+});
+
+// Dataset
+Cypress.Commands.add('createDataset', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/dataset',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ dataSet: name,
+ },
+ }).then((res) => {
+ return res.body.dataSetId;
+ });
+});
+
+// Delete Dataset
+Cypress.Commands.add('deleteDataset', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/dataset/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
+
+// Sync Group
+Cypress.Commands.add('createSyncGroup', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/syncgroup/add',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ syncPublisherPort: 9590,
+ },
+ }).then((res) => {
+ return res.body.datasetId;
+ });
+});
+
+// DayPart
+Cypress.Commands.add('createDayPart', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/daypart',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ startTime: '01:00:00',
+ endTime: '02:00:00',
+ },
+ }).then((res) => {
+ return res.body.dayPartId;
+ });
+});
+
+// Delete DayPart
+Cypress.Commands.add('deleteDayPart', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/daypart/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
+
+// Tag
+Cypress.Commands.add('createTag', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/tag',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ },
+ }).then((res) => {
+ return res.body.id;
+ });
+});
+
+// Application
+Cypress.Commands.add('createApplication', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/application',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ },
+ }).then((res) => {
+ return res.body.key;
+ });
+});
+
+/**
+ * Open playlist editor modal and wait for toolbar user prefs to load
+ * @param {String} playlistName
+ */
+Cypress.Commands.add('openPlaylistEditorAndLoadPrefs', function(playlistId) {
+ cy.intercept('GET', '/user/pref?preference=toolbar').as('userPrefsLoad');
+
+ // Reload playlist table page
+ cy.visit('/playlist/view');
+
+ // Clear toolbar preferences
+ cy.clearToolbarPrefs();
+
+ cy.window().then((win) => {
+ win.XiboCustomFormRender(win.$(''));
+
+ // Wait for user prefs to load
+ cy.wait('@userPrefsLoad');
+ });
+});
+
+/**
+ * Add media items to library
+ */
+Cypress.Commands.add('populateLibraryWithMedia', function() {
+ // Add audio media to library
+ cy.addMediaToLibrary('../assets/audioSample.mp3');
+
+ // Add image media to library
+ cy.addMediaToLibrary('../assets/imageSample.png');
+});
+
+/**
+ * Drag one element to another one
+ * @param {string} draggableSelector
+ * @param {string} dropableSelector
+ */
+Cypress.Commands.add('dragToElement', function(draggableSelector, dropableSelector) {
+ return cy.get(dropableSelector).then(($el) => {
+ const position = {
+ x: $el.offset().left + $el.width() / 2 + window.scrollX,
+ y: $el.offset().top + $el.height() / 2 + window.scrollY,
+ };
+
+ cy.get(draggableSelector).invoke('show');
+
+ cy.get(draggableSelector)
+ .trigger('mousedown', {
+ which: 1,
+ })
+ .trigger('mousemove', {
+ which: 1,
+ pageX: position.x,
+ pageY: position.y,
+ })
+ .trigger('mouseup');
+ });
+});
+
+/**
+ * Go to layout editor page and wait for toolbar user prefs to load
+ * @param {number} layoutId
+ */
+Cypress.Commands.add('goToLayoutAndLoadPrefs', function(layoutId) {
+ cy.intercept('GET', '/user/pref?preference=toolbar').as('userPrefsLoad');
+
+ cy.clearToolbarPrefs();
+
+ cy.visit('/layout/designer/' + layoutId);
+
+ // Wait for user prefs to load
+ cy.wait('@userPrefsLoad');
+});
+
+Cypress.Commands.add('removeAllSelectedOptions', (select2) => {
+ cy.get(select2)
+ .as('select2Container');
+
+ cy.get('@select2Container')
+ .then(($select2Container) => {
+ if ($select2Container.find('.select2-selection__choice').length > 0) {
+ cy.wrap($select2Container)
+ .find('.select2-selection__choice')
+ .each(($selectedOption) => {
+ cy.wrap($selectedOption)
+ .find('.select2-selection__choice__remove')
+ .click(); // Click on the remove button for each selected option
+ });
+ } else {
+ // No options are selected
+ cy.log('No options are selected');
+ }
+ });
+});
+
+// Select an item from the select2 dropdown
+Cypress.Commands.add('selectFromDropdown', (selector, searchText, expected, alias, index = 0) => {
+ cy.get(selector).type(searchText);
+
+ if (alias) {
+ cy.wait(alias).its('response.statusCode').should('eq', 200);
+ }
+
+ cy.get('.select2-container--open .select2-dropdown .select2-results > ul')
+ .should('contain', expected);
+ cy.get('.select2-container--open .select2-dropdown .select2-results > ul > li')
+ .eq(index)
+ .click();
+});
+
+// Select an option from the select2
+Cypress.Commands.add('selectOption', (content) => {
+ cy.get('.select2-container--open').contains(content);
+ cy.get('.select2-container--open .select2-results > ul > li').should('have.length', 1);
+ cy.get('.select2-container--open .select2-results > ul > li:first').contains(content).click();
+});
+
+// Schedule a layout
+Cypress.Commands.add('scheduleCampaign', function(campaignId, displayName) {
+ cy.request({
+ method: 'POST',
+ url: '/api/scheduleCampaign',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ campaignId: campaignId,
+ displayName: displayName,
+ },
+ }).then((res) => {
+ return res.body.eventId;
+ });
+});
+
+// Open Options Menu within the Layout Editor
+Cypress.Commands.add('openOptionsMenu', () => {
+ cy.get('.navbar-submenu')
+ .should('be.visible')
+ .within(() => {
+ cy.get('#optionsContainerTop')
+ .should('be.visible')
+ .and('not.be.disabled')
+ .click({force: true})
+ .should('have.attr', 'aria-expanded', 'true');
+ });
+});
+
+// Open Row Menu of the first item on the Layouts page
+Cypress.Commands.add('openRowMenu', () => {
+ cy.get('#layouts tbody tr').first().within(() => {
+ cy.get('.btn-group .btn.dropdown-toggle')
+ .click()
+ .should('have.attr', 'aria-expanded', 'true');
+ });
+});
+
+/**
+ * Update data on CKEditor instance
+ * @param {string} ckeditorId
+ * @param {string} value
+ */
+Cypress.Commands.add('updateCKEditor', function(ckeditorId, value) {
+ cy.get('textarea[name="' + ckeditorId + '"]').invoke('prop', 'id').then((id) => {
+ cy.window().then((win) => {
+ win.formHelpers.getCKEditorInstance(
+ id,
+ ).setData(value);
+ });
+ });
+});
diff --git a/cypress/support/displayCommands.js b/cypress/support/displayCommands.js
new file mode 100644
index 0000000..66d8dbe
--- /dev/null
+++ b/cypress/support/displayCommands.js
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+// Display Group
+Cypress.Commands.add('createDisplaygroup', function(name, isDynamic = false, criteria) {
+ // Define the request body object
+ const requestBody = {
+ displayGroup: name,
+ };
+
+ // Add 'isDynamic' to the request body if it's true
+ if (isDynamic) {
+ requestBody.isDynamic = true;
+ }
+ // Add 'isDynamic' to the request body if it's true
+ if (criteria) {
+ requestBody.dynamicCriteria = criteria;
+ }
+
+ cy.request({
+ method: 'POST',
+ url: '/api/displaygroup',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: requestBody,
+ }).then((res) => {
+ return res.body.displaygroupId;
+ });
+});
+
+Cypress.Commands.add('deleteDisplaygroup', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/displaygroup/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
+
+// Display Profile
+Cypress.Commands.add('createDisplayProfile', function(name, type) {
+ cy.request({
+ method: 'POST',
+ url: '/api/displayprofile',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ type: type,
+ },
+ }).then((res) => {
+ return res.body.displayProfileId;
+ });
+});
+
+Cypress.Commands.add('deleteDisplayProfile', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/displayprofile/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
+
+// Display Status
+Cypress.Commands.add('displaySetStatus', function(displayName, statusId) {
+ cy.request({
+ method: 'POST',
+ url: '/api/displaySetStatus',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ displayName: displayName,
+ statusId: statusId,
+ },
+ }).then((res) => {
+ return res.body;
+ });
+});
+
+Cypress.Commands.add('displayStatusEquals', function(displayName, statusId) {
+ cy.request({
+ method: 'GET',
+ url: '/api/displayStatusEquals',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ displayName: displayName,
+ statusId: statusId,
+ },
+ }).then((res) => {
+ return res;
+ });
+});
\ No newline at end of file
diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js
new file mode 100644
index 0000000..30ce2b4
--- /dev/null
+++ b/cypress/support/e2e.js
@@ -0,0 +1,39 @@
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands';
+import './toolbarCommands';
+import './layoutCommands';
+import './playlistCommands';
+import './displayCommands';
+import './menuboardCommands';
+import './userCommands';
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
+
+// Run before every test spec, to disable User Welcome tour
+before(function() {
+ cy.login().then(() => {
+ cy.tutorialClose();
+ });
+});
+
+Cypress.on('uncaught:exception', (err, runnable) => {
+ // returning false here prevents Cypress from
+ // failing the test
+ return false
+})
\ No newline at end of file
diff --git a/cypress/support/layoutCommands.js b/cypress/support/layoutCommands.js
new file mode 100644
index 0000000..fe8500d
--- /dev/null
+++ b/cypress/support/layoutCommands.js
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+// Layout
+Cypress.Commands.add('createLayout', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/layout',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ resolutionId: 1, // HD landscape on the testing build
+ },
+ }).then((res) => {
+ return res.body.layoutId;
+ });
+});
+
+Cypress.Commands.add('checkoutLayout', function(id) {
+ cy.request({
+ method: 'PUT',
+ url: '/api/layout/checkout/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ });
+});
+
+Cypress.Commands.add('importLayout', function(fileName) {
+ // Declarations
+ const method = 'POST';
+ const url = '/api/layout/import';
+ const fileType = 'application/zip';
+
+ // Get file from fixtures as binary
+ cy.fixture(fileName, 'binary').then((zipBin) => {
+ // File in binary format gets converted to blob so it can be sent as Form data
+ const blob = Cypress.Blob.binaryStringToBlob(zipBin, fileType);
+
+ // Build up the form
+ const formData = new FormData();
+
+ // Create random name
+ const uuid = Cypress._.random(0, 1e9);
+
+ formData.set('files[]', blob, fileName); // adding a file to the form
+ formData.set('name[]', uuid); // adding a name to the form
+
+ // Perform the request
+ return cy.formRequest(method, url, formData).then((res) => {
+ const parsedJSON = JSON.parse(res);
+ // Return id
+ return parsedJSON.files[0].id;
+ });
+ });
+});
+
+Cypress.Commands.add('deleteLayout', function(id) {
+ cy.request({
+ method: 'DELETE',
+ failOnStatusCode: false,
+ url: '/api/layout/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ });
+});
\ No newline at end of file
diff --git a/cypress/support/menuboardCommands.js b/cypress/support/menuboardCommands.js
new file mode 100644
index 0000000..e9540f4
--- /dev/null
+++ b/cypress/support/menuboardCommands.js
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+// Menuboard
+Cypress.Commands.add('createMenuboard', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/menuboard',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ },
+ }).then((res) => {
+ return res.body.menuId;
+ });
+});
+
+Cypress.Commands.add('createMenuboardCat', function(name, menuId) {
+ cy.request({
+ method: 'POST',
+ url: '/api/menuboard/' + menuId + '/' + 'category',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ },
+ }).then((res) => {
+ return res.body.menuCategoryId;
+ });
+});
+
+Cypress.Commands.add('createMenuboardCatProd', function(name, menuCatId) {
+ cy.request({
+ method: 'POST',
+ url: '/api/menuboard/' + menuCatId + '/' + 'product',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ name: name,
+ },
+ }).then((res) => {
+ return res.body.menuProductId;
+ });
+});
+
+Cypress.Commands.add('deleteMenuboard', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/menuboard/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
\ No newline at end of file
diff --git a/cypress/support/playlistCommands.js b/cypress/support/playlistCommands.js
new file mode 100644
index 0000000..3cb0944
--- /dev/null
+++ b/cypress/support/playlistCommands.js
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+// Playlist
+Cypress.Commands.add('createNonDynamicPlaylist', (name) => {
+ return cy.request({
+ method: 'POST',
+ url: '/api/playlist',
+ headers: {
+ Authorization: `Bearer ${Cypress.env('accessToken')}`,
+ },
+ body: { name },
+ form: true,
+ }).then((response) => response.body.playlistId);
+});
+
+Cypress.Commands.add('addWidgetToPlaylist', function(playlistId, widgetType, widgetData) {
+ cy.request({
+ method: 'POST',
+ url: '/api/playlist/widget/' + widgetType + '/' + playlistId,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: widgetData,
+ });
+});
+
+Cypress.Commands.add('addRandomMediaToPlaylist', function(playlistId) {
+ // Get media
+ cy.request({
+ method: 'GET',
+ url: '/api/library?retired=0&assignable=1&start=0&length=1',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ }).then((res) => {
+ const media = [];
+ media.push(res.body[0].mediaId);
+
+ // Add media to playlist
+ cy.request({
+ method: 'POST',
+ url: '/api/playlist/library/assign/' + playlistId,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ media: media,
+ },
+ });
+ });
+});
+
+Cypress.Commands.add('deletePlaylist', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/playlist/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ });
+});
\ No newline at end of file
diff --git a/cypress/support/toolbarCommands.js b/cypress/support/toolbarCommands.js
new file mode 100644
index 0000000..b7b6314
--- /dev/null
+++ b/cypress/support/toolbarCommands.js
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+Cypress.Commands.add('clearToolbarPrefs', function() {
+ const preference = [];
+
+ preference[0] =
+ {
+ option: 'toolbar',
+ value: JSON.stringify({
+ menuItems: {},
+ openedMenu: -1,
+ }),
+ };
+
+ cy.request({
+ method: 'POST',
+ url: '/api/user/pref',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ preference: preference,
+ },
+ });
+});
+
+/**
+ * Force open toolbar menu
+ * @param {number} menuIdx
+ * @param {boolean} load
+ */
+Cypress.Commands.add('openToolbarMenu', (menuIdx, load = true) => {
+ cy.intercept('GET', '/user/pref?preference=toolbar').as('toolbarPrefsLoad');
+ cy.intercept('GET', '/user/pref?preference=editor').as('editorPrefsLoad');
+ cy.intercept('POST', '/user/pref?preference=toolbar').as('toolbarPrefsSave');
+
+ if (load) {
+ cy.wait('@toolbarPrefsLoad');
+ cy.wait('@editorPrefsLoad');
+ }
+
+ cy.get('.editor-side-bar').then(($toolbar) => {
+ const $submenu = $toolbar.find('#content-' + menuIdx + ' .close-submenu');
+ const $menuButton = $toolbar.find('#btn-menu-' + menuIdx);
+
+ if ($submenu.length > 0) {
+ cy.log('Just close sub-menu!');
+ cy.get('#content-' + menuIdx + ' .close-submenu')
+ .should('be.visible')
+ .click();
+ } else if (!$menuButton.hasClass('active')) {
+ cy.log('Open menu!');
+ cy.get('[data-test="toolbarTabs"]').eq(menuIdx).click();
+ } else {
+ cy.log('Do nothing!');
+ }
+ });
+});
+
+/**
+ * Force open toolbar menu when we are on playlist editor
+ * @param {number} menuIdx
+ */
+Cypress.Commands.add('openToolbarMenuForPlaylist', function(menuIdx) {
+ cy.intercept('POST', '/user/pref').as('toolbarPrefsLoadForPlaylist');
+
+ // Wait for the toolbar to reload when getting prefs at start
+ cy.wait('@toolbarPrefsLoadForPlaylist');
+
+ cy.get('.editor-toolbar').then(($toolbar) => {
+ if ($toolbar.find('#content-' + menuIdx + ' .close-submenu').length > 0) {
+ cy.log('Just close sub-menu!');
+ cy.get('.close-submenu').click();
+ } else if ($toolbar.find('#btn-menu-' + menuIdx + '.active').length == 0) {
+ cy.log('Open menu!');
+ cy.get('.editor-main-toolbar #btn-menu-' + menuIdx).click();
+ } else {
+ cy.log('Do nothing!');
+ }
+ });
+});
+
+Cypress.Commands.add('toolbarSearch', (textToType) => {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+
+ // Clear the search box first
+ cy.get('input#input-name')
+ .filter(':visible')
+ .should('have.length', 1)
+ .invoke('val')
+ .then((value) => {
+ if (value !== '') {
+ cy.get('input#input-name')
+ .filter(':visible')
+ .clear();
+ cy.wait('@updatePreferences');
+ }
+ });
+ // Type keyword to search
+ cy.get('input#input-name')
+ .filter(':visible')
+ .type(textToType);
+ cy.wait('@updatePreferences');
+});
+
+Cypress.Commands.add('toolbarSearchWithActiveFilter', (textToType) => {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+ cy.intercept('GET', '/library/search*').as('librarySearch');
+
+ // Clear the search box first
+ cy.get('input#input-name')
+ .filter(':visible')
+ .should('have.length', 1)
+ .invoke('val')
+ .then((value) => {
+ if (value !== '') {
+ cy.get('input#input-name')
+ .filter(':visible')
+ .clear();
+ cy.wait('@updatePreferences');
+ cy.wait('@librarySearch');
+ }
+ });
+ // Type keyword to search
+ cy.get('input#input-name')
+ .filter(':visible')
+ .type(textToType);
+ cy.wait('@updatePreferences');
+ cy.wait('@librarySearch');
+});
+
+Cypress.Commands.add('toolbarFilterByFolder', (folderName, folderId) => {
+ cy.intercept('POST', '/user/pref').as('updatePreferences');
+ cy.intercept('GET', '/folders?start=0&length=10').as('loadFolders');
+ cy.intercept('GET', '/library/search*').as('librarySearch');
+
+ // Open folder dropdown
+ cy.get('#input-folder')
+ .parent()
+ .find('.select2-selection')
+ .click();
+ cy.wait('@loadFolders');
+
+ // Select the specified folder
+ cy.get('.select2-results__option')
+ .contains(folderName)
+ .should('be.visible')
+ .click();
+
+ cy.wait('@updatePreferences');
+
+ // Verify library search response
+ cy.wait('@librarySearch').then(({response}) => {
+ expect(response.statusCode).to.eq(200);
+ expect(response.url).to.include(`folderId=${folderId}`);
+ });
+});
\ No newline at end of file
diff --git a/cypress/support/userCommands.js b/cypress/support/userCommands.js
new file mode 100644
index 0000000..dc81b17
--- /dev/null
+++ b/cypress/support/userCommands.js
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+/* eslint-disable max-len */
+// User
+Cypress.Commands.add('createUser', function(name, password, userTypeId, homeFolderId) {
+ cy.request({
+ method: 'POST',
+ url: '/api/user',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ userName: name,
+ password: password,
+ userTypeId: userTypeId,
+ homeFolderId: homeFolderId,
+ homePageId: 'icondashboard.view',
+ },
+ }).then((res) => {
+ return res.body.userId;
+ });
+});
+
+Cypress.Commands.add('deleteUser', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/user/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
+
+// User Group
+Cypress.Commands.add('createUsergroup', function(name) {
+ cy.request({
+ method: 'POST',
+ url: '/api/group',
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {
+ group: name,
+ },
+ }).then((res) => {
+ return res.body.groupId;
+ });
+});
+
+Cypress.Commands.add('deleteUsergroup', function(id) {
+ cy.request({
+ method: 'DELETE',
+ url: '/api/group/' + id,
+ form: true,
+ headers: {
+ Authorization: 'Bearer ' + Cypress.env('accessToken'),
+ },
+ body: {},
+ }).then((res) => {
+ return res;
+ });
+});
\ No newline at end of file
diff --git a/db/migrations/20180130073838_install_migration.php b/db/migrations/20180130073838_install_migration.php
new file mode 100644
index 0000000..510288a
--- /dev/null
+++ b/db/migrations/20180130073838_install_migration.php
@@ -0,0 +1,1223 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class InstallMigration
+ * migration for initial installation of database
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ * @phpcs:disable Generic.Files.LineLength.TooLong
+ */
+class InstallMigration extends AbstractMigration
+{
+ /**
+ * Migrate Up
+ * Create a new Database if necessary
+ * @throws Exception
+ */
+ public function up()
+ {
+ // At this point, we've no idea if we're an upgrade from a version without phinx or a fresh installation.
+ // if we're an upgrade, we'd expect to find a version table
+ // note: if we are a phinx "upgrade" we've already run this migration before and therefore don't need
+ // to worry about anything below
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // we must be on at least DB version 84 to continue
+ if ($dbVersion < 84)
+ throw new Exception('Upgrading from an unsupported version, please ensure you have at least 1.7.0');
+
+ // That is all we do for this migration - we've checked that the upgrade is supported
+ // subsequent migrations will make the necessary changes
+
+ } else {
+ // No version table - add initial structure and data.
+ // This is a fresh installation!
+ $this->addStructure();
+ $this->addData();
+ }
+ }
+
+ private function addStructure()
+ {
+ // Session
+ $session = $this->table('session', ['id' => false, 'primary_key' => 'session_id']);
+ $session
+ ->addColumn('session_id', 'string', ['limit' => 160])
+ ->addColumn('session_data', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])
+ ->addColumn('session_expiration', 'integer', ['limit' => 10, 'signed' => false, 'default' => 0])
+ ->addColumn('lastAccessed', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('userId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('isExpired', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('userAgent', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addColumn('remoteAddr', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addIndex('userId')
+ ->save();
+
+ // Settings table
+ $settings = $this->table('setting', ['id' => 'settingId']);
+ $settings
+ ->addColumn('setting', 'string', ['limit' => 50])
+ ->addColumn('type', 'string', ['limit' => 50])
+ ->addColumn('title', 'string', ['limit' => 254])
+ ->addColumn('value', 'string', ['limit' => 1000])
+ ->addColumn('default', 'string', ['limit' => 1000])
+ ->addColumn('fieldType', 'string', ['limit' => 24])
+ ->addColumn('helpText', 'text', ['default' => null, 'null' => true])
+ ->addColumn('options', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->addColumn('cat', 'string', ['limit' => 24, 'default' => 'General'])
+ ->addColumn('validation', 'string', ['limit' => 50])
+ ->addColumn('ordering', 'integer')
+ ->addColumn('userSee', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('userChange', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->save();
+
+ // User Type
+ $userType = $this->table('usertype', ['id' => 'userTypeId']);
+ $userType
+ ->addColumn('userType', 'string', ['limit' => 16])
+ ->insert([
+ ['userTypeId' => 1, 'userType' => 'Super Admin'],
+ ['userTypeId' => 2, 'userType' => 'Group Admin'],
+ ['userTypeId' => 3, 'userType' => 'User'],
+ ])
+ ->save();
+
+ // Start with the user table
+ $user = $this->table('user', ['id' => 'userId']);
+ $user
+ ->addColumn('userTypeId', 'integer')
+ ->addColumn('userName', 'string', ['limit' => 50])
+ ->addColumn('userPassword', 'string', ['limit' => 255])
+ ->addColumn('loggedIn', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('lastAccessed', 'datetime', ['null' => true])
+ ->addColumn('email', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addColumn('homePageId', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('retired', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('csprng', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('newUserWizard', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('firstName', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('lastName', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('phone', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref1', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref2', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref3', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref4', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref5', 'string', ['limit' => 254, 'null' => true])
+ ->addForeignKey('userTypeId', 'usertype', 'userTypeId')
+ ->insert([
+ 'userId' => 1,
+ 'userTypeId' => 1,
+ 'userName' => 'xibo_admin',
+ 'userPassword' => '5f4dcc3b5aa765d61d8327deb882cf99',
+ 'loggedIn' => 0,
+ 'lastAccessed' => null,
+ 'homePageId' => 29
+ ])
+ ->save();
+
+ $userOption = $this->table('useroption', ['id' => false, 'primary_key' => ['userId', 'option']]);
+ $userOption
+ ->addColumn('userId', 'integer')
+ ->addColumn('option', 'string', ['limit' => 50])
+ ->addColumn('value', 'text', ['default' => null, 'null' => true])
+ ->save();
+
+ // User Group
+ $userGroup = $this->table('group', ['id' => 'groupId']);
+ $userGroup
+ ->addColumn('group', 'string', ['limit' => 50])
+ ->addColumn('isUserSpecific', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('isEveryone', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('libraryQuota', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('isSystemNotification', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('isDisplayNotification', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->insert([
+ ['groupId' => 1, 'group' => 'Users', 'isUserSpecific' => 0, 'isEveryone' => 0, 'isSystemNotification' => 0],
+ ['groupId' => 2, 'group' => 'Everyone', 'isUserSpecific' => 0, 'isEveryone' => 1, 'isSystemNotification' => 0],
+ ['groupId' => 3, 'group' => 'xibo_admin', 'isUserSpecific' => 1, 'isEveryone' => 0, 'isSystemNotification' => 1],
+ ['groupId' => 4, 'group' => 'System Notifications', 'isUserSpecific' => 0, 'isEveryone' => 0, 'isSystemNotification' => 1],
+ ])
+ ->save();
+
+ // Link User and User Group
+ $linkUserUserGroup = $this->table('lkusergroup', ['id' => 'lkUserGroupID']);
+ $linkUserUserGroup
+ ->addColumn('groupId', 'integer')
+ ->addColumn('userId', 'integer')
+ ->addIndex(['groupId', 'userId'], ['unique' => true])
+ ->addForeignKey('groupId', 'group', 'groupId')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->insert([
+ 'groupId' => 3,
+ 'userId' => 1
+ ])
+ ->save();
+
+
+ // Display Profile
+ $displayProfile = $this->table('displayprofile', ['id' => 'displayProfileId']);
+ $displayProfile
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('type', 'string', ['limit' => 15])
+ ->addColumn('config', 'text', ['default' => null, 'null' => true])
+ ->addColumn('isDefault', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->insert([
+ ['name' => 'Windows', 'type' => 'windows', 'config' => '[]', 'userId' => 1, 'isDefault' => 1],
+ ['name' => 'Android', 'type' => 'android', 'config' => '[]', 'userId' => 1, 'isDefault' => 1],
+ ['name' => 'webOS', 'type' => 'lg', 'config' => '[]', 'userId' => 1, 'isDefault' => 1],
+ ])
+ ->save();
+
+ // Display Table
+ $display = $this->table('display', ['id' => 'displayId']);
+ $display
+ ->addColumn('display', 'string', ['limit' => 50])
+ ->addColumn('auditingUntil', 'integer', ['default' => 0])
+ ->addColumn('defaultLayoutId', 'integer')
+ ->addColumn('license', 'string', ['limit' => 40, 'default' => null, 'null' => true])
+ ->addColumn('licensed', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('loggedIn', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('lastAccessed', 'integer', ['limit' => 11, 'default' => null, 'null' => true])
+ ->addColumn('inc_schedule', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('email_alert', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('alert_timeout', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('clientAddress', 'string', ['limit' => 50, 'default' => null, 'null' => true])
+ ->addColumn('mediaInventoryStatus', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('macAddress', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('lastChanged', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('numberOfMacAddressChanges', 'integer', ['default' => 0])
+ ->addColumn('lastWakeOnLanCommandSent', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('wakeOnLan', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('wakeOnLanTime', 'string', ['limit' => 5, 'default' => null, 'null' => true])
+ ->addColumn('broadCastAddress', 'string', ['limit' => 100, 'default' => null, 'null' => true])
+ ->addColumn('secureOn', 'string', ['limit' => 17, 'default' => null, 'null' => true])
+ ->addColumn('cidr', 'string', ['limit' => 6, 'default' => null, 'null' => true])
+ ->addColumn('geoLocation', 'point', ['default' => null, 'null' => true])
+ ->addColumn('version_instructions', 'string', ['limit' => 255, 'default' => null, 'null' => true])
+ ->addColumn('client_type', 'string', ['limit' => 20, 'default' => null, 'null' => true])
+ ->addColumn('client_version', 'string', ['limit' => 15, 'default' => null, 'null' => true])
+ ->addColumn('client_code', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL, 'null' => true])
+ ->addColumn('displayProfileId', 'integer', ['null' => true])
+ ->addColumn('screenShotRequested', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('storageAvailableSpace', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->addColumn('storageTotalSpace', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->addColumn('xmrChannel', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('xmrPubKey', 'text', ['default' => null, 'null' => true])
+ ->addColumn('lastCommandSuccess', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 2])
+ ->addColumn('deviceName', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('timeZone', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addIndex('defaultLayoutId')
+ ->addForeignKey('displayProfileId', 'displayprofile', 'displayProfileId')
+ ->save();
+
+ // Display Group
+ $displayGroup = $this->table('displaygroup', ['id' => 'displayGroupId']);
+ $displayGroup
+ ->addColumn('displayGroup', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('isDisplaySpecific', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('isDynamic', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('dynamicCriteria', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ // Link Display Group / Display
+ $linkDisplayGroupDisplay = $this->table('lkdisplaydg', ['id' => 'LkDisplayDGID']);
+ $linkDisplayGroupDisplay
+ ->addColumn('displayGroupId', 'integer')
+ ->addColumn('displayId', 'integer')
+ ->addIndex(['displayGroupId', 'displayId'], ['unique' => true])
+ ->addForeignKey('displayGroupId', 'displaygroup', 'displayGroupId')
+ ->addForeignKey('displayId', 'display', 'displayId')
+ ->save();
+
+ // Link Display Group / Display Group
+ $linkDisplayGroup = $this->table('lkdgdg', ['id' => false, ['primary_key' => ['parentId', 'childId', 'depth']]]);
+ $linkDisplayGroup
+ ->addColumn('parentId', 'integer')
+ ->addColumn('childId', 'integer')
+ ->addColumn('depth', 'integer')
+ ->addIndex(['childId', 'parentId', 'depth'], ['unique' => true])
+ ->save();
+
+ // Module Table
+ $module = $this->table('module', ['id' => 'moduleId']);
+ $module
+ ->addColumn('module', 'string', ['limit' => 50])
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('enabled', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('regionSpecific', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('description', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('imageUri', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('schemaVersion', 'integer', ['default' => 1])
+ ->addColumn('validExtensions', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('previewEnabled', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('assignable', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('render_as', 'string', ['limit' => 10, 'default' => null, 'null' => true])
+ ->addColumn('settings', 'text', ['default' => null, 'null' => true])
+ ->addColumn('viewPath', 'string', ['limit' => 254, 'default' => '../modules'])
+ ->addColumn('class', 'string', ['limit' => 254])
+ ->addColumn('defaultDuration', 'integer')
+ ->addColumn('installName', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->save();
+
+ // Media Table
+ $media = $this->table('media', ['id' => 'mediaId']);
+ $media
+ ->addColumn('name', 'string', ['limit' => 100])
+ ->addColumn('type', 'string', ['limit' => 15])
+ ->addColumn('duration', 'integer')
+ ->addColumn('originalFileName', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('storedAs', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('md5', 'string', ['limit' => 32, 'default' => null, 'null' => true])
+ ->addColumn('fileSize', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true])
+ ->addColumn('userId', 'integer')
+ ->addColumn('retired', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('isEdited', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('editedMediaId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('moduleSystemFile', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('valid', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('expires', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('released', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('apiRef', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ // Link Media to Display
+ $linkMediaDisplayGroup = $this->table('lkmediadisplaygroup');
+ $linkMediaDisplayGroup
+ ->addColumn('displayGroupId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->addIndex(['displayGroupId', 'mediaId'], ['unique' => true])
+ ->addForeignKey('displayGroupId', 'displaygroup', 'displayGroupId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+
+ // Resolution
+ $resolution = $this->table('resolution', ['id' => 'resolutionId']);
+ $resolution
+ ->addColumn('resolution', 'string', ['limit' => 254])
+ ->addColumn('width', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('height', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('intended_width', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('intended_height', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('version', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('enabled', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->insert([
+ ['resolution' => '1080p HD Landscape', 'width' => 800, 'height' => 450, 'intended_width' => 1920, 'intended_height' => 1080, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => '720p HD Landscape', 'width' => 800, 'height' => 450, 'intended_width' => 1280, 'intended_height' => 720, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => '1080p HD Portrait', 'width' => 450, 'height' => 800, 'intended_width' => 1080, 'intended_height' => 1920, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => '720p HD Portrait', 'width' => 450, 'height' => 800, 'intended_width' => 720, 'intended_height' => 1280, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => '4k cinema', 'width' => 800, 'height' => 450, 'intended_width' => 4096, 'intended_height' => 2304, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => 'Common PC Monitor 4:3', 'width' => 800, 'height' => 600, 'intended_width' => 1024, 'intended_height' => 768, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => '4k UHD Landscape', 'width' => 450, 'height' => 800, 'intended_width' => 3840, 'intended_height' => 2160, 'version' => 2, 'enabled' => 1, 'userId' => 1],
+ ['resolution' => '4k UHD Portrait', 'width' => 800, 'height' => 450, 'intended_width' => 2160, 'intended_height' => 3840, 'version' => 2, 'enabled' => 1, 'userId' => 1]
+ ])
+ ->save();
+
+ // Layout
+ $layout = $this->table('layout', ['id' => 'layoutId']);
+ $layout
+ ->addColumn('layout', 'string', ['limit' => 254])
+ ->addColumn('userId', 'integer')
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('description', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('retired', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('duration', 'integer')
+ ->addColumn('backgroundImageId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('status', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('width', 'decimal')
+ ->addColumn('height', 'decimal')
+ ->addColumn('backgroundColor', 'string', ['limit' => 25, 'default' => null, 'null' => true])
+ ->addColumn('backgroundzIndex', 'integer', ['default' => 1])
+ ->addColumn('schemaVersion', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 2])
+ ->addColumn('statusMessage', 'text', ['default' => null, 'null' => true])
+ ->addForeignKey('userId', 'user', 'userId')
+ ->addForeignKey('backgroundImageId', 'media', 'mediaId')
+ ->save();
+
+ // Campaign Table
+ $campaign = $this->table('campaign', ['id' => 'campaignId']);
+ $campaign
+ ->addColumn('campaign', 'string', ['limit' => 254])
+ ->addColumn('isLayoutSpecific', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ // Layout/Campaign Link
+ $linkCampaignLayout = $this->table('lkcampaignlayout', ['id' => 'lkCampaignLayoutId']);
+ $linkCampaignLayout
+ ->addColumn('campaignId', 'integer')
+ ->addColumn('layoutId', 'integer')
+ ->addColumn('displayOrder', 'integer')
+ ->addIndex(['campaignId', 'layoutId', 'displayOrder'], ['unique' => true])
+ ->addForeignKey('campaignId', 'campaign', 'campaignId')
+ ->addForeignKey('layoutId', 'layout', 'layoutId')
+ ->save();
+
+ // Layout/Display Group Link
+ $linkLayoutDisplayGroup = $this->table('lklayoutdisplaygroup');
+ $linkLayoutDisplayGroup
+ ->addColumn('displayGroupId', 'integer')
+ ->addColumn('layoutId', 'integer')
+ ->addIndex(['displayGroupId', 'layoutId'], ['unique' => true])
+ ->addForeignKey('displayGroupId', 'displaygroup', 'displayGroupId')
+ ->addForeignKey('layoutId', 'layout', 'layoutId')
+ ->save();
+
+ // Region
+ $region = $this->table('region', ['id' => 'regionId']);
+ $region
+ ->addColumn('layoutId', 'integer')
+ ->addColumn('ownerId', 'integer')
+ ->addColumn('name', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('width', 'decimal')
+ ->addColumn('height', 'decimal')
+ ->addColumn('top', 'decimal')
+ ->addColumn('left', 'decimal')
+ ->addColumn('zIndex', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('duration', 'integer', ['default' => 0])
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->addForeignKey('layoutId', 'layout', 'layoutId')
+ ->save();
+
+ $regionOption = $this->table('regionoption', ['id' => false, 'primary_key' => ['regionId', 'option']]);
+ $regionOption
+ ->addColumn('regionId', 'integer')
+ ->addColumn('option', 'string', ['limit' => 50])
+ ->addColumn('value', 'text', ['null' => true])
+ ->save();
+
+ // Playlist
+ $playlist = $this->table('playlist', ['id' => 'playlistId']);
+ $playlist
+ ->addColumn('name', 'string', ['limit' => 254])
+ ->addColumn('ownerId', 'integer')
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->save();
+
+ $linkRegionPlaylist = $this->table('lkregionplaylist', ['id' => false, 'primary_key' => 'regionId', 'playlistId', 'displayOrder']);
+ $linkRegionPlaylist
+ ->addColumn('regionId', 'integer')
+ ->addColumn('playlistId', 'integer')
+ ->addColumn('displayOrder', 'integer')
+ // No point in adding the foreign keys here, we know they will be removed in a future migration (we drop the table)
+ ->save();
+
+ // Widget
+ $widget = $this->table('widget', ['id' => 'widgetId']);
+ $widget
+ ->addColumn('playlistId', 'integer')
+ ->addColumn('ownerId', 'integer')
+ ->addColumn('type', 'string', ['limit' => 50])
+ ->addColumn('duration', 'integer')
+ ->addColumn('displayOrder', 'integer')
+ ->addColumn('calculatedDuration', 'integer')
+ ->addColumn('useDuration', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addForeignKey('playlistId', 'playlist', 'playlistId')
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->save();
+
+ $widgetOption = $this->table('widgetoption', ['id' => false, 'primary_key' => ['widgetId', 'type', 'option']]);
+ $widgetOption
+ ->addColumn('widgetId', 'integer')
+ ->addColumn('type', 'string', ['limit' => 50])
+ ->addColumn('option', 'string', ['limit' => 254])
+ ->addColumn('value', 'text', ['null' => true])
+ ->addForeignKey('widgetId', 'widget', 'widgetId')
+ ->save();
+
+ $linkWidgetMedia = $this->table('lkwidgetmedia', ['id' => false, 'primary_key' => ['widgetId', 'mediaId']]);
+ $linkWidgetMedia
+ ->addColumn('widgetId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->addForeignKey('widgetId', 'widget', 'widgetId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+
+ $linkWidgetAudio = $this->table('lkwidgetaudio', ['id' => false, 'primary_key' => ['widgetId', 'mediaId']]);
+ $linkWidgetAudio
+ ->addColumn('widgetId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->addColumn('volume', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('loop', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addForeignKey('widgetId', 'widget', 'widgetId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+
+
+ // Day Part
+ $dayPart = $this->table('daypart', ['id' => 'dayPartId']);
+ $dayPart
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 50, 'null' => true])
+ ->addColumn('isRetired', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('userId', 'integer')
+ ->addColumn('startTime', 'string', ['limit' => 8, 'default' => '00:00:00'])
+ ->addColumn('endTime', 'string', ['limit' => 8, 'default' => '00:00:00'])
+ ->addColumn('exceptions', 'text', ['default' => null, 'null' => true])
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ // Schedule
+ $schedule = $this->table('schedule', ['id' => 'eventId']);
+ $schedule
+ ->addColumn('eventTypeId', 'integer')
+ ->addColumn('campaignId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('commandId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('dayPartId', 'integer', ['default' => 0])
+ ->addColumn('userId', 'integer')
+ ->addColumn('fromDt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true])
+ ->addColumn('toDt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true])
+ ->addColumn('is_priority', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('displayOrder', 'integer', ['default' => 0])
+ ->addColumn('lastRecurrenceWatermark', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true])
+ ->addColumn('syncTimezone', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('recurrence_type', 'enum', ['values' => ['Minute', 'Hour', 'Day', 'Week', 'Month', 'Year'], 'default' => null, 'null' => true])
+ ->addColumn('recurrence_detail', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('recurrence_range', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true])
+ ->addColumn('recurrenceRepeatsOn', 'string', ['limit' => 14, 'default' => null, 'null' => true])
+ ->addIndex('campaignId')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ $linkScheduleDisplayGroup = $this->table('lkscheduledisplaygroup', ['id' => false, 'primary_key' => ['eventId', 'displayGroupId']]);
+ $linkScheduleDisplayGroup
+ ->addColumn('eventId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->addForeignKey('eventId', 'schedule', 'eventId')
+ ->addForeignKey('displayGroupId', 'displaygroup', 'displayGroupId')
+ ->save();
+
+ // DataSet
+ $dataSet = $this->table('dataset', ['id' => 'dataSetId']);
+ $dataSet
+ ->addColumn('dataSet', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('userId', 'integer')
+ ->addColumn('lastDataEdit', 'integer', ['default' => 0])
+ ->addColumn('code', 'string', ['limit' => 50, 'null' => true])
+ ->addColumn('isLookup', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('isRemote', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('method', 'enum', ['values' => ['GET', 'POST'], 'null' => true])
+ ->addColumn('uri', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('postData', 'text', ['null' => true])
+ ->addColumn('authentication', 'enum', ['values' => ['none', 'plain', 'basic', 'digest'], 'null' => true])
+ ->addColumn('username', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('password', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('refreshRate', 'integer', ['default' => 86400])
+ ->addColumn('clearRate', 'integer', ['default' => 0])
+ ->addColumn('runsAfter', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('dataRoot', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('lastSync', 'integer', ['default' => 0])
+ ->addColumn('summarize', 'string', ['limit' => 10, 'null' => true])
+ ->addColumn('summarizeField', 'string', ['limit' => 250, 'null' => true])
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ $dataType = $this->table('datatype', ['id' => 'dataTypeId']);
+ $dataType
+ ->addColumn('dataType', 'string', ['limit' => 100])
+ ->insert([
+ ['dataTypeId' => 1, 'dataType' => 'String'],
+ ['dataTypeId' => 2, 'dataType' => 'Number'],
+ ['dataTypeId' => 3, 'dataType' => 'Date'],
+ ['dataTypeId' => 4, 'dataType' => 'External Image'],
+ ['dataTypeId' => 5, 'dataType' => 'Library Image'],
+ ])
+ ->save();
+
+ $dataSetColumnType = $this->table('datasetcolumntype', ['id' => 'dataSetColumnTypeId']);
+ $dataSetColumnType
+ ->addColumn('dataSetColumnType', 'string', ['limit' => 100])
+ ->insert([
+ ['dataSetColumnTypeId' => 1, 'dataSetColumnType' => 'Value'],
+ ['dataSetColumnTypeId' => 2, 'dataSetColumnType' => 'Formula'],
+ ['dataSetColumnTypeId' => 3, 'dataSetColumnType' => 'Remote'],
+ ])
+ ->save();
+
+ $dataSetColumn = $this->table('datasetcolumn', ['id' => 'dataSetColumnId']);
+ $dataSetColumn
+ ->addColumn('dataSetId', 'integer')
+ ->addColumn('heading', 'string', ['limit' => 50])
+ ->addColumn('dataTypeId', 'integer')
+ ->addColumn('dataSetColumnTypeId', 'integer')
+ ->addColumn('listContent', 'string', ['limit' => 1000, 'null' => true])
+ ->addColumn('columnOrder', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('formula', 'string', ['limit' => 1000, 'null' => true])
+ ->addColumn('remoteField', 'string', ['limit' => 250, 'null' => true])
+ ->addForeignKey('dataSetId', 'dataset', 'dataSetId')
+ ->addForeignKey('dataTypeId', 'datatype', 'dataTypeId')
+ ->addForeignKey('dataSetColumnTypeId', 'datasetcolumntype', 'dataSetColumnTypeId')
+ ->save();
+
+ // Notifications
+ $notification = $this->table('notification', ['id' => 'notificationId']);
+ $notification
+ ->addColumn('subject', 'string', ['limit' => 255])
+ ->addColumn('body', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])
+ ->addColumn('createDt', 'integer')
+ ->addColumn('releaseDt', 'integer')
+ ->addColumn('isEmail', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('isInterrupt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('isSystem', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ $linkNotificationDg = $this->table('lknotificationdg', ['id' => 'lkNotificationDgId']);
+ $linkNotificationDg
+ ->addColumn('notificationId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->addIndex(['notificationId', 'displayGroupId'], ['unique' => true])
+ ->addForeignKey('notificationId', 'notification', 'notificationId')
+ ->save();
+
+ $linkNotificationGroup = $this->table('lknotificationgroup', ['id' => 'lkNotificationGroupId']);
+ $linkNotificationGroup
+ ->addColumn('notificationId', 'integer')
+ ->addColumn('groupId', 'integer')
+ ->addIndex(['notificationId', 'groupId'], ['unique' => true])
+ ->addForeignKey('notificationId', 'notification', 'notificationId')
+ ->addForeignKey('groupId', 'group', 'groupId')
+ ->save();
+
+ $linkNotificationUser = $this->table('lknotificationuser', ['id' => 'lkNotificationUserId']);
+ $linkNotificationUser
+ ->addColumn('notificationId', 'integer')
+ ->addColumn('userId', 'integer')
+ ->addColumn('read', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('readDt', 'integer')
+ ->addColumn('emailDt', 'integer')
+ ->addIndex(['notificationId', 'userId'], ['unique' => true])
+ ->addForeignKey('notificationId', 'notification', 'notificationId')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+
+ // Commands
+ $command = $this->table('command', ['id' => 'commandId']);
+ $command
+ ->addColumn('command', 'string', ['limit' => 254])
+ ->addColumn('code', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 1000, 'null' => true])
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ $linkCommandDisplayProfile = $this->table('lkcommanddisplayprofile', ['id' => false, 'primary_key' => ['commandId', 'displayProfileId']]);
+ $linkCommandDisplayProfile->addColumn('commandId', 'integer')
+ ->addColumn('displayProfileId', 'integer')
+ ->addColumn('commandString', 'string', ['limit' => 1000])
+ ->addColumn('validationString', 'string', ['limit' => 1000, 'null' => true])
+ ->addForeignKey('commandId', 'command', 'commandId')
+ ->addForeignKey('displayProfileId', 'displayprofile', 'displayProfileId')
+ ->save();
+
+ // Permissions
+ $pages = $this->table('pages', ['id' => 'pageId']);
+ $pages
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('title', 'string', ['limit' => 100])
+ ->addColumn('asHome', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->insert([
+ ['pageId' => 1, 'name' => 'dashboard', 'title' => 'Dashboard', 'asHome' => 1],
+ ['pageId' => 2, 'name' => 'schedule', 'title' => 'Schedule', 'asHome' => 1],
+ ['pageId' => 3, 'name' => 'mediamanager', 'title' => 'Media Dashboard','asHome' => 1],
+ ['pageId' => 4, 'name' => 'layout', 'title' => 'Layout', 'asHome' => 1],
+ ['pageId' => 5, 'name' => 'library', 'title' => 'Library', 'asHome' => 1],
+ ['pageId' => 6, 'name' => 'display', 'title' => 'Displays', 'asHome' => 1],
+ ['pageId' => 7, 'name' => 'update', 'title' => 'Update', 'asHome' => 0],
+ ['pageId' => 8, 'name' => 'admin', 'title' => 'Administration', 'asHome' => 0],
+ ['pageId' => 9, 'name' => 'group', 'title' => 'User Groups','asHome' => 1],
+ ['pageId' => 10, 'name' => 'log', 'title' => 'Log', 'asHome' => 1],
+ ['pageId' => 11, 'name' => 'user', 'title' => 'Users', 'asHome' => 1],
+ ['pageId' => 12, 'name' => 'license', 'title' => 'Licence', 'asHome' => 1],
+ ['pageId' => 13, 'name' => 'index', 'title' => 'Home', 'asHome' => 0],
+ ['pageId' => 14, 'name' => 'module', 'title' => 'Modules', 'asHome' => 1],
+ ['pageId' => 15, 'name' => 'template', 'title' => 'Templates', 'asHome' => 1],
+ ['pageId' => 16, 'name' => 'fault', 'title' => 'Report Fault','asHome' => 1],
+ ['pageId' => 17, 'name' => 'stats', 'title' => 'Statistics', 'asHome' => 1],
+ ['pageId' => 18, 'name' => 'manual', 'title' => 'Manual', 'asHome' => 0],
+ ['pageId' => 19, 'name' => 'resolution', 'title' => 'Resolutions', 'asHome' => 1],
+ ['pageId' => 20, 'name' => 'help', 'title' => 'Help Links','asHome' => 1],
+ ['pageId' => 21, 'name' => 'clock', 'title' => 'Clock', 'asHome' => 0],
+ ['pageId' => 22, 'name' => 'displaygroup', 'title' => 'Display Groups','asHome' => 1],
+ ['pageId' => 23, 'name' => 'application', 'title' => 'Applications', 'asHome' => 1],
+ ['pageId' => 24, 'name' => 'dataset', 'title' => 'DataSets', 'asHome' => 1],
+ ['pageId' => 25, 'name' => 'campaign', 'title' => 'Campaigns', 'asHome' => 1],
+ ['pageId' => 26, 'name' => 'transition', 'title' => 'Transitions', 'asHome' => 1],
+ ['pageId' => 27, 'name' => 'sessions', 'title' => 'Sessions', 'asHome' => 1],
+ ['pageId' => 28, 'name' => 'preview', 'title' => 'Preview', 'asHome' => 0],
+ ['pageId' => 29, 'name' => 'statusdashboard', 'title' => 'Status Dashboard','asHome' => 1],
+ ['pageId' => 30, 'name' => 'displayprofile', 'title' => 'Display Profiles','asHome' => 1],
+ ['pageId' => 31, 'name' => 'audit', 'title' => 'Audit Trail','asHome' => 0],
+ ['pageId' => 32, 'name' => 'region', 'title' => 'Regions', 'asHome' => 0],
+ ['pageId' => 33, 'name' => 'playlist', 'title' => 'Playlist', 'asHome' => 0],
+ ['pageId' => 34, 'name' => 'maintenance', 'title' => 'Maintenance', 'asHome' => 0],
+ ['pageId' => 35, 'name' => 'command', 'title' => 'Commands', 'asHome' => 1],
+ ['pageId' => 36, 'name' => 'notification', 'title' => 'Notifications', 'asHome' => 0],
+ ['pageId' => 37, 'name' => 'drawer', 'title' => 'Notification Drawer','asHome' => 0],
+ ['pageId' => 38, 'name' => 'daypart', 'title' => 'Dayparting', 'asHome' => 0],
+ ['pageId' => 39, 'name' => 'task', 'title' => 'Tasks', 'asHome' => 1]
+ ])
+ ->save();
+
+ $permissionEntity = $this->table('permissionentity', ['id' => 'entityId']);
+ $permissionEntity->addColumn('entity', 'string', ['limit' => 50])
+ ->addIndex('entity', ['unique' => true])
+ ->insert([
+ ['entityId' => 1, 'entity' => 'Xibo\Entity\Page'],
+ ['entityId' => 2, 'entity' => 'Xibo\Entity\DisplayGroup'],
+ ['entityId' => 3, 'entity' => 'Xibo\Entity\Media'],
+ ['entityId' => 4, 'entity' => 'Xibo\Entity\Campaign'],
+ ['entityId' => 5, 'entity' => 'Xibo\Entity\Widget'],
+ ['entityId' => 7, 'entity' => 'Xibo\Entity\Region'],
+ ['entityId' => 8, 'entity' => 'Xibo\Entity\Playlist'],
+ ['entityId' => 9, 'entity' => 'Xibo\Entity\DataSet'],
+ ['entityId' => 10, 'entity' => 'Xibo\Entity\Notification'],
+ ['entityId' => 11, 'entity' => 'Xibo\Entity\DayPart'],
+ ])
+ ->save();
+
+ $permission = $this->table('permission', ['id' => 'permissionId']);
+ $permission->addColumn('entityId', 'integer')
+ ->addColumn('groupId', 'integer')
+ ->addColumn('objectId', 'integer')
+ ->addColumn('view', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('edit', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('delete', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->insert([
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 1, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 13, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 4, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 5, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 3, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 33, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 28, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 32, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 2, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 29, 'view' => 1, 'edit' => 0, 'delete' => 0],
+ ['entityId' => 1, 'groupId' => 1, 'objectId' => 11, 'view' => 1, 'edit' => 0, 'delete' => 0]
+ ])
+ ->save();
+
+ // Oauth
+ //
+
+ $oauthClients = $this->table('oauth_clients', ['id' => false, 'primary_key' => ['id']]);
+ $oauthClients
+ ->addColumn('id', 'string', ['limit' => 254])
+ ->addColumn('secret', 'string', ['limit' => 254])
+ ->addColumn('name', 'string', ['limit' => 254])
+ ->addColumn('userId', 'integer')
+ ->addColumn('authCode', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('clientCredentials', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ $oauthSessions = $this->table('oauth_sessions');
+ $oauthSessions
+ ->addColumn('owner_type', 'string', ['limit' => 254])
+ ->addColumn('owner_id', 'string', ['limit' => 254])
+ ->addColumn('client_id', 'string', ['limit' => 254])
+ ->addColumn('client_redirect_uri', 'string', ['limit' => 500, 'null' => true])
+ ->addForeignKey('client_id', 'oauth_clients', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthScopes = $this->table('oauth_scopes', ['id' => false, 'primary_key' => ['id']]);
+ $oauthScopes
+ ->addColumn('id', 'string', ['limit' => 254])
+ ->addColumn('description', 'string', ['limit' => 1000])
+ ->insert([
+ ['id' => 'all', 'description' => 'All']
+ ])
+ ->save();
+
+ $oauthAccessTokens = $this->table('oauth_access_tokens', ['id' => false, 'primary_key' => ['access_token']]);
+ $oauthAccessTokens
+ ->addColumn('access_token', 'string', ['limit' => 254])
+ ->addColumn('session_id', 'integer')
+ ->addColumn('expire_time', 'integer')
+ ->addForeignKey('session_id', 'oauth_sessions', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthAccessTokenScopes = $this->table('oauth_access_token_scopes');
+ $oauthAccessTokenScopes
+ ->addColumn('access_token', 'string', ['limit' => 254])
+ ->addColumn('scope', 'string', ['limit' => 254])
+ ->addForeignKey('access_token', 'oauth_access_tokens', 'access_token', ['delete' => 'CASCADE'])
+ ->addForeignKey('scope', 'oauth_scopes', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthAuthCodes = $this->table('oauth_auth_codes', ['id' => false, 'primary_key' => ['auth_code']]);
+ $oauthAuthCodes
+ ->addColumn('auth_code', 'string', ['limit' => 254])
+ ->addColumn('session_id', 'integer')
+ ->addColumn('expire_time', 'integer')
+ ->addColumn('client_redirect_uri', 'string', ['limit' => 500])
+ ->addForeignKey('session_id', 'oauth_sessions', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthAuthCodeScopes = $this->table('oauth_auth_code_scopes');
+ $oauthAuthCodeScopes
+ ->addColumn('auth_code', 'string', ['limit' => 254])
+ ->addColumn('scope', 'string', ['limit' => 254])
+ ->addForeignKey('auth_code', 'oauth_auth_codes', 'auth_code', ['delete' => 'CASCADE'])
+ ->addForeignKey('scope', 'oauth_scopes', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthClientRedirects = $this->table('oauth_client_redirect_uris');
+ $oauthClientRedirects
+ ->addColumn('client_id', 'string', ['limit' => 254])
+ ->addColumn('redirect_uri', 'string', ['limit' => 500])
+ ->save();
+
+ $oauthRefreshToeksn = $this->table('oauth_refresh_tokens', ['id' => false, 'primary_key' => ['refresh_token']]);
+ $oauthRefreshToeksn
+ ->addColumn('refresh_token', 'string', ['limit' => 254])
+ ->addColumn('expire_time', 'integer')
+ ->addColumn('access_token', 'string', ['limit' => 254])
+ ->addForeignKey('access_token', 'oauth_access_tokens', 'access_token', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthSessionsScopes = $this->table('oauth_session_scopes');
+ $oauthSessionsScopes
+ ->addColumn('session_id', 'integer')
+ ->addColumn('scope', 'string', ['limit' => 254])
+ ->addForeignKey('session_id', 'oauth_sessions', 'id', ['delete' => 'CASCADE'])
+ ->addForeignKey('scope', 'oauth_scopes', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthClientScopes = $this->table('oauth_client_scopes');
+ $oauthClientScopes
+ ->addColumn('clientId', 'string', ['limit' => 254])
+ ->addColumn('scopeId', 'string', ['limit' => 254])
+ ->addIndex(['clientId', 'scopeId'], ['unique' => true])
+ ->save();
+
+ $oauthRouteScopes = $this->table('oauth_scope_routes');
+ $oauthRouteScopes
+ ->addColumn('scopeId', 'string', ['limit' => 254])
+ ->addColumn('route', 'string', ['limit' => 1000])
+ ->addColumn('method', 'string', ['limit' => 8])
+ ->save();
+ //
+
+ // Tasks
+ $task = $this->table('task', ['id' => 'taskId']);
+ $task
+ ->addColumn('name', 'string', ['limit' => 254])
+ ->addColumn('class', 'string', ['limit' => 254])
+ ->addColumn('status', 'integer', ['default' => 2, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('pid', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('options', 'text', ['default' => null, 'null' => true])
+ ->addColumn('schedule', 'string', ['limit' => 254])
+ ->addColumn('lastRunDt', 'integer', ['default' => 0])
+ ->addColumn('lastRunStartDt', 'integer', ['null' => true])
+ ->addColumn('lastRunMessage', 'string', ['null' => true])
+ ->addColumn('lastRunStatus', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('lastRunDuration', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('lastRunExitCode', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('isActive', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('runNow', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('configFile', 'string', ['limit' => 254])
+ ->insert([
+ ['name' => 'Daily Maintenance', 'class' => '\Xibo\XTR\MaintenanceDailyTask', 'status' => 2, 'options' => '[]', 'schedule' => '0 0 * * * *', 'isActive' => 1, 'configFile' => '/tasks/maintenance-daily.task'],
+ ['name' => 'Regular Maintenance', 'class' => '\Xibo\XTR\MaintenanceRegularTask', 'status' => 2, 'options' => '[]', 'schedule' => '*/5 * * * * *', 'isActive' => 1, 'configFile' => '/tasks/maintenance-regular.task'],
+ ['name' => 'Email Notifications', 'class' => '\Xibo\XTR\EmailNotificationsTask', 'status' => 2, 'options' => '[]', 'schedule' => '*/5 * * * * *', 'isActive' => 1, 'configFile' => '/tasks/email-notifications.task'],
+ ['name' => 'Stats Archive', 'class' => '\Xibo\XTR\StatsArchiveTask', 'status' => 2, 'options' => '{"periodSizeInDays":"7","maxPeriods":"4", "archiveStats":"Off"}', 'schedule' => '0 0 * * Mon', 'isActive' => 1, 'configFile' => '/tasks/stats-archiver.task'],
+ ['name' => 'Remove old Notifications', 'class' => '\Xibo\XTR\NotificationTidyTask', 'status' => 2, 'options' => '{"maxAgeDays":"7","systemOnly":"1","readOnly":"0"}', 'schedule' => '15 0 * * *', 'isActive' => 1, 'configFile' => '/tasks/notification-tidy.task'],
+ ['name' => 'Fetch Remote DataSets', 'class' => '\Xibo\XTR\RemoteDataSetFetchTask', 'status' => 2, 'options' => '[]', 'schedule' => '30 * * * * *', 'isActive' => 1, 'configFile' => '/tasks/remote-dataset.task'],
+ ['name' => 'Drop Player Cache', 'class' => '\Xibo\XTR\DropPlayerCacheTask', 'options' => '[]', 'schedule' => '0 0 1 1 *', 'isActive' => '0', 'configFile' => '/tasks/drop-player-cache.task'],
+ ])
+ ->save();
+
+ // Required Files
+ $requiredFile = $this->table('requiredfile', ['id' => 'rfId']);
+ $requiredFile
+ ->addColumn('displayId', 'integer')
+ ->addColumn('type', 'string', ['limit' => 1])
+ ->addColumn('itemId', 'integer', ['null' => true])
+ ->addColumn('bytesRequested', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG])
+ ->addColumn('complete', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('path', 'string', ['null' => true, 'limit' => 255])
+ ->addColumn('size', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => 0])
+ ->addIndex(['displayId', 'type'])
+ ->addForeignKey('displayId', 'display', 'displayId')
+ ->save();
+
+ // Tags
+ $tag = $this->table('tag', ['id' => 'tagId']);
+ $tag
+ ->addColumn('tag', 'string', ['limit' => 50])
+ ->insert([
+ ['tag' => 'template'],
+ ['tag' => 'background'],
+ ['tag' => 'thumbnail'],
+ ['tag' => 'imported'],
+ ])
+ ->save();
+
+ // Tag Links
+ $linkCampaignTag = $this->table('lktagcampaign', ['id' => 'lkTagCampaignId']);
+ $linkCampaignTag
+ ->addColumn('tagId', 'integer')
+ ->addColumn('campaignId', 'integer')
+ ->addIndex(['tagId', 'campaignId'], ['unique' => true])
+ ->addForeignKey('tagId', 'tag', 'tagId')
+ ->addForeignKey('campaignId', 'campaign', 'campaignId')
+ ->save();
+
+ $linkLayoutTag = $this->table('lktaglayout', ['id' => 'lkTagLayoutId']);
+ $linkLayoutTag
+ ->addColumn('tagId', 'integer')
+ ->addColumn('layoutId', 'integer')
+ ->addIndex(['tagId', 'layoutId'], ['unique' => true])
+ ->addForeignKey('tagId', 'tag', 'tagId')
+ ->addForeignKey('layoutId', 'layout', 'layoutId')
+ ->save();
+
+ $linkMediaTag = $this->table('lktagmedia', ['id' => 'lkTagMediaId']);
+ $linkMediaTag
+ ->addColumn('tagId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->addIndex(['tagId', 'mediaId'], ['unique' => true])
+ ->addForeignKey('tagId', 'tag', 'tagId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+
+ $linkDisplayGroupTag = $this->table('lktagdisplaygroup', ['id' => 'lkTagDisplayGroupId']);
+ $linkDisplayGroupTag
+ ->addColumn('tagId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->addIndex(['tagId', 'displayGroupId'], ['unique' => true])
+ ->addForeignKey('tagId', 'tag', 'tagId')
+ ->addForeignKey('displayGroupId', 'displaygroup', 'displayGroupId')
+ ->save();
+
+ // Transitions
+ $transitions = $this->table('transition', ['id' => 'transitionId']);
+ $transitions
+ ->addColumn('transition', 'string', ['limit' => 254])
+ ->addColumn('code', 'string', ['limit' => 254])
+ ->addColumn('hasDuration', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('hasDirection', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('availableAsIn', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('availableAsOut', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->insert([
+ ['transition' => 'Fade In', 'code' => 'fadeIn', 'hasDuration' => 1, 'hasDirection' => 0, 'availableAsIn' => 1, 'availableAsOut' => 0],
+ ['transition' => 'Fade Out', 'code' => 'fadeOut', 'hasDuration' => 1, 'hasDirection' => 0, 'availableAsIn' => 0, 'availableAsOut' => 1],
+ ['transition' => 'Fly', 'code' => 'fly', 'hasDuration' => 1, 'hasDirection' => 1, 'availableAsIn' => 1, 'availableAsOut' => 1],
+ ])
+ ->save();
+
+ // Stats
+ $stat = $this->table('stat', ['id' => 'statId']);
+ $stat
+ ->addColumn('type', 'string', ['limit' => 20])
+ ->addColumn('statDate', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('scheduleId', 'integer')
+ ->addColumn('displayId', 'integer')
+ ->addColumn('layoutId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('mediaId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('widgetId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('start', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('end', 'datetime', ['default' => null, 'null' => true])
+ ->addColumn('tag', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addIndex('statDate')
+ ->addIndex(['displayId', 'end', 'type'])
+ ->save();
+
+ // Display Events
+ $displayEvent = $this->table('displayevent', ['id' => 'displayEventId']);
+ $displayEvent
+ ->addColumn('eventDate', 'integer')
+ ->addColumn('displayId', 'integer')
+ ->addColumn('start', 'integer')
+ ->addColumn('end', 'integer', ['null' => true])
+ ->addIndex('eventDate')
+ ->addIndex(['displayId', 'end'])
+ ->save();
+
+ // Log
+ $log = $this->table('log', ['id' => 'logId']);
+ $log
+ ->addColumn('runNo', 'string', ['limit' => 10])
+ ->addColumn('logDate', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('channel', 'string', ['limit' => 20])
+ ->addColumn('type', 'string', ['limit' => 254])
+ ->addColumn('page', 'string', ['limit' => 50])
+ ->addColumn('function', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('message', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])
+ ->addColumn('userId', 'integer', ['default' => 0])
+ ->addColumn('displayId', 'integer', ['null' => true, 'default' => null])
+ ->addIndex('logDate')
+ ->save();
+
+ // Audit Log
+ $auditLog = $this->table('auditlog', ['id' => 'logId']);
+ $auditLog->addColumn('logDate', 'integer')
+ ->addColumn('userId', 'integer')
+ ->addColumn('message', 'string', ['limit' => 255])
+ ->addColumn('entity', 'string', ['limit' => 50])
+ ->addColumn('entityId', 'integer')
+ ->addColumn('objectAfter', 'text', ['default' => null, 'null' => true])
+ ->save();
+
+ // Bandwidth Tracking
+ $bandwidthType = $this->table('bandwidthtype', ['id' => 'bandwidthTypeId']);
+ $bandwidthType
+ ->addColumn('name', 'string', ['limit' => 25])
+ ->insert([
+ ['bandwidthTypeId' => 1, 'name' => 'Register'],
+ ['bandwidthTypeId' => 2, 'name' => 'Required Files'],
+ ['bandwidthTypeId' => 3, 'name' => 'Schedule'],
+ ['bandwidthTypeId' => 4, 'name' => 'Get File'],
+ ['bandwidthTypeId' => 5, 'name' => 'Get Resource'],
+ ['bandwidthTypeId' => 6, 'name' => 'Media Inventory'],
+ ['bandwidthTypeId' => 7, 'name' => 'Notify Status'],
+ ['bandwidthTypeId' => 8, 'name' => 'Submit Stats'],
+ ['bandwidthTypeId' => 9, 'name' => 'Submit Log'],
+ ['bandwidthTypeId' => 10, 'name' => 'Report Fault'],
+ ['bandwidthTypeId' => 11, 'name' => 'Screen Shot'],
+ ])
+ ->save();
+
+ $bandwidth = $this->table('bandwidth', ['id' => false, 'primary_key' => ['displayId', 'type', 'month']]);
+ $bandwidth->addColumn('displayId', 'integer')
+ ->addColumn('type', 'integer')
+ ->addColumn('month', 'integer')
+ ->addColumn('size', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG])
+ ->addForeignKey('type', 'bandwidthtype', 'bandwidthTypeId')
+ ->save();
+
+ // Help
+ $help = $this->table('help', ['id' => 'helpId']);
+ $help
+ ->addColumn('topic', 'string', ['limit' => 254])
+ ->addColumn('category', 'string', ['limit' => 254, 'default' => 'General'])
+ ->addColumn('link', 'string', ['limit' => 254])
+ ->save();
+ }
+
+ private function addData()
+ {
+ // Add settings
+ $this->execute('
+INSERT INTO `setting` (`settingid`, `setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`) VALUES
+(3, \'defaultUsertype\', \'User\', \'dropdown\', \'Sets the default user type selected when creating a user.\r\n \r\nWe recommend that this is set to "User"\', \'User|Group Admin|Super Admin\', \'users\', 1, \'Default User Type\', \'\', 10, \'User\', 1, \'string\'),
+(7, \'userModule\', \'module_user_general.php\', \'dirselect\', \'This sets which user authentication module is currently being used.\', NULL, \'users\', 0, \'User Module\', \'\', 0, \'module_user_general.php\', 0, \'string\'),
+(11, \'defaultTimezone\', \'Europe/London\', \'timezone\', \'Set the default timezone for the application\', \'Europe/London\', \'regional\', 1, \'Timezone\', \'\', 20, \'Europe/London\', 1, \'string\'),
+(18, \'mail_to\', \'mail@yoursite.com\', \'email\', \'Errors will be mailed here\', NULL, \'maintenance\', 1, \'Admin email address\', \'\', 30, \'mail@yoursite.com\', 1, \'string\'),
+(19, \'mail_from\', \'mail@yoursite.com\', \'email\', \'Mail will be sent from this address\', NULL, \'maintenance\', 1, \'Sending email address\', \'\', 40, \'mail@yoursite.com\', 1, \'string\'),
+(30, \'audit\', \'Error\', \'dropdown\', \'Set the level of logging the CMS should record. In production systems "error" is recommended.\', \'Emergency|Alert|Critical|Error|Warning|Notice|Info|Debug\', \'troubleshooting\', 1, \'Log Level\', \'\', 20, \'error\', 1, \'word\'),
+(33, \'LIBRARY_LOCATION\', \'\', \'text\', \'The fully qualified path to the CMS library location.\', NULL, \'configuration\', 1, \'Library Location\', \'required\', 10, \'\', 1, \'string\'),
+(34, \'SERVER_KEY\', \'\', \'text\', NULL, NULL, \'configuration\', 1, \'CMS Secret Key\', \'required\', 20, \'\', 1, \'string\'),
+(35, \'HELP_BASE\', \'https://xibosignage.com/manual/en/\', \'text\', NULL, NULL, \'general\', 1, \'Location of the Manual\', \'required\', 10, \'https://xibosignage.com/manual/\', 1, \'string\'),
+(36, \'PHONE_HOME\', \'1\', \'checkbox\', \'Should the server send anonymous statistics back to the Xibo project?\', NULL, \'general\', 1, \'Allow usage tracking?\', \'\', 10, \'1\', 1, \'checkbox\'),
+(37, \'PHONE_HOME_KEY\', \'\', \'text\', \'Key used to distinguish each Xibo instance. This is generated randomly based on the time you first installed Xibo, and is completely untraceable.\', NULL, \'general\', 0, \'Phone home key\', \'\', 20, \'\', 0, \'string\'),
+(38, \'PHONE_HOME_URL\', \'https://xibo.org.uk/api/stats/track\', \'text\', \'The URL to connect to to PHONE_HOME (if enabled)\', NULL, \'network\', 0, \'Phone home URL\', \'\', 60, \'https://xibo.org.uk/api/stats/track\', 0, \'string\'),
+(39, \'PHONE_HOME_DATE\', \'0\', \'text\', \'The last time we PHONED_HOME in seconds since the epoch\', NULL, \'general\', 0, \'Phone home time\', \'\', 30, \'0\', 0, \'int\'),
+(40, \'SERVER_MODE\', \'Production\', \'dropdown\', \'This should only be set if you want to display the maximum allowed error messaging through the user interface. Useful for capturing critical php errors and environment issues.\', \'Production|Test\', \'troubleshooting\', 1, \'Server Mode\', \'\', 30, \'Production\', 1, \'word\'),
+(41, \'MAINTENANCE_ENABLED\', \'Off\', \'dropdown\', \'Allow the maintenance script to run if it is called?\', \'Protected|On|Off\', \'maintenance\', 1, \'Enable Maintenance?\', \'\', 10, \'Off\', 1, \'word\'),
+(42, \'MAINTENANCE_EMAIL_ALERTS\', \'On\', \'dropdown\', \'Global switch for email alerts to be sent\', \'On|Off\', \'maintenance\', 1, \'Enable Email Alerts?\', \'\', 20, \'On\', 1, \'word\'),
+(43, \'MAINTENANCE_KEY\', \'changeme\', \'text\', \'String appended to the maintenance script to prevent malicious calls to the script.\', NULL, \'maintenance\', 1, \'Maintenance Key\', \'\', 50, \'changeme\', 1, \'string\'),
+(44, \'MAINTENANCE_LOG_MAXAGE\', \'30\', \'number\', \'Maximum age for log entries. Set to 0 to keep logs indefinitely.\', NULL, \'maintenance\', 1, \'Max Log Age\', \'\', 60, \'30\', 1, \'int\'),
+(45, \'MAINTENANCE_STAT_MAXAGE\', \'30\', \'number\', \'Maximum age for statistics entries. Set to 0 to keep statistics indefinitely.\', NULL, \'maintenance\', 1, \'Max Statistics Age\', \'\', 70, \'30\', 1, \'int\'),
+(46, \'MAINTENANCE_ALERT_TOUT\', \'12\', \'number\', \'How long in minutes after the last time a client connects should we send an alert? Can be overridden on a per client basis.\', NULL, \'maintenance\', 1, \'Max Display Timeout\', \'\', 80, \'12\', 1, \'int\'),
+(47, \'SHOW_DISPLAY_AS_VNCLINK\', \'\', \'text\', \'Turn the display name in display management into a VNC link using the IP address last collected. The %s is replaced with the IP address. Leave blank to disable.\', NULL, \'displays\', 1, \'Display a VNC Link?\', \'\', 30, \'\', 1, \'string\'),
+(48, \'SHOW_DISPLAY_AS_VNC_TGT\', \'_top\', \'text\', \'If the display name is shown as a link in display management, what target should the link have? Set _top to open the link in the same window or _blank to open in a new window.\', NULL, \'displays\', 1, \'Open VNC Link in new window?\', \'\', 40, \'_top\', 1, \'string\'),
+(49, \'MAINTENANCE_ALWAYS_ALERT\', \'Off\', \'dropdown\', \'Should Xibo send an email if a display is in an error state every time the maintenance script runs?\', \'On|Off\', \'maintenance\', 1, \'Send repeat Display Timeouts\', \'\', 80, \'Off\', 1, \'word\'),
+(50, \'SCHEDULE_LOOKAHEAD\', \'On\', \'dropdown\', \'Should Xibo send future schedule information to clients?\', \'On|Off\', \'general\', 0, \'Send Schedule in advance?\', \'\', 40, \'On\', 1, \'word\'),
+(51, \'REQUIRED_FILES_LOOKAHEAD\', \'172800\', \'number\', \'How many seconds in to the future should the calls to RequiredFiles look?\', NULL, \'general\', 1, \'Send files in advance?\', \'\', 50, \'172800\', 1, \'int\'),
+(52, \'REGION_OPTIONS_COLOURING\', \'Media Colouring\', \'dropdown\', NULL, \'Media Colouring|Permissions Colouring\', \'permissions\', 1, \'How to colour Media on the Region Timeline\', \'\', 30, \'Media Colouring\', 1, \'string\'),
+(53, \'LAYOUT_COPY_MEDIA_CHECKB\', \'Unchecked\', \'dropdown\', \'Default the checkbox for making duplicates of media when copying layouts\', \'Checked|Unchecked\', \'defaults\', 1, \'Default copy media when copying a layout?\', \'\', 20, \'Unchecked\', 1, \'word\'),
+(54, \'MAX_LICENSED_DISPLAYS\', \'0\', \'number\', \'The maximum number of licensed clients for this server installation. 0 = unlimited\', NULL, \'displays\', 0, \'Number of display slots\', \'\', 50, \'0\', 0, \'int\'),
+(55, \'LIBRARY_MEDIA_UPDATEINALL_CHECKB\', \'Checked\', \'dropdown\', \'Default the checkbox for updating media on all layouts when editing in the library\', \'Checked|Unchecked\', \'defaults\', 1, \'Default update media in all layouts\', \'\', 10, \'Unchecked\', 1, \'word\'),
+(56, \'USER_PASSWORD_POLICY\', \'\', \'text\', \'Regular Expression for password complexity, leave blank for no policy.\', \'\', \'users\', 1, \'Password Policy Regular Expression\', \'\', 20, \'\', 1, \'string\'),
+(57, \'USER_PASSWORD_ERROR\', \'\', \'text\', \'A text description of this password policy. Will be show to users when their password does not meet the required policy\', \'\', \'users\', 1, \'Description of Password Policy\', \'\', 30, \'\', 1, \'string\'),
+(59, \'LIBRARY_SIZE_LIMIT_KB\', \'0\', \'number\', \'The Limit for the Library Size in KB\', NULL, \'network\', 0, \'Library Size Limit\', \'\', 50, \'0\', 1, \'int\'),
+(60, \'MONTHLY_XMDS_TRANSFER_LIMIT_KB\', \'0\', \'number\', \'XMDS Transfer Limit in KB/month\', NULL, \'network\', 0, \'Monthly bandwidth Limit\', \'\', 40, \'0\', 1, \'int\'),
+(61, \'DEFAULT_LANGUAGE\', \'en_GB\', \'text\', \'The default language to use\', NULL, \'regional\', 1, \'Default Language\', \'\', 10, \'en_GB\', 1, \'string\'),
+(62, \'TRANSITION_CONFIG_LOCKED_CHECKB\', \'Unchecked\', \'dropdown\', \'Is the Transition config locked?\', \'Checked|Unchecked\', \'defaults\', 0, \'Allow modifications to the transition configuration?\', \'\', 40, \'Unchecked\', 1, \'word\'),
+(63, \'GLOBAL_THEME_NAME\', \'default\', \'text\', \'The Theme to apply to all pages by default\', NULL, \'configuration\', 1, \'CMS Theme\', \'\', 30, \'default\', 1, \'word\'),
+(64, \'DEFAULT_LAT\', \'51.504\', \'number\', \'The Latitude to apply for any Geo aware Previews\', NULL, \'displays\', 1, \'Default Latitude\', \'\', 10, \'51.504\', 1, \'double\'),
+(65, \'DEFAULT_LONG\', \'-0.104\', \'number\', \'The Longitude to apply for any Geo aware Previews\', NULL, \'displays\', 1, \'Default Longitude\', \'\', 20, \'-0.104\', 1, \'double\'),
+(66, \'SCHEDULE_WITH_VIEW_PERMISSION\', \'No\', \'dropdown\', \'Should users with View permissions on displays be allowed to schedule to them?\', \'Yes|No\', \'permissions\', 1, \'Schedule with view permissions?\', \'\', 40, \'No\', 1, \'word\'),
+(67, \'SETTING_IMPORT_ENABLED\', \'1\', \'checkbox\', NULL, NULL, \'general\', 1, \'Allow Import?\', \'\', 80, \'1\', 1, \'checkbox\'),
+(68, \'SETTING_LIBRARY_TIDY_ENABLED\', \'1\', \'checkbox\', NULL, NULL, \'general\', 1, \'Enable Library Tidy?\', \'\', 90, \'1\', 1, \'checkbox\'),
+(69, \'SENDFILE_MODE\', \'Off\', \'dropdown\', \'When a user downloads a file from the library or previews a layout, should we attempt to use Apache X-Sendfile, Nginx X-Accel, or PHP (Off) to return the file from the library?\', \'Off|Apache|Nginx\', \'general\', 1, \'File download mode\', \'\', 60, \'Off\', 1, \'word\'),
+(70, \'EMBEDDED_STATUS_WIDGET\', \'\', \'text\', \'HTML to embed in an iframe on the Status Dashboard\', NULL, \'general\', 0, \'Status Dashboard Widget\', \'\', 70, \'\', 1, \'htmlstring\'),
+(71, \'PROXY_HOST\', \'\', \'text\', \'The Proxy URL\', NULL, \'network\', 1, \'Proxy URL\', \'\', 10, \'\', 1, \'string\'),
+(72, \'PROXY_PORT\', \'0\', \'number\', \'The Proxy Port\', NULL, \'network\', 1, \'Proxy Port\', \'\', 20, \'0\', 1, \'int\'),
+(73, \'PROXY_AUTH\', \'\', \'text\', \'The Authentication information for this proxy. username:password\', NULL, \'network\', 1, \'Proxy Credentials\', \'\', 30, \'\', 1, \'string\'),
+(74, \'DATE_FORMAT\', \'Y-m-d H:i\', \'text\', \'The Date Format to use when displaying dates in the CMS.\', NULL , \'regional\', \'1\', \'Date Format\', \'required\', 30, \'Y-m-d\', \'1\', \'string\'),
+(75, \'DETECT_LANGUAGE\', \'1\', \'checkbox\', \'Detect the browser language?\', NULL , \'regional\', \'1\', \'Detect Language\', \'\', 40, \'1\', 1, \'checkbox\'),
+(76, \'DEFAULTS_IMPORTED\', \'0\', \'text\', \'Has the default layout been imported?\', NULL, \'general\', 0, \'Defaults Imported?\', \'required\', 100, \'0\', 0, \'checkbox\'),
+(77, \'FORCE_HTTPS\', \'0\', \'checkbox\', \'Force the portal into HTTPS?\', NULL, \'network\', 1, \'Force HTTPS?\', \'\', 70, \'0\', 1, \'checkbox\'),
+(78, \'ISSUE_STS\', \'0\', \'checkbox\', \'Add STS to the response headers? Make sure you fully understand STS before turning it on as it will prevent access via HTTP after the first successful HTTPS connection.\', NULL, \'network\', 1, \'Enable STS?\', \'\', 80, \'0\', 1, \'checkbox\'),
+(79, \'STS_TTL\', \'600\', \'text\', \'The Time to Live (maxage) of the STS header expressed in seconds.\', NULL, \'network\', 1, \'STS Time out\', \'\', 90, \'600\', 1, \'int\'),
+(81, \'CALENDAR_TYPE\', \'Gregorian\', \'dropdown\', \'Which Calendar Type should the CMS use?\', \'Gregorian|Jalali\', \'regional\', 1, \'Calendar Type\', \'\', 50, \'Gregorian\', 1, \'string\'),
+(82, \'DASHBOARD_LATEST_NEWS_ENABLED\', \'1\', \'checkbox\', \'Should the Dashboard show latest news? The address is provided by the theme.\', \'\', \'general\', 1, \'Enable Latest News?\', \'\', 110, \'1\', 1, \'checkbox\'),
+(83, \'LIBRARY_MEDIA_DELETEOLDVER_CHECKB\',\'Checked\',\'dropdown\',\'Default the checkbox for Deleting Old Version of media when a new file is being uploaded to the library.\',\'Checked|Unchecked\',\'defaults\',1,\'Default for "Delete old version of Media" checkbox. Shown when Editing Library Media.\', \'\', 50, \'Unchecked\', 1, \'dropdown\'),
+(84, \'PROXY_EXCEPTIONS\', \'\', \'text\', \'Hosts and Keywords that should not be loaded via the Proxy Specified. These should be comma separated.\', \'\', \'network\', 1, \'Proxy Exceptions\', \'\', 32, \'\', 1, \'text\'),
+(85, \'INSTANCE_SUSPENDED\', \'0\', \'checkbox\', \'Is this instance suspended?\', NULL, \'general\', 0, \'Instance Suspended\', \'\', 120, \'0\', 0, \'checkbox\'),
+(87, \'XMR_ADDRESS\', \'tcp://localhost:5555\', \'text\', \'Please enter the private address for XMR.\', NULL, \'displays\', 1, \'XMR Private Address\', \'\', 5, \'tcp:://localhost:5555\', 1, \'string\'),
+(88, \'XMR_PUB_ADDRESS\', \'\', \'text\', \'Please enter the public address for XMR.\', NULL, \'displays\', 1, \'XMR Public Address\', \'\', 6, \'\', 1, \'string\'),
+(89, \'CDN_URL\', \'\', \'text\', \'Content Delivery Network Address for serving file requests to Players\', \'\', \'network\', 0, \'CDN Address\', \'\', 33, \'\', 0, \'string\'),
+(90, \'ELEVATE_LOG_UNTIL\', \'1463396415\', \'datetime\', \'Elevate the log level until this date.\', null, \'troubleshooting\', 1, \'Elevate Log Until\', \' \', 25, \'\', 1, \'datetime\'),
+(91, \'RESTING_LOG_LEVEL\', \'Error\', \'dropdown\', \'Set the level of the resting log level. The CMS will revert to this log level after an elevated period ends. In production systems "error" is recommended.\', \'Emergency|Alert|Critical|Error\', \'troubleshooting\', 1, \'Resting Log Level\', \'\', 19, \'error\', 1, \'word\'),
+(92, \'TASK_CONFIG_LOCKED_CHECKB\', \'Unchecked\', \'dropdown\', \'Is the task config locked? Useful for Service providers.\', \'Checked|Unchecked\', \'defaults\', 0, \'Lock Task Config\', \'\', 30, \'Unchecked\', 0, \'word\'),
+(93, \'WHITELIST_LOAD_BALANCERS\', \'\', \'text\', \'If the CMS is behind a load balancer, what are the load balancer IP addresses, comma delimited.\', \'\', \'network\', 1, \'Whitelist Load Balancers\', \'\', 100, \'\', 1, \'string\'),
+(94, \'DEFAULT_LAYOUT\', \'1\', \'text\', \'The default layout to assign for new displays and displays which have their current default deleted.\', \'1\', \'displays\', 1, \'Default Layout\', \'\', 4, \'\', 1, \'int\'),
+(95, \'DISPLAY_PROFILE_STATS_DEFAULT\', \'0\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Default setting for Statistics Enabled?\', \'\', 70, \'0\', 1, \'checkbox\'),
+(96, \'DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED\', \'1\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Enable the option to report the current layout status?\', \'\', 80, \'0\', 1, \'checkbox\'),
+(97, \'DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED\', \'1\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Enable the option to set the screenshot interval?\', \'\', 90, \'0\', 1, \'checkbox\'),
+(98, \'DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT\', \'200\', \'number\', \'The default size in pixels for the Display Screenshots\', NULL, \'displays\', 1, \'Display Screenshot Default Size\', \'\', 100, \'200\', 1, \'int\'),
+(99, \'LATEST_NEWS_URL\', \'http://xibo.org.uk/feed\', \'text\', \'RSS/Atom Feed to be displayed on the Status Dashboard\', \'\', \'general\', 0, \'Latest News URL\', \'\', 111, \'\', 0, \'string\'),
+(100, \'DISPLAY_LOCK_NAME_TO_DEVICENAME\', \'0\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Lock the Display Name to the device name provided by the Player?\', \'\', 80, \'0\', 1, \'checkbox\');
+ ');
+
+ // Add help
+ $this->execute('
+INSERT INTO `help` (`HelpID`, `Topic`, `Category`, `Link`) VALUES
+(1, \'Layout\', \'General\', \'layouts.html\'),
+(2, \'Content\', \'General\', \'media.html\'),
+(4, \'Schedule\', \'General\', \'scheduling.html\'),
+(5, \'Group\', \'General\', \'users_groups.html\'),
+(6, \'Admin\', \'General\', \'cms_settings.html\'),
+(7, \'Report\', \'General\', \'troubleshooting.html\'),
+(8, \'Dashboard\', \'General\', \'tour.html\'),
+(9, \'User\', \'General\', \'users.html\'),
+(10, \'Display\', \'General\', \'displays.html\'),
+(11, \'DisplayGroup\', \'General\', \'displays_groups.html\'),
+(12, \'Layout\', \'Add\', \'layouts.html#Add_Layout\'),
+(13, \'Layout\', \'Background\', \'layouts_designer.html#Background\'),
+(14, \'Content\', \'Assign\', \'layouts_playlists.html#Assigning_Content\'),
+(15, \'Layout\', \'RegionOptions\', \'layouts_regions.html\'),
+(16, \'Content\', \'AddtoLibrary\', \'media_library.html\'),
+(17, \'Display\', \'Edit\', \'displays.html#Display_Edit\'),
+(18, \'Display\', \'Delete\', \'displays.html#Display_Delete\'),
+(19, \'Displays\', \'Groups\', \'displays_groups.html#Group_Members\'),
+(20, \'UserGroup\', \'Add\', \'users_groups.html#Adding_Group\'),
+(21, \'User\', \'Add\', \'users_administration.html#Add_User\'),
+(22, \'User\', \'Delete\', \'users_administration.html#Delete_User\'),
+(23, \'Content\', \'Config\', \'cms_settings.html#Content\'),
+(24, \'LayoutMedia\', \'Permissions\', \'users_permissions.html\'),
+(25, \'Region\', \'Permissions\', \'users_permissions.html\'),
+(26, \'Library\', \'Assign\', \'layouts_playlists.html#Add_From_Library\'),
+(27, \'Media\', \'Delete\', \'media_library.html#Delete\'),
+(28, \'DisplayGroup\', \'Add\', \'displays_groups.html#Add_Group\'),
+(29, \'DisplayGroup\', \'Edit\', \'displays_groups.html#Edit_Group\'),
+(30, \'DisplayGroup\', \'Delete\', \'displays_groups.html#Delete_Group\'),
+(31, \'DisplayGroup\', \'Members\', \'displays_groups.html#Group_Members\'),
+(32, \'DisplayGroup\', \'Permissions\', \'users_permissions.html\'),
+(34, \'Schedule\', \'ScheduleNow\', \'scheduling_now.html\'),
+(35, \'Layout\', \'Delete\', \'layouts.html#Delete_Layout\'),
+(36, \'Layout\', \'Copy\', \'layouts.html#Copy_Layout\'),
+(37, \'Schedule\', \'Edit\', \'scheduling_events.html#Edit\'),
+(38, \'Schedule\', \'Add\', \'scheduling_events.html#Add\'),
+(39, \'Layout\', \'Permissions\', \'users_permissions.html\'),
+(40, \'Display\', \'MediaInventory\', \'displays.html#Media_Inventory\'),
+(41, \'User\', \'ChangePassword\', \'users.html#Change_Password\'),
+(42, \'Schedule\', \'Delete\', \'scheduling_events.html\'),
+(43, \'Layout\', \'Edit\', \'layouts_designer.html#Edit_Layout\'),
+(44, \'Media\', \'Permissions\', \'users_permissions.html\'),
+(45, \'Display\', \'DefaultLayout\', \'displays.html#DefaultLayout\'),
+(46, \'UserGroup\', \'Edit\', \'users_groups.html#Edit_Group\'),
+(47, \'UserGroup\', \'Members\', \'users_groups.html#Group_Member\'),
+(48, \'User\', \'PageSecurity\', \'users_permissions.html#Page_Security\'),
+(49, \'User\', \'MenuSecurity\', \'users_permissions.html#Menu_Security\'),
+(50, \'UserGroup\', \'Delete\', \'users_groups.html#Delete_Group\'),
+(51, \'User\', \'Edit\', \'users_administration.html#Edit_User\'),
+(52, \'User\', \'Applications\', \'users_administration.html#Users_MyApplications\'),
+(53, \'User\', \'SetHomepage\', \'users_administration.html#Media_Dashboard\'),
+(54, \'DataSet\', \'General\', \'media_datasets.html\'),
+(55, \'DataSet\', \'Add\', \'media_datasets.html#Create_Dataset\'),
+(56, \'DataSet\', \'Edit\', \'media_datasets.html#Edit_Dataset\'),
+(57, \'DataSet\', \'Delete\', \'media_datasets.html#Delete_Dataset\'),
+(58, \'DataSet\', \'AddColumn\', \'media_datasets.html#Dataset_Column\'),
+(59, \'DataSet\', \'EditColumn\', \'media_datasets.html#Dataset_Column\'),
+(60, \'DataSet\', \'DeleteColumn\', \'media_datasets.html#Dataset_Column\'),
+(61, \'DataSet\', \'Data\', \'media_datasets.html#Dataset_Row\'),
+(62, \'DataSet\', \'Permissions\', \'users_permissions.html\'),
+(63, \'Fault\', \'General\', \'troubleshooting.html#Report_Fault\'),
+(65, \'Stats\', \'General\', \'displays_metrics.html\'),
+(66, \'Resolution\', \'General\', \'layouts_resolutions.html\'),
+(67, \'Template\', \'General\', \'layouts_templates.html\'),
+(68, \'Services\', \'Register\', \'#Registered_Applications\'),
+(69, \'OAuth\', \'General\', \'api_oauth.html\'),
+(70, \'Services\', \'Log\', \'api_oauth.html#oAuthLog\'),
+(71, \'Module\', \'Edit\', \'media_modules.html\'),
+(72, \'Module\', \'General\', \'media_modules.html\'),
+(73, \'Campaign\', \'General\', \'layouts_campaigns.html\'),
+(74, \'License\', \'General\', \'licence_information.html\'),
+(75, \'DataSet\', \'ViewColumns\', \'media_datasets.html#Dataset_Column\'),
+(76, \'Campaign\', \'Permissions\', \'users_permissions.html\'),
+(77, \'Transition\', \'Edit\', \'layouts_transitions.html\'),
+(78, \'User\', \'SetPassword\', \'users_administration.html#Set_Password\'),
+(79, \'DataSet\', \'ImportCSV\', \'media_datasets.htmlmedia_datasets.html#Import_CSV\'),
+(80, \'DisplayGroup\', \'FileAssociations\', \'displays_fileassociations.html\'),
+(81, \'Statusdashboard\', \'General\', \'tour_status_dashboard.html\'),
+(82, \'Displayprofile\', \'General\', \'displays_settings.html\'),
+(83, \'DisplayProfile\', \'Edit\', \'displays_settings.html#edit\'),
+(84, \'DisplayProfile\', \'Delete\', \'displays_settings.html#delete\');
+ ');
+
+ // Add modules
+ $this->execute('
+INSERT INTO `module` (`ModuleID`, `Module`, `Name`, `Enabled`, `RegionSpecific`, `Description`, `ImageUri`, `SchemaVersion`, `ValidExtensions`, `PreviewEnabled`, `assignable`, `render_as`, `settings`, `viewPath`, `class`, `defaultDuration`) VALUES
+ (1, \'Image\', \'Image\', 1, 0, \'Upload Image files to assign to Layouts\', \'forms/image.gif\', 1, \'jpg,jpeg,png,bmp,gif\', 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\Image\', 10),
+ (2, \'Video\', \'Video\', 1, 0, \'Upload Video files to assign to Layouts\', \'forms/video.gif\', 1, \'wmv,avi,mpg,mpeg,webm,mp4\', 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\Video\', 0),
+ (4, \'PowerPoint\', \'PowerPoint\', 1, 0, \'Upload a PowerPoint file to assign to Layouts\', \'forms/powerpoint.gif\', 1, \'ppt,pps,pptx\', 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\PowerPoint\', 10),
+ (5, \'Webpage\', \'Webpage\', 1, 1, \'Embed a Webpage\', \'forms/webpage.gif\', 1, NULL, 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\WebPage\', 60),
+ (6, \'Ticker\', \'Ticker\', 1, 1, \'Display dynamic feed content\', \'forms/ticker.gif\', 1, NULL, 1, 1, NULL, \'[]\', \'../modules\', \'Xibo\\\\Widget\\\\Ticker\', 5),
+ (7, \'Text\', \'Text\', 1, 1, \'Add Text directly to a Layout\', \'forms/text.gif\', 1, NULL, 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\Text\', 5),
+ (8, \'Embedded\', \'Embedded\', 1, 1, \'Embed HTML and JavaScript\', \'forms/webpage.gif\', 1, NULL, 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\Embedded\', 60),
+ (11, \'datasetview\', \'Data Set\', 1, 1, \'Organise and display DataSet data in a tabular format\', \'forms/datasetview.gif\', 1, NULL, 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\DataSetView\', 60),
+ (12, \'shellcommand\', \'Shell Command\', 1, 1, \'Instruct a Display to execute a command using the operating system shell\', \'forms/shellcommand.gif\', 1, NULL, 1, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\ShellCommand\', 3),
+ (13, \'localvideo\', \'Local Video\', 1, 1, \'Display Video that only exists on the Display by providing a local file path or URL\', \'forms/video.gif\', 1, NULL, 0, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\LocalVideo\', 60),
+ (14, \'genericfile\', \'Generic File\', 1, 0, \'A generic file to be stored in the library\', \'forms/library.gif\', 1, \'apk,ipk,js,html,htm\', 0, 0, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\GenericFile\', 10),
+ (15, \'clock\', \'Clock\', 1, 1, \'Assign a type of Clock or a Countdown\', \'forms/library.gif\', 1, NULL, 1, 1, \'html\', \'[]\', \'../modules\', \'Xibo\\\\Widget\\\\Clock\', 5),
+ (16, \'font\', \'Font\', 1, 0, \'A font to use in other Modules\', \'forms/library.gif\', 1, \'ttf,otf,eot,svg,woff\', 0, 0, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\Font\', 10),
+ (17, \'audio\', \'Audio\', 1, 0, \'Upload Audio files to assign to Layouts\', \'forms/video.gif\', 1, \'mp3,wav\', 0, 1, NULL, NULL, \'../modules\', \'Xibo\\\\Widget\\\\Audio\', 0),
+ (18, \'pdf\', \'PDF\', 1, 0, \'Upload PDF files to assign to Layouts\', \'forms/pdf.gif\', 1, \'pdf\', 1, 1, \'html\', null, \'../modules\', \'Xibo\\\\Widget\\\\Pdf\', 60),
+ (19, \'notificationview\', \'Notification\', 1, 1, \'Display messages created in the Notification Drawer of the CMS\', \'forms/library.gif\', 1, null, 1, 1, \'html\', null, \'../modules\', \'Xibo\\\\Widget\\\\NotificationView\', 10);
+ ');
+ }
+}
diff --git a/db/migrations/20180131113100_old_upgrade_step85_migration.php b/db/migrations/20180131113100_old_upgrade_step85_migration.php
new file mode 100644
index 0000000..bbc1bb9
--- /dev/null
+++ b/db/migrations/20180131113100_old_upgrade_step85_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class OldUpgradeStep85Migration extends AbstractMigration
+{
+ public function up()
+ {
+ $STEP = 85;
+
+ // Are we an upgrade from an older version?
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ $display = $this->table('display');
+
+ if (!$display->hasColumn('storageAvailableSpace')) {
+ $display
+ ->addColumn('storageAvailableSpace', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->addColumn('storageTotalSpace', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->save();
+ }
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131113853_old_upgrade_step86_migration.php b/db/migrations/20180131113853_old_upgrade_step86_migration.php
new file mode 100644
index 0000000..89b9dab
--- /dev/null
+++ b/db/migrations/20180131113853_old_upgrade_step86_migration.php
@@ -0,0 +1,65 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $settings = $this->table('setting');
+ $settings
+ ->insert([
+ [
+ 'setting' => 'DASHBOARD_LATEST_NEWS_ENABLED',
+ 'title' => 'Enable Latest News?',
+ 'helptext' => 'Should the Dashboard show latest news? The address is provided by the theme.',
+ 'value' => '1',
+ 'fieldType' => 'checkbox',
+ 'options' => '',
+ 'cat' => 'general',
+ 'userChange' => '1',
+ 'type' => 'checkbox',
+ 'validation' => '',
+ 'ordering' => '110',
+ 'default' => '1',
+ 'userSee' => '1',
+ ],
+ [
+ 'setting' => 'LIBRARY_MEDIA_DELETEOLDVER_CHECKB',
+ 'title' => 'Default for \"Delete old version of Media\" checkbox. Shown when Editing Library Media.',
+ 'helptext' => 'Default the checkbox for Deleting Old Version of media when a new file is being uploaded to the library.',
+ 'value' => 'Unchecked',
+ 'fieldType' => 'dropdown',
+ 'options' => 'Checked|Unchecked',
+ 'cat' => 'defaults',
+ 'userChange' => '1',
+ 'type' => 'dropdown',
+ 'validation' => '',
+ 'ordering' => '50',
+ 'default' => 'Unchecked',
+ 'userSee' => '1',
+ ]
+ ])
+ ->save();
+
+ // Update a setting
+ $this->execute('UPDATE `setting` SET `type` = \'checkbox\', `fieldType` = \'checkbox\' WHERE setting = \'SETTING_LIBRARY_TIDY_ENABLED\' OR setting = \'SETTING_IMPORT_ENABLED\';');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131113941_old_upgrade_step87_migration.php b/db/migrations/20180131113941_old_upgrade_step87_migration.php
new file mode 100644
index 0000000..9643a71
--- /dev/null
+++ b/db/migrations/20180131113941_old_upgrade_step87_migration.php
@@ -0,0 +1,53 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $settings = $this->table('setting');
+ $settings
+ ->insert([
+ 'setting' => 'PROXY_EXCEPTIONS',
+ 'title' => 'Proxy Exceptions',
+ 'helptext' => 'Hosts and Keywords that should not be loaded via the Proxy Specified. These should be comma separated.',
+ 'value' => '1',
+ 'fieldType' => 'text',
+ 'options' => '',
+ 'cat' => 'network',
+ 'userChange' => '1',
+ 'type' => 'string',
+ 'validation' => '',
+ 'ordering' => '32',
+ 'default' => '',
+ 'userSee' => '1',
+ ])
+ ->save();
+
+ // If we haven't run step85 during this migration, then we will want to update our storageAvailable columns
+ // Change to big ints.
+ $display = $this->table('display');
+ $display
+ ->changeColumn('storageAvailableSpace', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->changeColumn('storageTotalSpace', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->save();
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131113948_old_upgrade_step88_migration.php b/db/migrations/20180131113948_old_upgrade_step88_migration.php
new file mode 100644
index 0000000..5ca0c8a
--- /dev/null
+++ b/db/migrations/20180131113948_old_upgrade_step88_migration.php
@@ -0,0 +1,43 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $auditLog = $this->table('auditlog', ['id' => 'logId']);
+ $auditLog->addColumn('logDate', 'integer')
+ ->addColumn('userId', 'integer')
+ ->addColumn('message', 'string', ['limit' => 255])
+ ->addColumn('entity', 'string', ['limit' => 50])
+ ->addColumn('entityId', 'integer')
+ ->addColumn('objectAfter', 'text')
+ ->save();
+
+ $this->execute('INSERT INTO `pages` (`name`, `pagegroupID`) SELECT \'auditlog\', pagegroupID FROM `pagegroup` WHERE pagegroup.pagegroup = \'Reports\';');
+
+ $group = $this->table('group');
+ if (!$group->hasColumn('libraryQuota')) {
+ $group->addColumn('libraryQuota', 'integer', ['null' => true])
+ ->save();
+ }
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131113952_old_upgrade_step92_migration.php b/db/migrations/20180131113952_old_upgrade_step92_migration.php
new file mode 100644
index 0000000..e97eff3
--- /dev/null
+++ b/db/migrations/20180131113952_old_upgrade_step92_migration.php
@@ -0,0 +1,49 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $settings = $this->table('setting');
+ $settings
+ ->insert([
+ 'setting' => 'CDN_URL',
+ 'title' => 'CDN Address',
+ 'helptext' => 'Content Delivery Network Address for serving file requests to Players',
+ 'value' => '',
+ 'fieldType' => 'text',
+ 'options' => '',
+ 'cat' => 'network',
+ 'userChange' => '0',
+ 'type' => 'string',
+ 'validation' => '',
+ 'ordering' => '33',
+ 'default' => '',
+ 'userSee' => '0',
+ ])
+ ->save();
+
+ $this->execute('ALTER TABLE `datasetcolumn` CHANGE `ListContent` `ListContent` VARCHAR( 1000 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL;');
+
+ $this->execute('ALTER TABLE `stat` ADD INDEX Type (`displayID`, `end`, `Type`);');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131113957_old_upgrade_step120_migration.php b/db/migrations/20180131113957_old_upgrade_step120_migration.php
new file mode 100644
index 0000000..2cebc58
--- /dev/null
+++ b/db/migrations/20180131113957_old_upgrade_step120_migration.php
@@ -0,0 +1,370 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class OldUpgradeStep120Migration extends AbstractMigration
+{
+ public function up()
+ {
+ $STEP = 120;
+
+ // Are we an upgrade from an older version?
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $log = $this->table('log');
+ $log->removeColumn('scheduleId')
+ ->removeColumn('layoutId')
+ ->removeColumn('mediaId')
+ ->removeColumn('requestUri')
+ ->removeColumn('remoteAddr')
+ ->removeColumn('userAgent')
+ ->changeColumn('type', 'string', ['limit' => 254])
+ ->addColumn('channel', 'string', ['limit' => 5, 'after' => 'logDate'])
+ ->addColumn('runNo', 'string', ['limit' => 10])
+ ->save();
+
+ $module = $this->table('module');
+ $module->addColumn('viewPath', 'string', ['limit' => 254, 'default' => '../modules'])
+ ->addColumn('class', 'string', ['limit' => 254])
+ ->save();
+
+ $permission = $this->table('permission', ['id' => 'permissionId']);
+ $permission->addColumn('entityId', 'integer')
+ ->addColumn('groupId', 'integer')
+ ->addColumn('objectId', 'integer')
+ ->addColumn('view', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('edit', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('delete', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+
+ $permissionEntity = $this->table('permissionentity', ['id' => 'entityId']);
+ $permissionEntity->addColumn('entity', 'string', ['limit' => 50])
+ ->addIndex('entity', ['unique' => true])
+ ->insert([
+ ['entity' => 'Xibo\\Entity\\Campaign'],
+ ['entity' => 'Xibo\\Entity\\DataSet'],
+ ['entity' => 'Xibo\\Entity\\DisplayGroup'],
+ ['entity' => 'Xibo\\Entity\\Media'],
+ ['entity' => 'Xibo\\Entity\\Page'],
+ ['entity' => 'Xibo\\Entity\\Playlist'],
+ ['entity' => 'Xibo\\Entity\\Region'],
+ ['entity' => 'Xibo\\Entity\\Widget'],
+ ])
+ ->save();
+
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT groupId, 1, pageId, 1, 0, 0 FROM `lkpagegroup`;');
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT groupId, 5, campaignId, view, edit, del FROM `lkcampaigngroup`;');
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT groupId, 4, mediaId, view, edit, del FROM `lkmediagroup`;');
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT groupId, 9, dataSetId, view, edit, del FROM `lkdatasetgroup`;');
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT groupId, 3, displayGroupId, view, edit, del FROM `lkdisplaygroupgroup`;');
+
+ $this->table('lkpagegroup')->drop()->save();
+ $this->table('lkmenuitemgroup')->drop()->save();
+ $this->table('lkcampaigngroup')->drop()->save();
+ $this->table('lkmediagroup')->drop()->save();
+ $this->table('lkdatasetgroup')->drop()->save();
+ $this->table('lkdisplaygroupgroup')->drop()->save();
+
+ $pages = $this->table('pages');
+ $pages
+ ->removeIndexByName('pages_ibfk_1')
+ ->dropForeignKey('pageGroupId')
+ ->removeColumn('pageGroupId')
+ ->addColumn('title', 'string', ['limit' => 50])
+ ->addColumn('asHome', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->insert([
+ ['name' => 'region', 'title' => ''],
+ ['name' => 'playlist', 'title' => ''],
+ ['name' => 'maintenance', 'title' => ''],
+ ])
+ ->save();
+
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT `groupId`, 1, (SELECT pageId FROM `pages` WHERE `name` = \'region\'), `view`, `edit`, `delete` FROM `permission` WHERE `objectId` = (SELECT pageId FROM `pages` WHERE `name` = \'layout\') AND `entityId` = 1;');
+ $this->execute('INSERT INTO `permission` (`groupId`, `entityId`, `objectId`, `view`, `edit`, `delete`) SELECT `groupId`, 1, (SELECT pageId FROM `pages` WHERE `name` = \'playlist\'), `view`, `edit`, `delete` FROM `permission` WHERE `objectId` = (SELECT pageId FROM `pages` WHERE `name` = \'layout\') AND `entityId` = 1;');
+ $this->execute('UPDATE `pages` SET title = CONCAT(UCASE(LEFT(name, 1)), SUBSTRING(name, 2)), asHome = 1;');
+ $this->execute('UPDATE `pages` SET `name` = \'audit\' WHERE `name` = \'auditlog\';');
+ $this->execute('UPDATE `pages` SET asHome = 0 WHERE `name` IN (\'update\',\'admin\',\'manual\',\'help\',\'clock\',\'preview\',\'region\',\'playlist\',\'maintenance\');');
+ $this->execute('UPDATE `pages` SET `name` = \'library\', `title` = \'Library\' WHERE `pages`.`name` = \'content\';');
+ $this->execute('UPDATE `pages` SET `name` = \'applications\', `title` = \'Applications\' WHERE `pages`.`name` = \'oauth\';');
+ $this->execute('UPDATE `pages` SET `title` = \'Media Dashboard\' WHERE `pages`.`name` = \'mediamanager\';');
+ $this->execute('UPDATE `pages` SET `title` = \'Status Dashboard\' WHERE `pages`.`name` = \'statusdashboard\';');
+ $this->execute('UPDATE `pages` SET `title` = \'Display Profiles\' WHERE `pages`.`name` = \'displayprofile\';');
+ $this->execute('UPDATE `pages` SET `title` = \'Display Groups\' WHERE `pages`.`name` = \'displaygroup\';');
+ $this->execute('UPDATE `pages` SET `title` = \'Home\' WHERE `pages`.`name` = \'index\';');
+ $this->execute('UPDATE `pages` SET `title` = \'Audit Trail\' WHERE `pages`.`name` = \'auditlog\';');
+
+ $this->table('menuitem')->drop()->save();
+ $this->table('menu')->drop()->save();
+ $this->table('pagegroup')->drop()->save();
+
+ $layout = $this->table('layout');
+ $layout->addColumn('width', 'decimal')
+ ->addColumn('height', 'decimal')
+ ->addColumn('backgroundColor', 'string', ['limit' => 25, 'null' => true])
+ ->addColumn('backgroundzIndex', 'integer', ['default' => 1])
+ ->addColumn('schemaVersion', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->changeColumn('xml', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, 'null' => true])
+ ->addColumn('statusMessage', 'text', ['null' => true])
+ ->save();
+
+ $this->execute('UPDATE `user` SET homepage = IFNULL((SELECT pageId FROM `pages` WHERE pages.name = `user`.homepage LIMIT 1), 1);');
+ $this->execute('ALTER TABLE `user` CHANGE `homepage` `homePageId` INT NOT NULL DEFAULT \'1\' COMMENT \'The users homepage\';');
+
+ $this->execute('DELETE FROM module WHERE module = \'counter\';');
+
+ $linkRegionPlaylist = $this->table('lkregionplaylist', ['id' => false, 'primary_key' => 'regionId', 'playlistId', 'displayOrder']);
+ $linkRegionPlaylist->addColumn('regionId', 'integer')
+ ->addColumn('playlistId', 'integer')
+ ->addColumn('displayOrder', 'integer')
+ ->save();
+
+ $linkWidgetMedia = $this->table('lkwidgetmedia', ['id' => false, 'primary_key' => ['widgetId', 'mediaId']]);
+ $linkWidgetMedia->addColumn('widgetId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->save();
+
+ $playlist = $this->table('playlist', ['id' => 'playlistId']);
+ $playlist->addColumn('name', 'string', ['limit' => 254])
+ ->addColumn('ownerId', 'integer')
+ ->save();
+
+ $region = $this->table('region', ['id' => 'regionId']);
+ $region
+ ->addColumn('layoutId', 'integer')
+ ->addColumn('ownerId', 'integer')
+ ->addColumn('name', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('width', 'decimal')
+ ->addColumn('height', 'decimal')
+ ->addColumn('top', 'decimal')
+ ->addColumn('left', 'decimal')
+ ->addColumn('zIndex', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('duration', 'integer', ['default' => 0])
+ ->save();
+
+ $regionOption = $this->table('regionoption', ['id' => false, 'primary_key' => ['regionId', 'option']]);
+ $regionOption->addColumn('regionId', 'integer')
+ ->addColumn('option', 'string', ['limit' => 50])
+ ->addColumn('value', 'text', ['null' => true])
+ ->save();
+
+ $widget = $this->table('widget', ['id' => 'widgetId']);
+ $widget
+ ->addColumn('playlistId', 'integer')
+ ->addColumn('ownerId', 'integer')
+ ->addColumn('type', 'string', ['limit' => 50])
+ ->addColumn('duration', 'integer')
+ ->addColumn('displayOrder', 'integer')
+ ->addColumn('calculatedDuration', 'integer')
+ ->addColumn('useDuration', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addForeignKey('playlistId', 'playlist', 'playlistId')
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->save();
+
+ $widgetOption = $this->table('widgetoption', ['id' => false, 'primary_key' => ['widgetId', 'type', 'option']]);
+ $widgetOption->addColumn('widgetId', 'integer')
+ ->addColumn('type', 'string', ['limit' => 50])
+ ->addColumn('option', 'string', ['limit' => 254])
+ ->addColumn('value', 'text', ['null' => true])
+ ->addForeignKey('widgetId', 'widget', 'widgetId')
+ ->save();
+
+ $this->table('oauth_log')->drop()->save();
+ $this->table('oauth_server_nonce')->drop()->save();
+ $this->table('oauth_server_token')->drop()->save();
+ $this->table('oauth_server_registry')->drop()->save();
+
+ // New oAuth tables
+ $oauthClients = $this->table('oauth_clients', ['id' => false, 'primary_key' => ['id']]);
+ $oauthClients
+ ->addColumn('id', 'string', ['limit' => 254])
+ ->addColumn('secret', 'string', ['limit' => 254])
+ ->addColumn('name', 'string', ['limit' => 254])
+ ->addColumn('userId', 'integer')
+ ->addColumn('authCode', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('clientCredentials', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+
+ $oauthSessions = $this->table('oauth_sessions');
+ $oauthSessions
+ ->addColumn('owner_type', 'string', ['limit' => 254])
+ ->addColumn('owner_id', 'string', ['limit' => 254])
+ ->addColumn('client_id', 'string', ['limit' => 254])
+ ->addColumn('client_redirect_uri', 'string', ['limit' => 500, 'null' => true])
+ ->addForeignKey('client_id', 'oauth_clients', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthScopes = $this->table('oauth_scopes', ['id' => false, 'primary_key' => ['id']]);
+ $oauthScopes
+ ->addColumn('id', 'string', ['limit' => 254])
+ ->addColumn('description', 'string', ['limit' => 1000])
+ ->save();
+
+ $oauthAccessTokens = $this->table('oauth_access_tokens', ['id' => false, 'primary_key' => ['access_token']]);
+ $oauthAccessTokens
+ ->addColumn('access_token', 'string', ['limit' => 254])
+ ->addColumn('session_id', 'integer')
+ ->addColumn('expire_time', 'integer')
+ ->addForeignKey('session_id', 'oauth_sessions', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthAccessTokenScopes = $this->table('oauth_access_token_scopes');
+ $oauthAccessTokenScopes
+ ->addColumn('access_token', 'string', ['limit' => 254])
+ ->addColumn('scope', 'string', ['limit' => 254])
+ ->addForeignKey('access_token', 'oauth_access_tokens', 'access_token', ['delete' => 'CASCADE'])
+ ->addForeignKey('scope', 'oauth_scopes', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthAuthCodes = $this->table('oauth_auth_codes', ['id' => false, 'primary_key' => ['auth_code']]);
+ $oauthAuthCodes
+ ->addColumn('auth_code', 'string', ['limit' => 254])
+ ->addColumn('session_id', 'integer')
+ ->addColumn('expire_time', 'integer')
+ ->addColumn('client_redirect_uri', 'string', ['limit' => 500])
+ ->addForeignKey('session_id', 'oauth_sessions', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthAuthCodeScopes = $this->table('oauth_auth_code_scopes');
+ $oauthAuthCodeScopes
+ ->addColumn('auth_code', 'string', ['limit' => 254])
+ ->addColumn('scope', 'string', ['limit' => 254])
+ ->addForeignKey('auth_code', 'oauth_auth_codes', 'auth_code', ['delete' => 'CASCADE'])
+ ->addForeignKey('scope', 'oauth_scopes', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthClientRedirects = $this->table('oauth_client_redirect_uris');
+ $oauthClientRedirects
+ ->addColumn('client_id', 'string', ['limit' => 254])
+ ->addColumn('redirect_uri', 'string', ['limit' => 500])
+ ->save();
+
+ $oauthRefreshToeksn = $this->table('oauth_refresh_tokens', ['id' => false, 'primary_key' => ['refresh_token']]);
+ $oauthRefreshToeksn
+ ->addColumn('refresh_token', 'string', ['limit' => 254])
+ ->addColumn('expire_time', 'integer')
+ ->addColumn('access_token', 'string', ['limit' => 254])
+ ->addForeignKey('access_token', 'oauth_access_tokens', 'access_token', ['delete' => 'CASCADE'])
+ ->save();
+
+ $oauthSessionsScopes = $this->table('oauth_session_scopes');
+ $oauthSessionsScopes
+ ->addColumn('session_id', 'integer')
+ ->addColumn('scope', 'string', ['limit' => 254])
+ ->addForeignKey('session_id', 'oauth_sessions', 'id', ['delete' => 'CASCADE'])
+ ->addForeignKey('scope', 'oauth_scopes', 'id', ['delete' => 'CASCADE'])
+ ->save();
+
+ $this->table('file')->drop()->save();
+
+ $this->execute('TRUNCATE TABLE `xmdsnonce`;');
+ $this->execute('RENAME TABLE `xmdsnonce` TO `requiredfile`;');
+
+ $requiredFile = $this->table('requiredfile');
+ $requiredFile->addColumn('requestKey', 'string', ['limit' => 10])
+ ->addColumn('bytesRequested', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG])
+ ->addColumn('complete' , 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+
+ $this->execute('ALTER TABLE `requiredfile` CHANGE `nonceId` `rfId` BIGINT( 20 ) NOT NULL AUTO_INCREMENT;');
+ $this->execute('ALTER TABLE `requiredfile` CHANGE `regionId` `regionId` INT NULL;');
+ $this->execute('ALTER TABLE `requiredfile` DROP `fileId`;');
+
+ $display = $this->table('display');
+ $display
+ ->removeColumn('MediaInventoryXml')
+ ->save();
+
+ $this->execute('DELETE FROM `setting` WHERE setting = \'USE_INTL_DATEFORMAT\';');
+ $this->execute('UPDATE `setting` SET `options` = \'Emergency|Alert|Critical|Error|Warning|Notice|Info|Debug\', value = \'Error\' WHERE setting = \'audit\';');
+ $this->execute('UPDATE `setting` SET `options` = \'private|group|public\' WHERE `setting`.`setting` IN (\'MEDIA_DEFAULT\', \'LAYOUT_DEFAULT\');');
+ $this->execute('INSERT INTO `setting` (`settingid`, `setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`) VALUES (NULL, \'INSTANCE_SUSPENDED\', \'0\', \'checkbox\', \'Is this instance suspended?\', NULL, \'general\', \'0\', \'Instance Suspended\', \'\', \'120\', \'0\', \'0\', \'checkbox\'),(NULL, \'INHERIT_PARENT_PERMISSIONS\', \'1\', \'checkbox\', \'Inherit permissions from Parent when adding a new item?\', NULL, \'permissions\', \'1\', \'Inherit permissions\', \'\', \'50\', \'1\', \'1\', \'checkbox\');');
+ $this->execute('INSERT INTO `datatype` (`DataTypeID`, `DataType`) VALUES (\'5\', \'Library Image\');');
+ $this->execute('UPDATE `datatype` SET `DataType` = \'External Image\' WHERE `datatype`.`DataTypeID` =4 AND `datatype`.`DataType` = \'Image\' LIMIT 1 ;');
+
+ $this->table('lkdatasetlayout')->drop()->save();
+
+ $this->execute('CREATE TABLE `temp_lkmediadisplaygroup` AS SELECT `mediaid` ,`displaygroupid` FROM `lkmediadisplaygroup` WHERE 1 GROUP BY `mediaid` ,`displaygroupid`;');
+ $this->execute('DROP TABLE `lkmediadisplaygroup`;');
+ $this->execute('RENAME TABLE `temp_lkmediadisplaygroup` TO `lkmediadisplaygroup`;');
+
+ $this->execute('ALTER TABLE `lkmediadisplaygroup` ADD UNIQUE (`mediaid` ,`displaygroupid`);');
+ $this->execute('ALTER TABLE `lkcampaignlayout` ADD UNIQUE (`CampaignID` ,`LayoutID` ,`DisplayOrder`);');
+
+ $linkScheduleDisplayGroup = $this->table('lkscheduledisplaygroup', ['id' => false, 'primary_key' => ['eventId', 'displayGroupId']]);
+ $linkScheduleDisplayGroup
+ ->addColumn('eventId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->save();
+
+ $this->execute('ALTER TABLE `schedule_detail` DROP FOREIGN KEY `schedule_detail_ibfk_8` ;');
+ $this->execute('ALTER TABLE `schedule_detail` DROP `DisplayGroupID`;');
+
+ // Get all events and their Associated display group id's
+ foreach ($this->fetchAll('SELECT eventId, displayGroupIds FROM `schedule`') as $event) {
+ // Ping open the displayGroupIds
+ $displayGroupIds = explode(',', $event['displayGroupIds']);
+
+ // Construct some SQL to add the link
+ $sql = 'INSERT INTO `lkscheduledisplaygroup` (eventId, displayGroupId) VALUES ';
+
+ foreach ($displayGroupIds as $id) {
+ $sql .= '(' . $event['eventId'] . ',' . $id . '),';
+ }
+
+ $sql = rtrim($sql, ',');
+
+ $this->execute($sql);
+ }
+
+ $this->execute('ALTER TABLE `schedule` DROP `DisplayGroupIDs`;');
+
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Image\' WHERE module = \'Image\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Video\' WHERE module = \'Video\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Flash\' WHERE module = \'Flash\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\PowerPoint\' WHERE module = \'PowerPoint\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\WebPage\' WHERE module = \'Webpage\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Ticker\' WHERE module = \'Ticker\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Text\' WHERE module = \'Text\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Embedded\' WHERE module = \'Embedded\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\DataSetView\' WHERE module = \'datasetview\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\ShellCommand\' WHERE module = \'shellcommand\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\LocalVideo\' WHERE module = \'localvideo\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\GenericFile\' WHERE module = \'genericfile\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Clock\' WHERE module = \'Clock\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Font\' WHERE module = \'Font\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Twitter\' WHERE module = \'Twitter\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\ForecastIo\' WHERE module = \'forecastio\';');
+ $this->execute('UPDATE `module` SET `class` = \'\\\\Xibo\\\\Widget\\\\Finance\' WHERE module = \'Finance\';');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114002_old_upgrade_step121_migration.php b/db/migrations/20180131114002_old_upgrade_step121_migration.php
new file mode 100644
index 0000000..8e6d46e
--- /dev/null
+++ b/db/migrations/20180131114002_old_upgrade_step121_migration.php
@@ -0,0 +1,144 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class OldUpgradeStep121Migration extends AbstractMigration
+{
+ public function up()
+ {
+ $STEP = 121;
+
+ // Are we an upgrade from an older version?
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $display = $this->table('display');
+ $display
+ ->addColumn('xmrChannel', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('xmrPubKey', 'text', ['null' => true])
+ ->addColumn('lastCommandSuccess', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 2])
+ ->save();
+
+ $settings = $this->table('setting');
+ $settings
+ ->insert([
+ [
+ 'setting' => 'XMR_ADDRESS',
+ 'title' => 'XMR Private Address',
+ 'helptext' => 'Please enter the private address for XMR.',
+ 'value' => 'http:://localhost:8081',
+ 'fieldType' => 'checkbox',
+ 'options' => '',
+ 'cat' => 'displays',
+ 'userChange' => '1',
+ 'type' => 'string',
+ 'validation' => '',
+ 'ordering' => '5',
+ 'default' => 'http:://localhost:8081',
+ 'userSee' => '1',
+ ],
+ [
+ 'setting' => 'XMR_PUB_ADDRESS',
+ 'title' => 'XMR Public Address',
+ 'helptext' => 'Please enter the public address for XMR.',
+ 'value' => 'tcp:://localhost:5556',
+ 'fieldType' => 'dropdown',
+ 'options' => 'Checked|Unchecked',
+ 'cat' => 'displays',
+ 'userChange' => '1',
+ 'type' => 'string',
+ 'validation' => '',
+ 'ordering' => '6',
+ 'default' => 'tcp:://localhost:5556',
+ 'userSee' => '1',
+ ]
+ ])
+ ->save();
+
+ $linkLayoutDisplayGroup = $this->table('lklayoutdisplaygroup', ['comment' => 'Layout associations directly to Display Groups']);
+ $linkLayoutDisplayGroup->addColumn('layoutId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->addIndex(['layoutId', 'displayGroupId'], ['unique' => true])
+ ->save();
+
+ $pages = $this->table('pages');
+ $pages->insert([
+ 'name' => 'command',
+ 'title' => 'Commands',
+ 'asHome' => 1
+ ])->save();
+
+ $command = $this->table('command', ['id' => 'commandId']);
+ $command->addColumn('command', 'string', ['limit' => 254])
+ ->addColumn('code', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 1000, 'null' => true])
+ ->addColumn('userId', 'integer')
+ ->save();
+
+ $linkCommandDisplayProfile = $this->table('lkcommanddisplayprofile', ['id' => false, 'primary_key' => ['commandId', 'displayProfileId']]);
+ $linkCommandDisplayProfile->addColumn('commandId', 'integer')
+ ->addColumn('displayProfileId', 'integer')
+ ->addColumn('commandString', 'string', ['limit' => 1000])
+ ->addColumn('validationString', 'string', ['limit' => 1000])
+ ->save();
+
+ $schedule = $this->table('schedule');
+ $schedule->changeColumn('campaignId', 'integer', ['null' => true])
+ ->addColumn('eventTypeId', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'after' => 'eventId', 'default' => 1])
+ ->addColumn('commandId', 'integer', ['after' => 'campaignId'])
+ ->changeColumn('toDt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->save();
+
+ $this->execute('UPDATE `schedule` SET `eventTypeId` = 1;');
+
+ $scheduleDetail = $this->table('schedule_detail');
+ $scheduleDetail->changeColumn('toDt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->save();
+
+ $media = $this->table('media');
+ $media->addColumn('released', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('apiRef', 'string', ['limit' => 254, 'null' => true])
+ ->save();
+
+ $user = $this->table('user');
+ $user->addColumn('firstName', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('lastName', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('phone', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref1', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref2', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref3', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref4', 'string', ['limit' => 254, 'null' => true])
+ ->addColumn('ref5', 'string', ['limit' => 254, 'null' => true])
+ ->save();
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114007_old_upgrade_step122_migration.php b/db/migrations/20180131114007_old_upgrade_step122_migration.php
new file mode 100644
index 0000000..e93cdb6
--- /dev/null
+++ b/db/migrations/20180131114007_old_upgrade_step122_migration.php
@@ -0,0 +1,87 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $sql = '
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = \'' . $_SERVER['MYSQL_DATABASE'] . '\'
+ AND ENGINE = \'MyISAM\'
+ ';
+
+ foreach ($this->fetchAll($sql) as $table) {
+ $this->execute('ALTER TABLE `' . $table['TABLE_NAME'] . '` ENGINE=INNODB', []);
+ }
+
+ $auditLog = $this->table('auditlog');
+ $auditLog->changeColumn('userId', 'integer', ['null' => true])
+ ->save();
+
+ $dataSet = $this->table('dataset');
+ $dataSet->addColumn('code', 'string', ['limit' => 50, 'null' => true])
+ ->addColumn('isLookup', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->save();
+
+ $module = $this->table('module');
+ $module->addColumn('defaultDuration', 'integer')
+ ->save();
+
+ $this->execute('UPDATE `module` SET defaultDuration = 10;');
+ $this->execute('UPDATE `module` SET defaultDuration = (SELECT MAX(value) FROM `setting` WHERE setting = \'jpg_length\') WHERE `module` = \'image\';');
+ $this->execute('UPDATE `module` SET defaultDuration = (SELECT MAX(value) FROM `setting` WHERE setting = \'swf_length\') WHERE `module` = \'flash\';');
+ $this->execute('UPDATE `module` SET defaultDuration = (SELECT MAX(value) FROM `setting` WHERE setting = \'ppt_length\') WHERE `module` = \'powerpoint\';');
+ $this->execute('UPDATE `module` SET defaultDuration = 0 WHERE `module` = \'video\';');
+ $this->execute('DELETE FROM `setting` WHERE setting IN (\'ppt_length\', \'jpg_length\', \'swf_length\');');
+ $this->execute('UPDATE `widget` SET `calculatedDuration` = `duration`;');
+
+ $userOption = $this->table('useroption', ['id' => false, 'primary_key' => ['userId', 'option']]);
+ $userOption->addColumn('userId', 'integer')
+ ->addColumn('option', 'string', ['limit' => 50])
+ ->addColumn('value', 'text')
+ ->save();
+
+ $displayGroup = $this->table('displaygroup');
+ $displayGroup->addColumn('isDynamic', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('dynamicCriteria', 'string', ['null' => true, 'limit' => 2000])
+ ->addColumn('userId', 'integer')
+ ->save();
+
+ $this->execute('UPDATE `displaygroup` SET userId = (SELECT userId FROM `user` WHERE usertypeid = 1 LIMIT 1) WHERE userId = 0;');
+
+ $session = $this->table('session');
+ $session->removeColumn('lastPage')
+ ->removeColumn('securityToken')
+ ->save();
+
+ $linkDisplayGroup = $this->table('lkdgdg', ['id' => false, ['primary_key' => ['parentId', 'childId', 'depth']]]);
+ $linkDisplayGroup
+ ->addColumn('parentId', 'integer')
+ ->addColumn('childId', 'integer')
+ ->addColumn('depth', 'integer')
+ ->addIndex(['childId', 'parentId', 'depth'], ['unique' => true])
+ ->save();
+
+ $this->execute('INSERT INTO `lkdgdg` (parentId, childId, depth) SELECT displayGroupId, displayGroupId, 0 FROM `displaygroup` WHERE `displayGroupID` NOT IN (SELECT `parentId` FROM `lkdgdg` WHERE depth = 0);');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114013_old_upgrade_step123_migration.php b/db/migrations/20180131114013_old_upgrade_step123_migration.php
new file mode 100644
index 0000000..4ca8c81
--- /dev/null
+++ b/db/migrations/20180131114013_old_upgrade_step123_migration.php
@@ -0,0 +1,53 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $schedule = $this->table('schedule');
+ $schedule->addColumn('dayPartId', 'integer')
+ ->changeColumn('fromDt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->save();
+
+ // The following was added in step 92, we need to check to see if we already have this
+ if (!$this->fetchRow('SELECT * FROM setting WHERE setting = \'CDN_URL\'')) {
+ $settings = $this->table('setting');
+ $settings
+ ->insert([
+ 'setting' => 'CDN_URL',
+ 'title' => 'CDN Address',
+ 'helptext' => 'Content Delivery Network Address for serving file requests to Players',
+ 'value' => '',
+ 'fieldType' => 'text',
+ 'options' => '',
+ 'cat' => 'network',
+ 'userChange' => '0',
+ 'type' => 'string',
+ 'validation' => '',
+ 'ordering' => '33',
+ 'default' => '',
+ 'userSee' => '0',
+ ])
+ ->save();
+ }
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114017_old_upgrade_step124_migration.php b/db/migrations/20180131114017_old_upgrade_step124_migration.php
new file mode 100644
index 0000000..9d284af
--- /dev/null
+++ b/db/migrations/20180131114017_old_upgrade_step124_migration.php
@@ -0,0 +1,137 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $group = $this->table('group');
+ $group->addColumn('isSystemNotification', 'integer', ['default' => 0])
+ ->insert([
+ 'group' => 'System Notifications',
+ 'isUserSpecific' => 0,
+ 'isSystemNotification' => 1
+ ])
+ ->save();
+
+ $notification = $this->table('notification', ['id' => 'notificationId']);
+ $notification
+ ->addColumn('subject', 'string', ['limit' => 255])
+ ->addColumn('body', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])
+ ->addColumn('createDt', 'integer')
+ ->addColumn('releaseDt', 'integer')
+ ->addColumn('isEmail', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('isInterrupt', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('isSystem', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('userId', 'integer')
+ ->save();
+
+ $linkNotificationDg = $this->table('lknotificationdg', ['id' => 'lkNotificationDgId']);
+ $linkNotificationDg
+ ->addColumn('notificationId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->addIndex(['notificationId', 'displayGroupId'], ['unique' => true])
+ ->save();
+
+ $linkNotificationGroup = $this->table('lknotificationgroup', ['id' => 'lkNotificationGroupId']);
+ $linkNotificationGroup
+ ->addColumn('notificationId', 'integer')
+ ->addColumn('groupId', 'integer')
+ ->addIndex(['notificationId', 'groupId'], ['unique' => true])
+ ->save();
+
+ $linkNotificationUser = $this->table('lknotificationuser', ['id' => 'lkNotificationUserId']);
+ $linkNotificationUser
+ ->addColumn('notificationId', 'integer')
+ ->addColumn('userId', 'integer')
+ ->addColumn('read', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('readDt', 'integer')
+ ->addColumn('emailDt', 'integer')
+ ->addIndex(['notificationId', 'userId'], ['unique' => true])
+ ->save();
+
+ $pages = $this->table('pages');
+ $pages->insert([
+ [
+ 'name' => 'notification',
+ 'title' => 'Notifications',
+ 'asHome' => 0
+ ],
+ [
+ 'name' => 'drawer',
+ 'title' => 'Notification Drawer',
+ 'asHome' => 0
+ ]
+ ])->save();
+
+ $permissionEntity = $this->table('permissionentity');
+ $permissionEntity->insert([
+ 'entity' => '\\Xibo\\Entity\\Notification'
+ ])->save();
+
+ $this->execute('UPDATE `group` SET isSystemNotification = 1 WHERE isUserSpecific = 1 AND `groupId` IN (SELECT `groupId` FROM `lkusergroup` INNER JOIN `user` ON `user`.userId = `lkusergroup`.userId WHERE `user`.userTypeId = 1);');
+
+ // If we've run step 92 as part of this upgrade, then don't do the below
+ $this->execute('ALTER TABLE `datasetcolumn` CHANGE `ListContent` `ListContent` VARCHAR( 1000 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL;');
+
+ if (!$this->checkIndexExists('stat', ['displayId', 'end', 'type'], false)) {
+ $this->execute('ALTER TABLE `stat` ADD INDEX Type (`displayID`, `end`, `Type`);');
+ }
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+
+ /**
+ * Check if an index exists
+ * @param string $table
+ * @param string[] $columns
+ * @param bool $isUnique
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ private function checkIndexExists($table, $columns, $isUnique)
+ {
+ if (!is_array($columns) || count($columns) <= 0)
+ throw new InvalidArgumentException('Incorrect call to checkIndexExists', 'columns');
+
+ // Use the information schema to see if the index exists or not.
+ // all users have permission to the information schema
+ $sql = '
+ SELECT *
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE table_schema=DATABASE()
+ AND table_name = \'' . $table . '\'
+ AND non_unique = \'' . (($isUnique) ? 0 : 1) . '\'
+ AND (
+ ';
+
+ $i = 0;
+ foreach ($columns as $column) {
+ $i++;
+
+ $sql .= (($i == 1) ? '' : ' OR') . ' (seq_in_index = \'' . $i . '\' AND column_name = \'' . $column . '\') ';
+ }
+
+ $sql .= ' )';
+
+ $indexes = $this->fetchAll($sql);
+
+ return (count($indexes) === count($columns));
+ }
+}
diff --git a/db/migrations/20180131114021_old_upgrade_step125_migration.php b/db/migrations/20180131114021_old_upgrade_step125_migration.php
new file mode 100644
index 0000000..ac8778e
--- /dev/null
+++ b/db/migrations/20180131114021_old_upgrade_step125_migration.php
@@ -0,0 +1,77 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $schedule = $this->table('schedule');
+ $schedule->changeColumn('is_priority', 'integer')
+ ->save();
+
+ $this->execute('
+ INSERT INTO module (Module, Name, Enabled, RegionSpecific, Description, ImageUri, SchemaVersion, ValidExtensions, PreviewEnabled, assignable, render_as, settings, viewPath, class, defaultDuration) VALUES
+ (\'audio\', \'Audio\', 1, 0, \'Audio - support varies depending on the client hardware\', \'forms/video.gif\', 1, \'mp3,wav\', 1, 1, null, null, \'../modules\', \'Xibo\\\\Widget\\\\Audio\', 0),
+ (\'pdf\', \'PDF\', 1, 0, \'PDF document viewer\', \'forms/pdf.gif\', 1, \'pdf\', 1, 1, \'html\', null, \'../modules\', \'Xibo\\\\Widget\\\\Pdf\', 60);
+ ');
+
+ $linkWidgetAudio = $this->table('lkwidgetaudio', ['id' => false, 'primary_key' => ['widgetId', 'mediaId']]);
+ $linkWidgetAudio->addColumn('widgetId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->addColumn('volume', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('loop', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+
+ $oauthClientScopes = $this->table('oauth_client_scopes');
+ $oauthClientScopes
+ ->addColumn('clientId', 'string', ['limit' => 254])
+ ->addColumn('scopeId', 'string', ['limit' => 254])
+ ->addIndex(['clientId', 'scopeId'], ['unique' => true])
+ ->save();
+
+ // Bulk insert doesn't appear to handle non auto-index primary keys?!
+ $this->execute('
+ INSERT INTO `oauth_scopes` (id, description) VALUES
+ (\'all\', \'All access\'),
+ (\'mcaas\', \'Media Conversion as a Service\')
+ ');
+
+ $oauthRouteScopes = $this->table('oauth_scope_routes');
+ $oauthRouteScopes
+ ->addColumn('scopeId', 'string', ['limit' => 254])
+ ->addColumn('route', 'string', ['limit' => 1000])
+ ->addColumn('method', 'string', ['limit' => 8])
+ ->insert([
+ ['scopeId' => 'mcaas', 'route' => '/', 'method' => 'GET'],
+ ['scopeId' => 'mcaas', 'route' => '/library/download/:id(/:type)', 'method' => 'GET'],
+ ['scopeId' => 'mcaas', 'route' => '/library/mcaas/:id', 'method' => 'POST'],
+ ])
+ ->save();
+
+ $module = $this->table('module');
+ $module->addColumn('installName', 'string', ['limit' => 254, 'null' => true])
+ ->save();
+
+ $this->execute('ALTER TABLE display CHANGE isAuditing auditingUntil int NOT NULL DEFAULT \'0\' COMMENT \'Is this display auditing\';');
+
+ $this->execute('INSERT INTO setting (setting, value, fieldType, helptext, options, cat, userChange, title, validation, ordering, `default`, userSee, type) VALUES (\'ELEVATE_LOG_UNTIL\', \'1463396415\', \'datetime\', \'Elevate the log level until this date.\', null, \'troubleshooting\', 1, \'Elevate Log Until\', \' \', 25, \'\', 1, \'datetime\');');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114025_old_upgrade_step126_migration.php b/db/migrations/20180131114025_old_upgrade_step126_migration.php
new file mode 100644
index 0000000..41d2ed4
--- /dev/null
+++ b/db/migrations/20180131114025_old_upgrade_step126_migration.php
@@ -0,0 +1,44 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $stat = $this->table('stat');
+ $stat->addColumn('widgetId', 'integer', ['null' => true])
+ ->save();
+
+ $displayEvent = $this->table('displayevent', ['id' => 'displayEventId']);
+ $displayEvent
+ ->addColumn('eventDate', 'integer')
+ ->addColumn('displayId', 'integer')
+ ->addColumn('start', 'integer')
+ ->addColumn('end', 'integer', ['null' => true])
+ ->addIndex('eventDate')
+ ->addIndex('end')
+ ->save();
+
+ $this->execute('INSERT INTO displayevent (eventDate, displayId, start, end) SELECT UNIX_TIMESTAMP(statDate), displayID, UNIX_TIMESTAMP(start), UNIX_TIMESTAMP(end) FROM stat WHERE Type = \'displaydown\';');
+
+ $this->execute('DELETE FROM stat WHERE Type = \'displaydown\';');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114030_old_upgrade_step127_migration.php b/db/migrations/20180131114030_old_upgrade_step127_migration.php
new file mode 100644
index 0000000..ea6d899
--- /dev/null
+++ b/db/migrations/20180131114030_old_upgrade_step127_migration.php
@@ -0,0 +1,126 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $schedule = $this->table('schedule');
+ $schedule->addColumn('recurrenceRepeatsOn', 'string', ['null' => true])
+ ->save();
+
+ $this->execute('INSERT INTO `setting` (`setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`) VALUES (\'RESTING_LOG_LEVEL\', \'Error\', \'dropdown\', \'Set the level of the resting log level. The CMS will revert to this log level after an elevated period ends. In production systems \"error\" is recommended.\', \'Emergency|Alert|Critical|Error\', \'troubleshooting\', 1, \'Resting Log Level\', \'\', 19, \'error\', 1, \'word\');');
+
+ $dataSet = $this->table('dataset');
+ $dataSet->changeColumn('code', 'string', ['limit' => 50, 'null' => true])
+ ->save();
+
+ $this->execute('INSERT INTO `pages` (`name`, `Title`, `asHome`) VALUES (\'daypart\', \'Dayparting\', 0);');
+
+ $dayPart = $this->table('daypart', ['id' => 'dayPartId']);
+ $dayPart
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 50, 'null' => true])
+ ->addColumn('isRetired', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('userId', 'integer')
+ ->addColumn('startTime', 'string', ['limit' => 8, 'default' => '00:00:00'])
+ ->addColumn('endTime', 'string', ['limit' => 8, 'default' => '00:00:00'])
+ ->addColumn('exceptions', 'text')
+ ->save();
+
+ $this->execute('INSERT INTO `permissionentity` (`entityId`, `entity`) VALUES (NULL, \'Xibo\\Entity\\DayPart\');');
+
+ $user = $this->table('user');
+ $user->changeColumn('userPassword', 'string', ['limit' => 255]);
+
+ $this->execute('INSERT INTO pages (name, title, asHome) VALUES (\'task\', \'Task\', 1);');
+
+ $this->execute('INSERT INTO setting (setting, value, fieldType, helptext, options, cat, userChange, title, validation, ordering, `default`, userSee, type) VALUES (\'TASK_CONFIG_LOCKED_CHECKB\', \'Unchecked\', \'dropdown\', \'Is the task config locked? Useful for Service providers.\', \'Checked|Unchecked\', \'defaults\', 0, \'Lock Task Config\', \'\', 30, \'Unchecked\', 0, \'word\');');
+
+ $task = $this->table('task', ['id' => 'taskId']);
+ $task
+ ->addColumn('name', 'string', ['limit' => 254])
+ ->addColumn('class', 'string', ['limit' => 254])
+ ->addColumn('status', 'integer', ['default' => 2, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('pid', 'integer')
+ ->addColumn('options', 'text')
+ ->addColumn('schedule', 'string', ['limit' => 254])
+ ->addColumn('lastRunDt', 'integer')
+ ->addColumn('lastRunMessage', 'string', ['null' => true])
+ ->addColumn('lastRunStatus', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('lastRunDuration', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('lastRunExitCode', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL])
+ ->addColumn('isActive', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('runNow', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('configFile', 'string', ['limit' => 254])
+ ->insert([
+ [
+ 'name' => 'Daily Maintenance',
+ 'class' => '\Xibo\XTR\MaintenanceDailyTask',
+ 'options' => '[]',
+ 'schedule' => '0 0 * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/maintenance-daily.task'
+ ],
+ [
+ 'name' => 'Regular Maintenance',
+ 'class' => '\Xibo\XTR\MaintenanceRegularTask',
+ 'options' => '[]',
+ 'schedule' => '*/5 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/maintenance-regular.task'
+ ],
+ [
+ 'name' => 'Email Notifications',
+ 'class' => '\Xibo\XTR\EmailNotificationsTask',
+ 'options' => '[]',
+ 'schedule' => '*/5 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/email-notifications.task'
+ ],
+ [
+ 'name' => 'Stats Archive',
+ 'class' => '\Xibo\XTR\StatsArchiveTask',
+ 'options' => '{"periodSizeInDays":"7","maxPeriods":"4"}',
+ 'schedule' => '0 0 * * Mon',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/stats-archiver.task'
+ ],
+ [
+ 'name' => 'Remove old Notifications',
+ 'class' => '\Xibo\XTR\NotificationTidyTask',
+ 'options' => '{"maxAgeDays":"7","systemOnly":"1","readOnly":"0"}',
+ 'schedule' => '15 0 * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/notification-tidy.task'
+ ]
+ ])
+ ->save();
+
+ $this->execute('INSERT INTO `setting` (setting, value, fieldType, helptext, options, cat, userChange, title, validation, ordering, `default`, userSee, type) VALUES(\'WHITELIST_LOAD_BALANCERS\', \'\', \'text\', \'If the CMS is behind a load balancer, what are the load balancer IP addresses, comma delimited.\', \'\', \'network\', 1, \'Whitelist Load Balancers\', \'\', 100, \'\', 1, \'string\');');
+
+ $this->execute('INSERT INTO `setting` (setting, value, fieldType, helptext, options, cat, userChange, title, validation, ordering, `default`, userSee, type) VALUES(\'DEFAULT_LAYOUT\', \'1\', \'text\', \'The default layout to assign for new displays and displays which have their current default deleted.\', \'1\', \'displays\', 1, \'Default Layout\', \'\', 4, \'\', 1, \'int\');');
+
+ $display = $this->table('display');
+ $display->addColumn('deviceName', 'string', ['null' => true])
+ ->save();
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114050_old_upgrade_step128_migration.php b/db/migrations/20180131114050_old_upgrade_step128_migration.php
new file mode 100644
index 0000000..d3fa355
--- /dev/null
+++ b/db/migrations/20180131114050_old_upgrade_step128_migration.php
@@ -0,0 +1,128 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class OldUpgradeStep128Migration extends AbstractMigration
+{
+ public function up()
+ {
+ $STEP = 128;
+
+ // Are we an upgrade from an older version?
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $this->execute('UPDATE `resolution` SET resolution = \'4k cinema\' WHERE resolution = \'4k\';');
+
+ $this->execute('INSERT INTO `resolution` (`resolution`, `width`, `height`, `intended_width`, `intended_height`, `version`, `enabled`) VALUES(\'4k UHD Landscape\', 450, 800, 3840, 2160, 2, 1),(\'4k UHD Portrait\', 800, 450, 2160, 3840, 2, 1);');
+
+ $this->execute('UPDATE schedule SET fromDt = 0, toDt = 2556057600 WHERE dayPartId = 1');
+
+ $this->table('schedule_detail')->drop()->save();
+
+ $schedule = $this->table('schedule');
+ $schedule->addColumn('lastRecurrenceWatermark', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'null' => true])
+ ->save();
+
+ $this->table('requiredfile')->drop()->save();
+
+ $log = $this->table('log');
+ $log
+ ->changeColumn('channel', 'string', ['limit' => 20])
+ ->save();
+
+ $this->execute('UPDATE `setting` SET `helpText` = \'The Time to Live (maxage) of the STS header expressed in seconds.\' WHERE `setting` = \'STS_TTL\';');
+
+ if (!$this->checkIndexExists('lkdisplaydg', ['displayGroupId', 'displayId'], 1)) {
+ $index = 'CREATE UNIQUE INDEX lkdisplaydg_displayGroupId_displayId_uindex ON `lkdisplaydg` (displayGroupId, displayId);';
+
+ // Try to create the index, if we fail, assume duplicates
+ try {
+ $this->execute($index);
+ } catch (\PDOException $e) {
+ // Create a verify table
+ $this->execute('CREATE TABLE lkdisplaydg_verify AS SELECT * FROM lkdisplaydg WHERE 1 GROUP BY displaygroupId, displayId;');
+
+ // Delete from original table
+ $this->execute('DELETE FROM lkdisplaydg;');
+
+ // Insert the de-duped records
+ $this->execute('INSERT INTO lkdisplaydg SELECT * FROM lkdisplaydg_verify;');
+
+ // Drop the verify table
+ $this->execute('DROP TABLE lkdisplaydg_verify;');
+
+ // Create the index fresh, now that duplicates removed
+ $this->execute($index);
+ }
+ }
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+
+ /**
+ * Check if an index exists
+ * @param string $table
+ * @param string[] $columns
+ * @param bool $isUnique
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ private function checkIndexExists($table, $columns, $isUnique)
+ {
+ if (!is_array($columns) || count($columns) <= 0)
+ throw new InvalidArgumentException('Incorrect call to checkIndexExists', 'columns');
+
+ // Use the information schema to see if the index exists or not.
+ // all users have permission to the information schema
+ $sql = '
+ SELECT *
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE table_schema=DATABASE()
+ AND table_name = \'' . $table . '\'
+ AND non_unique = \'' . (($isUnique) ? 0 : 1) . '\'
+ AND (
+ ';
+
+ $i = 0;
+ foreach ($columns as $column) {
+ $i++;
+
+ $sql .= (($i == 1) ? '' : ' OR') . ' (seq_in_index = \'' . $i . '\' AND column_name = \'' . $column . '\') ';
+ }
+
+ $sql .= ' )';
+
+ $indexes = $this->fetchAll($sql);
+
+ return (count($indexes) === count($columns));
+ }
+}
diff --git a/db/migrations/20180131114058_old_upgrade_step129_migration.php b/db/migrations/20180131114058_old_upgrade_step129_migration.php
new file mode 100644
index 0000000..20424ee
--- /dev/null
+++ b/db/migrations/20180131114058_old_upgrade_step129_migration.php
@@ -0,0 +1,60 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $requiredFile = $this->table('requiredfile', ['id' => 'rfId']);
+ $requiredFile
+ ->addColumn('displayId', 'integer')
+ ->addColumn('type', 'string', ['limit' => 1])
+ ->addColumn('class', 'string', ['limit' => 1])
+ ->addColumn('itemId', 'integer', ['null' => true])
+ ->addColumn('bytesRequested', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG])
+ ->addColumn('complete', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('path', 'string', ['null' => true, 'limit' => 255])
+ ->addColumn('size', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => 0])
+ ->addIndex(['displayId', 'type'])
+ ->save();
+
+ $resolution = $this->table('resolution');
+ $resolution
+ ->addColumn('userId', 'integer')
+ ->save();
+
+ $this->execute('UPDATE `resolution` SET userId = 0;');
+
+ $this->execute('UPDATE `setting` SET `options` = \'private|group|group write|public|public write\' WHERE setting IN (\'MEDIA_DEFAULT\', \'LAYOUT_DEFAULT\');');
+
+ $linkCampaignTag = $this->table('lktagcampaign', ['id' => 'lkTagCampaignId']);
+ $linkCampaignTag
+ ->addColumn('tagId', 'integer')
+ ->addColumn('campaignId', 'integer')
+ ->addIndex(['tagId', 'campaignId'], ['unique' => true])
+ ->save();
+
+ $display = $this->table('display');
+ $display
+ ->addColumn('timeZone', 'string', ['limit' => 254, 'null' => true])
+ ->save();
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114103_old_upgrade_step130_migration.php b/db/migrations/20180131114103_old_upgrade_step130_migration.php
new file mode 100644
index 0000000..48bd053
--- /dev/null
+++ b/db/migrations/20180131114103_old_upgrade_step130_migration.php
@@ -0,0 +1,43 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $schedule = $this->table('schedule');
+ $schedule
+ ->addColumn('syncTimezone', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+
+ $this->execute('UPDATE `permissionentity` SET entity = \'Xibo\\Entity\\Notification\' WHERE entity = \'XiboEntityNotification\';');
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'oauth_clients%\' AND referenced_table_name = \'user\';')) {
+
+ $this->execute('UPDATE `oauth_clients` SET userId = (SELECT userId FROM `user` WHERE userTypeId = 1 LIMIT 1)
+ WHERE userId NOT IN (SELECT userId FROM `user`);');
+
+ $this->execute('ALTER TABLE `oauth_clients` ADD CONSTRAINT oauth_clients_user_UserID_fk FOREIGN KEY (userId) REFERENCES `user` (UserID);');
+ }
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114107_old_upgrade_step131_migration.php b/db/migrations/20180131114107_old_upgrade_step131_migration.php
new file mode 100644
index 0000000..4475403
--- /dev/null
+++ b/db/migrations/20180131114107_old_upgrade_step131_migration.php
@@ -0,0 +1,55 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class OldUpgradeStep131Migration extends AbstractMigration
+{
+ public function up()
+ {
+ $STEP = 131;
+
+ // Are we an upgrade from an older version?
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $this->execute('INSERT INTO `setting` (`setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`) VALUES (\'DISPLAY_PROFILE_STATS_DEFAULT\', \'0\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Default setting for Statistics Enabled?\', \'\', 70, \'0\', 1, \'checkbox\'),(\'DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED\', \'1\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Enable the option to report the current layout status?\', \'\', 80, \'0\', 1, \'checkbox\'),(\'DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED\', \'1\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Enable the option to set the screenshot interval?\', \'\', 90, \'0\', 1, \'checkbox\'),(\'DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT\', \'200\', \'number\', \'The default size in pixels for the Display Screenshots\', NULL, \'displays\', 1, \'Display Screenshot Default Size\', \'\', 100, \'200\', 1, \'int\'),(\'LATEST_NEWS_URL\', \'https://xibosignage.com/feed\', \'text\', \'RSS/Atom Feed to be displayed on the Status Dashboard\', \'\', \'general\', 0, \'Latest News URL\', \'\', 111, \'\', 0, \'string\');');
+
+ $display = $this->table('display');
+ $display->removeColumn('currentLayoutId')->save();
+
+ $permissionEntity = $this->table('permissionentity');
+ $permissionEntity->insert([
+ 'entity' => 'Xibo\\Entity\\Display'
+ ])->save();
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114110_old_upgrade_step132_migration.php b/db/migrations/20180131114110_old_upgrade_step132_migration.php
new file mode 100644
index 0000000..8e414cb
--- /dev/null
+++ b/db/migrations/20180131114110_old_upgrade_step132_migration.php
@@ -0,0 +1,43 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $this->execute('UPDATE `schedule` SET toDt = 2147483647 WHERE toDt = 2556057600;');
+
+ $this->execute('UPDATE `permission` SET
+ entityId = (
+ SELECT entityId
+ FROM permissionentity
+ WHERE entity = \'Xibo\\Entity\\DisplayGroup\'),
+ objectId = (
+ SELECT lkdisplaydg.DisplayGroupID
+ FROM `lkdisplaydg`
+ INNER JOIN `displaygroup` ON `displaygroup`.DisplayGroupID = `lkdisplaydg`.DisplayGroupID
+ AND `displaygroup`.IsDisplaySpecific = 1
+ WHERE permission.objectId = `lkdisplaydg`.DisplayID)
+ WHERE entityId IN (SELECT entityId FROM permissionentity WHERE entity = \'Xibo\\Entity\\Display\');');
+
+ $this->execute('DELETE FROM `permissionentity` WHERE `entity` = \'Xibo\\Entity\\Display\';');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114114_old_upgrade_step133_migration.php b/db/migrations/20180131114114_old_upgrade_step133_migration.php
new file mode 100644
index 0000000..588b901
--- /dev/null
+++ b/db/migrations/20180131114114_old_upgrade_step133_migration.php
@@ -0,0 +1,33 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $this->execute('INSERT INTO `setting` (`setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`) VALUES (\'DISPLAY_LOCK_NAME_TO_DEVICENAME\', \'0\', \'checkbox\', NULL, NULL, \'displays\', 1, \'Lock the Display Name to the device name provided by the Player?\', \'\', 80, \'0\', 1, \'checkbox\');');
+
+ $task = $this->table('task');
+ $task
+ ->addColumn('lastRunStartDt', 'integer', ['null' => true])
+ ->save();
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114118_old_upgrade_step134_migration.php b/db/migrations/20180131114118_old_upgrade_step134_migration.php
new file mode 100644
index 0000000..f5bc68b
--- /dev/null
+++ b/db/migrations/20180131114118_old_upgrade_step134_migration.php
@@ -0,0 +1,55 @@
+hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $this->execute('INSERT INTO displayprofile (name, type, config, isdefault, userId) VALUES (\'webOS\', \'lg\', \'{}\', 1, 1)');
+ $this->execute('INSERT INTO module (Module, Name, Enabled, RegionSpecific, Description, ImageUri, SchemaVersion, ValidExtensions, PreviewEnabled, assignable, render_as, settings, viewPath, class, defaultDuration) VALUES (\'notificationview\', \'Notification\', 1, 1, \'Display Notifications from the Notification Centre\', \'forms/library.gif\', 1, null, 1, 1, \'html\', null, \'../modules\', \'Xibo\\\\Widget\\\\NotificationView\', 10);');
+
+ $group = $this->table('group');
+ $group
+ ->addColumn('isDisplayNotification', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->save();
+
+ $this->execute('DELETE FROM `setting` WHERE setting = \'MAINTENANCE_ALERTS_FOR_VIEW_USERS\';');
+
+ $linkTagDisplayGroup = $this->table('lktagdisplaygroup', ['id' => 'lkTagDisplayGroupId']);
+ $linkTagDisplayGroup
+ ->addColumn('tagId', 'integer')
+ ->addColumn('displayGroupId', 'integer')
+ ->addIndex(['tagId', 'displayGroupId'], ['unique' => true])
+ ->save();
+
+ $media = $this->table('media');
+ $media
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->save();
+
+ $this->execute('UPDATE `module` SET validextensions = CONCAT(validextensions, \',ipk\') WHERE module = \'genericfile\' LIMIT 1;');
+
+ $this->execute('UPDATE `module` SET description = \'A module for showing Currency pairs and exchange rates\' WHERE module = \'currencies\';');
+
+ $this->execute('UPDATE `module` SET description = \'A module for showing Stock quotes\' WHERE module = \'stocks\';');
+
+ // Bump our version
+ $this->execute('UPDATE `version` SET DBVersion = ' . $STEP);
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131114123_old_upgrade_step135_migration.php b/db/migrations/20180131114123_old_upgrade_step135_migration.php
new file mode 100644
index 0000000..7f99ca3
--- /dev/null
+++ b/db/migrations/20180131114123_old_upgrade_step135_migration.php
@@ -0,0 +1,120 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class OldUpgradeStep135Migration extends AbstractMigration
+{
+ public function up()
+ {
+ $STEP = 135;
+
+ // Are we an upgrade from an older version?
+ if ($this->hasTable('version')) {
+ // We do have a version table, so we're an upgrade from anything 1.7.0 onward.
+ $row = $this->fetchRow('SELECT * FROM `version`');
+ $dbVersion = $row['DBVersion'];
+
+ // Are we on the relevent step for this upgrade?
+ if ($dbVersion < $STEP) {
+ // Perform the upgrade
+ $dataSet = $this->table('dataset');
+ $dataSet
+ ->addColumn('isRemote', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('method', 'enum', ['values' => ['GET', 'POST'], 'null' => true])
+ ->addColumn('uri', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('postData', 'text', ['null' => true])
+ ->addColumn('authentication', 'enum', ['values' => ['none', 'plain', 'basic', 'digest'], 'null' => true])
+ ->addColumn('username', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('password', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('refreshRate', 'integer', ['default' => 86400])
+ ->addColumn('clearRate', 'integer', ['default' => 0])
+ ->addColumn('runsAfter', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('dataRoot', 'string', ['limit' => 250, 'null' => true])
+ ->addColumn('lastSync', 'integer', ['default' => 0])
+ ->addColumn('summarize', 'string', ['limit' => 10, 'null' => true])
+ ->addColumn('summarizeField', 'string', ['limit' => 250, 'null' => true])
+ ->save();
+
+ $dataSetColumn = $this->table('datasetcolumn');
+ $dataSetColumn
+ ->addColumn('remoteField', 'string', ['limit' => 250, 'null' => true, 'after' => 'formula'])
+ ->save();
+
+ $task = $this->table('task');
+ $task->insert([
+ [
+ 'name' => 'Fetch Remote DataSets',
+ 'class' => '\Xibo\XTR\RemoteDataSetFetchTask',
+ 'options' => '[]',
+ 'schedule' => '30 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/remote-dataset.task'
+ ],
+ [
+ 'name' => 'Update Empty Video Durations',
+ 'class' => '\Xibo\XTR\UpdateEmptyVideoDurations',
+ 'options' => '[]',
+ 'schedule' => '0 0 1 1 *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/update-empty-video-durations.task',
+ 'runNow' => 1
+ ],
+ [
+ 'name' => 'Drop Player Cache',
+ 'class' => '\Xibo\XTR\DropPlayerCacheTask',
+ 'options' => '[]',
+ 'schedule' => '0 0 1 1 *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/drop-player-cache.task',
+ 'runNow' => 1
+ ],
+ [
+ 'name' => 'DataSet Convert (only run once)',
+ 'class' => '\Xibo\XTR\DataSetConvertTask',
+ 'options' => '[]',
+ 'schedule' => '0 0 1 1 *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/dataset-convert.task',
+ 'runNow' => 1
+ ],
+ [
+ 'name' => 'Layout Convert (only run once)',
+ 'class' => '\Xibo\XTR\LayoutConvertTask',
+ 'options' => '[]',
+ 'schedule' => '0 0 1 1 *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/layout-convert.task',
+ 'runNow' => 1
+ ],
+ ])->save();
+
+ // If we've run the old upgrader, remove it
+ if ($this->hasTable('upgrade'))
+ $this->table('upgrade')->drop()->save();
+
+ // Remove the version table
+ $this->table('version')->drop()->save();
+ }
+ }
+ }
+}
diff --git a/db/migrations/20180131122645_one_region_per_playlist_migration.php b/db/migrations/20180131122645_one_region_per_playlist_migration.php
new file mode 100644
index 0000000..14e862e
--- /dev/null
+++ b/db/migrations/20180131122645_one_region_per_playlist_migration.php
@@ -0,0 +1,71 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ * @phpcs:disable Generic.Files.LineLength.TooLong
+ */
+class OneRegionPerPlaylistMigration extends AbstractMigration
+{
+ /**
+ * Up
+ */
+ public function up()
+ {
+ $playlist = $this->table('playlist');
+ $playlist
+ ->addColumn('regionId', 'integer', ['null' => true])
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('duration', 'integer', ['default' => 0])
+ ->addColumn(
+ 'requiresDurationUpdate',
+ 'integer',
+ ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY]
+ )
+ ->addIndex('regionId')
+ ->save();
+
+ $this->execute('UPDATE `playlist` SET regionId = (SELECT MAX(regionId) FROM lkregionplaylist WHERE playlist.playlistId = lkregionplaylist.playlistId);');
+
+ $this->table('lkregionplaylist')->drop()->save();
+
+ $this->execute('UPDATE `pages` SET asHome = 1 WHERE name = \'playlist\';');
+
+ $this->execute('
+ INSERT INTO module (Module, Name, Enabled, RegionSpecific, Description, ImageUri, SchemaVersion, ValidExtensions, PreviewEnabled, assignable, render_as, settings, viewPath, class, defaultDuration)
+ VALUES (\'subplaylist\', \'Sub-Playlist\', 1, 1, \'Embed a Sub-Playlist\', \'forms/library.gif\', 1, null, 1, 1, \'native\', null, \'../modules\', \'Xibo\\\\Widget\\\\SubPlaylist\', 10);
+ ');
+
+ $playlistClosure = $this->table('lkplaylistplaylist', ['id' => false, 'primary_key' => ['parentId', 'childId', 'depth']]);
+ $playlistClosure
+ ->addColumn('parentId', 'integer')
+ ->addColumn('childId', 'integer')
+ ->addColumn('depth', 'integer')
+ ->addIndex(['childId', 'parentId', 'depth'], ['unique' => true])
+ ->save();
+
+ $this->execute('INSERT INTO lkplaylistplaylist (parentId, childId, depth) SELECT playlistId, playlistId, 0 FROM playlist;');
+ }
+}
diff --git a/db/migrations/20180131123038_playlist_tags_migration.php b/db/migrations/20180131123038_playlist_tags_migration.php
new file mode 100644
index 0000000..f72885b
--- /dev/null
+++ b/db/migrations/20180131123038_playlist_tags_migration.php
@@ -0,0 +1,20 @@
+table('lktagplaylist', ['id' => 'lkTagPlaylistId']);
+ $linkPlaylistTag
+ ->addColumn('tagId', 'integer')
+ ->addColumn('playlistId', 'integer')
+ ->addIndex(['tagId', 'playlistId'], ['unique' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20180131123248_widget_from_to_dt_migration.php b/db/migrations/20180131123248_widget_from_to_dt_migration.php
new file mode 100644
index 0000000..342a0b3
--- /dev/null
+++ b/db/migrations/20180131123248_widget_from_to_dt_migration.php
@@ -0,0 +1,21 @@
+table('widget');
+ $widget
+ ->addColumn('fromDt', 'integer')
+ ->addColumn('toDt', 'integer')
+ ->save();
+
+ $this->execute('UPDATE `widget` SET fromDt = 0, toDt = 2147483647;');
+ }
+}
diff --git a/db/migrations/20180212143336_daypart_system_entries_as_records.php b/db/migrations/20180212143336_daypart_system_entries_as_records.php
new file mode 100644
index 0000000..71a73b2
--- /dev/null
+++ b/db/migrations/20180212143336_daypart_system_entries_as_records.php
@@ -0,0 +1,80 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DaypartSystemEntriesAsRecords
+ */
+class DaypartSystemEntriesAsRecords extends AbstractMigration
+{
+ /**
+ * @inheritdoc
+ */
+ public function up()
+ {
+ $dayPart = $this->table('daypart');
+
+ if (!$dayPart->hasColumn('isAlways')) {
+ $dayPart
+ ->addColumn('isAlways', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('isCustom', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->insert([
+ [
+ 'name' => 'Custom',
+ 'description' => 'User specifies the from/to date',
+ 'isRetired' => 0,
+ 'userid' => 1,
+ 'startTime' => '',
+ 'endTime' => '',
+ 'exceptions' => '',
+ 'isAlways' => 0,
+ 'isCustom' => 1
+ ], [
+ 'name' => 'Always',
+ 'description' => 'Event runs always',
+ 'isRetired' => 0,
+ 'userid' => 1,
+ 'startTime' => '',
+ 'endTime' => '',
+ 'exceptions' => '',
+ 'isAlways' => 1,
+ 'isCustom' => 0
+ ]
+ ])
+ ->save();
+
+ // Execute some SQL to bring the existing records into line.
+ $this->execute('UPDATE `schedule` SET dayPartId = (SELECT dayPartId FROM daypart WHERE isAlways = 1) WHERE dayPartId = 1');
+ $this->execute('UPDATE `schedule` SET dayPartId = (SELECT dayPartId FROM daypart WHERE isCustom = 1) WHERE dayPartId = 0');
+
+ // Add some default permissions
+ $this->execute('
+ INSERT INTO `permission` (entityId, groupId, objectId, view, edit, `delete`)
+ SELECT entityId, groupId, dayPartId, 1, 0, 0
+ FROM daypart
+ CROSS JOIN permissionentity
+ CROSS JOIN `group`
+ WHERE entity LIKE \'%DayPart\' AND IsEveryone = 1 AND (isCustom = 1 OR isAlways = 1);
+ ');
+ }
+ }
+}
diff --git a/db/migrations/20180213173846_mail_from_name_setting_migration.php b/db/migrations/20180213173846_mail_from_name_setting_migration.php
new file mode 100644
index 0000000..c5f339c
--- /dev/null
+++ b/db/migrations/20180213173846_mail_from_name_setting_migration.php
@@ -0,0 +1,34 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class MailFromNameSettingMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function up()
+ {
+ // Check to see if the mail_from_name setting exists
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'mail_from_name\'')) {
+ $this->execute('INSERT INTO setting (setting, value, fieldType, helptext, options, cat, userChange, title, validation, ordering, `default`, userSee, type) VALUES (\'mail_from_name\', \'\', \'text\', \'Mail will be sent under this name\', NULL, \'maintenance\', 1, \'Sending email name\', \'\', 45, \'\', 1, \'string\');');
+ }
+ }
+}
diff --git a/db/migrations/20180219141257_display_group_closure_index_to_non_unique.php b/db/migrations/20180219141257_display_group_closure_index_to_non_unique.php
new file mode 100644
index 0000000..4151e4a
--- /dev/null
+++ b/db/migrations/20180219141257_display_group_closure_index_to_non_unique.php
@@ -0,0 +1,85 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class DisplayGroupClosureIndexToNonUnique extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function up()
+ {
+ // Drop the existing indexes if they exist
+ $indexName = $this->checkIndexExists('lkdgdg', ['parentId', 'childId', 'depth'], true);
+ if ($indexName !== false) {
+ $this->execute('DROP INDEX `' . $indexName . '` ON `lkdgdg`');
+ }
+
+ $indexName = $this->checkIndexExists('lkdgdg', ['childId', 'parentId', 'depth'], true);
+ if ($indexName !== false) {
+ $this->execute('DROP INDEX `' . $indexName . '` ON `lkdgdg`');
+ }
+
+ // Add new indexes
+ $table = $this->table('lkdgdg');
+ $table
+ ->addIndex(['parentId', 'childId', 'depth'])
+ ->addIndex(['childId', 'parentId', 'depth'])
+ ->update();
+ }
+
+ /**
+ * Check if an index exists
+ * @param string $table
+ * @param string[] $columns
+ * @param bool $isUnique
+ * @return string|false
+ * @throws InvalidArgumentException
+ */
+ private function checkIndexExists($table, $columns, $isUnique)
+ {
+ if (!is_array($columns) || count($columns) <= 0)
+ throw new InvalidArgumentException('Incorrect call to checkIndexExists', 'columns');
+
+ // Use the information schema to see if the index exists or not.
+ // all users have permission to the information schema
+ $sql = '
+ SELECT *
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE table_schema=DATABASE()
+ AND table_name = \'' . $table . '\'
+ AND non_unique = \'' . (($isUnique) ? 0 : 1) . '\'
+ AND (
+ ';
+
+ $i = 0;
+ foreach ($columns as $column) {
+ $i++;
+
+ $sql .= (($i == 1) ? '' : ' OR') . ' (seq_in_index = \'' . $i . '\' AND column_name = \'' . $column . '\') ';
+ }
+
+ $sql .= ' )';
+
+ $indexes = $this->fetchAll($sql);
+
+ return (count($indexes) === count($columns)) ? $indexes[0]['INDEX_NAME'] : false;
+ }
+}
diff --git a/db/migrations/20180223180534_data_set_column_filter_and_sort_options_migration.php b/db/migrations/20180223180534_data_set_column_filter_and_sort_options_migration.php
new file mode 100644
index 0000000..2177dc9
--- /dev/null
+++ b/db/migrations/20180223180534_data_set_column_filter_and_sort_options_migration.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DataSetColumnFilterAndSortOptionsMigration
+ */
+class DataSetColumnFilterAndSortOptionsMigration extends AbstractMigration
+{
+ /**
+ * Add the show filter and show sort columns if they do not yet exist.
+ */
+ public function change()
+ {
+ $table = $this->table('datasetcolumn');
+
+ if (!$table->hasColumn('showFilter')) {
+ $table
+ ->addColumn('showFilter', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->addColumn('showSort', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->update();
+ }
+ }
+}
diff --git a/db/migrations/20180302182421_widget_created_and_modified_dt_migration.php b/db/migrations/20180302182421_widget_created_and_modified_dt_migration.php
new file mode 100644
index 0000000..06bfe11
--- /dev/null
+++ b/db/migrations/20180302182421_widget_created_and_modified_dt_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class WidgetCreatedAndModifiedDtMigration
+ */
+class WidgetCreatedAndModifiedDtMigration extends AbstractMigration
+{
+ /**
+ *
+ */
+ public function change()
+ {
+ $table = $this->table('widget');
+
+ if (!$table->hasColumn('modifiedDt')) {
+
+ $table
+ ->addColumn('createdDt', 'integer', ['default' => 0])
+ ->addColumn('modifiedDt', 'integer', ['default' => 0])
+ ->update();
+ }
+ }
+}
diff --git a/db/migrations/20180313085749_media_table_edited_id_index_migration.php b/db/migrations/20180313085749_media_table_edited_id_index_migration.php
new file mode 100644
index 0000000..64f3af3
--- /dev/null
+++ b/db/migrations/20180313085749_media_table_edited_id_index_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class MediaTableEditedIdIndexMigration
+ * Add EditedMediaId index to the Media table
+ */
+class MediaTableEditedIdIndexMigration extends AbstractMigration
+{
+ /**
+ * @inheritdoc
+ */
+ public function change()
+ {
+ $this->execute('UPDATE `media` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `media` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $table = $this->table('media');
+ $table
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addIndex('editedMediaId')
+ ->update();
+ }
+}
diff --git a/db/migrations/20180320154652_playlist_add_dynamic_filter_migration.php b/db/migrations/20180320154652_playlist_add_dynamic_filter_migration.php
new file mode 100644
index 0000000..bf44dc2
--- /dev/null
+++ b/db/migrations/20180320154652_playlist_add_dynamic_filter_migration.php
@@ -0,0 +1,57 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class PlaylistAddDynamicFilterMigration
+ * add dynamic playlist filtering
+ */
+class PlaylistAddDynamicFilterMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('playlist');
+
+ $this->execute('UPDATE `playlist` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `playlist` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $table
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('isDynamic', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('filterMediaName', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addColumn('filterMediaTags', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->update();
+
+ $task = $this->table('task');
+ $task->insert([
+ 'name' => 'Sync Dynamic Playlists',
+ 'class' => '\Xibo\XTR\DynamicPlaylistSyncTask',
+ 'options' => '[]',
+ 'schedule' => '* * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/dynamic-playlist-sync.task'
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20180327153325_remove_user_logged_in_migration.php b/db/migrations/20180327153325_remove_user_logged_in_migration.php
new file mode 100644
index 0000000..d56fb27
--- /dev/null
+++ b/db/migrations/20180327153325_remove_user_logged_in_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class RemoveUserLoggedInMigration
+ * Removes the logged in column if it still exists.
+ */
+class RemoveUserLoggedInMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('user');
+ if ($table->hasColumn('loggedIn')) {
+ $table->removeColumn('loggedIn')->update();
+ }
+ }
+}
diff --git a/db/migrations/20180514114415_fix_case_on_help_text_field_migration.php b/db/migrations/20180514114415_fix_case_on_help_text_field_migration.php
new file mode 100644
index 0000000..681f992
--- /dev/null
+++ b/db/migrations/20180514114415_fix_case_on_help_text_field_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class FixCaseOnHelpTextFieldMigration
+ */
+class FixCaseOnHelpTextFieldMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('setting');
+ if ($table->hasColumn('helptext')) {
+ $table->renameColumn('helptext', 'helpText')->save();
+ }
+ }
+}
diff --git a/db/migrations/20180515123835_layout_publish_draft_migration.php b/db/migrations/20180515123835_layout_publish_draft_migration.php
new file mode 100644
index 0000000..2fe76d1
--- /dev/null
+++ b/db/migrations/20180515123835_layout_publish_draft_migration.php
@@ -0,0 +1,54 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class LayoutPublishDraftMigration
+ */
+class LayoutPublishDraftMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a status table
+ $status = $this->table('status');
+ $status
+ ->addColumn('status', 'string', ['limit' => 254])
+ ->save();
+
+ // We must ensure that the IDs are added as we expect (don't rely on auto_increment)
+ $this->execute('INSERT INTO `status` (`id`, `status`) VALUES (1, \'Published\'), (2, \'Draft\'), (3, \'Pending Approval\')');
+
+ $this->execute('UPDATE `layout` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `layout` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ // Add a reference to the Layout and Playlist tables for "parentId"
+ $layout = $this->table('layout');
+ $layout
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('parentId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('publishedStatusId', 'integer', ['default' => 1])
+ ->addForeignKey('publishedStatusId', 'status')
+ ->save();
+ }
+}
diff --git a/db/migrations/20180529065816_data_set_truncate_fix_migration.php b/db/migrations/20180529065816_data_set_truncate_fix_migration.php
new file mode 100644
index 0000000..0365563
--- /dev/null
+++ b/db/migrations/20180529065816_data_set_truncate_fix_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DataSetTruncateFixMigration
+ */
+class DataSetTruncateFixMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('dataset');
+
+ if (!$table->hasColumn('lastClear')) {
+ $table
+ ->addColumn('lastClear', 'integer')
+ ->changeColumn('authentication', 'string', ['limit' => 10, 'default' => null, 'null' => true])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20180529073531_display_as_vnc_link_migration.php b/db/migrations/20180529073531_display_as_vnc_link_migration.php
new file mode 100644
index 0000000..ca4d17a
--- /dev/null
+++ b/db/migrations/20180529073531_display_as_vnc_link_migration.php
@@ -0,0 +1,36 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DisplayAsVncLinkMigration
+ */
+class DisplayAsVncLinkMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $this->query('UPDATE `setting` SET title = \'Add a link to the Display name using this format mask?\', helpText = \'Turn the display name in display management into a link using the IP address last collected. The %s is replaced with the IP address. Leave blank to disable.\' WHERE setting = \'SHOW_DISPLAY_AS_VNCLINK\';');
+
+ $this->query('UPDATE `setting` SET title = \'The target attribute for the above link\' WHERE setting = \'SHOW_DISPLAY_AS_VNC_TGT\';');
+ }
+}
diff --git a/db/migrations/20180621134013_add_widget_sync_task_migration.php b/db/migrations/20180621134013_add_widget_sync_task_migration.php
new file mode 100644
index 0000000..4d3a855
--- /dev/null
+++ b/db/migrations/20180621134013_add_widget_sync_task_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddWidgetSyncTaskMigration
+ */
+class AddWidgetSyncTaskMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Check to see if the mail_from_name setting exists
+ if (!$this->fetchRow('SELECT * FROM `task` WHERE name = \'Widget Sync\'')) {
+ $this->execute('INSERT INTO `task` SET `name`=\'Widget Sync\', `class`=\'\\\\Xibo\\\\XTR\\\\WidgetSyncTask\', `status`=2, `isActive`=1, `configFile`=\'/tasks/widget-sync.task\', `options`=\'{}\', `schedule`=\'*/3 * * * *\';');
+ }
+ }
+}
diff --git a/db/migrations/20180621134250_event_layout_permission_setting_migration.php b/db/migrations/20180621134250_event_layout_permission_setting_migration.php
new file mode 100644
index 0000000..f4cf7fc
--- /dev/null
+++ b/db/migrations/20180621134250_event_layout_permission_setting_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class EventLayoutPermissionSettingMigration
+ */
+class EventLayoutPermissionSettingMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function up()
+ {
+ // Check to see if the mail_from_name setting exists
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'SCHEDULE_SHOW_LAYOUT_NAME\'')) {
+ $this->execute('INSERT INTO `setting` (`setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`) VALUES (\'SCHEDULE_SHOW_LAYOUT_NAME\', \'0\', \'checkbox\', \'If checked then the Schedule will show the Layout for existing events even if the logged in User does not have permission to see that Layout.\', null, \'permissions\', 1, \'Show event Layout regardless of User permission?\', \'\', 45, \'\', 1, \'checkbox\');');
+ }
+ }
+}
diff --git a/db/migrations/20180906115552_add_foreign_keys_to_tags_migration.php b/db/migrations/20180906115552_add_foreign_keys_to_tags_migration.php
new file mode 100644
index 0000000..cabced1
--- /dev/null
+++ b/db/migrations/20180906115552_add_foreign_keys_to_tags_migration.php
@@ -0,0 +1,69 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddForeignKeysToTagsMigration
+ */
+class AddForeignKeysToTagsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagcampaign\' AND referenced_table_name = \'campaign\';')) {
+ // Delete any records which result in a constraint failure (the records would be orphaned anyway)
+ $this->execute('DELETE FROM `lktagcampaign` WHERE campaignId NOT IN (SELECT campaignId FROM `campaign`)');
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagcampaign` ADD CONSTRAINT `lktagcampaign_ibfk_1` FOREIGN KEY (`campaignId`) REFERENCES `campaign` (`campaignId`);');
+ }
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktaglayout\' AND referenced_table_name = \'layout\';')) {
+ // Delete any records which result in a constraint failure (the records would be orphaned anyway)
+ $this->execute('DELETE FROM `lktaglayout` WHERE layoutId NOT IN (SELECT layoutId FROM `layout`)');
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktaglayout` ADD CONSTRAINT `lktaglayout_ibfk_1` FOREIGN KEY (`layoutId`) REFERENCES `layout` (`layoutId`);');
+ }
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagmedia\' AND referenced_table_name = \'media\';')) {
+ // Delete any records which result in a constraint failure (the records would be orphaned anyway)
+ $this->execute('DELETE FROM `lktagmedia` WHERE mediaId NOT IN (SELECT mediaId FROM `media`)');
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagmedia` ADD CONSTRAINT `lktagmedia_ibfk_1` FOREIGN KEY (`mediaId`) REFERENCES `media` (`mediaId`);');
+ }
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagdisplaygroup\' AND referenced_table_name = \'displaygroup\';')) {
+ // Delete any records which result in a constraint failure (the records would be orphaned anyway)
+ $this->execute('DELETE FROM `lktagdisplaygroup` WHERE displayGroupId NOT IN (SELECT displayGroupId FROM `displaygroup`)');
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagdisplaygroup` ADD CONSTRAINT `lktagdisplaygroup_ibfk_1` FOREIGN KEY (`displayGroupId`) REFERENCES `displaygroup` (`displayGroupId`);');
+ }
+ }
+}
diff --git a/db/migrations/20180906115606_add_foreign_keys_to_permissions_migration.php b/db/migrations/20180906115606_add_foreign_keys_to_permissions_migration.php
new file mode 100644
index 0000000..803291d
--- /dev/null
+++ b/db/migrations/20180906115606_add_foreign_keys_to_permissions_migration.php
@@ -0,0 +1,92 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddForeignKeysToPermissionsMigration
+ */
+class AddForeignKeysToPermissionsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'permission\' AND referenced_table_name = \'group\';')) {
+ // Delete any records which result in a constraint failure (the records would be orphaned anyway)
+ $this->execute('DELETE FROM `permission` WHERE groupId NOT IN (SELECT groupId FROM `group`)');
+ // Add the constraint
+ $this->execute('ALTER TABLE `permission` ADD CONSTRAINT `permission_ibfk_1` FOREIGN KEY (`groupId`) REFERENCES `group` (`groupId`);');
+ }
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'permission\' AND referenced_table_name = \'permissionentity\';')) {
+ // Delete any records which result in a constraint failure (the records would be orphaned anyway)
+ $this->execute('DELETE FROM `permission` WHERE entityId NOT IN (SELECT entityId FROM `permissionentity`)');
+ // Add the constraint
+ $this->execute('ALTER TABLE `permission` ADD CONSTRAINT `permission_ibfk_2` FOREIGN KEY (`entityId`) REFERENCES `permissionentity` (`entityId`);');
+ }
+
+ // Index
+ if (!$this->checkIndexExists('permission', ['objectId'], 0)) {
+ $this->execute('CREATE INDEX permission_objectId_index ON permission (objectId);');
+ }
+ }
+
+ /**
+ * Check if an index exists
+ * @param string $table
+ * @param string[] $columns
+ * @param bool $isUnique
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ private function checkIndexExists($table, $columns, $isUnique)
+ {
+ if (!is_array($columns) || count($columns) <= 0)
+ throw new InvalidArgumentException('Incorrect call to checkIndexExists', 'columns');
+
+ // Use the information schema to see if the index exists or not.
+ // all users have permission to the information schema
+ $sql = '
+ SELECT *
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE table_schema=DATABASE()
+ AND table_name = \'' . $table . '\'
+ AND non_unique = \'' . (($isUnique) ? 0 : 1) . '\'
+ AND (
+ ';
+
+ $i = 0;
+ foreach ($columns as $column) {
+ $i++;
+ $sql .= (($i == 1) ? '' : ' OR') . ' (seq_in_index = \'' . $i . '\' AND column_name = \'' . $column . '\') ';
+ }
+ $sql .= ' )';
+ $indexes = $this->fetchAll($sql);
+
+ return (count($indexes) === count($columns));
+ }
+
+}
diff --git a/db/migrations/20180906115712_add_foreign_keys_to_widget_media_migration.php b/db/migrations/20180906115712_add_foreign_keys_to_widget_media_migration.php
new file mode 100644
index 0000000..4880d0c
--- /dev/null
+++ b/db/migrations/20180906115712_add_foreign_keys_to_widget_media_migration.php
@@ -0,0 +1,53 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddForeignKeysToWidgetMediaMigration
+ */
+class AddForeignKeysToWidgetMediaMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lkwidgetmedia\' AND referenced_table_name = \'media\';')) {
+
+ $this->execute('DELETE FROM `lkwidgetmedia` WHERE NOT EXISTS (SELECT mediaId FROM `media` WHERE `media`.mediaId = `lkwidgetmedia`.mediaId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lkwidgetmedia` ADD CONSTRAINT `lkwidgetmedia_ibfk_1` FOREIGN KEY (`mediaId`) REFERENCES `media` (`mediaId`);');
+ }
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lkwidgetmedia\' AND referenced_table_name = \'widget\';')) {
+
+ $this->execute('DELETE FROM `lkwidgetmedia` WHERE NOT EXISTS (SELECT widgetId FROM `widget` WHERE `widget`.widgetId = `lkwidgetmedia`.widgetId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lkwidgetmedia` ADD CONSTRAINT `lkwidgetmedia_ibfk_2` FOREIGN KEY (`widgetId`) REFERENCES `widget` (`widgetId`);');
+ }
+ }
+}
diff --git a/db/migrations/20180906131643_forgotten_password_reminder_migration.php b/db/migrations/20180906131643_forgotten_password_reminder_migration.php
new file mode 100644
index 0000000..348c451
--- /dev/null
+++ b/db/migrations/20180906131643_forgotten_password_reminder_migration.php
@@ -0,0 +1,55 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class ForgottenPasswordReminderMigration
+ */
+class ForgottenPasswordReminderMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_USERGROUP\'')) {
+ $this->execute('
+ INSERT INTO `setting` (`setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`)
+ VALUES (\'DEFAULT_USERGROUP\', \'1\', \'text\', \'The default User Group for new Users\', \'1\', \'users\', 1, \'Default User Group\', \'\', 4, \'\', 1, \'int\');
+ ');
+ }
+
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'PASSWORD_REMINDER_ENABLED\'')) {
+ $this->execute('
+ INSERT INTO `setting` (`setting`, `value`, `fieldType`, `helptext`, `options`, `cat`, `userChange`, `title`, `validation`, `ordering`, `default`, `userSee`, `type`)
+ VALUES (\'PASSWORD_REMINDER_ENABLED\', \'Off\', \'dropdown\', \'Is password reminder enabled?\', \'On|On except Admin|Off\', \'users\', 1, \'Password Reminder\', \'\', 50, \'Off\', 1, \'string\');
+ ');
+ }
+
+ $table = $this->table('user');
+ if (!$table->hasColumn('isPasswordChangeRequired')) {
+ $table
+ ->addColumn('isPasswordChangeRequired', 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20180906131716_data_set_rss_migration.php b/db/migrations/20180906131716_data_set_rss_migration.php
new file mode 100644
index 0000000..00a98f0
--- /dev/null
+++ b/db/migrations/20180906131716_data_set_rss_migration.php
@@ -0,0 +1,50 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DataSetRssMigration
+ */
+class DataSetRssMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->hasTable('datasetrss')) {
+ $table = $this->table('datasetrss');
+ $table
+ ->addColumn('dataSetId', 'integer')
+ ->addColumn('psk', 'string', ['limit' => 254])
+ ->addColumn('title', 'string', ['limit' => 254])
+ ->addColumn('author', 'string', ['limit' => 254])
+ ->addColumn('titleColumnId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('summaryColumnId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('contentColumnId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('publishedDateColumnId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('sort', 'text', ['null' => true, 'default' => null])
+ ->addColumn('filter', 'text', ['null' => true, 'default' => null])
+ ->addForeignKey('dataSetId', 'dataset', 'dataSetId')
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20181011160130_simple_settings_migration.php b/db/migrations/20181011160130_simple_settings_migration.php
new file mode 100644
index 0000000..c6783d1
--- /dev/null
+++ b/db/migrations/20181011160130_simple_settings_migration.php
@@ -0,0 +1,58 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class SimpleSettingsMigration
+ */
+class SimpleSettingsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Update all of our old "Checked|Unchecked" boxes to be proper checkboxes
+ $this->execute('UPDATE `setting` SET `value` = 0 WHERE `value` = \'Unchecked\'');
+ $this->execute('UPDATE `setting` SET `value` = 1 WHERE `value` = \'Checked\'');
+
+ // Update all of our old "Yes|No" boxes to be proper checkboxes
+ $this->execute('UPDATE `setting` SET `value` = 0 WHERE `value` = \'No\'');
+ $this->execute('UPDATE `setting` SET `value` = 1 WHERE `value` = \'Yes\'');
+
+ // Update all of our old "Off|On" boxes to be proper checkboxes (unless there are more than 2 options)
+ $this->execute('UPDATE `setting` SET `value` = 0 WHERE `value` = \'Off\' AND `setting` NOT IN (\'MAINTENANCE_ENABLED\', \'PASSWORD_REMINDER_ENABLED\', \'SENDFILE_MODE\')');
+ $this->execute('UPDATE `setting` SET `value` = 1 WHERE `value` = \'On\' AND `setting` NOT IN (\'MAINTENANCE_ENABLED\', \'PASSWORD_REMINDER_ENABLED\')');
+
+ $table = $this->table('setting');
+ $table
+ ->removeColumn('type')
+ ->removeColumn('title')
+ ->removeColumn('default')
+ ->removeColumn('fieldType')
+ ->removeColumn('helpText')
+ ->removeColumn('options')
+ ->removeColumn('cat')
+ ->removeColumn('validation')
+ ->removeColumn('ordering')
+ ->save();
+ }
+}
diff --git a/db/migrations/20181113173310_remove_finance_module_migration.php b/db/migrations/20181113173310_remove_finance_module_migration.php
new file mode 100644
index 0000000..ac6a8eb
--- /dev/null
+++ b/db/migrations/20181113173310_remove_finance_module_migration.php
@@ -0,0 +1,36 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class RemoveFinanceModuleMigration
+ */
+class RemoveFinanceModuleMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Delete the finance module from the modules table.
+ $this->execute('DELETE FROM module WHERE `module` = \'finance\'');
+ }
+}
diff --git a/db/migrations/20181113180337_split_ticker_module_migration.php b/db/migrations/20181113180337_split_ticker_module_migration.php
new file mode 100644
index 0000000..85efce2
--- /dev/null
+++ b/db/migrations/20181113180337_split_ticker_module_migration.php
@@ -0,0 +1,47 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class SplitTickerModuleMigration
+ */
+class SplitTickerModuleMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add the new module.
+ $this->execute('
+ INSERT INTO `module`
+ (`Module`, `Name`, `Enabled`, `RegionSpecific`, `Description`, `ImageUri`, `SchemaVersion`, `ValidExtensions`, `PreviewEnabled`, `assignable`, `render_as`, `settings`, `viewPath`, `class`, `defaultDuration`)
+ VALUES
+ (\'datasetticker\', \'DataSet Ticker\', 1, 1, \'Ticker with a DataSet providing the items\', \'forms/ticker.gif\', 1, NULL, 1, 1, \'html\', NULL, \'../modules\', \'Xibo\\\\Widget\\\\DataSetTicker\', 10);
+ ');
+
+ // Find all of the existing tickers which have a dataSet source, and update them to point at the new
+ // module `datasetticker`
+ $this->execute('
+ UPDATE `widget` SET type = \'datasetticker\' WHERE type = \'ticker\' AND widgetId IN (SELECT DISTINCT widgetId FROM `widgetoption` WHERE `option` = \'sourceId\' AND `value` = \'2\')
+ ');
+ }
+}
diff --git a/db/migrations/20181126113231_release1812_migration.php b/db/migrations/20181126113231_release1812_migration.php
new file mode 100644
index 0000000..19be7f5
--- /dev/null
+++ b/db/migrations/20181126113231_release1812_migration.php
@@ -0,0 +1,57 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class Release1812Migration
+ * applicable changes from 143.json
+ */
+class Release1812Migration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users to auto authorise new displays
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DISPLAY_AUTO_AUTH\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DISPLAY_AUTO_AUTH',
+ 'value' => 0,
+ 'userSee' => 0,
+ 'userChange' => 0
+ ]
+ ])->save();
+ }
+
+ // Rename Dashboard to Icon Dashboard
+ $this->execute('UPDATE `pages` set title = \'Icon Dashboard\', name = \'icondashboard\' WHERE `name` = \'dashboard\'');
+
+ // Change the DataSet View module name
+ $this->execute('UPDATE `module` set Name = \'DataSet View\' WHERE `Module` = \'datasetview\'');
+
+ // Add M4V extension to Video module
+ if (!$this->fetchRow('SELECT * FROM `module` WHERE `module` = \'video\' AND validExtensions LIKE \'%m4v%\'')) {
+ $this->execute('UPDATE `module` SET validExtensions = CONCAT(validextensions, \',m4v\') WHERE `module` = \'video\' LIMIT 1;');
+ }
+ }
+}
diff --git a/db/migrations/20181210092443_remove_image_uri_module_migration.php b/db/migrations/20181210092443_remove_image_uri_module_migration.php
new file mode 100644
index 0000000..dc256fb
--- /dev/null
+++ b/db/migrations/20181210092443_remove_image_uri_module_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class RemoveImageUriModuleMigration
+ * Remove the imageUri column from modules table
+ */
+class RemoveImageUriModuleMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('module');
+ if ($table->hasColumn('imageUri')) {
+ $table->removeColumn('imageUri')->update();
+ }
+ }
+}
diff --git a/db/migrations/20181212114400_create_player_versions_table_migration.php b/db/migrations/20181212114400_create_player_versions_table_migration.php
new file mode 100644
index 0000000..01b6200
--- /dev/null
+++ b/db/migrations/20181212114400_create_player_versions_table_migration.php
@@ -0,0 +1,71 @@
+.
+ */
+
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CreatePlayerVersionsTableMigration
+ * Create a new table to store information about player versions
+ * Install playersoftware widget
+ * Remove apk,ipk from validExtensions in genericfiles module
+ */
+class CreatePlayerVersionsTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->hasTable('player_software')) {
+ $versions = $this->table('player_software', ['id' => 'versionId']);
+
+ $versions->addColumn('player_type', 'string', ['limit' => 20, 'default' => null, 'null' => true])
+ ->addColumn('player_version', 'string', ['limit' => 15, 'default' => null, 'null' => true])
+ ->addColumn('player_code', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL, 'null' => true])
+ ->addColumn('mediaId', 'integer')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->create();
+ }
+
+ // Add the player_software module
+ $modules = $this->table('module');
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'playersoftware\'')) {
+ $modules->insert([
+ 'module' => 'playersoftware',
+ 'name' => 'Player Software',
+ 'enabled' => 1,
+ 'regionSpecific' => 0,
+ 'description' => 'A module for managing Player Versions',
+ 'schemaVersion' => 1,
+ 'validExtensions' => 'apk,ipk,wgt',
+ 'previewEnabled' => 0,
+ 'assignable' => 0,
+ 'render_as' => null,
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\PlayerSoftware',
+ 'defaultDuration' => 10
+ ])->save();
+ }
+
+ // remove apk and ipk from valid extensions in generic file module
+ $this->execute('UPDATE `module` SET validextensions = REPLACE(validextensions, \'apk,ipk\', \'\') WHERE module = \'genericfile\' LIMIT 1;');
+ }
+}
diff --git a/db/migrations/20181217135044_event_sync_migration.php b/db/migrations/20181217135044_event_sync_migration.php
new file mode 100644
index 0000000..6060667
--- /dev/null
+++ b/db/migrations/20181217135044_event_sync_migration.php
@@ -0,0 +1,50 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class EventSyncMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users enable event sync on applicable events
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'EVENT_SYNC\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'EVENT_SYNC',
+ 'value' => 0,
+ 'userSee' => 0,
+ 'userChange' => 0
+ ]
+ ])->save();
+ }
+
+ $scheduleTable = $this->table('schedule');
+
+ if (!$scheduleTable->hasColumn('syncEvent')) {
+ $scheduleTable
+ ->addColumn('syncEvent', 'integer', ['default' => 0, 'null' => false])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190121092556_player_upgrade_and_override_config_migration.php b/db/migrations/20190121092556_player_upgrade_and_override_config_migration.php
new file mode 100644
index 0000000..9a4d95a
--- /dev/null
+++ b/db/migrations/20190121092556_player_upgrade_and_override_config_migration.php
@@ -0,0 +1,82 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class PlayerUpgradeAndOverrideConfigMigration
+ * Add Player Software to Pages
+ * Remove version_instructions column from Display table
+ * Add overrideConfig column to display table
+ * Add default profile for Tizen
+ */
+class PlayerUpgradeAndOverrideConfigMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $pages = $this->table('pages');
+ $displayTable = $this->table('display');
+ $displayProfileTable = $this->table('displayprofile');
+
+ // add Player Software page
+ if (!$this->fetchRow('SELECT * FROM pages WHERE name = \'playersoftware\'')) {
+ $pages->insert([
+ 'name' => 'playersoftware',
+ 'title' => 'Player Software',
+ 'asHome' => 0
+ ])->save();
+ }
+
+ $displayTableModified = false;
+
+ // remove version_instructions from display table
+ if ($displayTable->hasColumn('version_instructions')) {
+ $displayTable->removeColumn('version_instructions');
+ $displayTableModified = true;
+ }
+
+ // add overrideConfig column to display table
+ if (!$displayTable->hasColumn('overrideConfig')) {
+ $displayTable->addColumn('overrideConfig', 'text');
+ $displayTableModified = true;
+ }
+
+ if ($displayTableModified) {
+ $displayTable->save();
+ }
+
+ // Get system user
+ $user = $this->fetchRow("SELECT userId FROM `user` WHERE userTypeId = 1");
+
+ // add default display profile for tizen
+ if (!$this->fetchRow('SELECT * FROM displayprofile WHERE type = \'sssp\' AND isDefault = 1')) {
+ $displayProfileTable->insert([
+ 'name' => 'Tizen',
+ 'type' => 'sssp',
+ 'config' => '[]',
+ 'userId' => $user['userId'],
+ 'isDefault' => 1
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190125170130_player_software_version_field_migration.php b/db/migrations/20190125170130_player_software_version_field_migration.php
new file mode 100644
index 0000000..09b2f4b
--- /dev/null
+++ b/db/migrations/20190125170130_player_software_version_field_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class PlayerSoftwareVersionFieldMigration
+ */
+class PlayerSoftwareVersionFieldMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('player_software');
+
+ if (!$table->hasColumn('playerShowVersion')) {
+ $table
+ ->addColumn('playerShowVersion', 'string', ['limit' => 50])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190129103831_add_linux_display_profile_migration.php b/db/migrations/20190129103831_add_linux_display_profile_migration.php
new file mode 100644
index 0000000..3bfb372
--- /dev/null
+++ b/db/migrations/20190129103831_add_linux_display_profile_migration.php
@@ -0,0 +1,48 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddLinuxDisplayProfileMigration
+ */
+class AddLinuxDisplayProfileMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Check to see if we already have a Linux default display profile. if not, add it.
+ if (!$this->fetchRow('SELECT * FROM displayprofile WHERE type = \'linux\' AND isDefault = 1')) {
+ // Get system user
+ $user = $this->fetchRow('SELECT userId FROM `user` WHERE userTypeId = 1');
+
+ $table = $this->table('displayprofile');
+ $table->insert([
+ 'name' => 'Linux',
+ 'type' => 'linux',
+ 'config' => '[]',
+ 'userId' => $user['userId'],
+ 'isDefault' => 1
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190212112534_add_proof_of_play_stats_duration_and_count_migration.php b/db/migrations/20190212112534_add_proof_of_play_stats_duration_and_count_migration.php
new file mode 100644
index 0000000..7eb4f31
--- /dev/null
+++ b/db/migrations/20190212112534_add_proof_of_play_stats_duration_and_count_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddProofOfPlayStatsDurationAndCountMigration
+ */
+class AddProofOfPlayStatsDurationAndCountMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Removed from here to streamline the upgrade from 2.0 to 2.1
+ }
+}
diff --git a/db/migrations/20190212115432_add_default_transition_duration_setting_migration.php b/db/migrations/20190212115432_add_default_transition_duration_setting_migration.php
new file mode 100644
index 0000000..57221c1
--- /dev/null
+++ b/db/migrations/20190212115432_add_default_transition_duration_setting_migration.php
@@ -0,0 +1,23 @@
+fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_TRANSITION_DURATION\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_TRANSITION_DURATION',
+ 'value' => 1000,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190213160914_add_global_stat_setting_migration.php b/db/migrations/20190213160914_add_global_stat_setting_migration.php
new file mode 100644
index 0000000..1d44e09
--- /dev/null
+++ b/db/migrations/20190213160914_add_global_stat_setting_migration.php
@@ -0,0 +1,89 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddGlobalStatSettingMigration
+ */
+class AddGlobalStatSettingMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $dateTime = new \DateTime();
+ $earlierMonth = $dateTime->modify( '-1 month' )->format( 'Y-m-d' );
+
+ $result = $this->fetchRow('SELECT EXISTS (SELECT * FROM `stat` where `stat`.end > \'' . $earlierMonth . '\' LIMIT 1)');
+ $table = $this->table('setting');
+
+ // if there are no stats recorded in last 1 month then layout stat is Off
+ if ($result[0] <= 0 ) {
+ $table
+ ->insert([
+ [
+ 'setting' => 'LAYOUT_STATS_ENABLED_DEFAULT',
+ 'value' => 0,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])
+ ->save();
+ } else {
+ $table
+ ->insert([
+ [
+ 'setting' => 'LAYOUT_STATS_ENABLED_DEFAULT',
+ 'value' => 1,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])
+ ->save();
+ }
+
+
+ // Media and widget stat is always set to Inherit
+ $table
+ ->insert([
+ [
+ 'setting' => 'DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT',
+ 'value' => 'Individual',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ],
+ [
+ 'setting' => 'MEDIA_STATS_ENABLED_DEFAULT',
+ 'value' => 'Inherit',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ],
+ [
+ 'setting' => 'WIDGET_STATS_ENABLED_DEFAULT',
+ 'value' => 'Inherit',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190213162212_add_horizontal_menu_setting_migration.php b/db/migrations/20190213162212_add_horizontal_menu_setting_migration.php
new file mode 100644
index 0000000..9095c53
--- /dev/null
+++ b/db/migrations/20190213162212_add_horizontal_menu_setting_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddHorizontalMenuSettingMigration
+ */
+class AddHorizontalMenuSettingMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users enable event sync on applicable events
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'NAVIGATION_MENU_POSITION\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'NAVIGATION_MENU_POSITION',
+ 'value' => 'vertical',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190214102508_add_layout_enable_stat.php b/db/migrations/20190214102508_add_layout_enable_stat.php
new file mode 100644
index 0000000..8cac25e
--- /dev/null
+++ b/db/migrations/20190214102508_add_layout_enable_stat.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddLayoutEnableStat
+ */
+class AddLayoutEnableStat extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $this->execute('UPDATE `layout` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `layout` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $table = $this->table('layout');
+ $table
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('enableStat', 'integer', ['null' => true])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190214102516_add_media_enable_stat.php b/db/migrations/20190214102516_add_media_enable_stat.php
new file mode 100644
index 0000000..672c05c
--- /dev/null
+++ b/db/migrations/20190214102516_add_media_enable_stat.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddMediaEnableStat
+ */
+class AddMediaEnableStat extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $this->execute('UPDATE `media` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `media` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $table = $this->table('media');
+ $table
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('enableStat', 'string', ['null' => true])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190220165703_add_schedule_recurrence_monthly_repeats_on_migration.php b/db/migrations/20190220165703_add_schedule_recurrence_monthly_repeats_on_migration.php
new file mode 100644
index 0000000..143b170
--- /dev/null
+++ b/db/migrations/20190220165703_add_schedule_recurrence_monthly_repeats_on_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddScheduleRecurrenceMonthlyRepeatsOnMigration
+ */
+class AddScheduleRecurrenceMonthlyRepeatsOnMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('schedule');
+ $table
+ ->addColumn('recurrenceMonthlyRepeatsOn', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+ }
+}
diff --git a/db/migrations/20190227101705_make_display_license_column_unique_migration.php b/db/migrations/20190227101705_make_display_license_column_unique_migration.php
new file mode 100644
index 0000000..918f3aa
--- /dev/null
+++ b/db/migrations/20190227101705_make_display_license_column_unique_migration.php
@@ -0,0 +1,62 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class MakeDisplayLicenseColumnUniqueMigration
+ */
+class MakeDisplayLicenseColumnUniqueMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // query the database and look for duplicate entries
+ $duplicatesData = $this->query('SELECT displayId, license FROM display WHERE license IN ( SELECT license FROM display GROUP BY license HAVING count(*) > 1) GROUP BY displayId;');
+ $rowsDuplicatesData = $duplicatesData->fetchAll(PDO::FETCH_ASSOC);
+ // only execute this code if any duplicates were found
+ if (count($rowsDuplicatesData) > 0) {
+ $licences = [];
+ $filtered = [];
+ // create new array with license as the key
+ foreach ($rowsDuplicatesData as $row) {
+ $licences[$row['license']][] = $row['displayId'];
+ }
+ // iterate through the array and remove first element from each of the arrays with displayIds
+ foreach ($licences as $licence) {
+ array_shift($licence);
+ $filtered[] = $licence;
+ }
+ // iterate through our new filtered array, that only contains displayIds that should be removed and execute the SQL DELETE statements
+ foreach ($filtered as $item) {
+ foreach ($item as $displayId) {
+ $this->execute('DELETE FROM lkdisplaydg WHERE displayId = ' . $displayId);
+ $this->execute('DELETE FROM display WHERE `displayId` = ' . $displayId);
+ }
+ }
+ }
+
+ // add unique index to license column
+ $table = $this->table('display');
+ $table->addIndex(['license'], ['unique' => true])->update();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190228120603_add_dynamic_criteria_tags_migration.php b/db/migrations/20190228120603_add_dynamic_criteria_tags_migration.php
new file mode 100644
index 0000000..dc048d2
--- /dev/null
+++ b/db/migrations/20190228120603_add_dynamic_criteria_tags_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddDynamicCriteriaTagsMigration
+ */
+class AddDynamicCriteriaTagsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('displaygroup');
+ $table
+ ->addColumn('dynamicCriteriaTags', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20190301115046_adjust_genericfile_valid_extensions_migration.php b/db/migrations/20190301115046_adjust_genericfile_valid_extensions_migration.php
new file mode 100644
index 0000000..1464811
--- /dev/null
+++ b/db/migrations/20190301115046_adjust_genericfile_valid_extensions_migration.php
@@ -0,0 +1,60 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AdjustGenericfileValidExtensionsMigration
+ */
+class AdjustGenericfileValidExtensionsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // get the current validExtensions for genericfile module
+ $extensionsData = $this->query('SELECT `validExtensions` FROM `module` WHERE `Module` = \'genericfile\';');
+ $extensions = $extensionsData->fetchAll(PDO::FETCH_ASSOC);
+ $newExtensions = [];
+
+ //iterate through the array
+ foreach ($extensions as $extension) {
+ foreach ($extension as $validExt) {
+
+ // make an array out of comma separated string
+ $explode = explode(',', $validExt);
+
+ // iterate through our array, remove apk and ipk extensions from it and put them in a new array
+ foreach ($explode as $item) {
+ if ($item != 'apk' && $item != 'ipk') {
+ $newExtensions[] = $item;
+ }
+ }
+ }
+ }
+
+ // make a comma separated string from our new array
+ $newValidExtensions = implode(',', $newExtensions);
+
+ // update validExtensions for genericfile module with our adjusted extensions
+ $this->execute('UPDATE `module` SET `validExtensions` = \'' . $newValidExtensions . '\' WHERE module = \'genericfile\' LIMIT 1;');
+ }
+}
diff --git a/db/migrations/20190315134628_add_bandwidth_limit_column_to_displaygroup_migration.php b/db/migrations/20190315134628_add_bandwidth_limit_column_to_displaygroup_migration.php
new file mode 100644
index 0000000..5a71454
--- /dev/null
+++ b/db/migrations/20190315134628_add_bandwidth_limit_column_to_displaygroup_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddBandwidthLimitColumnToDisplaygroupMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $displayGroupTable = $this->table('displaygroup');
+
+ if (!$displayGroupTable->hasColumn('bandwidthLimit')) {
+ $displayGroupTable
+ ->addColumn('bandwidthLimit', 'integer', ['default' => 0, 'null' => false])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190322162052_add_published_date_column_migration.php b/db/migrations/20190322162052_add_published_date_column_migration.php
new file mode 100644
index 0000000..53cd648
--- /dev/null
+++ b/db/migrations/20190322162052_add_published_date_column_migration.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddPublishedDateColumnMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $layoutTable = $this->table('layout');
+
+ if (!$layoutTable->hasColumn('publishedDate')) {
+ $this->execute('UPDATE `layout` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `layout` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $layoutTable
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('publishedDate', 'datetime', ['null' => true, 'default' => null])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190326163016_create_layout_history_table_migration.php b/db/migrations/20190326163016_create_layout_history_table_migration.php
new file mode 100644
index 0000000..fbefb11
--- /dev/null
+++ b/db/migrations/20190326163016_create_layout_history_table_migration.php
@@ -0,0 +1,57 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CreateLayoutHistoryTableMigration
+ */
+class CreateLayoutHistoryTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('layouthistory', ['id' => 'layoutHistoryId']);
+ $table->addColumn('campaignId', 'integer')
+ ->addColumn('layoutId', 'integer')
+ ->addColumn('publishedDate', 'datetime', ['null' => true, 'default' => null])
+ ->addForeignKey('campaignId', 'campaign', 'campaignId')
+ ->create();
+
+ // insert all published layoutIds and their corresponding campaignId in the layouthistory
+ $this->execute('INSERT INTO `layouthistory` (campaignId, layoutId, publishedDate)
+ SELECT T.campaignId, L.layoutId, L.modifiedDt
+ FROM layout L
+ INNER JOIN
+ (SELECT
+ lkc.layoutId, lkc.campaignId
+ FROM
+ `campaign` C
+ INNER JOIN `lkcampaignlayout` lkc
+ ON C.campaignId = lkc.campaignId
+ WHERE
+ isLayoutSpecific = 1) T
+ ON T.layoutId = L.layoutId
+ WHERE
+ L.parentId IS NULL;');
+ }
+}
diff --git a/db/migrations/20190328111718_add_campaign_stat_migration.php b/db/migrations/20190328111718_add_campaign_stat_migration.php
new file mode 100644
index 0000000..e244336
--- /dev/null
+++ b/db/migrations/20190328111718_add_campaign_stat_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddCampaignStatMigration
+ */
+class AddCampaignStatMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Removed from here to streamline the upgrade from 2.0 to 2.1
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190401150256_add_schedule_now_page_migration.php b/db/migrations/20190401150256_add_schedule_now_page_migration.php
new file mode 100644
index 0000000..e8c9e6a
--- /dev/null
+++ b/db/migrations/20190401150256_add_schedule_now_page_migration.php
@@ -0,0 +1,59 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddScheduleNowPageMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $pages = $this->table('pages');
+
+ // add schedule now page
+ if (!$this->fetchRow('SELECT * FROM pages WHERE name = \'schedulenow\'')) {
+ $pages->insert([
+ 'name' => 'schedulenow',
+ 'title' => 'Schedule Now',
+ 'asHome' => 0
+ ])->save();
+ }
+
+ // add permission to the schedule now page to every group and user, excluding "Everyone"
+ $permissions = $this->table('permission');
+ $scheduleNowPageId = $this->fetchRow('SELECT pageId FROM `pages` WHERE `name` = \'schedulenow\' ');
+ $groupIds = $this->fetchAll('SELECT groupId FROM `group` WHERE `isEveryone` = 0 ');
+
+ foreach ($groupIds as $groupId) {
+ $permissions->insert([
+ [
+ 'entityId' => 1,
+ 'groupId' => $groupId['groupId'],
+ 'objectId' => $scheduleNowPageId[0],
+ 'view' => 1,
+ 'edit' => 0,
+ 'delete' => 0
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190509101525_create_report_schedule_table_migration.php b/db/migrations/20190509101525_create_report_schedule_table_migration.php
new file mode 100644
index 0000000..ed6079b
--- /dev/null
+++ b/db/migrations/20190509101525_create_report_schedule_table_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CreateReportScheduleTableMigration
+ */
+class CreateReportScheduleTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('reportschedule', ['id' => 'reportScheduleId']);
+ $table->addColumn('name', 'string')
+ ->addColumn('reportName', 'string')
+ ->addColumn('filterCriteria', 'text')
+ ->addColumn('schedule', 'string')
+ ->addColumn('lastRunDt', 'integer', ['default' => 0])
+ ->addColumn('userId', 'integer')
+ ->addColumn('createdDt', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+ }
+}
+
diff --git a/db/migrations/20190509102648_create_saved_report_table_migration.php b/db/migrations/20190509102648_create_saved_report_table_migration.php
new file mode 100644
index 0000000..1b073ce
--- /dev/null
+++ b/db/migrations/20190509102648_create_saved_report_table_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CreateSavedReportTableMigration
+ */
+class CreateSavedReportTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('saved_report', ['id' => 'savedReportId']);
+ $table->addColumn('saveAs', 'string')
+ ->addColumn('reportName', 'string')
+ ->addColumn('mediaId', 'integer')
+ ->addColumn('reportScheduleId', 'integer')
+ ->addColumn('generatedOn', 'integer')
+ ->addColumn('userId', 'integer')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->addForeignKey('reportScheduleId', 'reportschedule', 'reportScheduleId')
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190509113001_add_report_page_migration.php b/db/migrations/20190509113001_add_report_page_migration.php
new file mode 100644
index 0000000..c6194c2
--- /dev/null
+++ b/db/migrations/20190509113001_add_report_page_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddReportPageMigration
+ */
+class AddReportPageMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $pages = $this->table('pages');
+
+ // add report page
+ if (!$this->fetchRow('SELECT * FROM pages WHERE name = \'report\'')) {
+ $pages->insert([
+ 'name' => 'report',
+ 'title' => 'Report',
+ 'asHome' => 0
+ ])->save();
+ }
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190510140126_two_factor_auth_migration.php b/db/migrations/20190510140126_two_factor_auth_migration.php
new file mode 100644
index 0000000..cc3c1db
--- /dev/null
+++ b/db/migrations/20190510140126_two_factor_auth_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class TwoFactorAuthMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $userTable = $this->table('user');
+
+ if (!$userTable->hasColumn('twoFactorTypeId')) {
+ $userTable
+ ->addColumn('twoFactorTypeId', 'integer', ['default' => 0, 'null' => false])
+ ->addColumn('twoFactorSecret', 'text', ['default' => NULL, 'null' => true])
+ ->addColumn('twoFactorRecoveryCodes', 'text', ['default' => NULL, 'null' => true])
+ ->save();
+ }
+
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'TWOFACTOR_ISSUER\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'TWOFACTOR_ISSUER',
+ 'value' => '',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+
+ }
+}
diff --git a/db/migrations/20190514134430_nullable_text_fields_migration.php b/db/migrations/20190514134430_nullable_text_fields_migration.php
new file mode 100644
index 0000000..883d54f
--- /dev/null
+++ b/db/migrations/20190514134430_nullable_text_fields_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class NullableTextFieldsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $notNullableTextColumnsQuery = $this->query('SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE DATA_TYPE = \'text\' AND IS_NULLABLE = \'NO\' AND TABLE_SCHEMA = DATABASE() ' );
+ $notNullableTextColumns = $notNullableTextColumnsQuery->fetchAll(PDO::FETCH_ASSOC);
+
+ foreach ($notNullableTextColumns as $columns) {
+ $this->execute('ALTER TABLE ' . $columns['TABLE_NAME'] . ' MODIFY ' . $columns['COLUMN_NAME'] . ' TEXT NULL;');
+ }
+ }
+}
diff --git a/db/migrations/20190515094133_add_html_datatype_migration.php b/db/migrations/20190515094133_add_html_datatype_migration.php
new file mode 100644
index 0000000..3152313
--- /dev/null
+++ b/db/migrations/20190515094133_add_html_datatype_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddHtmlDatatypeMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->fetchRow('SELECT * FROM `datatype` WHERE dataType = \'HTML\'')) {
+ $this->table('datatype')->insert([
+ [
+ 'dataTypeId' => 6,
+ 'dataType' => 'HTML'
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190515105624_install_additional_standard_modules_migration.php b/db/migrations/20190515105624_install_additional_standard_modules_migration.php
new file mode 100644
index 0000000..c4fa407
--- /dev/null
+++ b/db/migrations/20190515105624_install_additional_standard_modules_migration.php
@@ -0,0 +1,122 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class InstallAdditionalStandardModulesMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $modules = $this->table('module');
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'htmlpackage\'')) {
+ $modules->insert([
+ 'module' => 'htmlpackage',
+ 'name' => 'HTML Package',
+ 'enabled' => 1,
+ 'regionSpecific' => 0,
+ 'description' => 'Upload a complete package to distribute to Players',
+ 'schemaVersion' => 1,
+ 'validExtensions' => 'htz',
+ 'previewEnabled' => 0,
+ 'assignable' => 1,
+ 'render_as' => 'native',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\HtmlPackage',
+ 'defaultDuration' => 60
+ ])->save();
+ }
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'videoin\'')) {
+ $modules->insert([
+ 'module' => 'videoin',
+ 'name' => 'Video In',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'Display input from an external source',
+ 'schemaVersion' => 1,
+ 'validExtensions' => null,
+ 'previewEnabled' => 0,
+ 'assignable' => 1,
+ 'render_as' => 'native',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\VideoIn',
+ 'defaultDuration' => 60
+ ])->save();
+ }
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'hls\'')) {
+ $modules->insert([
+ 'module' => 'hls',
+ 'name' => 'HLS',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'Display live streamed content',
+ 'schemaVersion' => 1,
+ 'validExtensions' => null,
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\Hls',
+ 'defaultDuration' => 60
+ ])->save();
+ }
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'calendar\'')) {
+ $modules->insert([
+ 'module' => 'calendar',
+ 'name' => 'Calendar',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'Display events from an iCAL feed',
+ 'schemaVersion' => 1,
+ 'validExtensions' => null,
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\Calendar',
+ 'defaultDuration' => 60
+ ])->save();
+ }
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'chart\'')) {
+ $modules->insert([
+ 'module' => 'chart',
+ 'name' => 'Chart',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'Display information held in a DataSet as a type of Chart',
+ 'schemaVersion' => 1,
+ 'validExtensions' => null,
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\Chart',
+ 'defaultDuration' => 240
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190517080033_add_foreign_keys_to_lktag_tables_migration.php b/db/migrations/20190517080033_add_foreign_keys_to_lktag_tables_migration.php
new file mode 100644
index 0000000..072e859
--- /dev/null
+++ b/db/migrations/20190517080033_add_foreign_keys_to_lktag_tables_migration.php
@@ -0,0 +1,95 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddForeignKeysToLktagTablesMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ //lktagcampaign
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagcampaign\' AND referenced_table_name = \'campaign\';')) {
+
+ $this->execute('DELETE FROM `lktagcampaign` WHERE NOT EXISTS (SELECT campaignId FROM `campaign` WHERE `campaign`.campaignId = `lktagcampaign`.campaignId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagcampaign` ADD CONSTRAINT `lktagcampaign_ibfk_2` FOREIGN KEY (`campaignId`) REFERENCES `campaign` (`campaignId`);');
+ }
+
+ //lktagdisplaygroup
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagdisplaygroup\' AND referenced_table_name = \'displaygroup\';')) {
+
+ $this->execute('DELETE FROM `lktagdisplaygroup` WHERE NOT EXISTS (SELECT displayGroupId FROM `displaygroup` WHERE `displaygroup`.displayGroupId = `lktagdisplaygroup`.displayGroupId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagdisplaygroup` ADD CONSTRAINT `lktagdisplaygroup_ibfk_2` FOREIGN KEY (`displayGroupId`) REFERENCES `displaygroup` (`displayGroupId`);');
+ }
+
+ //lktaglayout
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktaglayout\' AND referenced_table_name = \'layout\';')) {
+
+ $this->execute('DELETE FROM `lktaglayout` WHERE NOT EXISTS (SELECT layoutId FROM `layout` WHERE `layout`.layoutId = `lktaglayout`.layoutId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktaglayout` ADD CONSTRAINT `lktaglayout_ibfk_2` FOREIGN KEY (`layoutId`) REFERENCES `layout` (`layoutId`);');
+ }
+
+ //lktagmedia
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagmedia\' AND referenced_table_name = \'media\';')) {
+
+ $this->execute('DELETE FROM `lktagmedia` WHERE NOT EXISTS (SELECT mediaId FROM `media` WHERE `media`.mediaId = `lktagmedia`.mediaId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagmedia` ADD CONSTRAINT `lktagmedia_ibfk_2` FOREIGN KEY (`mediaId`) REFERENCES `media` (`mediaId`);');
+ }
+
+ //lktagplaylist
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagplaylist\' AND referenced_table_name = \'tag\';')) {
+
+ $this->execute('DELETE FROM `lktagplaylist` WHERE NOT EXISTS (SELECT tagId FROM `tag` WHERE `tag`.tagId = `lktagplaylist`.tagId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagplaylist` ADD CONSTRAINT `lktagplaylist_ibfk_1` FOREIGN KEY (`tagId`) REFERENCES `tag` (`tagId`);');
+ }
+
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'lktagplaylist\' AND referenced_table_name = \'playlist\';')) {
+
+ $this->execute('DELETE FROM `lktagplaylist` WHERE NOT EXISTS (SELECT playlistId FROM `playlist` WHERE `playlist`.playlistId = `lktagplaylist`.playlistId) ');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `lktagplaylist` ADD CONSTRAINT `lktagplaylist_ibfk_2` FOREIGN KEY (`playlistId`) REFERENCES `playlist` (`playlistId`);');
+ }
+ }
+}
diff --git a/db/migrations/20190521092700_add_report_schedule_task_migration.php b/db/migrations/20190521092700_add_report_schedule_task_migration.php
new file mode 100644
index 0000000..45e6e09
--- /dev/null
+++ b/db/migrations/20190521092700_add_report_schedule_task_migration.php
@@ -0,0 +1,47 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddReportScheduleTaskMigration
+ */
+class AddReportScheduleTaskMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('task');
+ if (!$this->fetchRow('SELECT * FROM `task` WHERE name = \'Report Schedule\'')) {
+ $table->insert([
+ [
+ 'name' => 'Report Schedule',
+ 'class' => '\Xibo\XTR\ReportScheduleTask',
+ 'options' => '[]',
+ 'schedule' => '*/5 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/report-schedule.task'
+ ],
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190521092930_add_previous_run_date_report_schedule_migration.php b/db/migrations/20190521092930_add_previous_run_date_report_schedule_migration.php
new file mode 100644
index 0000000..e9e53c8
--- /dev/null
+++ b/db/migrations/20190521092930_add_previous_run_date_report_schedule_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddPreviousRunDateReportScheduleMigration
+ */
+class AddPreviousRunDateReportScheduleMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('reportschedule');
+ $table
+ ->addColumn('previousRunDt', 'integer', ['default' => 0, 'after' => 'lastRunDt'])
+ ->addColumn('lastSavedReportId', 'integer', ['default' => 0, 'after' => 'schedule'])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190521102635_playlist_duration_update_at_timestamp.php b/db/migrations/20190521102635_playlist_duration_update_at_timestamp.php
new file mode 100644
index 0000000..01809cc
--- /dev/null
+++ b/db/migrations/20190521102635_playlist_duration_update_at_timestamp.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class PlaylistDurationUpdateAtTimestamp
+ */
+class PlaylistDurationUpdateAtTimestamp extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->execute('UPDATE `playlist` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `playlist` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $table = $this->table('playlist');
+ $table
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('requiresDurationUpdate', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR, 'default' => 0, 'null' => false])
+ ->save();
+ }
+}
diff --git a/db/migrations/20190603083836_change_stat_table_start_end_column_migration.php b/db/migrations/20190603083836_change_stat_table_start_end_column_migration.php
new file mode 100644
index 0000000..e73043c
--- /dev/null
+++ b/db/migrations/20190603083836_change_stat_table_start_end_column_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddPreviousRunDateReportScheduleMigration
+ */
+class ChangeStatTableStartEndColumnMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $this->table('stat')->rename('stat_archive')->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190610150331_tags_with_values_migration.php b/db/migrations/20190610150331_tags_with_values_migration.php
new file mode 100644
index 0000000..72d20ce
--- /dev/null
+++ b/db/migrations/20190610150331_tags_with_values_migration.php
@@ -0,0 +1,58 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class TagsWithValuesMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $tagTable = $this->table('tag');
+
+ // add new columns to the tag table
+ if (!$tagTable->hasColumn('isSystem')) {
+
+ $tagTable
+ ->addColumn('isSystem', 'integer', ['default' => 0, 'null' => false])
+ ->addColumn('options', 'text', ['default' => null, 'null' => true])
+ ->addColumn('isRequired', 'integer', ['default' => 0, 'null' => false])
+ ->save();
+ }
+
+ // set isSystem flag on these tags
+ $this->execute('UPDATE `tag` SET `isSystem` = 1 WHERE tag IN (\'template\', \'background\', \'thumbnail\', \'imported\')');
+
+ // add value column to lktag tables
+ $lktagTables = ["lktagcampaign", "lktagdisplaygroup", "lktaglayout", "lktagmedia", "lktagplaylist"];
+
+ foreach ($lktagTables as $lktagTable) {
+ $table = $this->table($lktagTable);
+
+ if(!$table->hasColumn('value')) {
+ $table
+ ->addColumn('value', 'text', ['default' => null, 'null' => true])
+ ->save();
+ }
+ }
+ }
+}
diff --git a/db/migrations/20190611145607_remove_old_version_table_migration.php b/db/migrations/20190611145607_remove_old_version_table_migration.php
new file mode 100644
index 0000000..1e6408b
--- /dev/null
+++ b/db/migrations/20190611145607_remove_old_version_table_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class RemoveOldVersionTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if ($this->hasTable('version')) {
+ $this->execute('DROP TABLE version');
+ }
+
+ }
+}
diff --git a/db/migrations/20190612140955_display_table_database_schema_adjustments_migration.php b/db/migrations/20190612140955_display_table_database_schema_adjustments_migration.php
new file mode 100644
index 0000000..fdd17ff
--- /dev/null
+++ b/db/migrations/20190612140955_display_table_database_schema_adjustments_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class DisplayTableDatabaseSchemaAdjustmentsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // nullable and default values adjusted
+ $this->execute('ALTER TABLE display MODIFY `lastaccessed` int(11) NULL DEFAULT NULL');
+ $this->execute('ALTER TABLE display MODIFY `license` varchar(40) NULL DEFAULT NULL');
+ $this->execute('ALTER TABLE display MODIFY `alert_timeout` int(11) DEFAULT 0');
+ $this->execute('ALTER TABLE display MODIFY `clientAddress` varchar(50) NULL DEFAULT NULL');
+ $this->execute('ALTER TABLE display MODIFY `macAddress` varchar(254) NULL DEFAULT NULL');
+ $this->execute('ALTER TABLE display MODIFY `lastChanged` int(11) NULL DEFAULT NULL');
+ $this->execute('ALTER TABLE display MODIFY `numberOfMacAddressChanges` int(11) DEFAULT 0');
+ $this->execute('ALTER TABLE display MODIFY `lastWakeOnLanCommandSent` int(11) NULL DEFAULT NULL');
+ $this->execute('ALTER TABLE display MODIFY `email_alert` int(11) DEFAULT 0');
+
+ // display profile foreign key
+ if (!$this->fetchRow('
+ SELECT * FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE constraint_schema=DATABASE()
+ AND `table_name` = \'display\' AND referenced_table_name = \'displayprofile\';')) {
+
+ $this->execute('UPDATE `display` SET displayProfileId = NULL WHERE NOT EXISTS (SELECT displayProfileId FROM `displayprofile` WHERE `displayprofile`.displayProfileId = `display`.displayProfileId)');
+
+ // Add the constraint
+ $this->execute('ALTER TABLE `display` ADD CONSTRAINT `display_ibfk_1` FOREIGN KEY (`displayProfileId`) REFERENCES `displayprofile` (`displayProfileId`);');
+ }
+ }
+}
diff --git a/db/migrations/20190620112611_move_tidy_stats_to_stats_archive_task_migration.php b/db/migrations/20190620112611_move_tidy_stats_to_stats_archive_task_migration.php
new file mode 100644
index 0000000..ffbd595
--- /dev/null
+++ b/db/migrations/20190620112611_move_tidy_stats_to_stats_archive_task_migration.php
@@ -0,0 +1,54 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class MoveTidyStatsToStatsArchiveTaskMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // query the database and look for Stats Archive task
+ $statsArchiveQuery = $this->query('SELECT taskId, name, options, isActive FROM `task` WHERE `name` = \'Stats Archive\' ;');
+ $statsArchiveData = $statsArchiveQuery->fetchAll(PDO::FETCH_ASSOC);
+
+ if (count($statsArchiveData) > 0) {
+ foreach ($statsArchiveData as $row) {
+ $taskId = $row['taskId'];
+ $isActive = $row['isActive'];
+ $options = json_decode($row['options']);
+
+ // if the task is current set as Active, we need to ensure that archiveStats option is set to On (default is Off)
+ if ($isActive == 1) {
+ $options->archiveStats = 'On';
+ } else {
+ $options->archiveStats = 'Off';
+ }
+
+ // save updated options to variable
+ $newOptions = json_encode($options);
+
+ $this->execute('UPDATE `task` SET isActive = 1, options = \'' . $newOptions . '\' WHERE taskId = '. $taskId );
+ }
+ }
+ }
+}
diff --git a/db/migrations/20190620142655_add_playlist_enable_stat_migration.php b/db/migrations/20190620142655_add_playlist_enable_stat_migration.php
new file mode 100644
index 0000000..fbef58a
--- /dev/null
+++ b/db/migrations/20190620142655_add_playlist_enable_stat_migration.php
@@ -0,0 +1,58 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddPlaylistEnableStatMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $playlistTable = $this->table('playlist');
+
+ if (!$playlistTable->hasColumn('enableStat')) {
+ $this->execute('UPDATE `playlist` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `playlist` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $playlistTable
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('enableStat', 'string', ['null' => true])
+ ->save();
+ }
+
+ $settingsTable = $this->table('setting');
+
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'PLAYLIST_STATS_ENABLED_DEFAULT\'')) {
+ $settingsTable
+ ->insert([
+ [
+ 'setting' => 'PLAYLIST_STATS_ENABLED_DEFAULT',
+ 'value' => 'Inherit',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190626091331_widget_history_migration.php b/db/migrations/20190626091331_widget_history_migration.php
new file mode 100644
index 0000000..cab4e48
--- /dev/null
+++ b/db/migrations/20190626091331_widget_history_migration.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class WidgetHistoryMigration
+ */
+class WidgetHistoryMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $table = $this->table('widgethistory');
+ $table
+ ->addColumn('layoutHistoryId', 'integer')
+ ->addColumn('widgetId', 'integer')
+ ->addColumn('mediaId', 'integer', ['null' => true])
+ ->addColumn('type', 'string', ['limit' => 50])
+ ->addColumn('name', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addForeignKey('layoutHistoryId', 'layouthistory', 'layoutHistoryId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20190626110359_create_stat_table_migration.php b/db/migrations/20190626110359_create_stat_table_migration.php
new file mode 100644
index 0000000..e66684e
--- /dev/null
+++ b/db/migrations/20190626110359_create_stat_table_migration.php
@@ -0,0 +1,62 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CreateStatTableMigration
+ */
+class CreateStatTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // If stat table exists then rename it
+ if ($this->hasTable('stat')) {
+ $this->table('stat')->rename('stat_archive')->save();
+ }
+
+ // Create stat table
+ $table = $this->table('stat', ['id' => 'statId']);
+ $table
+
+ ->addColumn('type', 'string', ['limit' => 20])
+ ->addColumn('statDate', 'integer')
+ ->addColumn('scheduleId', 'integer')
+ ->addColumn('displayId', 'integer')
+ ->addColumn('campaignId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('layoutId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('mediaId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('widgetId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('start', 'integer')
+ ->addColumn('end', 'integer')
+ ->addColumn('tag', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('duration', 'integer')
+ ->addColumn('count', 'integer')
+
+ ->addIndex('statDate')
+ ->addIndex(['displayId', 'end', 'type'])
+ ->save();
+
+
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190628083649_add_stats_migration_task_migration.php b/db/migrations/20190628083649_add_stats_migration_task_migration.php
new file mode 100644
index 0000000..4b82dd0
--- /dev/null
+++ b/db/migrations/20190628083649_add_stats_migration_task_migration.php
@@ -0,0 +1,47 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddStatsMigrationTaskMigration
+ */
+class AddStatsMigrationTaskMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('task');
+ if (!$this->fetchRow('SELECT * FROM `task` WHERE name = \'Statistics Migration\'')) {
+ $table->insert([
+ [
+ 'name' => 'Statistics Migration',
+ 'class' => '\Xibo\XTR\StatsMigrationTask',
+ 'options' => '{"killSwitch":"0","numberOfRecords":"5000","numberOfLoops":"10","pauseBetweenLoops":"1","optimiseOnComplete":"1"}',
+ 'schedule' => '*/10 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/stats-migration.task'
+ ],
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190710213414_add_is_active_report_schedule_migration.php b/db/migrations/20190710213414_add_is_active_report_schedule_migration.php
new file mode 100644
index 0000000..070db68
--- /dev/null
+++ b/db/migrations/20190710213414_add_is_active_report_schedule_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddIsActiveReportScheduleMigration
+ */
+class AddIsActiveReportScheduleMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('reportschedule');
+ $table
+ ->addColumn('isActive', 'integer', ['default' => 1, 'after' => 'userId'])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190717101342_nullable_command_validation_string_migration.php b/db/migrations/20190717101342_nullable_command_validation_string_migration.php
new file mode 100644
index 0000000..b03f915
--- /dev/null
+++ b/db/migrations/20190717101342_nullable_command_validation_string_migration.php
@@ -0,0 +1,33 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class NullableCommandValidationStringMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('lkcommanddisplayprofile');
+ $table->changeColumn('validationString', 'string', ['limit' => 1000, 'null' => true])->save();
+ }
+}
diff --git a/db/migrations/20190719074601_missing_default_value_migration.php b/db/migrations/20190719074601_missing_default_value_migration.php
new file mode 100644
index 0000000..223cb55
--- /dev/null
+++ b/db/migrations/20190719074601_missing_default_value_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class MissingDefaultValueMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('dataset');
+ $table->changeColumn('lastDataEdit', 'integer', ['default' => 0])
+ ->changeColumn('lastClear', 'integer', ['default' => 0])
+ ->save();
+ }
+}
diff --git a/db/migrations/20190725115532_add_schedule_reminder_task_migration.php b/db/migrations/20190725115532_add_schedule_reminder_task_migration.php
new file mode 100644
index 0000000..f7468b3
--- /dev/null
+++ b/db/migrations/20190725115532_add_schedule_reminder_task_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddScheduleReminderTaskMigration
+ */
+class AddScheduleReminderTaskMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('task');
+ $table->insert([
+ [
+ 'name' => 'Schedule Reminder',
+ 'class' => '\Xibo\XTR\ScheduleReminderTask',
+ 'options' => '[]',
+ 'schedule' => '*/5 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/schedule-reminder.task'
+ ],
+ ])->save();
+ }
+}
diff --git a/db/migrations/20190801102042_display_profile_command_link_fix_migration.php b/db/migrations/20190801102042_display_profile_command_link_fix_migration.php
new file mode 100644
index 0000000..91bf69f
--- /dev/null
+++ b/db/migrations/20190801102042_display_profile_command_link_fix_migration.php
@@ -0,0 +1,69 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class DisplayProfileCommandLinkFixMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // query the database and look for duplicate entries
+ $duplicatesData = $this->query('SELECT commandId, displayProfileId FROM lkcommanddisplayprofile WHERE commandId IN ( SELECT commandId FROM lkcommanddisplayprofile GROUP BY commandId HAVING COUNT(*) > 1) ');
+ $rowsDuplicatesData = $duplicatesData->fetchAll(PDO::FETCH_ASSOC);
+
+ // only execute this code if any duplicates were found
+ if (count($rowsDuplicatesData) > 0) {
+ $duplicates = [];
+ // create new array with displayProfileId as the key
+ foreach ($rowsDuplicatesData as $row) {
+ $duplicates[$row['displayProfileId']][] = $row['commandId'];
+ }
+
+ // iterate through the arrays get unique commandIds, calculate the limit and execute Delete query.
+ foreach ($duplicates as $displayProfileId => $commandId) {
+
+ // commandId is an array, get the unique Ids from it
+ $uniqueCommandIds = array_unique($commandId);
+
+ // iterate through our array of uniqueCommandIds and calculate the LIMIT for our SQL Delete statement
+ foreach ($uniqueCommandIds as $uniqueCommandId) {
+ // create an array with commandId as the key and count of duplicate as value
+ $limits = array_count_values($commandId);
+
+ // Limits is an array with uniqueCommandId as the key and count of duplicate as value, we want to leave one record, hence we subtract 1
+ $limit = $limits[$uniqueCommandId] - 1;
+
+ // if we have any duplicates then run the delete statement, for each displayProfileId with uniqueCommandId and calculated limit per uniqueCommandId
+ if ($limit > 0) {
+ $this->execute('DELETE FROM lkcommanddisplayprofile WHERE commandId = ' . $uniqueCommandId . ' AND displayProfileId = ' . $displayProfileId . ' LIMIT ' . $limit);
+ }
+ }
+ }
+ }
+
+ // add the primary key for CMS upgrades, fresh CMS Installations will have it correctly added in installation migration.
+ if (!$this->fetchRow('SELECT * FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_NAME = \'lkcommanddisplayprofile\' AND CONSTRAINT_TYPE = \'PRIMARY KEY\' AND CONSTRAINT_SCHEMA = Database();')) {
+ $this->execute('ALTER TABLE lkcommanddisplayprofile ADD PRIMARY KEY (commandId, displayProfileId);');
+ }
+ }
+}
diff --git a/db/migrations/20190801141737_dataset_add_custom_headers_column_migration.php b/db/migrations/20190801141737_dataset_add_custom_headers_column_migration.php
new file mode 100644
index 0000000..77a8ff9
--- /dev/null
+++ b/db/migrations/20190801141737_dataset_add_custom_headers_column_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class DatasetAddCustomHeadersColumnMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $dataSetTable = $this->table('dataset');
+
+ if (!$dataSetTable->hasColumn('customHeaders')) {
+ $dataSetTable
+ ->addColumn('customHeaders', 'text', ['null' => true, 'default' => null, 'after' => 'password'])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190801142302_add_dooh_user_type_migration.php b/db/migrations/20190801142302_add_dooh_user_type_migration.php
new file mode 100644
index 0000000..8fc923d
--- /dev/null
+++ b/db/migrations/20190801142302_add_dooh_user_type_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddDoohUserTypeMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $userTypeTable = $this->table('usertype');
+
+ if (!$this->fetchRow('SELECT * FROM usertype WHERE `userType` = \'DOOH\'')) {
+ $userTypeTable->insert([
+ 'userTypeId' => 4,
+ 'userType' => 'DOOH'
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190802145636_create_schedule_reminder_table_migration.php b/db/migrations/20190802145636_create_schedule_reminder_table_migration.php
new file mode 100644
index 0000000..84da399
--- /dev/null
+++ b/db/migrations/20190802145636_create_schedule_reminder_table_migration.php
@@ -0,0 +1,50 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CreateScheduleReminderTableMigration
+ */
+class CreateScheduleReminderTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+
+ // Create table
+ $table = $this->table('schedulereminder', ['id' => 'scheduleReminderId']);
+ $table
+
+ ->addColumn('eventId', 'integer')
+ ->addColumn('value', 'integer')
+ ->addColumn('type', 'integer')
+ ->addColumn('option', 'integer')
+ ->addColumn('reminderDt', 'integer')
+ ->addColumn('lastReminderDt', 'integer')
+ ->addColumn('isEmail', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addForeignKey('eventId', 'schedule', 'eventId')
+ ->save();
+
+
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20190806144729_add_show_content_from_migration.php b/db/migrations/20190806144729_add_show_content_from_migration.php
new file mode 100644
index 0000000..9d55b91
--- /dev/null
+++ b/db/migrations/20190806144729_add_show_content_from_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddShowContentFromMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $userTable = $this->table('user');
+
+ if (!$userTable->hasColumn('showContentFrom')) {
+ $userTable
+ ->addColumn('showContentFrom', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->save();
+ }
+
+ }
+}
diff --git a/db/migrations/20190823081448_add_image_processing_task_migration.php b/db/migrations/20190823081448_add_image_processing_task_migration.php
new file mode 100644
index 0000000..fc64a38
--- /dev/null
+++ b/db/migrations/20190823081448_add_image_processing_task_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddImageProcessingTaskMigration
+ */
+class AddImageProcessingTaskMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('task');
+ $table->insert([
+ [
+ 'name' => 'Image Processing',
+ 'class' => '\Xibo\XTR\ImageProcessingTask',
+ 'options' => '[]',
+ 'schedule' => '*/5 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/image-processing.task'
+ ],
+ ])->save();
+ }
+}
diff --git a/db/migrations/20190828123735_add_default_setting_resize_limit_resize_threshold_migration.php b/db/migrations/20190828123735_add_default_setting_resize_limit_resize_threshold_migration.php
new file mode 100644
index 0000000..0765e35
--- /dev/null
+++ b/db/migrations/20190828123735_add_default_setting_resize_limit_resize_threshold_migration.php
@@ -0,0 +1,58 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddDefaultSettingResizeLimitResizeThresholdMigration
+ */
+class AddDefaultSettingResizeLimitResizeThresholdMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users to maximum image resizing to 1920
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_RESIZE_LIMIT\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_RESIZE_LIMIT',
+ 'value' => 6000,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+
+ // Add a setting allowing users set the limit to identify a large image dimensions
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_RESIZE_THRESHOLD\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_RESIZE_THRESHOLD',
+ 'value' => 1920,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
+
diff --git a/db/migrations/20190903083314_add_global_setting_for_cascade_permissions_migration.php b/db/migrations/20190903083314_add_global_setting_for_cascade_permissions_migration.php
new file mode 100644
index 0000000..194f595
--- /dev/null
+++ b/db/migrations/20190903083314_add_global_setting_for_cascade_permissions_migration.php
@@ -0,0 +1,42 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddGlobalSettingForCascadePermissionsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users to set the default value for the cascade permission checkbox, default to 1
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_CASCADE_PERMISSION_CHECKB\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_CASCADE_PERMISSION_CHECKB',
+ 'value' => 1,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190905084201_add_setting_for_default_transition_type_migration.php b/db/migrations/20190905084201_add_setting_for_default_transition_type_migration.php
new file mode 100644
index 0000000..0e4e370
--- /dev/null
+++ b/db/migrations/20190905084201_add_setting_for_default_transition_type_migration.php
@@ -0,0 +1,48 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddSettingForDefaultTransitionTypeMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users to set the default value for IN and OUT Transition type
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_TRANSITION_IN\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_TRANSITION_IN',
+ 'value' => 'fadeIn',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ],
+ [
+ 'setting' => 'DEFAULT_TRANSITION_OUT',
+ 'value' => 'fadeOut',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20190905084642_add_setting_for_auto_layout_publish_migration.php b/db/migrations/20190905084642_add_setting_for_auto_layout_publish_migration.php
new file mode 100644
index 0000000..fab0224
--- /dev/null
+++ b/db/migrations/20190905084642_add_setting_for_auto_layout_publish_migration.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddSettingForAutoLayoutPublishMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting allowing users to set the Layout to be automatically published 30 min after last change to the Layout was made
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB',
+ 'value' => 0,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+
+ }
+}
diff --git a/db/migrations/20190910132520_display_move_cms_migration.php b/db/migrations/20190910132520_display_move_cms_migration.php
new file mode 100644
index 0000000..c9e8822
--- /dev/null
+++ b/db/migrations/20190910132520_display_move_cms_migration.php
@@ -0,0 +1,40 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class DisplayMoveCmsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $displayTable = $this->table('display');
+
+ // Add a two new columns to Display table, newCmsAddress and newCmsKey
+ if (!$displayTable->hasColumn('newCmsAddress')) {
+ $displayTable
+ ->addColumn('newCmsAddress', 'string', ['limit' => 1000, 'default' => null, 'null' => true])
+ ->addColumn('newCmsKey', 'string', ['limit' => 40, 'default' => null, 'null' => true])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190917093141_interrupt_layout_migration.php b/db/migrations/20190917093141_interrupt_layout_migration.php
new file mode 100644
index 0000000..561d3f9
--- /dev/null
+++ b/db/migrations/20190917093141_interrupt_layout_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class InterruptLayoutMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $scheduleTable = $this->table('schedule');
+
+ // Add a new column to Schedule table - shareOfVoice
+ if (!$scheduleTable->hasColumn('shareOfVoice')) {
+ $scheduleTable
+ ->addColumn('shareOfVoice', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => null, 'null' => true])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190918090608_add_default_setting_quick_chart_migration.php b/db/migrations/20190918090608_add_default_setting_quick_chart_migration.php
new file mode 100644
index 0000000..e83dd71
--- /dev/null
+++ b/db/migrations/20190918090608_add_default_setting_quick_chart_migration.php
@@ -0,0 +1,47 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddDefaultSettingQuickChartMigration
+ */
+class AddDefaultSettingQuickChartMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting to allow report converted to pdf
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'QUICK_CHART_URL\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'QUICK_CHART_URL',
+ 'value' => 'https://quickchart.io',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
+
+
diff --git a/db/migrations/20190919154513_add_notification_attachment_filename_non_users_migration.php b/db/migrations/20190919154513_add_notification_attachment_filename_non_users_migration.php
new file mode 100644
index 0000000..12f701e
--- /dev/null
+++ b/db/migrations/20190919154513_add_notification_attachment_filename_non_users_migration.php
@@ -0,0 +1,40 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddNotificationAttachmentFilenameNonUsersMigration
+ */
+
+class AddNotificationAttachmentFilenameNonUsersMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('notification');
+ $table
+ ->addColumn('filename', 'string', ['limit' => 1000, 'null' => true])
+ ->addColumn('nonusers', 'string', ['limit' => 1000, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20190926135518_add_setting_for_transition_auto_apply_to_layout_migration.php b/db/migrations/20190926135518_add_setting_for_transition_auto_apply_to_layout_migration.php
new file mode 100644
index 0000000..180de5f
--- /dev/null
+++ b/db/migrations/20190926135518_add_setting_for_transition_auto_apply_to_layout_migration.php
@@ -0,0 +1,56 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddSettingForTransitionAutoApplyToLayoutMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Add a setting for default value of layout->autoApplyTransitions checkbox
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_TRANSITION_AUTO_APPLY\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_TRANSITION_AUTO_APPLY',
+ 'value' => 0,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+
+ $layoutTable = $this->table('layout');
+
+ // Add a new column to Layout table - autoApplyTransitions
+ if (!$layoutTable->hasColumn('autoApplyTransitions')) {
+ $this->execute('UPDATE `layout` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `layout` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ $layoutTable
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('autoApplyTransitions', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20190926140809_install_saved_reports_and_spacer_modules_migration.php b/db/migrations/20190926140809_install_saved_reports_and_spacer_modules_migration.php
new file mode 100644
index 0000000..70345e3
--- /dev/null
+++ b/db/migrations/20190926140809_install_saved_reports_and_spacer_modules_migration.php
@@ -0,0 +1,68 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class InstallSavedReportsAndSpacerModulesMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $modules = $this->table('module');
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'savedreport\'')) {
+ $modules->insert([
+ 'module' => 'savedreport',
+ 'name' => 'Saved Reports',
+ 'enabled' => 1,
+ 'regionSpecific' => 0,
+ 'description' => 'A saved report to be stored in the library',
+ 'schemaVersion' => 1,
+ 'previewEnabled' => 0,
+ 'assignable' => 0,
+ 'render_as' => null,
+ 'class' => 'Xibo\Widget\SavedReport',
+ 'defaultDuration' => 10,
+ 'validExtensions' => 'json',
+ 'installName' => 'savedreport'
+ ])->save();
+ }
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'spacer\'')) {
+ $modules->insert([
+ 'module' => 'spacer',
+ 'name' => 'Spacer',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'Make a Region empty for a specified duration',
+ 'schemaVersion' => 1,
+ 'previewEnabled' => 0,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'class' => 'Xibo\Widget\Spacer',
+ 'defaultDuration' => 60,
+ 'validExtensions' => null,
+ 'installName' => 'spacer'
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20191001092651_add_notification_original_filename_migration.php b/db/migrations/20191001092651_add_notification_original_filename_migration.php
new file mode 100644
index 0000000..b17bc73
--- /dev/null
+++ b/db/migrations/20191001092651_add_notification_original_filename_migration.php
@@ -0,0 +1,40 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddNotificationOriginalFilenameMigration
+ */
+
+class AddNotificationOriginalFilenameMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('notification');
+ $table
+ ->addColumn('originalFileName', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->save();
+ }
+}
+
diff --git a/db/migrations/20191022101141_add_command_entity_to_permission_entity_migration.php b/db/migrations/20191022101141_add_command_entity_to_permission_entity_migration.php
new file mode 100644
index 0000000..fdf2039
--- /dev/null
+++ b/db/migrations/20191022101141_add_command_entity_to_permission_entity_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddCommandEntityToPermissionEntityMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $permissionEntity = $this->table('permissionentity');
+ $permissionEntity
+ ->insert([
+ ['entity' => 'Xibo\Entity\Command']
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20191024152519_add_schedule_exclusions_table_migration.php b/db/migrations/20191024152519_add_schedule_exclusions_table_migration.php
new file mode 100644
index 0000000..0a57a85
--- /dev/null
+++ b/db/migrations/20191024152519_add_schedule_exclusions_table_migration.php
@@ -0,0 +1,42 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddScheduleExclusionsTableMigration
+ */
+
+class AddScheduleExclusionsTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('scheduleexclusions', ['id' => 'scheduleExclusionId']);
+ $table
+ ->addColumn('eventId', 'integer')
+ ->addColumn('fromDt', 'integer')
+ ->addColumn('toDt', 'integer')
+ ->addForeignKey('eventId', 'schedule', 'eventId')
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20191122114104_fix_duplicate_module_files_migration.php b/db/migrations/20191122114104_fix_duplicate_module_files_migration.php
new file mode 100644
index 0000000..64cd686
--- /dev/null
+++ b/db/migrations/20191122114104_fix_duplicate_module_files_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class FixDuplicateModuleFilesMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // get System User
+ $getSystemUserQuery = $this->query('SELECT userId FROM user WHERE userTypeId = 1 ORDER BY userId LIMIT 1 ');
+ $getSystemUserResult = $getSystemUserQuery->fetchAll(PDO::FETCH_ASSOC);
+
+ $userId = $getSystemUserResult[0]['userId'];
+
+ // set System User as owner of the module files
+ $this->execute('UPDATE `media` SET userId = ' . $userId . ' WHERE moduleSystemFile = 1 AND userId = 0; ');
+ }
+}
diff --git a/db/migrations/20191126103120_geo_schedule_migration.php b/db/migrations/20191126103120_geo_schedule_migration.php
new file mode 100644
index 0000000..a89ae9e
--- /dev/null
+++ b/db/migrations/20191126103120_geo_schedule_migration.php
@@ -0,0 +1,40 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class GeoScheduleMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $scheduleTable = $this->table('schedule');
+
+ // Add new columns to Schedule table - isGeoAware and geoLocation
+ if (!$scheduleTable->hasColumn('isGeoAware')) {
+ $scheduleTable
+ ->addColumn('isGeoAware', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('geoLocation', 'text', ['default' => null, 'null' => true])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20191126141140_remote_data_set_csv_source_migration.php b/db/migrations/20191126141140_remote_data_set_csv_source_migration.php
new file mode 100644
index 0000000..1338362
--- /dev/null
+++ b/db/migrations/20191126141140_remote_data_set_csv_source_migration.php
@@ -0,0 +1,30 @@
+table('dataset');
+
+ // Add new columns to dataSet table - ignoreFirstRow and sourceId
+ if (!$dataSetTable->hasColumn('sourceId')) {
+ $dataSetTable
+ ->addColumn('ignoreFirstRow', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => null, 'null' => true])
+ ->addColumn('sourceId', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => null, 'null' => true])
+ ->save();
+ }
+
+ // get all existing remote dataSets
+ $getRemoteDataSetsQuery = $this->query('SELECT dataSetId, dataSet FROM dataset WHERE isRemote = 1');
+ $getRemoteDataSetsResults = $getRemoteDataSetsQuery->fetchAll(PDO::FETCH_ASSOC);
+
+ // set the sourceId to 1 (json) on all existing remote dataSets
+ foreach ($getRemoteDataSetsResults as $dataSetsResult) {
+ $this->execute('UPDATE dataset SET sourceId = 1 WHERE dataSetId = ' . $dataSetsResult['dataSetId']);
+ }
+ }
+}
diff --git a/db/migrations/20191205180116_add_playlist_dashboard_page_user_migration.php b/db/migrations/20191205180116_add_playlist_dashboard_page_user_migration.php
new file mode 100644
index 0000000..9a75f16
--- /dev/null
+++ b/db/migrations/20191205180116_add_playlist_dashboard_page_user_migration.php
@@ -0,0 +1,36 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddPlaylistDashboardPageUserMigration
+ */
+class AddPlaylistDashboardPageUserMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+
+ }
+}
+
diff --git a/db/migrations/20200107082625_display_add_resolution_migration.php b/db/migrations/20200107082625_display_add_resolution_migration.php
new file mode 100644
index 0000000..8112ae8
--- /dev/null
+++ b/db/migrations/20200107082625_display_add_resolution_migration.php
@@ -0,0 +1,43 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DisplayAddResolutionMigration
+ */
+class DisplayAddResolutionMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Add orientation and resolution to the display table
+ // these are informational fields intended to be updated by the Player during a NotifyStatus call
+ // the Player will send the resolution as two integers of width and height, which we will combine to
+ // WxH in the resolution column and use to work out the orientation.
+ $display = $this->table('display');
+ $display
+ ->addColumn('orientation', 'string', ['limit' => 10, 'null' => true, 'default' => null])
+ ->addColumn('resolution', 'string', ['limit' => 10, 'null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20200115115935_add_report_schedule_message_migration.php b/db/migrations/20200115115935_add_report_schedule_message_migration.php
new file mode 100755
index 0000000..0de41c0
--- /dev/null
+++ b/db/migrations/20200115115935_add_report_schedule_message_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddReportScheduleMessageMigration
+ */
+class AddReportScheduleMessageMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('reportschedule');
+ $table
+ ->addColumn('message', 'string', ['null' => true, 'default' => null])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20200122143630_add_released_required_file_migration.php b/db/migrations/20200122143630_add_released_required_file_migration.php
new file mode 100755
index 0000000..174b8e7
--- /dev/null
+++ b/db/migrations/20200122143630_add_released_required_file_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddReleasedRequiredFileMigration
+ */
+class AddReleasedRequiredFileMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('requiredfile');
+ $table
+ ->addColumn('released', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->save();
+ }
+}
diff --git a/db/migrations/20200122191232_add_commercial_licence_column_migration.php b/db/migrations/20200122191232_add_commercial_licence_column_migration.php
new file mode 100644
index 0000000..fa9c3dc
--- /dev/null
+++ b/db/migrations/20200122191232_add_commercial_licence_column_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddCommercialLicenceColumnMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $displayTable = $this->table('display');
+
+ // Add new column to Display table - commercialLicence. 0 - Not licensed, 1 - licensed, 2 - trial licence, 3 - not applicable
+ if (!$displayTable->hasColumn('commercialLicence')) {
+ $displayTable
+ ->addColumn('commercialLicence', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => null, 'null' => true])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20200129104944_add_engagements_stats_migration.php b/db/migrations/20200129104944_add_engagements_stats_migration.php
new file mode 100755
index 0000000..570fe87
--- /dev/null
+++ b/db/migrations/20200129104944_add_engagements_stats_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddEngagementsStatsMigration
+ */
+class AddEngagementsStatsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('stat');
+ $table
+ ->addColumn('engagements', 'text', ['default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20200130165443_countdown_module_add_migration.php b/db/migrations/20200130165443_countdown_module_add_migration.php
new file mode 100644
index 0000000..1fb3c7a
--- /dev/null
+++ b/db/migrations/20200130165443_countdown_module_add_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CountdownModuleAddMigration
+ */
+class CountdownModuleAddMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'countdown\'')) {
+ $modules = $this->table('module');
+ $modules->insert([
+ 'module' => 'countdown',
+ 'name' => 'Countdown',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'Countdown Module',
+ 'schemaVersion' => 1,
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\Countdown',
+ 'defaultDuration' => 60,
+ 'installName' => 'countdown'
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20200311110512_add_is_drawer_column_migration.php b/db/migrations/20200311110512_add_is_drawer_column_migration.php
new file mode 100644
index 0000000..d2e7e43
--- /dev/null
+++ b/db/migrations/20200311110512_add_is_drawer_column_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddIsDrawerColumnMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $regionTable = $this->table('region');
+
+ if (!$regionTable->hasColumn('isDrawer')) {
+ $regionTable
+ ->addColumn('isDrawer', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20200311110535_create_action_table_migration.php b/db/migrations/20200311110535_create_action_table_migration.php
new file mode 100644
index 0000000..1421c6a
--- /dev/null
+++ b/db/migrations/20200311110535_create_action_table_migration.php
@@ -0,0 +1,46 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class CreateActionTableMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // Create table
+ $table = $this->table('action', ['id' => 'actionId']);
+ $table
+ ->addColumn('ownerId', 'integer')
+ ->addColumn('triggerType', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('triggerCode', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('actionType', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('source', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('sourceId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('target', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('targetId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('widgetId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('layoutCode', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20200319093235_change_interrupt_layout_migration.php b/db/migrations/20200319093235_change_interrupt_layout_migration.php
new file mode 100755
index 0000000..f4350cd
--- /dev/null
+++ b/db/migrations/20200319093235_change_interrupt_layout_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class ChangeInterruptLayoutMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $scheduleTable = $this->table('schedule');
+
+ // Add a new column to Schedule table - shareOfVoice
+ $scheduleTable
+ ->changeColumn('shareOfVoice', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL, 'default' => null, 'null' => true])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20200401121544_add_system_user_setting_migration.php b/db/migrations/20200401121544_add_system_user_setting_migration.php
new file mode 100644
index 0000000..c823686
--- /dev/null
+++ b/db/migrations/20200401121544_add_system_user_setting_migration.php
@@ -0,0 +1,51 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddSystemUserSettingMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'SYSTEM_USER\'')) {
+
+ // get System User
+ $getSystemUserQuery = $this->query('SELECT userId FROM user WHERE userTypeId = 1 ORDER BY userId LIMIT 1');
+ $getSystemUserResult = $getSystemUserQuery->fetchAll(PDO::FETCH_ASSOC);
+
+ // if for some reason there are no super admin Users in the CMS, ensure that migration does not fail.
+ if (count($getSystemUserResult) >= 1) {
+ $userId = $getSystemUserResult[0]['userId'];
+
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'SYSTEM_USER',
+ 'value' => $userId,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+ }
+}
diff --git a/db/migrations/20200407143200_add_code_column_to_layout_migration.php b/db/migrations/20200407143200_add_code_column_to_layout_migration.php
new file mode 100644
index 0000000..2debded
--- /dev/null
+++ b/db/migrations/20200407143200_add_code_column_to_layout_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddCodeColumnToLayoutMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $layoutTable = $this->table('layout');
+
+ if (!$layoutTable->hasColumn('code')) {
+ $layoutTable
+ ->addColumn('code', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20200422101006_add_data_set_row_limit_migration.php b/db/migrations/20200422101006_add_data_set_row_limit_migration.php
new file mode 100644
index 0000000..dd82570
--- /dev/null
+++ b/db/migrations/20200422101006_add_data_set_row_limit_migration.php
@@ -0,0 +1,53 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddDataSetRowLimitMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // add the CMS Setting for hard limit on DataSet size.
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DATASET_HARD_ROW_LIMIT\'')) {
+
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DATASET_HARD_ROW_LIMIT',
+ 'value' => 10000,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+
+ // add two new columns to DataSet table, soft limit on DataSet size and policy on what to do when the limit is hit (stop|fifo)
+ $dataSetTable = $this->table('dataset');
+
+ if (!$dataSetTable->hasColumn('rowLimit')) {
+ $dataSetTable
+ ->addColumn('rowLimit', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('limitPolicy', 'string', ['limit' => 50, 'default' => null, 'null' => true])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20200427085958_add_report_schedule_start_end_migration.php b/db/migrations/20200427085958_add_report_schedule_start_end_migration.php
new file mode 100755
index 0000000..ab45b72
--- /dev/null
+++ b/db/migrations/20200427085958_add_report_schedule_start_end_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddReportScheduleStartEndMigration
+ */
+class AddReportScheduleStartEndMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $table = $this->table('reportschedule');
+ $table
+ ->addColumn('toDt', 'integer', ['default' => 0, 'after' => 'isActive'])
+ ->addColumn('fromDt', 'integer', ['default' => 0, 'after' => 'isActive'])
+ ->save();
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/20200530103029_display_remote_links_migration.php b/db/migrations/20200530103029_display_remote_links_migration.php
new file mode 100644
index 0000000..4a897de
--- /dev/null
+++ b/db/migrations/20200530103029_display_remote_links_migration.php
@@ -0,0 +1,72 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DisplayRemoteLinksMigration
+ */
+class DisplayRemoteLinksMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $table = $this->table('display');
+
+ if (!$table->hasColumn('teamViewerSerial')) {
+ $table
+ ->addColumn('teamViewerSerial', 'string', ['limit' => 255, 'default' => null, 'null' => true])
+ ->addColumn('webkeySerial', 'string', ['limit' => 255, 'default' => null, 'null' => true])
+ ->save();
+ }
+
+ // Go through all existing displays, and see if the teamviewerSerial or webkeySerial have been set in Display settings.
+ foreach ($this->fetchAll('SELECT displayId, overrideConfig FROM `display`') as $row) {
+ $displayId = (int)$row['displayId'];
+ $overrideConfig = $row['overrideConfig'];
+
+ if (!empty($overrideConfig)) {
+ $teamViewerSerial = null;
+ $webkeySerial = null;
+ $overrideConfig = json_decode($overrideConfig, true);
+
+ if (is_array($overrideConfig)) {
+ foreach ($overrideConfig as $value) {
+ if ($value['name'] === 'teamViewerSerial') {
+ $teamViewerSerial = $value['value'];
+ } else if ($value['name'] === 'webkeySerial') {
+ $webkeySerial = $value['value'];
+ }
+ }
+ }
+
+ if ($teamViewerSerial !== null || $webkeySerial !== null) {
+ $this->execute(sprintf('UPDATE `display` SET teamViewerSerial = %s, webkeySerial = %s WHERE displayId = %d',
+ $teamViewerSerial === null ? 'NULL' : '\'' . $teamViewerSerial . '\'',
+ $webkeySerial === null ? 'NULL' : '\'' . $webkeySerial . '\'',
+ $displayId
+ ));
+ }
+ }
+ }
+ }
+}
diff --git a/db/migrations/20200604103141_command_improvements_migration.php b/db/migrations/20200604103141_command_improvements_migration.php
new file mode 100644
index 0000000..0cc1d03
--- /dev/null
+++ b/db/migrations/20200604103141_command_improvements_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class CommandImprovementsMigration
+ */
+class CommandImprovementsMigration extends AbstractMigration
+{
+ /**
+ * @inheritDoc
+ */
+ public function change()
+ {
+ $table = $this->table('command');
+
+ if (!$table->hasColumn('availableOn')) {
+ $table
+ ->addColumn('availableOn', 'string', ['default' => null, 'null' => true, 'limit' => 50])
+ ->addColumn('commandString', 'string', ['default' => null, 'null' => true, 'limit' => 1000])
+ ->addColumn('validationString', 'string', ['default' => null, 'null' => true, 'limit' => 1000])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20200612141755_oauth_upgrade_migration.php b/db/migrations/20200612141755_oauth_upgrade_migration.php
new file mode 100644
index 0000000..e6df943
--- /dev/null
+++ b/db/migrations/20200612141755_oauth_upgrade_migration.php
@@ -0,0 +1,53 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class OauthUpgradeMigration
+ */
+class OauthUpgradeMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Delete oAuth tables which are no longer in use.
+ $this->table('oauth_access_token_scopes')->drop()->save();
+ $this->table('oauth_session_scopes')->drop()->save();
+ $this->table('oauth_refresh_tokens')->drop()->save();
+ $this->table('oauth_auth_code_scopes')->drop()->save();
+ $this->table('oauth_auth_codes')->drop()->save();
+ $this->table('oauth_access_tokens')->drop()->save();
+ $this->table('oauth_sessions')->drop()->save();
+
+ // Add a new column to the Applications table to indicate whether an app is confidential or not
+ $clients = $this->table('oauth_clients');
+ if (!$clients->hasColumn('isConfidential')) {
+ $clients
+ ->addColumn('isConfidential', 'integer', [
+ 'default' => 1,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY
+ ])
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20200720092246_add_saved_report_schema_version_migration.php b/db/migrations/20200720092246_add_saved_report_schema_version_migration.php
new file mode 100755
index 0000000..97972ae
--- /dev/null
+++ b/db/migrations/20200720092246_add_saved_report_schema_version_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class AddSavedReportSchemaVersionMigration
+ */
+class AddSavedReportSchemaVersionMigration extends AbstractMigration
+{
+ /**
+ * @inheritDoc
+ */
+ public function change()
+ {
+ $table = $this->table('saved_report');
+ $table
+ ->addColumn('schemaVersion', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->save();
+ }
+}
+
diff --git a/db/migrations/20200916134817_remove_old_screenshots_migration.php b/db/migrations/20200916134817_remove_old_screenshots_migration.php
new file mode 100644
index 0000000..829f6b2
--- /dev/null
+++ b/db/migrations/20200916134817_remove_old_screenshots_migration.php
@@ -0,0 +1,55 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class RemoveOldScreenshotsMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // add the task
+ $table = $this->table('task');
+ $table->insert([
+ [
+ 'name' => 'Remove Old Screenshots',
+ 'class' => '\Xibo\XTR\RemoveOldScreenshotsTask',
+ 'options' => '[]',
+ 'schedule' => '0 0 * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/remove-old-screenshots.task'
+ ],
+ ])->save();
+
+ // Add the ttl setting
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DISPLAY_SCREENSHOT_TTL\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DISPLAY_SCREENSHOT_TTL',
+ 'value' => 0,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20200917140227_display_group_created_modified_dates.php b/db/migrations/20200917140227_display_group_created_modified_dates.php
new file mode 100644
index 0000000..7be4e04
--- /dev/null
+++ b/db/migrations/20200917140227_display_group_created_modified_dates.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class DisplayGroupCreatedModifiedDates
+ */
+class DisplayGroupCreatedModifiedDates extends AbstractMigration
+{
+ /**
+ * @inheritDoc
+ */
+ public function change()
+ {
+ $table = $this->table('displaygroup');
+ $table
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20201007080829_add_folders_migration.php b/db/migrations/20201007080829_add_folders_migration.php
new file mode 100644
index 0000000..86402c6
--- /dev/null
+++ b/db/migrations/20201007080829_add_folders_migration.php
@@ -0,0 +1,99 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddFoldersMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ // create Folders table
+ $table = $this->table('folder', ['id' => 'folderId']);
+ $table->addColumn('folderName', 'string')
+ ->addColumn('parentId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('isRoot', 'integer', ['default' => 0, 'null' => false])
+ ->addColumn('children', 'text', ['default' => null, 'null' => true])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1, 'null' => true])
+ ->create();
+
+ // insert records to Folders table for root objects
+ $table->insert([
+ [
+ 'folderId' => 1,
+ 'folderName' => '/',
+ 'parentId' => null,
+ 'isRoot' => 1,
+ 'children' => null,
+ 'permissionsFolderId' => null
+ ]
+ ])->save();
+
+ $this->table('permissionentity')
+ ->insert([
+ ['entity' => 'Xibo\Entity\Folder']
+ ])
+ ->save();
+
+ // update media/playlist to make sure there aren't any date times with a default value which is nolonger
+ // acceptable.
+ $this->execute('UPDATE `media` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `media` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+ $this->execute('UPDATE `playlist` SET createdDt = \'1970-01-01 00:00:00\' WHERE createdDt < \'2000-01-01\'');
+ $this->execute('UPDATE `playlist` SET modifiedDt = \'1970-01-01 00:00:00\' WHERE modifiedDt < \'2000-01-01\'');
+
+ // add folderId column to root object tables
+ $this->table('media')
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->save();
+
+ $this->table('campaign')
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->save();
+
+ $this->table('playlist')
+ ->changeColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->changeColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->save();
+
+ $this->table('displaygroup')
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->save();
+
+ $this->table('dataset')
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20201007093511_features_migration.php b/db/migrations/20201007093511_features_migration.php
new file mode 100644
index 0000000..40564d7
--- /dev/null
+++ b/db/migrations/20201007093511_features_migration.php
@@ -0,0 +1,117 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class FeaturesMigration
+ */
+class FeaturesMigration extends AbstractMigration
+{
+ /**
+ * @inheritDoc
+ */
+ public function change()
+ {
+ $this->table('group')
+ ->addColumn('features', 'text', [
+ 'null' => true,
+ 'default' => null,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG
+ ])
+ ->save();
+
+ $this->table('user')
+ ->changeColumn('homePageId', 'string', [
+ 'null' => true,
+ 'default' => 'null',
+ 'limit' => '255'
+ ])
+ ->save();
+
+ $this->execute('UPDATE `user` SET homePageId = (SELECT CONCAT(pages.name, \'.view\') FROM pages WHERE user.homePageId = pages.pageId)');
+
+ // Migrate Page Permissions
+ $entityId = $this->fetchRow('SELECT entityId FROM permissionentity WHERE entity LIKE \'%Page%\'')[0];
+
+ // Match old page permissions.
+ $pages = $this->query('
+ SELECT `group`.groupId, pages.name
+ FROM permission
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ INNER JOIN `pages`
+ ON `pages`.pageId = `permission`.objectId
+ WHERE entityId = 1
+ AND view = 1
+ ORDER BY groupId;
+ ');
+
+ $groupId = 0;
+ $features = [];
+ while ($page = $pages->fetch()) {
+ // Track the group we're on
+ if ($groupId !== $page['groupId']) {
+ if ($groupId !== 0) {
+ // Save the group we've been working on.
+ $this->execute('UPDATE `group` SET features = \'' . json_encode($features) . '\' WHERE groupId = ' . $groupId);
+ }
+
+ // Clear the decks
+ $groupId = $page['groupId'];
+ $features = [];
+ }
+
+ if (in_array($page['name'], ['index', 'manual', 'clock', 'home'])) {
+ // Ignore pages which we're not interested in
+ continue;
+ } else if ($page['name'] === 'schedulenow') {
+ // Schedule Now has its own feature.
+ $features[] = 'schedule.now';
+ } else {
+ // Pluralise some pages.
+ $pageName = in_array($page['name'], ['user', 'display']) ? $page['name'] . 's' : $page['name'];
+
+ // Not all features will have a .add/.modify, but this will grant the more permissive option and get
+ // reset when a user edits.
+ $features[] = $pageName . '.view';
+ $features[] = $pageName . '.add';
+ $features[] = $pageName . '.modify';
+ }
+ }
+
+ // Do the last one
+ if ($groupId !== 0) {
+ // Save the group we've been working on.
+ $this->execute('UPDATE `group` SET features = \'' . json_encode($features) . '\' WHERE groupId = ' . $groupId);
+ }
+
+ // Delete Page Permissions
+ $this->execute('DELETE FROM permission WHERE entityId = ' . $entityId);
+
+ // Delete Page Permission Entity
+ $this->execute('DELETE FROM permissionentity WHERE entityId = ' . $entityId);
+
+ // Delete Page Table
+ $this->table('pages')->drop()->save();
+ }
+}
diff --git a/db/migrations/20201025153753_remove_old_permission_settings_migration.php b/db/migrations/20201025153753_remove_old_permission_settings_migration.php
new file mode 100644
index 0000000..5114503
--- /dev/null
+++ b/db/migrations/20201025153753_remove_old_permission_settings_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class RemoveOldPermissionSettingsMigration extends AbstractMigration
+{
+ /** @inheritdoc */
+ public function change()
+ {
+ $this->execute('DELETE FROM `setting` WHERE setting = \'MEDIA_DEFAULT\';');
+ $this->execute('DELETE FROM `setting` WHERE setting = \'LAYOUT_DEFAULT\';');
+ $this->execute('DELETE FROM `setting` WHERE setting = \'INHERIT_PARENT_PERMISSIONS\';');
+ $this->execute('DELETE FROM `setting` WHERE setting = \'DEFAULT_CASCADE_PERMISSION_CHECKB\';');
+ }
+}
diff --git a/db/migrations/20210105105646_add_mcaas_scope_and_route_migration.php b/db/migrations/20210105105646_add_mcaas_scope_and_route_migration.php
new file mode 100644
index 0000000..f870f32
--- /dev/null
+++ b/db/migrations/20210105105646_add_mcaas_scope_and_route_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddMcaasScopeAndRouteMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Just in case, check if the mcaas scope id exists, if not add it.
+ if (!$this->fetchRow('SELECT * FROM `oauth_scopes` WHERE id = \'mcaas\'')) {
+ $this->table('oauth_scopes')
+ ->insert([
+ 'id' => 'mcaas',
+ 'description' => 'Media Conversion as a Service'
+ ])
+ ->save();
+ }
+
+ // clear existing scope routes for mcaas, to make the table clean
+ $this->execute('DELETE FROM oauth_scope_routes WHERE scopeId = \'mcaas\'');
+
+ // add mcaas scope routes with slim4 pattern
+ $this->table('oauth_scope_routes')
+ ->insert([
+ ['scopeId' => 'mcaas', 'route' => '/', 'method' => 'GET'],
+ ['scopeId' => 'mcaas', 'route' => '/library/download/{id}[/{type}]', 'method' => 'GET'],
+ ['scopeId' => 'mcaas', 'route' => '/library/mcaas/{id}', 'method' => 'POST'],
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20210113134628_enable_user_sharing_feature_on_users_group_migration.php b/db/migrations/20210113134628_enable_user_sharing_feature_on_users_group_migration.php
new file mode 100644
index 0000000..8f1ed18
--- /dev/null
+++ b/db/migrations/20210113134628_enable_user_sharing_feature_on_users_group_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class EnableUserSharingFeatureOnUsersGroupMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // get current set of features assigned to Users user group
+ $features = json_decode($this->fetchRow('SELECT features FROM `group` WHERE `group` = \'Users\' ')[0]);
+
+ // add the feature to share content
+ $features[] = 'user.sharing';
+
+ $this->execute('UPDATE `group` SET features = \'' . json_encode($features) . '\' WHERE `group` = \'Users\' ');
+ }
+}
diff --git a/db/migrations/20210128143602_menu_boards_migration.php b/db/migrations/20210128143602_menu_boards_migration.php
new file mode 100644
index 0000000..e2570c8
--- /dev/null
+++ b/db/migrations/20210128143602_menu_boards_migration.php
@@ -0,0 +1,100 @@
+.
+ *
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class MenuBoardsMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+
+ // Create menu board table
+ $this->table('menu_board', ['id' => 'menuId'])
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('description', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('userId', 'integer')
+ ->addColumn('modifiedDt', 'integer', ['default' => 0])
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->save();
+
+ $this->table('menu_category', ['id' => 'menuCategoryId'])
+ ->addColumn('menuId', 'integer')
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('mediaId', 'integer', ['default' => null, 'null' => true])
+ ->addForeignKey('menuId', 'menu_board', 'menuId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+
+ $this->table('menu_product', ['id' => 'menuProductId'])
+ ->addColumn('menuCategoryId', 'integer')
+ ->addColumn('menuId', 'integer')
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('price', 'string')
+ ->addColumn('description', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addColumn('mediaId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('availability', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('allergyInfo', 'string', ['limit' => 254, 'default' => null, 'null' => true])
+ ->addForeignKey('menuId', 'menu_board', 'menuId')
+ ->addForeignKey('menuCategoryId', 'menu_category', 'menuCategoryId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+
+ $this->table('menu_product_options', ['id' => false, 'primary_key' => ['menuProductId', 'option']])
+ ->addColumn('menuProductId', 'integer')
+ ->addColumn('option', 'string', ['limit' => 254])
+ ->addColumn('value', 'text', ['null' => true])
+ ->addForeignKey('menuProductId', 'menu_product', 'menuProductId')
+ ->save();
+
+ $this->table('permissionentity')
+ ->insert([
+ ['entity' => 'Xibo\Entity\MenuBoard']
+ ])
+ ->save();
+
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'menuboard\'')) {
+ // Add disabled for now - feature preview.
+ $this->table('module')->insert([
+ 'module' => 'menuboard',
+ 'name' => 'Menu Board',
+ 'enabled' => 0,
+ 'regionSpecific' => 1,
+ 'description' => 'Module for displaying Menu Boards',
+ 'schemaVersion' => 1,
+ 'validExtensions' => '',
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\MenuBoard',
+ 'defaultDuration' => 60,
+ 'installName' => 'menuboard'
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20210201150259_user_onboarding_migration.php b/db/migrations/20210201150259_user_onboarding_migration.php
new file mode 100644
index 0000000..82d8db1
--- /dev/null
+++ b/db/migrations/20210201150259_user_onboarding_migration.php
@@ -0,0 +1,100 @@
+table('group')
+ ->addColumn('description', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 500
+ ])
+ ->addColumn('isShownForAddUser', 'integer', [
+ 'default' => 0,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY
+ ])
+ ->addColumn('defaultHomepageId', 'string', [
+ 'null' => true,
+ 'default' => null,
+ 'limit' => '255'
+ ])
+ ->save();
+
+ // If we only have the preset user groups, add some more.
+ // We add a total of 3 preset groups by this point
+ $countGroups = $this->fetchRow('
+ SELECT COUNT(*) AS cnt
+ FROM `group`
+ WHERE `group`.isUserSpecific = 0
+ AND `group`.isEveryone = 0
+ AND `group`.group NOT IN (\'Users\', \'System Notifications\', \'Playlist Dashboard User\')
+ ');
+
+ if ($countGroups['cnt'] <= 0) {
+ // These can't be translated out the box as we don't know language on install?
+ $this->table('group')
+ ->insert([
+ [
+ 'group' => 'Content Manager',
+ 'description' => 'Management of all features related to Content Creation only.',
+ 'defaultHomepageId' => 'icondashboard.view',
+ 'isUserSpecific' => 0,
+ 'isEveryone' => 0,
+ 'isSystemNotification' => 0,
+ 'isDisplayNotification' => 0,
+ 'isShownForAddUser' => 1,
+ 'features' => '["folder.view","folder.add","folder.modify","library.view","library.add","library.modify","dataset.view","dataset.add","dataset.modify","dataset.data","playlist.view","playlist.add","playlist.modify","layout.view","layout.add","layout.modify","layout.export","template.view","template.add","template.modify","resolution.view","resolution.add","resolution.modify","campaign.view","campaign.add","campaign.modify","tag.view","tag.tagging","user.profile"]'
+ ],
+ [
+ 'group' => 'Playlist Manager',
+ 'description' => 'Management of specific Playlists to edit / replace Media only.',
+ 'defaultHomepageId' => 'playlistdashboard.view',
+ 'isUserSpecific' => 0,
+ 'isEveryone' => 0,
+ 'isSystemNotification' => 0,
+ 'isDisplayNotification' => 0,
+ 'isShownForAddUser' => 1,
+ 'features' => '["user.profile","dashboard.playlist"]'
+ ],
+ [
+ 'group' => 'Schedule Manager',
+ 'description' => 'Management of all features for the purpose of Event Scheduling only.',
+ 'defaultHomepageId' => 'icondashboard.view',
+ 'isUserSpecific' => 0,
+ 'isEveryone' => 0,
+ 'isSystemNotification' => 0,
+ 'isDisplayNotification' => 0,
+ 'isShownForAddUser' => 1,
+ 'features' => '["folder.view","schedule.view","schedule.agenda","schedule.add","schedule.modify","schedule.now","daypart.view","daypart.add","daypart.modify","user.profile"]'
+ ],
+ [
+ 'group' => 'Display Manager',
+ 'description' => 'Management of all features for the purpose of Display Administration only.',
+ 'defaultHomepageId' => 'statusdashboard.view',
+ 'isUserSpecific' => 0,
+ 'isEveryone' => 0,
+ 'isSystemNotification' => 0,
+ 'isDisplayNotification' => 1,
+ 'isShownForAddUser' => 1,
+ 'features' => '["report.view","displays.reporting","proof-of-play","folder.view","folder.add","folder.modify","tag.tagging","schedule.view","schedule.agenda","displays.view","displays.add","displays.modify","displaygroup.view","displaygroup.add","displaygroup.modify","displayprofile.view","displayprofile.add","displayprofile.modify","playersoftware.view","command.view","user.profile","notification.centre","notification.add","notification.modify","dashboard.status","log.view"]'
+ ],
+ ])
+ ->save();
+ } else {
+ // We should add something, otherwise we won't have any options when it comes to the new wizard
+ $this->execute('UPDATE `group` SET isShownForAddUser = 1 WHERE isUserSpecific = 0 AND isEveryone = 0 AND isSystemNotification = 0');
+ }
+ }
+}
diff --git a/db/migrations/20210305131937_fix_duplicate_tags_migration.php b/db/migrations/20210305131937_fix_duplicate_tags_migration.php
new file mode 100644
index 0000000..e0943a4
--- /dev/null
+++ b/db/migrations/20210305131937_fix_duplicate_tags_migration.php
@@ -0,0 +1,98 @@
+.
+ */
+
+/**
+ * Remove empty and duplicate tags from tag table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Class FixDuplicateTagsMigration
+ */
+class FixDuplicateTagsMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Get the duplicate tags leaving lowest ids and any empty/odd tags
+ $tagsToCheck = $this->query('SELECT DISTINCT t1.tagId, t1.tag FROM `tag` t1 INNER JOIN `tag` t2 WHERE (t1.tagId > t2.tagId AND t1.tag = t2.tag) OR (t1.tag = \'\' OR t1.tag = \',\' OR t1.tag = \' \') ');//phpcs:ignore
+ $tagsToCheckData = $tagsToCheck->fetchAll(PDO::FETCH_ASSOC);
+
+ // only execute this code if any tags we need to delete were found
+ if (count($tagsToCheckData) > 0) {
+ $tagsToRemove = [];
+ $tagLinksToRemove = [];
+ $tagLinksToUpdate = [];
+ foreach ($tagsToCheckData as $row) {
+ if ($row['tag'] == '' || $row['tag'] == ' ' || $row['tag'] == ',') {
+ $tagLinksToRemove[] = $row['tagId'];
+ } else {
+ $tagLinksToUpdate[$row['tagId']] = $row['tag'];
+ }
+ $tagsToRemove[] = $row['tagId'];
+ }
+
+ if (count($tagLinksToRemove) > 0) {
+ $tagLinksString = implode(',', $tagLinksToRemove);
+ // remove links to the tags we want to remove from lktag tables
+ $this->execute('DELETE FROM `lktagcampaign` WHERE tagId IN (' . $tagLinksString .')');
+ $this->execute('DELETE FROM `lktagdisplaygroup` WHERE tagId IN (' . $tagLinksString .')');
+ $this->execute('DELETE FROM `lktaglayout` WHERE tagId IN (' . $tagLinksString .')');
+ $this->execute('DELETE FROM `lktagmedia` WHERE tagId IN (' . $tagLinksString .')');
+ $this->execute('DELETE FROM `lktagplaylist` WHERE tagId IN (' . $tagLinksString .')');
+ }
+
+ // for duplicate tags, find the original (lowest id) and update lktag tables with it
+ foreach ($tagLinksToUpdate as $tagId => $tag) {
+ $lowestIdQuery = $this->query(
+ 'SELECT tagId FROM tag WHERE `tag`.tag = :tag ORDER BY tagId LIMIT 1',
+ ['tag' => $tag]
+ );
+ $lowestIdResult = $lowestIdQuery->fetchAll(PDO::FETCH_ASSOC);
+ $lowestId = $lowestIdResult[0]['tagId'];
+
+ $this->handleTagLinks('campaignId', 'lktagcampaign', $tagId, $lowestId);
+ $this->handleTagLinks('displayGroupId', 'lktagdisplaygroup', $tagId, $lowestId);
+ $this->handleTagLinks('layoutId', 'lktaglayout', $tagId, $lowestId);
+ $this->handleTagLinks('mediaId', 'lktagmedia', $tagId, $lowestId);
+ $this->handleTagLinks('playlistId', 'lktagplaylist', $tagId, $lowestId);
+ }
+
+ $tagsToRemoveString = implode(',', $tagsToRemove);
+ // finally remove the tag itself from tag table
+ $this->execute('DELETE FROM `tag` WHERE tagId IN (' . $tagsToRemoveString .')');
+ }
+ }
+
+ private function handleTagLinks($id, $table, $tagId, $lowestId)
+ {
+ foreach ($this->fetchAll('SELECT ' . $id . ' FROM ' . $table . ' WHERE tagId = ' . $tagId . ';') as $object) {
+ if (!$this->fetchRow('SELECT * FROM ' . $table . ' WHERE tagId =' . $lowestId . ' AND ' . $id . ' = ' . $object[$id] .';')) {//phpcs:ignore
+ $this->execute('UPDATE ' . $table . ' SET tagId = ' . $lowestId . ' WHERE tagId = ' . $tagId . ' AND ' . $id . ' = ' . $object[$id] .';');//phpcs:ignore
+ } else {
+ $this->execute('DELETE FROM ' . $table . ' WHERE tagId = ' . $tagId . ' AND '. $id . ' = ' . $object[$id] .';');//phpcs:ignore
+ }
+ }
+ }
+}
diff --git a/db/migrations/20210407111756_remove_report_name_column_from_saved_report_migration.php b/db/migrations/20210407111756_remove_report_name_column_from_saved_report_migration.php
new file mode 100644
index 0000000..5f4b389
--- /dev/null
+++ b/db/migrations/20210407111756_remove_report_name_column_from_saved_report_migration.php
@@ -0,0 +1,18 @@
+table('saved_report')
+ ->removeColumn('reportName')
+ ->save();
+ }
+}
diff --git a/db/migrations/20210421142731_remove_maintenance_key_migration.php b/db/migrations/20210421142731_remove_maintenance_key_migration.php
new file mode 100644
index 0000000..61a4aaf
--- /dev/null
+++ b/db/migrations/20210421142731_remove_maintenance_key_migration.php
@@ -0,0 +1,15 @@
+execute('DELETE FROM `setting` WHERE setting = \'MAINTENANCE_KEY\' ');
+ }
+}
diff --git a/db/migrations/20210601104441_add_user_agent_to_data_set_migration.php b/db/migrations/20210601104441_add_user_agent_to_data_set_migration.php
new file mode 100644
index 0000000..dca5f83
--- /dev/null
+++ b/db/migrations/20210601104441_add_user_agent_to_data_set_migration.php
@@ -0,0 +1,18 @@
+table('dataset')
+ ->addColumn('userAgent', 'text', ['null' => true, 'default' => null, 'after' => 'customHeaders'])
+ ->save();
+ }
+}
diff --git a/db/migrations/20210603114231_new_calendar_type_migration.php b/db/migrations/20210603114231_new_calendar_type_migration.php
new file mode 100644
index 0000000..e087c47
--- /dev/null
+++ b/db/migrations/20210603114231_new_calendar_type_migration.php
@@ -0,0 +1,37 @@
+table('module')->insert(
+ [
+ 'module' => 'agenda',
+ 'name' => 'Agenda',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'A module for displaying an agenda based on an iCal feed',
+ 'schemaVersion' => 1,
+ 'validExtensions' => '',
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\Agenda',
+ 'defaultDuration' => 60,
+ 'installName' => 'agenda'
+ ]
+ )->save();
+
+ // Update widgets type to the new agenda
+ $this->execute('UPDATE `widget` SET widget.type = \'agenda\' WHERE widget.type = \'calendar\' ');
+ }
+}
diff --git a/db/migrations/20210608142925_add_orientation_to_resolution_and_media_migration.php b/db/migrations/20210608142925_add_orientation_to_resolution_and_media_migration.php
new file mode 100644
index 0000000..5b7d7ec
--- /dev/null
+++ b/db/migrations/20210608142925_add_orientation_to_resolution_and_media_migration.php
@@ -0,0 +1,48 @@
+table('layout')
+ ->addColumn('orientation', 'string', ['limit' => 10, 'after' => 'height', 'null' => true, 'default' => null])
+ ->save();
+
+ $layouts = $this->query('SELECT layoutId, width, height FROM layout');
+ $layouts = $layouts->fetchAll(PDO::FETCH_ASSOC);
+
+ // go through existing Layouts and determine the orientation
+ foreach ($layouts as $layout) {
+ $orientation = ($layout['width'] >= $layout['height']) ? 'landscape' : 'portrait';
+ $this->execute('UPDATE `layout` SET `orientation` = "'. $orientation .'" WHERE layoutId = '. $layout['layoutId']);
+ }
+
+ $this->table('media')
+ ->addColumn('orientation', 'string', ['limit' => 10, 'null' => true, 'default' => null])
+ ->save();
+
+ $this->table('task')
+ ->insert([
+ [
+ 'name' => 'Media Orientation',
+ 'class' => '\Xibo\XTR\MediaOrientationTask',
+ 'options' => '[]',
+ 'schedule' => '*/5 * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/media-orientation.task',
+ 'pid' => null,
+ 'lastRunDt' => 0,
+ 'lastRunDuration' => 0,
+ 'lastRunExitCode' => 0
+ ],
+ ])->save();
+ }
+}
diff --git a/db/migrations/20210611122809_add_purge_list_table_migration.php b/db/migrations/20210611122809_add_purge_list_table_migration.php
new file mode 100644
index 0000000..718973c
--- /dev/null
+++ b/db/migrations/20210611122809_add_purge_list_table_migration.php
@@ -0,0 +1,96 @@
+.
+ */
+
+/**
+ * Blacklist revamp migration, remove Blacklist table, change bandwidthtype.
+ * Adds two new tables purge_list and player_faults.
+ * Adds new setting for default purge_list ttl.
+ * Adds new task that will clear expired entries in purge_list table.
+ *
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class AddPurgeListTableMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ if ($this->hasTable('blacklist')) {
+ $this->table('blacklist')->drop()->save();
+ }
+
+ $this->execute('UPDATE `bandwidthtype` SET `name` = \'Report Fault\' WHERE `name` = \'Blacklist\';');
+
+ // Add Purge List table, this will store media information and expiry date, sent to Players in RF.
+ $this->table('purge_list', ['id' => 'purgeListId'])
+ ->addColumn('mediaId', 'integer')
+ ->addColumn('storedAs', 'string')
+ ->addColumn('expiryDate', 'datetime', ['null' => true, 'default'=> null])
+ ->save();
+
+ // Add Player Faults table, this will store alerts details sent by Players in Soap6.
+ $this->table('player_faults', ['id' => 'playerFaultId'])
+ ->addColumn('displayId', 'integer')
+ ->addColumn('incidentDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('expires', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('code', 'string', ['null' => true, 'default' => null])
+ ->addColumn('reason', 'string', ['null' => true, 'default' => null])
+ ->addColumn('scheduleId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('layoutId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('regionId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('mediaId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('widgetId', 'integer', ['null' => true, 'default' => null])
+ ->addForeignKey('displayId', 'display', 'displayId')
+ ->save();
+
+ // Add a setting allowing users to set default Purge List TTL in days, default to a week.
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'DEFAULT_PURGE_LIST_TTL\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_PURGE_LIST_TTL',
+ 'value' => 7,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+
+ // Add a task that will clean-up Purge List table and remove entries older than specified TTL.
+ $this->table('task')
+ ->insert([
+ [
+ 'name' => 'Purge List Cleanup',
+ 'class' => '\Xibo\XTR\PurgeListCleanupTask',
+ 'options' => '[]',
+ 'schedule' => '0 0 * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/purge-list-cleanup.task',
+ 'pid' => 0,
+ 'lastRunDt' => 0,
+ 'lastRunDuration' => 0,
+ 'lastRunExitCode' => 0
+ ],
+ ])->save();
+ }
+}
diff --git a/db/migrations/20210806122814_add_number_of_items_to_playlist_migration.php b/db/migrations/20210806122814_add_number_of_items_to_playlist_migration.php
new file mode 100644
index 0000000..cebff3f
--- /dev/null
+++ b/db/migrations/20210806122814_add_number_of_items_to_playlist_migration.php
@@ -0,0 +1,53 @@
+table('playlist')
+ ->addColumn('maxNumberOfItems', 'integer', ['default' => null, 'null' => true, 'after' => 'filterMediaTags'])
+ ->save();
+
+ // get the count of Widgets in dynamic Playlists, first element will be the highest
+ $widgetCount = $this->query('
+ SELECT COUNT(*) AS cnt, widget.playlistId
+ FROM `widget`
+ WHERE playlistId IN (SELECT playlistId FROM `playlist` WHERE isDynamic = 1)
+ GROUP BY widget.playlistId
+ ORDER BY COUNT(*) DESC
+ ');
+ $widgetCountData = $widgetCount->fetchAll(PDO::FETCH_ASSOC);
+
+ // compare our proposed default values with the highest Widget count on dynamic Playlist in the system
+ $default = max($widgetCountData[0]['cnt'] ?? 0, 30);
+ $max = max($widgetCountData[0]['cnt'] ?? 0, 100);
+
+ // set all dynamic Playlists maxNumberOfItems to the default value
+ $this->execute('UPDATE `playlist` SET maxNumberOfItems = ' . $default. ' WHERE isDynamic = 1');
+
+ // insert new Settings with default and max values calculated above
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER',
+ 'value' => $default,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ],
+ [
+ 'setting' => 'DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT',
+ 'value' => $max,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+}
diff --git a/db/migrations/20210813094723_add_cycle_based_playback_option_to_campaign_migration.php b/db/migrations/20210813094723_add_cycle_based_playback_option_to_campaign_migration.php
new file mode 100644
index 0000000..94c9bb5
--- /dev/null
+++ b/db/migrations/20210813094723_add_cycle_based_playback_option_to_campaign_migration.php
@@ -0,0 +1,19 @@
+table('campaign')
+ ->addColumn('cyclePlaybackEnabled', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,'default' => 0])
+ ->addColumn('playCount', 'integer', ['default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20210817105702_add_custom_separator_to_dataset_migration.php b/db/migrations/20210817105702_add_custom_separator_to_dataset_migration.php
new file mode 100644
index 0000000..19084c4
--- /dev/null
+++ b/db/migrations/20210817105702_add_custom_separator_to_dataset_migration.php
@@ -0,0 +1,18 @@
+table('dataset')
+ ->addColumn('csvSeparator', 'string', ['limit' => 5, 'after' => 'limitPolicy', 'default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20210820100520_add_action_event_type_migration.php b/db/migrations/20210820100520_add_action_event_type_migration.php
new file mode 100644
index 0000000..e750b1f
--- /dev/null
+++ b/db/migrations/20210820100520_add_action_event_type_migration.php
@@ -0,0 +1,20 @@
+table('schedule')
+ ->addColumn('actionTriggerCode', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('actionType', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('actionLayoutCode', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20210901134913_add_ip_address_column_to_audit_log_migration.php b/db/migrations/20210901134913_add_ip_address_column_to_audit_log_migration.php
new file mode 100644
index 0000000..7a76884
--- /dev/null
+++ b/db/migrations/20210901134913_add_ip_address_column_to_audit_log_migration.php
@@ -0,0 +1,18 @@
+table('auditlog')
+ ->addColumn('ipAddress', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20210901150615_add_tooltip_and_is_required_columns_migration.php b/db/migrations/20210901150615_add_tooltip_and_is_required_columns_migration.php
new file mode 100644
index 0000000..4e3fab6
--- /dev/null
+++ b/db/migrations/20210901150615_add_tooltip_and_is_required_columns_migration.php
@@ -0,0 +1,19 @@
+table('datasetcolumn')
+ ->addColumn('tooltip', 'string', ['limit' => 100, 'null' => true, 'default' => null])
+ ->addColumn('isRequired', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->save();
+ }
+}
diff --git a/db/migrations/20211109130951_fix_orphaned_display_groups_migration.php b/db/migrations/20211109130951_fix_orphaned_display_groups_migration.php
new file mode 100644
index 0000000..efbfcf3
--- /dev/null
+++ b/db/migrations/20211109130951_fix_orphaned_display_groups_migration.php
@@ -0,0 +1,16 @@
+execute('UPDATE `displaygroup` SET userId = (SELECT userId FROM `user` WHERE userTypeId = 1 LIMIT 1) WHERE userId NOT IN (SELECT userId FROM `user`)');
+ }
+}
diff --git a/db/migrations/20211109134929_fix_playlist_manager_user_group_migration.php b/db/migrations/20211109134929_fix_playlist_manager_user_group_migration.php
new file mode 100644
index 0000000..219abbf
--- /dev/null
+++ b/db/migrations/20211109134929_fix_playlist_manager_user_group_migration.php
@@ -0,0 +1,50 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Fix default homepage for Playlist Manager User group
+ * Fix feature for old Playlist Dashboard User group
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class FixPlaylistManagerUserGroupMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->execute('UPDATE `group` SET defaultHomepageId = \'playlistdashboard.view\' WHERE `group` = \'Playlist Manager\'');
+
+ // get current set of features assigned to Playlist Dashboard User's group
+ $row = $this->fetchRow('SELECT features FROM `group` WHERE `group` = \'Playlist Dashboard User\' ');
+ if (is_array($row)) {
+ $features = json_decode($row[0]);
+
+ // add the feature to Playlist Dashboard
+ $features[] = 'dashboard.playlist';
+
+ // Update features and default homepage for Playlist Dashboard User UserGroup
+ $this->execute('UPDATE `group` SET features = \'' . json_encode($features) . '\' WHERE `group` = \'Playlist Dashboard User\' ');
+ $this->execute('UPDATE `group` SET defaultHomepageId = \'playlistdashboard.view\' WHERE `group` = \'Playlist Dashboard User\' ');
+ }
+ }
+}
diff --git a/db/migrations/20211109141925_dataset_add_option_to_truncate_on_no_new_data_migration.php b/db/migrations/20211109141925_dataset_add_option_to_truncate_on_no_new_data_migration.php
new file mode 100644
index 0000000..364a92d
--- /dev/null
+++ b/db/migrations/20211109141925_dataset_add_option_to_truncate_on_no_new_data_migration.php
@@ -0,0 +1,18 @@
+table('dataset')
+ ->addColumn('truncateOnEmpty', 'integer', ['default' => 0, 'after' => 'clearRate', 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+ }
+}
diff --git a/db/migrations/20211116153103_connectors_migration.php b/db/migrations/20211116153103_connectors_migration.php
new file mode 100644
index 0000000..d1b92b7
--- /dev/null
+++ b/db/migrations/20211116153103_connectors_migration.php
@@ -0,0 +1,53 @@
+.
+ */
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add the new connectors table and first connector (pixabay)
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ConnectorsMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $table = $this->table('connectors', ['id' => 'connectorId']);
+ $table
+ ->addColumn('className', 'string', ['limit' => 254])
+ ->addColumn('settings', 'text', [
+ 'limit' => MysqlAdapter::TEXT_REGULAR,
+ 'default' => null,
+ 'null' => true
+ ])
+ ->addColumn('isEnabled', 'integer', [
+ 'default' => 0,
+ 'null' => false,
+ 'limit' => MysqlAdapter::INT_TINY
+ ])
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\PixabayConnector',
+ 'isEnabled' => 0
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20211231141457_add_more_tag_filtering_options_migration.php b/db/migrations/20211231141457_add_more_tag_filtering_options_migration.php
new file mode 100644
index 0000000..9a5cd6d
--- /dev/null
+++ b/db/migrations/20211231141457_add_more_tag_filtering_options_migration.php
@@ -0,0 +1,25 @@
+table('playlist')
+ ->addColumn('filterExactTags', 'integer', ['after' => 'filterMediaTags', 'default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('filterLogicalOperator', 'string', ['after' => 'filterExactTags', 'default' => 'OR', 'limit' => 3])
+ ->save();
+
+ $this->table('displaygroup')
+ ->addColumn('dynamicCriteriaExactTags', 'integer', ['after' => 'dynamicCriteriaTags', 'default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->addColumn('dynamicCriteriaLogicalOperator', 'string', ['after' => 'dynamicCriteriaExactTags', 'default' => 'OR', 'limit' => 3])
+ ->save();
+ }
+}
diff --git a/db/migrations/20211231153355_add_is_custom_to_display_profile_migration.php b/db/migrations/20211231153355_add_is_custom_to_display_profile_migration.php
new file mode 100644
index 0000000..a8086bb
--- /dev/null
+++ b/db/migrations/20211231153355_add_is_custom_to_display_profile_migration.php
@@ -0,0 +1,18 @@
+table('displayprofile')
+ ->addColumn('isCustom', 'integer', ['default' => 0, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220117150212_add_layout_exchange_connector_migration.php b/db/migrations/20220117150212_add_layout_exchange_connector_migration.php
new file mode 100644
index 0000000..cfd7732
--- /dev/null
+++ b/db/migrations/20220117150212_add_layout_exchange_connector_migration.php
@@ -0,0 +1,48 @@
+.
+ */
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new column to connectors table (isVisible)
+ * Add a new connector (layoutExchange) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddLayoutExchangeConnectorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('connectors')
+ ->addColumn('isVisible', 'integer', [
+ 'default' => 1,
+ 'null' => false,
+ 'limit' => MysqlAdapter::INT_TINY
+ ])
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\XiboExchangeConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220119124436_add_code_column_to_menu_boards_migration.php b/db/migrations/20220119124436_add_code_column_to_menu_boards_migration.php
new file mode 100644
index 0000000..bc00162
--- /dev/null
+++ b/db/migrations/20220119124436_add_code_column_to_menu_boards_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new column (code) to menu_board, menu_category and menu_product tables
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddCodeColumnToMenuBoardsMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('menu_board')
+ ->addColumn('code', 'string', ['limit' => 50, 'after' => 'description', 'null' => true, 'default' => null])
+ ->save();
+
+ $this->table('menu_category')
+ ->addColumn('code', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->save();
+
+ $this->table('menu_product')
+ ->addColumn('code', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220131151346_add_date_format_to_data_set_column_migration.php b/db/migrations/20220131151346_add_date_format_to_data_set_column_migration.php
new file mode 100644
index 0000000..8fbcfda
--- /dev/null
+++ b/db/migrations/20220131151346_add_date_format_to_data_set_column_migration.php
@@ -0,0 +1,17 @@
+table('datasetcolumn')
+ ->addColumn('dateFormat', 'string', ['null' => true, 'default' => null, 'limit' => 20])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220201163832_report_logo_migration.php b/db/migrations/20220201163832_report_logo_migration.php
new file mode 100755
index 0000000..e8926f2
--- /dev/null
+++ b/db/migrations/20220201163832_report_logo_migration.php
@@ -0,0 +1,47 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Report Logo Migration
+ * ---------------------
+ * Add a setting for whether to show the logo on PDF exports
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ReportLogoMigration extends AbstractMigration
+{
+ /**
+ * @inheritDoc
+ */
+ public function change()
+ {
+ $this->table('setting')
+ ->insert([
+ 'setting' => 'REPORTS_EXPORT_SHOW_LOGO',
+ 'value' => 1,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220203145712_applications_tweaks_migration.php b/db/migrations/20220203145712_applications_tweaks_migration.php
new file mode 100644
index 0000000..034199d
--- /dev/null
+++ b/db/migrations/20220203145712_applications_tweaks_migration.php
@@ -0,0 +1,133 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Application Tweaks Migration
+ * ---------------------
+ * Add new aauth link table for storing authorised applications
+ * Add more scopes and routes
+ * Add more fields to oauth_clients table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ApplicationsTweaksMigration extends AbstractMigration
+{
+ public function change()
+ {
+ // make sure the oauth_client table uses utf8.
+ // without this change, for old CMS instances where it was using latin1,
+ // it will cause issues creating FK in the new oauth_lkclientuser table.
+ $this->execute('
+ ALTER TABLE `oauth_clients` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
+ CHANGE COLUMN `id` `id` VARCHAR(254) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ CHANGE COLUMN `secret` `secret` VARCHAR(254) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ CHANGE COLUMN `name` `name` VARCHAR(254) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL
+ ');
+
+ $this->table('oauth_lkclientuser', ['id' => 'lkClientUserId'])
+ ->addColumn('clientId', 'string', ['length' => 254])
+ ->addColumn('userId', 'integer')
+ ->addColumn('approvedDate', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('approvedIp', 'string', ['null' => true, 'default' => null])
+ ->addIndex(['clientId', 'userId'], ['unique' => true])
+ ->addForeignKey('clientId', 'oauth_clients', 'id')
+ ->addForeignKey('userId', 'user', 'userId')
+ ->create();
+
+ $this->table('oauth_scopes')
+ ->addColumn('useRegex', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->insert([
+ [
+ 'id' => 'design',
+ 'description' => 'Access to Library, Layouts, Playlists and Widgets',
+ 'useRegex' => 1
+ ],
+ [
+ 'id' => 'designDelete',
+ 'description' => 'Access to deleting content from Library, Layouts, Playlists and Widgets',
+ 'useRegex' => 1
+ ],
+ [
+ 'id' => 'displays',
+ 'description' => 'Access to Displays and Display Groups',
+ 'useRegex' => 1
+ ],
+ [
+ 'id' => 'displaysDelete',
+ 'description' => 'Access to deleting Displays and Display Groups',
+ 'useRegex' => 0
+ ],
+ [
+ 'id' => 'schedule',
+ 'description' => 'Access to Scheduling',
+ 'useRegex' => 1
+ ],
+ [
+ 'id' => 'scheduleDelete',
+ 'description' => 'Access to deleting Scheduled Events',
+ 'useRegex' => 1
+ ],
+ [
+ 'id' => 'datasets',
+ 'description' => 'Access to DataSets',
+ 'useRegex' => 1
+ ],
+ [
+ 'id' => 'datasetsDelete',
+ 'description' => 'Access to deleting DataSets',
+ 'useRegex' => 1
+ ]
+ ])->save();
+
+ $this->table('oauth_scope_routes')
+ ->changeColumn('method', 'string', ['limit' => 50])
+ ->save();
+
+ $this->table('oauth_scope_routes')
+ ->insert([
+ ['scopeId' => 'design', 'route' => '/library', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'design', 'route' => '/layout', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'design', 'route' => '/playlist', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'designDelete', 'route' => '/library', 'method' => 'DELETE'],
+ ['scopeId' => 'designDelete', 'route' => '/layout', 'method' => 'DELETE'],
+ ['scopeId' => 'designDelete', 'route' => '/playlist', 'method' => 'DELETE'],
+ ['scopeId' => 'displays', 'route' => '/display', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'displays', 'route' => '/displaygroup', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'displaysDelete', 'route' => '/display/{id}', 'method' => 'DELETE'],
+ ['scopeId' => 'displaysDelete', 'route' => '/displaygroup/{id}', 'method' => 'DELETE'],
+ ['scopeId' => 'schedule', 'route' => '/schedule', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'scheduleDelete', 'route' => '/schedule', 'method' => 'DELETE'],
+ ['scopeId' => 'datasets', 'route' => '/dataset', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'datasetsDelete', 'route' => '/dataset', 'method' => 'DELETE']
+ ])->save();
+
+ $this->table('oauth_clients')
+ ->addColumn('description', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->addColumn('logo', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->addColumn('coverImage', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->addColumn('companyName', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->addColumn('termsUrl', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->addColumn('privacyUrl', 'string', ['limit' => 254, 'null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220207160047_tidy_calendar_module_types.php b/db/migrations/20220207160047_tidy_calendar_module_types.php
new file mode 100644
index 0000000..79c5e68
--- /dev/null
+++ b/db/migrations/20220207160047_tidy_calendar_module_types.php
@@ -0,0 +1,66 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Update calendar/agenda widgets to respect the new type naming
+ * Insert of update the calendar-advanced table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class TidyCalendarModuleTypes extends AbstractMigration
+{
+ public function change()
+ {
+ $this->execute('UPDATE `widget` SET widget.type = \'calendaradvanced\' WHERE widget.type = \'calendar\' ');
+ $this->execute('UPDATE `widget` SET widget.type = \'calendar\' WHERE widget.type = \'agenda\' ');
+
+ // Is the new calendar module installed (we didn't auto install it)
+ if (count($this->fetchAll('SELECT * FROM `module` WHERE module = \'calendar\' ')) > 0) {
+ // Rename calendar type to advanced
+ $this->execute('UPDATE `module` SET module = \'calendaradvanced\', installName = \'calendaradvanced\' WHERE module = \'calendar\' ');
+ } else {
+ // Add the new calendar advanced type
+ $this->table('module')->insert(
+ [
+ 'module' => 'calendaradvanced',
+ 'name' => 'Calendar',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'A module for displaying a calendar based on an iCal feed',
+ 'schemaVersion' => 1,
+ 'validExtensions' => '',
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\Calendar',
+ 'defaultDuration' => 60,
+ 'installName' => 'calendaradvanced'
+ ]
+ )->save();
+ }
+
+ // Rename agenda type to calendar
+ $this->execute('UPDATE `module` SET module = \'calendar\', installName = \'calendar\' WHERE module = \'agenda\' ');
+ }
+}
diff --git a/db/migrations/20220224130122_add_resolution_routes_to_design_scope_migration.php b/db/migrations/20220224130122_add_resolution_routes_to_design_scope_migration.php
new file mode 100644
index 0000000..957369d
--- /dev/null
+++ b/db/migrations/20220224130122_add_resolution_routes_to_design_scope_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add Resolution Routes to the Design scope
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddResolutionRoutesToDesignScopeMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->execute('UPDATE `oauth_scopes` SET `description` = \'Full account access\' WHERE `id` = \'all\';');
+ $this->execute('UPDATE `oauth_scopes` SET `description` = \'Access to Library, Layouts, Playlists, Widgets and Resolutions\' WHERE `id` = \'design\';');
+ $this->execute('UPDATE `oauth_scopes` SET `description` = \'Access to deleting content from Library, Layouts, Playlists, Widgets and Resolutions\' WHERE `id` = \'designDelete\';');
+
+ $this->table('oauth_scope_routes')
+ ->insert([
+ ['scopeId' => 'design', 'route' => '/resolution', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'designDelete', 'route' => '/resolution', 'method' => 'DELETE'],
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220225105237_merge_weather_widgets.php b/db/migrations/20220225105237_merge_weather_widgets.php
new file mode 100755
index 0000000..6e247f9
--- /dev/null
+++ b/db/migrations/20220225105237_merge_weather_widgets.php
@@ -0,0 +1,36 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Remove weather tiles module to merge both weather widgets into one
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class MergeWeatherWidgets extends AbstractMigration
+{
+ public function change()
+ {
+ // Delete weather tiles table
+ $this->execute('DELETE FROM `module` WHERE module = \'weather\' ');
+ }
+}
diff --git a/db/migrations/20220302152503_layout_remove_orientation_migration.php b/db/migrations/20220302152503_layout_remove_orientation_migration.php
new file mode 100644
index 0000000..ea01cda
--- /dev/null
+++ b/db/migrations/20220302152503_layout_remove_orientation_migration.php
@@ -0,0 +1,39 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Remove weather tiles module to merge both weather widgets into one
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class LayoutRemoveOrientationMigration extends AbstractMigration
+{
+ /**
+ * @inheritDoc
+ */
+ public function change()
+ {
+ $table = $this->table('layout');
+ $table->removeColumn('orientation')->save();
+ }
+}
diff --git a/db/migrations/20220307134554_add_world_clock_migration.php b/db/migrations/20220307134554_add_world_clock_migration.php
new file mode 100644
index 0000000..7d3fd9f
--- /dev/null
+++ b/db/migrations/20220307134554_add_world_clock_migration.php
@@ -0,0 +1,53 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add the world clock module if it doesn't already exist
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddWorldClockMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ if (!$this->fetchRow('SELECT * FROM module WHERE module = \'worldclock\'')) {
+ $modules = $this->table('module');
+ $modules->insert([
+ 'module' => 'worldclock',
+ 'name' => 'World Clock',
+ 'enabled' => 1,
+ 'regionSpecific' => 1,
+ 'description' => 'WorldClock Module',
+ 'schemaVersion' => 1,
+ 'previewEnabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'viewPath' => '../modules',
+ 'class' => 'Xibo\Widget\WorldClock',
+ 'defaultDuration' => 60,
+ 'installName' => 'worldclock'
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20220330111440_modules_table_ver_four_migration.php b/db/migrations/20220330111440_modules_table_ver_four_migration.php
new file mode 100755
index 0000000..22eeb1a
--- /dev/null
+++ b/db/migrations/20220330111440_modules_table_ver_four_migration.php
@@ -0,0 +1,150 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Convert the modules table for v4
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ModulesTableVerFourMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Rename the old table.
+ $this->table('module')->rename('module_old')->save();
+
+ // Add our new table
+ $this->table('module', ['id' => false, 'primary_key' => ['moduleId']])
+ ->addColumn('moduleId', 'string', [
+ 'limit' => 35,
+ 'null' => false
+ ])
+ ->addColumn('enabled', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
+ 'default' => 0
+ ])
+ ->addColumn('previewEnabled', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
+ 'default' => 1
+ ])
+ ->addColumn('defaultDuration', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'default' => 60
+ ])
+ ->addColumn('settings', 'text', [
+ 'default' => null,
+ 'null' => true
+ ])
+ ->save();
+
+ // Pull through old modules, having a guess at their names.
+ try {
+ //phpcs:disable
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`)
+ SELECT LOWER(CASE WHEN `class` LIKE \'%Custom%\' THEN IFNULL(installname, module) ELSE CONCAT(\'core-\', `module`) END),
+ MAX(`enabled`),
+ MAX(`previewEnabled`),
+ MAX(`defaultDuration`),
+ MAX(`settings`)
+ FROM `module_old`
+ GROUP BY LOWER(CASE WHEN `class` LIKE \'%Custom%\' THEN IFNULL(installname, module) ELSE CONCAT(\'core-\', `module`) END)
+ ');
+ //phpcs:enable
+
+ // Handle any specific renames
+ $this->execute('UPDATE `module` SET moduleId = \'core-rss-ticker\' WHERE moduleId = \'core-ticker\'');
+ $this->execute('UPDATE `module` SET moduleId = \'core-dataset\' WHERE moduleId = \'core-datasetticker\'');
+ $this->execute('DELETE FROM `module` WHERE moduleId = \'core-datasetview\'');
+
+ // Drop the old table
+ $this->table('module_old')->drop()->save();
+
+ // Add more v4 modules
+
+ // Check clock and update/add v4 modules
+ $clock = $this->fetchRow('SELECT * FROM `module` WHERE `moduleId` = \'core-clock\'');
+ $enabled = $clock['enabled'];
+ $previewEnabled = $clock['previewEnabled'];
+ $defaultDuration = $clock['defaultDuration'];
+ $settings = $clock['settings'];
+
+ $this->execute('UPDATE `module` SET `moduleId` = \'core-clock-analogue\',
+ enabled = ' .$enabled. ', previewEnabled = ' .$previewEnabled. ',
+ defaultDuration = ' .$defaultDuration. ' WHERE `moduleId` = \'core-clock\';');
+
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`) VALUES
+ (\'core-clock-digital\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\'),
+ (\'core-clock-flip\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\');
+ ');
+
+ // Check countdown and update/add v4 modules
+ $countdown = $this->fetchRow('SELECT * FROM `module` WHERE `moduleId` = \'core-countdown\'');
+ $enabled = $countdown['enabled'];
+ $previewEnabled = $countdown['previewEnabled'];
+ $defaultDuration = $countdown['defaultDuration'];
+ $settings = $countdown['settings'];
+
+ $this->execute('UPDATE `module` SET `moduleId` = \'core-countdown-clock\',
+ enabled = ' .$enabled. ', previewEnabled = ' .$previewEnabled. ',
+ defaultDuration = ' .$defaultDuration. ' WHERE `moduleId` = \'core-countdown\';');
+
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`) VALUES
+ (\'core-countdown-days\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\'),
+ (\'core-countdown-table\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\'),
+ (\'core-countdown-text\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\');
+ ');
+
+ // Check worldclock and update/add v4 modules
+ $worldclock = $this->fetchRow('SELECT * FROM `module` WHERE `moduleId` = \'core-worldclock\'');
+ $enabled = $worldclock['enabled'];
+ $previewEnabled = $worldclock['previewEnabled'];
+ $defaultDuration = $worldclock['defaultDuration'];
+ $settings = $worldclock['settings'];
+
+ $this->execute('UPDATE `module` SET `moduleId` = \'core-worldclock-analogue\',
+ enabled = ' .$enabled. ', previewEnabled = ' .$previewEnabled. ',
+ defaultDuration = ' .$defaultDuration. ' WHERE `moduleId` = \'core-worldclock\';');
+
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`) VALUES
+ (\'core-worldclock-custom\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\'),
+ (\'core-worldclock-digital-date\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\'),
+ (\'core-worldclock-digital-text\', '.$enabled.', '.$previewEnabled.', '.$defaultDuration.', \''.$settings.'\');
+ ');
+
+ // Add new modules.
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`) VALUES
+ (\'core-canvas\', \'1\', \'1\', \'60\', \'[]\'),
+ (\'core-mastodon\', \'1\', \'1\', \'60\', \'[]\'),
+ (\'core-countdown-custom\', \'1\', \'1\', \'60\', \'[]\')
+ ');
+ } catch (Exception $e) {
+ // Keep the old module table around for diagnosis and just continue on.
+ }
+ }
+}
diff --git a/db/migrations/20220512130000_add_twitter_connector_migration.php b/db/migrations/20220512130000_add_twitter_connector_migration.php
new file mode 100644
index 0000000..2e735cc
--- /dev/null
+++ b/db/migrations/20220512130000_add_twitter_connector_migration.php
@@ -0,0 +1,42 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new column to connectors table (isVisible)
+ * Add a new connector (layoutExchange) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddTwitterConnectorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ /*$this->table('connectors')
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\TwitterConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ])
+ ->save();*/
+ }
+}
diff --git a/db/migrations/20220512155400_region_type_migration.php b/db/migrations/20220512155400_region_type_migration.php
new file mode 100644
index 0000000..f9c3e15
--- /dev/null
+++ b/db/migrations/20220512155400_region_type_migration.php
@@ -0,0 +1,56 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new column to connectors table (isVisible)
+ * Add a new connector (layoutExchange) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class RegionTypeMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('region')
+ ->addColumn('type', 'string', [
+ 'null' => false,
+ 'default' => 'playlist',
+ 'limit' => 10
+ ])
+ ->save();
+
+ // Update existing regions with the correct region type.
+ $this->execute('
+ UPDATE region SET type = \'frame\'
+ WHERE regionId IN (
+ SELECT regionId
+ FROM playlist
+ INNER JOIN widget
+ ON playlist.playlistId = widget.playlistId
+ WHERE IFNULL(regionId, 0) > 0
+ GROUP BY regionId
+ HAVING COUNT(widget.widgetId) = 1
+ )
+ ');
+ }
+}
diff --git a/db/migrations/20220520175400_display_media_migration.php b/db/migrations/20220520175400_display_media_migration.php
new file mode 100644
index 0000000..453ad9b
--- /dev/null
+++ b/db/migrations/20220520175400_display_media_migration.php
@@ -0,0 +1,49 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add the new display_media which will represent the link between module
+ * files and the displays they should be served to
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class DisplayMediaMigration extends AbstractMigration
+{
+ public function change()
+ {
+ // Link Media to Display
+ $table = $this->table('display_media');
+ $table
+ ->addColumn('displayId', 'integer')
+ ->addColumn('mediaId', 'integer')
+ ->addColumn('modifiedAt', 'timestamp', [
+ 'null' => true,
+ 'default' => null,
+ 'update' => 'CURRENT_TIMESTAMP'
+ ])
+ ->addIndex(['displayId', 'mediaId'], ['unique' => true])
+ ->addForeignKey('displayId', 'display', 'displayId')
+ ->addForeignKey('mediaId', 'media', 'mediaId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20220903153600_requiredfile_dependency_migration.php b/db/migrations/20220903153600_requiredfile_dependency_migration.php
new file mode 100644
index 0000000..c756862
--- /dev/null
+++ b/db/migrations/20220903153600_requiredfile_dependency_migration.php
@@ -0,0 +1,60 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add additional columns to required file so that we can handle dependencies separately to media
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class RequiredfileDependencyMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $table = $this->table('requiredfile');
+ $table
+ ->addColumn('fileType', 'string', [
+ 'limit' => 50,
+ 'null' => true,
+ 'default' => null
+ ])
+ ->addColumn('realId', 'string', [
+ 'limit' => 254,
+ 'null' => true,
+ 'default' => null
+ ])
+ ->save();
+
+ $this->table('bandwidthtype')
+ ->insert([
+ [
+ 'bandwidthTypeId' => 12,
+ 'name' => 'Get Data'
+ ],
+ [
+ 'bandwidthTypeId' => 13,
+ 'name' => 'Get Dependency'
+ ],
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220906085300_three_two_zero_migration.php b/db/migrations/20220906085300_three_two_zero_migration.php
new file mode 100644
index 0000000..1d9a99a
--- /dev/null
+++ b/db/migrations/20220906085300_three_two_zero_migration.php
@@ -0,0 +1,97 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Minor DB changes for 3.2.0
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ThreeTwoZeroMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // New Ip Address field
+ $this->table('display')
+ ->addColumn('lanIpAddress', 'string', [
+ 'limit' => 50,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->save();
+
+ // Add the Dashboards connector, disabled.
+ $this->table('connectors')
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\XiboDashboardConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ])
+ ->save();
+
+ // Dynamic criteria tags
+ $this->table('displaygroup')
+ ->changeColumn('dynamicCriteria', 'text', [
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->save();
+
+ $this->table('playlist')
+ ->changeColumn('filterMediaName', 'text', [
+ 'null' => true,
+ 'default' => null
+ ])
+ ->changeColumn('filterMediaTags', 'text', [
+ 'null' => true,
+ 'default' => null
+ ])
+ ->save();
+
+ // Resolution on media
+ $this->table('media')
+ ->addColumn('width', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_MEDIUM,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('height', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_MEDIUM,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->save();
+
+ // Setting for folders.
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE setting = \'FOLDERS_ALLOW_SAVE_IN_ROOT\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'FOLDERS_ALLOW_SAVE_IN_ROOT',
+ 'value' => '1',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20220907135653_add_logical_operator_name_migration.php b/db/migrations/20220907135653_add_logical_operator_name_migration.php
new file mode 100644
index 0000000..de7041e
--- /dev/null
+++ b/db/migrations/20220907135653_add_logical_operator_name_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Minor DB changes for 3.2.0 - add logicalOperator columns to playlist and displayGroup tables
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddLogicalOperatorNameMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->table('playlist')
+ ->renameColumn('filterLogicalOperator', 'filterMediaTagsLogicalOperator')
+ ->addColumn('filterMediaNameLogicalOperator', 'string', ['after' => 'filterMediaName', 'default' => 'OR', 'limit' => 3])
+ ->save();
+
+ $this->table('displaygroup')
+ ->renameColumn('dynamicCriteriaLogicalOperator', 'dynamicCriteriaTagsLogicalOperator')
+ ->addColumn('dynamicCriteriaLogicalOperator', 'string', ['after' => 'dynamicCriteria', 'default' => 'OR', 'limit' => 3])
+ ->save();
+ }
+}
diff --git a/db/migrations/20220907143500_user_home_folder_migration.php b/db/migrations/20220907143500_user_home_folder_migration.php
new file mode 100644
index 0000000..f6d2074
--- /dev/null
+++ b/db/migrations/20220907143500_user_home_folder_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add home folder to users.
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UserHomeFolderMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Add a new field for the home folder
+ $this->table('user')
+ ->addColumn('homeFolderId', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => false,
+ 'default' => 1,
+ ])
+ ->addForeignKey('homeFolderId', 'folder', 'folderId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20220915100902_add_fonts_table_migration.php b/db/migrations/20220915100902_add_fonts_table_migration.php
new file mode 100644
index 0000000..08f323a
--- /dev/null
+++ b/db/migrations/20220915100902_add_fonts_table_migration.php
@@ -0,0 +1,122 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add new table for fonts
+ * Insert Standard fonts into fonts table
+ * Convert existing font records in media table
+ * Delete font records in media table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddFontsTableMigration extends AbstractMigration
+{
+
+ public function change()
+ {
+ // create new table for fonts
+ $table = $this->table('fonts');
+ $table
+ ->addColumn('createdAt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedAt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedBy', 'string', ['null' => true, 'default' => null])
+ ->addColumn('name', 'string')
+ ->addColumn('fileName', 'string')
+ ->addColumn('familyName', 'string')
+ ->addColumn('size', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG,
+ 'default' => null,
+ 'null' => true,
+ ])
+ ->addColumn('md5', 'string', ['limit' => 32, 'default' => null, 'null' => true])
+ ->create();
+
+ // create fonts sub-folder in the library location
+ $libraryLocation = $this->fetchRow('
+ SELECT `setting`.`value`
+ FROM `setting`
+ WHERE `setting`.`setting` = \'LIBRARY_LOCATION\'')[0] ?? null;
+
+ // New installs won't have a library location yet (if they are non-docker).
+ if (!empty($libraryLocation)) {
+ if (!file_exists($libraryLocation . 'fonts')) {
+ mkdir($libraryLocation . 'fonts', 0777, true);
+ }
+
+ // Fix any potential incorrect dates in modifiedDt
+ $this->execute('UPDATE `media` SET `media`.modifiedDt = `media`.createdDt WHERE `media`.modifiedDt < \'2000-01-01\'');//phpcs:ignore
+
+ // get all existing font records in media table and convert them
+ foreach ($this->fetchAll('SELECT mediaId, name, type, createdDt, modifiedDt, storedAs, md5, fileSize, originalFileName FROM `media` WHERE media.type = \'font\'') as $fontMedia) {//phpcs:ignore
+ $table
+ ->insert([
+ 'createdAt' => $fontMedia['createdDt'] ?: null,
+ 'modifiedAt' => $fontMedia['modifiedDt'] ?: null,
+ 'name' => $fontMedia['name'],
+ 'fileName' => $fontMedia['originalFileName'],
+ 'familyName' => strtolower(preg_replace(
+ '/\s+/',
+ ' ',
+ preg_replace('/\d+/u', '', $fontMedia['name'])
+ )),
+ 'size' => $fontMedia['fileSize'],
+ 'md5' => $fontMedia['md5']
+ ])
+ ->save();
+
+ // move the stored files with new id to fonts folder
+ rename(
+ $libraryLocation . $fontMedia['storedAs'],
+ $libraryLocation . 'fonts/' . $fontMedia['originalFileName']
+ );
+
+ // remove any potential widget links (there shouldn't be any)
+ $this->execute('DELETE FROM `lkwidgetmedia` WHERE `lkwidgetmedia`.`mediaId` = '
+ . $fontMedia['mediaId']);
+
+ // remove any potential tagLinks from font media files
+ // otherwise we risk failing the migration on the next step when we remove records from media table.
+ $this->execute('DELETE FROM `lktagmedia` WHERE `lktagmedia`.`mediaId` = '
+ . $fontMedia['mediaId']);
+
+ // font files assigned directly to the Display.
+ $this->execute('DELETE FROM `lkmediadisplaygroup` WHERE `lkmediadisplaygroup`.mediaId = '
+ . $fontMedia['mediaId']);
+ }
+
+ // delete font records from media table
+ $this->execute('DELETE FROM `media` WHERE `media`.`type` = \'font\'');
+ }
+
+ // delete "module" record for fonts
+ $this->execute('DELETE FROM `module` WHERE `module`.`moduleId` = \'core-font\'');
+
+ // remove fonts.css file records from media table
+ $this->execute('DELETE FROM `media` WHERE `media`.`originalFileName` = \'fonts.css\' AND `media`.`type` = \'module\' AND `media`.`moduleSystemFile` = 1 ');//phpcs:ignore
+
+ // remove fonts.css from library folder
+ if (file_exists($libraryLocation . 'fonts.css')) {
+ @unlink($libraryLocation . 'fonts.css');
+ }
+ }
+}
diff --git a/db/migrations/20220928091249_player_software_refactor_migration.php b/db/migrations/20220928091249_player_software_refactor_migration.php
new file mode 100644
index 0000000..99c61f1
--- /dev/null
+++ b/db/migrations/20220928091249_player_software_refactor_migration.php
@@ -0,0 +1,214 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Remove playersoftware from media table
+ * Add more columns to player_software table
+ * Adjust versionMediaId
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class PlayerSoftwareRefactorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ // add some new columns
+ $table = $this->table('player_software');
+ $table
+ ->addColumn('createdAt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedAt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedBy', 'string', ['null' => true, 'default' => null])
+ ->addColumn('fileName', 'string')
+ ->addColumn(
+ 'size',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true]
+ )
+ ->addColumn('md5', 'string', ['limit' => 32, 'default' => null, 'null' => true])
+ ->save();
+
+ // create playersoftware sub-folder in the library location
+ $libraryLocation = $this->fetchRow('
+ SELECT `setting`.`value`
+ FROM `setting`
+ WHERE `setting`.`setting` = \'LIBRARY_LOCATION\'')[0] ?? null;
+
+ // New installs won't have a library location yet (if they are non-docker).
+ if (!empty($libraryLocation)) {
+ if (!file_exists($libraryLocation . 'playersoftware')) {
+ mkdir($libraryLocation . 'playersoftware', 0777, true);
+ }
+
+ // get all existing playersoftware records in media table and convert them
+ $sql = '
+ SELECT `mediaId`,
+ `name`,
+ `type`,
+ `createdDt`,
+ `modifiedDt`,
+ `storedAs`,
+ `md5`,
+ `fileSize`,
+ `originalFileName`
+ FROM `media`
+ WHERE `media`.`type` = \'playersoftware\'
+ ';
+
+ $updateSql = '
+ UPDATE `player_software`
+ SET `createdAt` = :createdAt,
+ `modifiedAt` = :modifiedAt,
+ `fileName` = :fileName,
+ `size` = :size,
+ `md5` = :md5
+ WHERE `mediaId` = :mediaId
+ ';
+
+ foreach ($this->fetchAll($sql) as $playersoftwareMedia) {
+ $this->execute($updateSql, [
+ 'mediaId' => $playersoftwareMedia['mediaId'],
+ 'createdAt' => $playersoftwareMedia['createdDt'] ?: null,
+ 'modifiedAt' => $playersoftwareMedia['modifiedDt'] ?: null,
+ 'fileName' => $playersoftwareMedia['originalFileName'],
+ 'size' => $playersoftwareMedia['fileSize'],
+ 'md5' => $playersoftwareMedia['md5']
+ ]);
+
+ // move the stored files with new id to fonts folder
+ rename(
+ $libraryLocation . $playersoftwareMedia['storedAs'],
+ $libraryLocation . 'playersoftware/' . $playersoftwareMedia['originalFileName']
+ );
+
+ // remove any potential widget links (there shouldn't be any)
+ $this->execute('DELETE FROM `lkwidgetmedia` WHERE `lkwidgetmedia`.`mediaId` = '
+ . $playersoftwareMedia['mediaId']);
+
+ // remove any potential tagLinks from playersoftware media files
+ // unlikely that there will be any, but just in case.
+ $this->execute('DELETE FROM `lktagmedia` WHERE `lktagmedia`.mediaId = '
+ . $playersoftwareMedia['mediaId']);
+
+ // player software files assigned directly to the Display.
+ $this->execute('DELETE FROM `lkmediadisplaygroup` WHERE `lkmediadisplaygroup`.mediaId = '
+ . $playersoftwareMedia['mediaId']);
+ }
+
+ // update versionMediaId in displayProfiles config
+ foreach ($this->fetchAll('SELECT displayProfileId, config FROM `displayprofile`') as $displayProfile) {
+ // check if there is anything in the config
+ if (!empty($displayProfile['config']) && $displayProfile['config'] !== '[]') {
+ $config = json_decode($displayProfile['config'], true);
+ for ($i = 0; $i < count($config); $i++) {
+ $configValue = $config[$i]['value'] ?? 0;
+
+ if (!empty($configValue) && $config[$i]['name'] === 'versionMediaId') {
+ $row = $this->fetchRow(
+ 'SELECT mediaId, versionId
+ FROM `player_software`
+ WHERE `player_software`.mediaId =' . $configValue
+ );
+
+ $config[$i]['value'] = $row['versionId'];
+ $sql = 'UPDATE `displayprofile` SET config = :config
+ WHERE `displayprofile`.displayProfileId = :displayProfileId';
+ $params = [
+ 'config' => json_encode($config),
+ 'displayProfileId' => $displayProfile['displayProfileId']
+ ];
+ $this->execute($sql, $params);
+ }
+ }
+ }
+ }
+
+ // update versionMediaId in display overrideConfig
+ foreach ($this->fetchAll('SELECT displayId, overrideConfig FROM `display`') as $display) {
+ // check if there is anything in the config
+ if (!empty($display['overrideConfig']) && $display['overrideConfig'] !== '[]') {
+ $overrideConfig = json_decode($display['overrideConfig'], true);
+ for ($i = 0; $i < count($overrideConfig); $i++) {
+ $overrideConfigValue = $overrideConfig[$i]['value'] ?? 0;
+ if (!empty($overrideConfigValue) && $overrideConfig[$i]['name'] === 'versionMediaId') {
+ $row = $this->fetchRow(
+ 'SELECT mediaId, versionId
+ FROM `player_software`
+ WHERE `player_software`.mediaId =' . $overrideConfigValue
+ );
+
+ $overrideConfig[$i]['value'] = $row['versionId'];
+ $sql = 'UPDATE `display` SET overrideConfig = :overrideConfig
+ WHERE `display`.displayId = :displayId';
+
+ $params = [
+ 'overrideConfig' => json_encode($overrideConfig),
+ 'displayId' => $display['displayId'],
+ ];
+
+ $this->execute($sql, $params);
+ }
+ }
+ }
+ }
+ }
+
+ // we are finally done
+ if ($this->checkIndexExists('player_software', 'player_software_ibfk_1')) {
+ $table->removeIndexByName('player_software_ibfk_1');
+ }
+
+ // remove mediaId column and index/key
+ if ($table->hasForeignKey('mediaId')) {
+ $table->dropForeignKey('mediaId');
+ }
+
+ $table
+ ->removeColumn('mediaId')
+ ->save();
+
+ // delete playersoftware records from media table
+ $this->execute('DELETE FROM `media` WHERE media.type = \'playersoftware\'');
+ // delete module record for playersoftware
+ $this->execute('DELETE FROM `module` WHERE `module`.moduleId = \'core-playersoftware\'');
+ }
+
+ /**
+ * Check if an index exists
+ * @param string $table
+ * @param $indexName
+ * @return bool
+ */
+ private function checkIndexExists($table, $indexName): bool
+ {
+ // Use the information schema to see if the index exists or not.
+ // all users have permission to the information schema
+ $sql = '
+ SELECT *
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE `table_schema` = DATABASE()
+ AND `table_name` = \'' . $table . '\'
+ AND `index_name` = \'' . $indexName . '\'';
+
+ return count($this->fetchAll($sql)) > 0;
+ }
+}
diff --git a/db/migrations/20221013103000_advertising_connectors_migration.php b/db/migrations/20221013103000_advertising_connectors_migration.php
new file mode 100644
index 0000000..6e9dec1
--- /dev/null
+++ b/db/migrations/20221013103000_advertising_connectors_migration.php
@@ -0,0 +1,49 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Minor DB changes for 3.2.0
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AdvertisingConnectorsMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->table('connectors')
+ ->insert([
+ [
+ 'className' => '\\Xibo\\Connector\\XiboSspConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ],
+ [
+ 'className' => '\\Xibo\\Connector\\XiboAudienceReportingConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ],
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20221024082400_ad_campaign_migration.php b/db/migrations/20221024082400_ad_campaign_migration.php
new file mode 100755
index 0000000..48a7791
--- /dev/null
+++ b/db/migrations/20221024082400_ad_campaign_migration.php
@@ -0,0 +1,199 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Ad Campaigns
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AdCampaignMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ // Schedule table gets some new fields.
+ // one to set the maximum number of plays per hour
+ // the other to indicate if the schedule is part of a parent campaign
+ $this->table('schedule')
+ ->addColumn('maxPlaysPerHour', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL,
+ 'default' => 0,
+ ])
+ ->addColumn('parentCampaignId', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->save();
+
+ // More information on each campaign.
+ $this->table('campaign')
+ ->addColumn('type', 'string', [
+ 'default' => 'list',
+ 'null' => false,
+ 'limit' => 10
+ ])
+ ->addColumn('startDt', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('endDt', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('targetType', 'string', [
+ 'length' => '6',
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('target', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('plays', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'default' => 0,
+ ])
+ ->addColumn('spend', 'decimal', [
+ 'precision' => 30,
+ 'scale' => 4,
+ 'default' => 0,
+ ])
+ ->addColumn('impressions', 'decimal', [
+ 'precision' => 30,
+ 'scale' => 4,
+ 'default' => 0,
+ ])
+ ->addColumn('lastPopId', 'string', [
+ 'length' => 50,
+ 'default' => null,
+ 'null' => true,
+ ])
+ ->addColumn('listPlayOrder', 'string', [
+ 'length' => 6,
+ 'default' => 'round',
+ 'null' => false,
+ ])
+ ->addColumn('ref1', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 254
+ ])
+ ->addColumn('ref2', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 254
+ ])
+ ->addColumn('ref3', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 254
+ ])
+ ->addColumn('ref4', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 254
+ ])
+ ->addColumn('ref5', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 254
+ ])
+ ->addColumn('createdAt', 'timestamp', [
+ 'default' => 'CURRENT_TIMESTAMP',
+ 'update' => ''
+ ])
+ ->addColumn('modifiedAt', 'timestamp', [
+ 'null' => true,
+ 'default' => null,
+ 'update' => 'CURRENT_TIMESTAMP'
+ ])
+ ->addColumn('modifiedBy', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'default' => 0,
+ ])
+ ->save();
+
+ // Direct links between the campaign and its target displays/groups
+ $this->table('lkcampaigndisplaygroup', [
+ 'id' => false,
+ 'primary_key' => ['campaignId', 'displayGroupId']
+ ])
+ ->addColumn('campaignId', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ ])
+ ->addColumn('displayGroupId', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ ])
+ ->addForeignKey('campaignId', 'campaign', 'campaignId')
+ ->addForeignKey('displayGroupId', 'displaygroup', 'displayGroupId')
+ ->save();
+
+ // Links between the campaign and the layout are extended to cover scheduling and geo fences.
+ $this->table('lkcampaignlayout')
+ ->addColumn('dayPartId', 'integer', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ ])
+ ->addColumn('daysOfWeek', 'string', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => 50,
+ ])
+ ->addColumn('geoFence', 'text', [
+ 'default' => null,
+ 'null' => true,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_MEDIUM,
+ ])
+ ->save();
+
+ // Add a task for keeping ad campaigns up to date
+ $this->table('task')
+ ->insert([
+ 'name' => 'Campaign Scheduler',
+ 'class' => '\Xibo\XTR\CampaignSchedulerTask',
+ 'options' => '[]',
+ 'schedule' => '45 * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/campaign-scheduler.task',
+ 'pid' => 0,
+ 'lastRunDt' => 0,
+ 'lastRunDuration' => 0,
+ 'lastRunExitCode' => 0
+ ])
+ ->save();
+
+ // Add parentCampaignId to the stats table.
+ $this->table('stat')
+ ->addColumn('parentCampaignId', 'integer', [
+ 'default' => 0,
+ 'null' => false,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20221101090337_create_display_type_table_migration.php b/db/migrations/20221101090337_create_display_type_table_migration.php
new file mode 100755
index 0000000..692766e
--- /dev/null
+++ b/db/migrations/20221101090337_create_display_type_table_migration.php
@@ -0,0 +1,50 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add display types
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class CreateDisplayTypeTableMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $displayType = $this->table('display_types', ['id' => 'displayTypeId']);
+ $displayType->addColumn('displayType', 'string', ['limit' => 100])
+ ->insert([
+ ['displayTypeId' => 1, 'displayType' => 'Billboard'],
+ ['displayTypeId' => 2, 'displayType' => 'Kiosk'],
+ ['displayTypeId' => 3, 'displayType' => 'LED Matrix / LED Video Wall'],
+ ['displayTypeId' => 4, 'displayType' => 'Monitor / Other'],
+ ['displayTypeId' => 5, 'displayType' => 'Projector'],
+ ['displayTypeId' => 6, 'displayType' => 'Shelf-edge Display'],
+ ['displayTypeId' => 7, 'displayType' => 'Smart Mirror'],
+ ['displayTypeId' => 8, 'displayType' => 'TV / Panel'],
+ ['displayTypeId' => 9, 'displayType' => 'Tablet'],
+ ['displayTypeId' => 10, 'displayType' => 'Totem'],
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20221101130018_add_display_meta_data_migration.php b/db/migrations/20221101130018_add_display_meta_data_migration.php
new file mode 100755
index 0000000..7387d7a
--- /dev/null
+++ b/db/migrations/20221101130018_add_display_meta_data_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add display metadata in display and display group
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddDisplayMetaDataMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->table('display')
+ ->addColumn('screenSize', 'integer', ['after' => 'display', 'default' => null, 'null' => true])
+ ->addColumn('displayTypeId', 'integer', ['after' => 'display', 'default' => null, 'null' => true])
+ ->addColumn('impressionsPerPlay', 'decimal', ['precision' => 10, 'scale' => 4, 'after' => 'lanIpAddress', 'default' => null, 'null' => true])
+ ->addColumn('costPerPlay', 'decimal', ['precision' => 10, 'scale' => 4, 'after' => 'lanIpAddress', 'default' => null, 'null' => true])
+ ->addColumn('isOutdoor', 'integer', ['after' => 'lanIpAddress', 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('customId', 'string', ['after' => 'lanIpAddress', 'limit' => 254, 'default' => null, 'null' => true])
+ ->addForeignKey('displayTypeId', 'display_types', 'displayTypeId')
+ ->save();
+
+ $this->table('displaygroup')
+ ->addColumn('ref5', 'text', ['after' => 'bandwidthLimit', 'default' => null, 'null' => true])
+ ->addColumn('ref4', 'text', ['after' => 'bandwidthLimit', 'default' => null, 'null' => true])
+ ->addColumn('ref3', 'text', ['after' => 'bandwidthLimit', 'default' => null, 'null' => true])
+ ->addColumn('ref2', 'text', ['after' => 'bandwidthLimit', 'default' => null, 'null' => true])
+ ->addColumn('ref1', 'text', ['after' => 'bandwidthLimit', 'default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20221104095722_create_display_location_type_table_migration.php b/db/migrations/20221104095722_create_display_location_type_table_migration.php
new file mode 100755
index 0000000..152dfa3
--- /dev/null
+++ b/db/migrations/20221104095722_create_display_location_type_table_migration.php
@@ -0,0 +1,42 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add display venue metadata
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+
+class CreateDisplayLocationTypeTableMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->table('display')
+ ->addColumn('venueId', 'integer', ['after' => 'screenSize', 'default' => null, 'null' => true])
+ ->addColumn('isMobile', 'integer', ['after' => 'screenSize', 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0])
+ ->addColumn('address', 'text', ['after' => 'screenSize', 'default' => null, 'null' => true])
+ ->addColumn('languages', 'text', ['after' => 'screenSize', 'default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20221124120259_add_layout_id_to_action_table_migration.php b/db/migrations/20221124120259_add_layout_id_to_action_table_migration.php
new file mode 100644
index 0000000..2fcfc96
--- /dev/null
+++ b/db/migrations/20221124120259_add_layout_id_to_action_table_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add layoutId column to Action table
+ * Populate layoutId for existing Actions
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddLayoutIdToActionTableMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $this->table('action')
+ ->addColumn('layoutId', 'integer', ['null' => true, 'default' => null])
+ ->save();
+
+ // Set layoutId to sourceId if the source is Layout
+ $this->execute('UPDATE `action` SET layoutId = `action`.sourceId WHERE `action`.source = \'layout\' ');
+
+ // Set layoutId to a layout corresponding with the regionId (sourceId) for region source
+ foreach ($this->fetchAll('SELECT `region`.layoutId, `region`.regionId FROM `action` INNER JOIN `region` ON `action`.sourceId = `region`.regionId AND `action`.source = \'region\' ') as $regionAction) {
+ $this->execute('UPDATE `action` SET `action`.layoutId =' . $regionAction['layoutId'] . ' WHERE `action`.sourceId = ' . $regionAction['regionId'] . ' AND `action`.source = \'region\' ');
+ }
+
+ // Set layoutId to Layout corresponding with widgetId (sourceId) for widget source
+ foreach ($this->fetchAll('SELECT `region`.layoutId, `widget`.widgetId FROM `action` INNER JOIN `widget` ON `action`.sourceId = `widget`.widgetId AND `action`.source = \'widget\' INNER JOIN `playlist` ON `widget`.playlistId = `playlist`.playlistId INNER JOIN `region` ON `playlist`.regionId = `region`.regionId') as $widgetAction) {
+ $this->execute('UPDATE `action` SET `action`.layoutId =' . $widgetAction['layoutId'] . ' WHERE `action`.sourceId = ' . $widgetAction['widgetId'] . ' AND `action`.source = \'widget\' ');
+ }
+ }
+}
diff --git a/db/migrations/20230118151031_add_alpha_vantage_connector_migration.php b/db/migrations/20230118151031_add_alpha_vantage_connector_migration.php
new file mode 100644
index 0000000..53a1a06
--- /dev/null
+++ b/db/migrations/20230118151031_add_alpha_vantage_connector_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new connector (AlphaVantage) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddAlphaVantageConnectorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('connectors')
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\AlphaVantageConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230130114907_update_campaign_display_column_data_type_migration.php b/db/migrations/20230130114907_update_campaign_display_column_data_type_migration.php
new file mode 100755
index 0000000..2cbe0e3
--- /dev/null
+++ b/db/migrations/20230130114907_update_campaign_display_column_data_type_migration.php
@@ -0,0 +1,54 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Update column type in display and campaign
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpdateCampaignDisplayColumnDataTypeMigration extends AbstractMigration
+{
+ /** @inheritDoc */
+ public function change()
+ {
+ $display = $this->table('display');
+ $display
+ ->changeColumn('impressionsPerPlay', 'decimal', ['precision' => 10, 'scale' => 4, 'default' => null, 'null' => true])
+ ->changeColumn('costPerPlay', 'decimal', ['precision' => 10, 'scale' => 4, 'default' => null, 'null' => true])
+ ->save();
+
+ $campaign = $this->table('campaign');
+ $campaign
+ ->changeColumn('spend', 'decimal', [
+ 'precision' => 30,
+ 'scale' => 4,
+ 'default' => 0,
+ ])
+ ->changeColumn('impressions', 'decimal', [
+ 'precision' => 30,
+ 'scale' => 4,
+ 'default' => 0,
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230209132720_add_open_weather_map_connector_migration.php b/db/migrations/20230209132720_add_open_weather_map_connector_migration.php
new file mode 100644
index 0000000..55415f6
--- /dev/null
+++ b/db/migrations/20230209132720_add_open_weather_map_connector_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new connector (Open Weather Map) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddOpenWeatherMapConnectorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('connectors')
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\OpenWeatherMapConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230214135035_remove_dooh_user_type_migration.php b/db/migrations/20230214135035_remove_dooh_user_type_migration.php
new file mode 100755
index 0000000..da47e0b
--- /dev/null
+++ b/db/migrations/20230214135035_remove_dooh_user_type_migration.php
@@ -0,0 +1,45 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Remove Dooh User Type and User showContentFrom
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class RemoveDoohUserTypeMigration extends AbstractMigration
+{
+ public function change()
+ {
+ // Update all DOOH users to super admins
+ $this->execute('UPDATE `user` SET userTypeId = 1 WHERE userTypeId = 4');
+
+ $this->execute('DELETE FROM `usertype` WHERE `userType` = \'DOOH\' ');
+
+ $userTable = $this->table('user');
+ if ($userTable->hasColumn('showContentFrom')) {
+ $userTable
+ ->removeColumn('showContentFrom')
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20230220213618_update_play_list_timeline_help_link_migration.php b/db/migrations/20230220213618_update_play_list_timeline_help_link_migration.php
new file mode 100755
index 0000000..419b53e
--- /dev/null
+++ b/db/migrations/20230220213618_update_play_list_timeline_help_link_migration.php
@@ -0,0 +1,36 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Update playlist timeline help link
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpdatePlayListTimelineHelpLinkMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->execute('UPDATE `help` SET `topic` = \'Media\', `category` = \'Playlists\', `link` = \'media_playlists.html\'
+ WHERE `topic` = \'Layout\' AND `category` = \'RegionOptions\' ');
+ }
+}
diff --git a/db/migrations/20230310143321_saved_report_move_out_migration.php b/db/migrations/20230310143321_saved_report_move_out_migration.php
new file mode 100755
index 0000000..ce4d0a3
--- /dev/null
+++ b/db/migrations/20230310143321_saved_report_move_out_migration.php
@@ -0,0 +1,105 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Remove savedreport from media table
+ * Add more columns to saved_report table
+ * Adjust fileName of saved report
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class SavedReportMoveOutMigration extends AbstractMigration
+{
+ public function change()
+ {
+ // add some new columns
+ $table = $this->table('saved_report');
+ $table
+ ->addColumn('fileName', 'string')
+ ->addColumn('size', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_BIG, 'default' => null, 'null' => true])
+ ->addColumn('md5', 'string', ['limit' => 32, 'default' => null, 'null' => true])
+ ->save();
+
+ // create savedreport sub-folder in the library location
+ $libraryLocation = $this->fetchRow('
+ SELECT `setting`.value
+ FROM `setting`
+ WHERE `setting`.setting = \'LIBRARY_LOCATION\'')[0] ?? null;
+
+ // New installs won't have a library location yet (if they are non-docker).
+ if (!empty($libraryLocation)) {
+ if (!file_exists($libraryLocation . 'savedreport')) {
+ mkdir($libraryLocation . 'savedreport', 0777, true);
+ }
+
+ // get all existing savedreport records in media table and convert them
+ foreach ($this->fetchAll('SELECT mediaId, name, type, createdDt, modifiedDt, storedAs, md5, fileSize FROM `media` WHERE media.type = \'savedreport\'') as $savedreportMedia) {
+ $this->execute('UPDATE `saved_report` SET fileName = \'' . $savedreportMedia['storedAs'] . '\',
+ size = ' . $savedreportMedia['fileSize'] . ',
+ md5 = \'' . $savedreportMedia['md5'] . '\'
+ WHERE `saved_report`.mediaId = ' . $savedreportMedia['mediaId']);
+
+ // move the stored files with new id to savedreport folder
+ rename($libraryLocation . $savedreportMedia['storedAs'], $libraryLocation . 'savedreport/' . $savedreportMedia['storedAs']);
+
+ // remove any potential tagLinks from savedreport media files
+ // unlikely that there will be any, but just in case.
+ $this->execute('DELETE FROM `lktagmedia` WHERE `lktagmedia`.mediaId = ' . $savedreportMedia['mediaId']);
+ }
+ }
+
+ // we are finally done
+ if ($this->checkIndexExists('saved_report', 'saved_report_ibfk_1')) {
+ $table->removeIndexByName('saved_report_ibfk_1');
+ }
+
+ // remove mediaId column and index/key
+ $table
+ ->dropForeignKey('mediaId')
+ ->removeColumn('mediaId')
+ ->save();
+
+ // delete savedreport records from media table
+ $this->execute('DELETE FROM `media` WHERE media.type = \'savedreport\'');
+ }
+
+ /**
+ * Check if an index exists
+ * @param string $table
+ * @param $indexName
+ * @return bool
+ */
+ private function checkIndexExists($table, $indexName): bool
+ {
+ // Use the information schema to see if the index exists or not.
+ // all users have permission to the information schema
+ $sql = '
+ SELECT *
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE `table_schema` = DATABASE()
+ AND `table_name` = \'' . $table . '\'
+ AND `index_name` = \'' . $indexName . '\'';
+
+ return count($this->fetchAll($sql)) > 0;
+ }
+}
diff --git a/db/migrations/20230321105750_add_schema_version_widget_migration.php b/db/migrations/20230321105750_add_schema_version_widget_migration.php
new file mode 100755
index 0000000..ae296db
--- /dev/null
+++ b/db/migrations/20230321105750_add_schema_version_widget_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add schemaVersion to the widget table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddSchemaVersionWidgetMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $widget = $this->table('widget');
+ $widget
+ ->addColumn('schemaVersion', 'integer', [ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 1])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230411090410_add_widget_compatibility_task_migration.php b/db/migrations/20230411090410_add_widget_compatibility_task_migration.php
new file mode 100755
index 0000000..bcf1003
--- /dev/null
+++ b/db/migrations/20230411090410_add_widget_compatibility_task_migration.php
@@ -0,0 +1,51 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+
+/**
+ * Add widget compatibility task
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddWidgetCompatibilityTaskMigration extends AbstractMigration
+{
+ public function change()
+ {
+
+ // Add a task for widget upgrade from v3 to v4
+ $this->table('task')
+ ->insert([
+ 'name' => 'Widget Compatibility',
+ 'class' => '\Xibo\XTR\WidgetCompatibilityTask',
+ 'options' => '[]',
+ 'schedule' => '* * * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/widget-compatibility.task',
+ 'pid' => 0,
+ 'lastRunDt' => 0,
+ 'lastRunDuration' => 0,
+ 'lastRunExitCode' => 0
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230509113820_content_sync_migration.php b/db/migrations/20230509113820_content_sync_migration.php
new file mode 100644
index 0000000..70de96b
--- /dev/null
+++ b/db/migrations/20230509113820_content_sync_migration.php
@@ -0,0 +1,73 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Content Sync changes
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ContentSyncMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('syncgroup', ['id' => 'syncGroupId'])
+ ->addColumn('name', 'string', ['limit' => 50])
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('ownerId', 'integer')
+ ->addColumn('modifiedBy', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('syncPublisherPort', 'integer', ['default' => 9590])
+ ->addColumn('leadDisplayId', 'integer', ['default' => null, 'null' => true])
+ ->addColumn('folderId', 'integer', ['default' => 1])
+ ->addColumn('permissionsFolderId', 'integer', ['default' => 1])
+ ->addForeignKey('folderId', 'folder', 'folderId')
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->addForeignKey('leadDisplayId', 'display', 'displayId')
+ ->create();
+
+ $this->table('display')
+ ->addColumn('syncGroupId', 'integer', ['default' => null, 'null' => true])
+ ->addForeignKey('syncGroupId', 'syncgroup', 'syncGroupId')
+ ->save();
+
+ $this->table('permissionentity')
+ ->insert([
+ ['entity' => 'Xibo\Entity\SyncGroup']
+ ])
+ ->save();
+
+ $this->table('schedule_sync', ['id' => false, 'primary_key' => ['eventId', 'displayId']])
+ ->addColumn('eventId', 'integer')
+ ->addColumn('displayId', 'integer')
+ ->addColumn('layoutId', 'integer')
+ ->addForeignKey('eventId', 'schedule', 'eventId')
+ ->addForeignKey('displayId', 'display', 'displayId')
+ ->addForeignKey('layoutId', 'layout', 'layoutId')
+ ->create();
+
+ $this->table('schedule')
+ ->addColumn('syncGroupId', 'integer', ['default' => null, 'null' => true])
+ ->addForeignKey('syncGroupId', 'syncgroup', 'syncGroupId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20230530124400_widget_option_size_migration.php b/db/migrations/20230530124400_widget_option_size_migration.php
new file mode 100644
index 0000000..9c9659e
--- /dev/null
+++ b/db/migrations/20230530124400_widget_option_size_migration.php
@@ -0,0 +1,36 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Content Sync changes
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class WidgetOptionSizeMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Use DDL because phinx doesn't want to change the datatype without truncating data.
+ $this->execute('ALTER TABLE `widgetoption` MODIFY `value` MEDIUMTEXT NULL');
+ }
+}
diff --git a/db/migrations/20230706103000_add_sync_key_to_zone_migration.php b/db/migrations/20230706103000_add_sync_key_to_zone_migration.php
new file mode 100644
index 0000000..8164bfa
--- /dev/null
+++ b/db/migrations/20230706103000_add_sync_key_to_zone_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add Sync Key column to region table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddSyncKeyToZoneMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('region')
+ ->addColumn('syncKey', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230718163600_remove_help_links_migration.php b/db/migrations/20230718163600_remove_help_links_migration.php
new file mode 100644
index 0000000..18d75fc
--- /dev/null
+++ b/db/migrations/20230718163600_remove_help_links_migration.php
@@ -0,0 +1,35 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add Sync Key column to region table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class RemoveHelpLinksMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('help')->drop()->save();
+ }
+}
diff --git a/db/migrations/20230719154200_collation_to_utfmb4_migration.php b/db/migrations/20230719154200_collation_to_utfmb4_migration.php
new file mode 100644
index 0000000..eccb872
--- /dev/null
+++ b/db/migrations/20230719154200_collation_to_utfmb4_migration.php
@@ -0,0 +1,65 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Convert all tables in the database to UF8MB4
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class CollationToUtfmb4Migration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Several tables have keys on varchar which will need to be resized to accomodate the max key length
+ // of MySQL 5.6 which is 767 bytes. This will mean 191.
+ $this->table('menu_product_options')->changeColumn('option', 'string', [
+ 'limit' => 191,
+ ])->save();
+
+ $this->table('widgetoption')->changeColumn('option', 'string', [
+ 'limit' => 191,
+ ])->save();
+
+ // Get all tables which need to have their collation converted.
+ // We will exclude some tables which have foreign keys on string columns.
+ $tables = $this->fetchAll('
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE `TABLE_SCHEMA` = DATABASE()
+ AND `TABLE_TYPE` = \'BASE TABLE\'
+ AND `ENGINE` = \'InnoDB\'
+ AND TABLE_COLLATION <> \'utf8mb4_general_ci\'
+ AND `TABLE_NAME` NOT IN (
+ \'oauth_clients\',
+ \'oauth_scopes\',
+ \'oauth_client_scopes\',
+ \'oauth_lkclientuser\'
+ )
+ ');
+
+ foreach ($tables as $row) {
+ $this->execute('ALTER TABLE `' . $row['TABLE_NAME']
+ . '` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
+ }
+ }
+}
diff --git a/db/migrations/20230725141000_schedule_meta_data_migration.php b/db/migrations/20230725141000_schedule_meta_data_migration.php
new file mode 100644
index 0000000..460ddb6
--- /dev/null
+++ b/db/migrations/20230725141000_schedule_meta_data_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add meta data fields to schedule table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ScheduleMetaDataMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('schedule')
+ ->addColumn('modifiedBy', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => true,
+ 'default' => null
+ ])
+ ->addColumn('createdOn', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('updatedOn', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('name', 'string', ['limit' => 50, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20230727102500_menuboard_additional_fields_migration.php b/db/migrations/20230727102500_menuboard_additional_fields_migration.php
new file mode 100644
index 0000000..eba787b
--- /dev/null
+++ b/db/migrations/20230727102500_menuboard_additional_fields_migration.php
@@ -0,0 +1,78 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add some additional fields to menu boards
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class MenuboardAdditionalFieldsMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Before I do this I need to make sure that all products in this table have a numeric field in price
+ foreach ($this->fetchAll('SELECT `menuProductId`, `price` FROM `menu_product`') as $row) {
+ if (!empty($row['price']) && !is_numeric($row['price'])) {
+ $this->execute('UPDATE `menu_product` SET `price` = :price WHERE menuProductId = :id', [
+ 'id' => $row['menuProductId'],
+ 'price' => preg_replace('/[^0-9.]/', '', $row['price']),
+ ]);
+ }
+ }
+
+ $this->table('menu_product')
+ ->addColumn('calories', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_SMALL,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('displayOrder', 'integer', [
+ 'length' => \Phinx\Db\Adapter\MysqlAdapter::INT_MEDIUM,
+ 'null' => false,
+ 'default' => 0,
+ ])
+ ->changeColumn('price', 'decimal', [
+ 'precision' => 10,
+ 'scale' => 4,
+ 'default' => null,
+ 'null' => true,
+ ])
+ ->save();
+
+ $this->table('menu_category')
+ ->addColumn('description', 'string', [
+ 'length' => 254,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->save();
+
+ // Drop the old menu-board module entirely, and insert the new ones.
+ $this->execute('DELETE FROM `module` WHERE moduleId = \'core-menuboard\'');
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`) VALUES
+ (\'core-menuboard-category\', \'1\', \'1\', \'60\', \'[]\'),
+ (\'core-menuboard-product\', \'1\', \'1\', \'60\', \'[]\');
+ ');
+ }
+}
diff --git a/db/migrations/20230731194700_lkdgdg_primary_key_migration.php b/db/migrations/20230731194700_lkdgdg_primary_key_migration.php
new file mode 100644
index 0000000..3bb1377
--- /dev/null
+++ b/db/migrations/20230731194700_lkdgdg_primary_key_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * lkdgdg must have a primary key for MySQL8 clustering
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class LkdgdgPrimaryKeyMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $pk = $this->fetchAll('SHOW KEYS FROM `lkdgdg` WHERE `Key_name` = \'PRIMARY\'');
+ if (count($pk) <= 0) {
+ $this->execute('ALTER TABLE `lkdgdg` ADD COLUMN `id` INT(11) PRIMARY KEY AUTO_INCREMENT');
+ }
+ }
+}
diff --git a/db/migrations/20231128144300_real_time_data_migration.php b/db/migrations/20231128144300_real_time_data_migration.php
new file mode 100644
index 0000000..3bfb807
--- /dev/null
+++ b/db/migrations/20231128144300_real_time_data_migration.php
@@ -0,0 +1,55 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for new real-time data
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class RealTimeDataMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('dataset')
+ ->addColumn('isRealTime', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
+ 'default' => 0,
+ 'null' => false,
+ ])
+ ->save();
+
+ $this->table('schedule')
+ ->addColumn('dataSetId', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'default' => null,
+ 'null' => true
+ ])
+ ->addColumn('dataSetParams', 'text', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_REGULAR,
+ 'default' => null,
+ 'null' => true
+ ])
+ ->addForeignKey('dataSetId', 'dataset', 'dataSetId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20231213120700_schedule_criteria_migration.php b/db/migrations/20231213120700_schedule_criteria_migration.php
new file mode 100644
index 0000000..21cd2f0
--- /dev/null
+++ b/db/migrations/20231213120700_schedule_criteria_migration.php
@@ -0,0 +1,57 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for schedule criteria
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class ScheduleCriteriaMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('schedule_criteria')
+ ->addColumn('eventId', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_REGULAR,
+ 'null' => false,
+ ])
+ ->addColumn('type', 'string', [
+ 'limit' => 20,
+ 'null' => false,
+ ])
+ ->addColumn('metric', 'string', [
+ 'limit' => 20,
+ 'null' => false,
+ ])
+ ->addColumn('condition', 'string', [
+ 'limit' => 20,
+ 'null' => false,
+ ])
+ ->addColumn('value', 'string', [
+ 'limit' => 255,
+ 'null' => false,
+ ])
+ ->addForeignKey('eventId', 'schedule', 'eventId')
+ ->save();
+ }
+}
diff --git a/db/migrations/20231220155800_user_module_templates_migration.php b/db/migrations/20231220155800_user_module_templates_migration.php
new file mode 100644
index 0000000..9cc00b9
--- /dev/null
+++ b/db/migrations/20231220155800_user_module_templates_migration.php
@@ -0,0 +1,61 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for adding user supplied module templates
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UserModuleTemplatesMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('module_templates')
+ ->addColumn('templateId', 'string', [
+ 'limit' => 50,
+ 'null' => false,
+ ])
+ ->addColumn('dataType', 'string', [
+ 'limit' => 50,
+ 'null' => false,
+ ])
+ ->addColumn('xml', 'text', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_MEDIUM,
+ 'null' => false,
+ ])
+ ->addColumn('enabled', 'integer', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
+ 'null' => false,
+ 'default' => 1,
+ ])
+ ->addColumn('ownerId', 'integer')
+ ->addForeignKey('ownerId', 'user', 'userId')
+ ->save();
+
+ $this->table('permissionentity')
+ ->insert([
+ ['entity' => 'Xibo\Entity\ModuleTemplate']
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240227100600_menuboard_field_lengths_migration.php b/db/migrations/20240227100600_menuboard_field_lengths_migration.php
new file mode 100644
index 0000000..1ff74f4
--- /dev/null
+++ b/db/migrations/20240227100600_menuboard_field_lengths_migration.php
@@ -0,0 +1,46 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration for adjusting the field length of the menu board product description/allergyInfo
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class MenuboardFieldLengthsMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('menu_product')
+ ->changeColumn('description', 'text', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_REGULAR,
+ 'default' => null,
+ 'null' => true,
+ ])
+ ->changeColumn('allergyInfo', 'text', [
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_REGULAR,
+ 'default' => null,
+ 'null' => true,
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240227102400_missing_indexes_migration.php b/db/migrations/20240227102400_missing_indexes_migration.php
new file mode 100644
index 0000000..0ada9fe
--- /dev/null
+++ b/db/migrations/20240227102400_missing_indexes_migration.php
@@ -0,0 +1,51 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration for adding missing indexes
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class MissingIndexesMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $region = $this->table('region');
+ if (!$region->hasForeignKey('layoutId')) {
+ // Take care of orphaned regions
+ $this->execute('DELETE FROM `region` WHERE `layoutId` NOT IN (SELECT `layoutId` FROM `layout`)');
+
+ // Add the FK
+ $region
+ ->addForeignKey('layoutId', 'layout', 'layoutId')
+ ->save();
+ }
+
+ $playlist = $this->table('playlist');
+ if (!$playlist->hasIndex('regionId')) {
+ $playlist
+ ->addIndex('regionId')
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20240408121908_display_alerts_migration.php b/db/migrations/20240408121908_display_alerts_migration.php
new file mode 100644
index 0000000..29071a4
--- /dev/null
+++ b/db/migrations/20240408121908_display_alerts_migration.php
@@ -0,0 +1,50 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration for adding more columns to displayevent table.
+ * Add a new column on Command table for createAlertOn.
+ * Add a new column on lkcommanddisplayprofile for createAlertOn.
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class DisplayAlertsMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('displayevent')
+ ->changeColumn('start', 'integer', ['null' => true])
+ ->addColumn('eventTypeId', 'integer', ['null' => false, 'default' => 1])
+ ->addColumn('refId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('detail', 'text', ['null' => true, 'default' => null])
+ ->save();
+
+ $this->table('command')
+ ->addColumn('createAlertOn', 'string', ['null' => false, 'default' => 'never'])
+ ->save();
+
+ $this->table('lkcommanddisplayprofile')
+ ->addColumn('createAlertOn', 'string', ['null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240422111609_notification_types_migration.php b/db/migrations/20240422111609_notification_types_migration.php
new file mode 100644
index 0000000..e2eac45
--- /dev/null
+++ b/db/migrations/20240422111609_notification_types_migration.php
@@ -0,0 +1,72 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add new notification type column
+ * Add new notification email options for user groups
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class NotificationTypesMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('notification')
+ ->removeColumn('isEmail')
+ ->addColumn('type', 'string', ['null' => false, 'default' => 'unknown', 'limit' => 50])
+ ->save();
+
+ $this->table('group')
+ ->addColumn(
+ 'isDataSetNotification',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]
+ )
+ ->addColumn(
+ 'isLayoutNotification',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]
+ )
+ ->addColumn(
+ 'isLibraryNotification',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]
+ )
+ ->addColumn(
+ 'isReportNotification',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]
+ )
+ ->addColumn(
+ 'isScheduleNotification',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]
+ )
+ ->addColumn(
+ 'isCustomNotification',
+ 'integer',
+ ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]
+ )
+ ->save();
+ }
+}
diff --git a/db/migrations/20240430112500_canvas_duration_fix_migration.php b/db/migrations/20240430112500_canvas_duration_fix_migration.php
new file mode 100644
index 0000000..a69cd0c
--- /dev/null
+++ b/db/migrations/20240430112500_canvas_duration_fix_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to fix the Canvas duration which mistakenly got added as 60s instead of 1s
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class CanvasDurationFixMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->execute('UPDATE `module` SET `defaultDuration` = 1, `enabled` = 1 WHERE `moduleId` = :moduleId', [
+ 'moduleId' => 'core-canvas',
+ ]);
+ }
+}
diff --git a/db/migrations/20240501111721_session_history_migration.php b/db/migrations/20240501111721_session_history_migration.php
new file mode 100644
index 0000000..5a26bc9
--- /dev/null
+++ b/db/migrations/20240501111721_session_history_migration.php
@@ -0,0 +1,49 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to Add a new table for session_history
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class SessionHistoryMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('session_history', ['id' => 'sessionId'])
+ ->addColumn('ipAddress', 'string', ['limit' => 50, 'null' => true, 'default' => null])
+ ->addColumn('userAgent', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addColumn('startTime', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('userId', 'integer', ['null' => true, 'default' => null])
+ ->addIndex('userId')
+ ->create();
+
+ $this->table('auditlog')
+ ->addColumn('sessionHistoryId', 'integer', ['null' => true, 'default' => null])
+ ->save();
+
+ $this->table('log')
+ ->addColumn('sessionHistoryId', 'integer', ['null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240605101251_auditing_api_requests_migration.php b/db/migrations/20240605101251_auditing_api_requests_migration.php
new file mode 100644
index 0000000..38205a2
--- /dev/null
+++ b/db/migrations/20240605101251_auditing_api_requests_migration.php
@@ -0,0 +1,52 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to Add a new table for session_history
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AuditingApiRequestsMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('application_requests_history', ['id' => 'requestId'])
+ ->addColumn('userId', 'integer', ['null' => true, 'default' => null])
+ ->addColumn('applicationId', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addColumn('url', 'string', ['limit' => 255, 'null' => true, 'default' => null])
+ ->addColumn('method', 'string', ['limit' => 20, 'null' => true, 'default' => null])
+ ->addColumn('startTime', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('endTime', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('duration', 'integer', ['null' => true, 'default' => null])
+ ->addIndex('userId')
+ ->create();
+
+ $this->table('auditlog')
+ ->addColumn('requestId', 'integer', ['null' => true, 'default' => null])
+ ->save();
+
+ $this->table('log')
+ ->addColumn('requestId', 'integer', ['null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240612112949_add_switch_delay_and_video_pause_delay_to_sync_group.php b/db/migrations/20240612112949_add_switch_delay_and_video_pause_delay_to_sync_group.php
new file mode 100755
index 0000000..1115918
--- /dev/null
+++ b/db/migrations/20240612112949_add_switch_delay_and_video_pause_delay_to_sync_group.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Added syncSwitchDelay and syncVideoPauseDelay columns to Sync Groups
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddSwitchDelayAndVideoPauseDelayToSyncGroup extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('syncgroup')
+ ->addColumn('syncSwitchDelay', 'integer', ['null' => false, 'default' => 1])
+ ->addColumn('syncVideoPauseDelay', 'integer', ['null' => true, 'default' => null])
+ ->update();
+ }
+}
diff --git a/db/migrations/20240614031633_update_switch_delay_and_video_pause_delay_migration.php b/db/migrations/20240614031633_update_switch_delay_and_video_pause_delay_migration.php
new file mode 100755
index 0000000..355af17
--- /dev/null
+++ b/db/migrations/20240614031633_update_switch_delay_and_video_pause_delay_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Updated syncSwitchDelay and syncVideoPauseDelay columns to accept null values
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpdateSwitchDelayAndVideoPauseDelayMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('syncgroup')
+ ->changeColumn('syncSwitchDelay', 'integer', ['null' => true, 'default' => null])
+ ->changeColumn('syncVideoPauseDelay', 'integer', ['null' => true, 'default' => null])
+ ->update();
+ }
+}
diff --git a/db/migrations/20240615155000_widget_fallback_data_migration.php b/db/migrations/20240615155000_widget_fallback_data_migration.php
new file mode 100644
index 0000000..0a3e66c
--- /dev/null
+++ b/db/migrations/20240615155000_widget_fallback_data_migration.php
@@ -0,0 +1,49 @@
+.
+ */
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to add a new table for modelling widget fallback data
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class WidgetFallbackDataMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('widgetdata')
+ ->addColumn('widgetId', 'integer', ['null' => false])
+ ->addColumn('data', 'text', [
+ 'limit' => MysqlAdapter::TEXT_MEDIUM,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('displayOrder', 'integer', [
+ 'limit' => MysqlAdapter::INT_MEDIUM,
+ ])
+ ->addColumn('createdDt', 'datetime', ['null' => true, 'default' => null])
+ ->addColumn('modifiedDt', 'datetime', ['null' => true, 'default' => null])
+ ->addForeignKey('widgetId', 'widget', 'widgetId')
+ ->create();
+ }
+}
diff --git a/db/migrations/20240617040320_add_o_s_details_to_display_table_migration.php b/db/migrations/20240617040320_add_o_s_details_to_display_table_migration.php
new file mode 100755
index 0000000..51476de
--- /dev/null
+++ b/db/migrations/20240617040320_add_o_s_details_to_display_table_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to Add osVersion, osSdk, manufacturer, brand, model columns to Display table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddOSDetailsToDisplayTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('display')
+ ->addColumn('osVersion', 'string', ['default' => null, 'null' => true])
+ ->addColumn('osSdk', 'string', ['default' => null, 'null' => true])
+ ->addColumn('manufacturer', 'string', ['default' => null, 'null' => true])
+ ->addColumn('brand', 'string', ['default' => null, 'null' => true])
+ ->addColumn('model', 'string', ['default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240717043020_add_data_connector_source_to_dataset_table_migration.php b/db/migrations/20240717043020_add_data_connector_source_to_dataset_table_migration.php
new file mode 100644
index 0000000..e12a3c9
--- /dev/null
+++ b/db/migrations/20240717043020_add_data_connector_source_to_dataset_table_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to add data source column to the dataset table.
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddDataConnectorSourceToDatasetTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('dataset')
+ ->addColumn('dataConnectorSource', 'string', ['default' => null, 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240717113400_add_last_used_time_to_session_history_table_migration.php b/db/migrations/20240717113400_add_last_used_time_to_session_history_table_migration.php
new file mode 100755
index 0000000..764c80b
--- /dev/null
+++ b/db/migrations/20240717113400_add_last_used_time_to_session_history_table_migration.php
@@ -0,0 +1,37 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migration to add lastUsedTime in session_history table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddLastUsedTimeToSessionHistoryTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('session_history')
+ ->addColumn('lastUsedTime', 'datetime', ['null' => true, 'default' => null])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240903142530_add_cap_connector_migration.php b/db/migrations/20240903142530_add_cap_connector_migration.php
new file mode 100644
index 0000000..f2e04f3
--- /dev/null
+++ b/db/migrations/20240903142530_add_cap_connector_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new connector (Common Alerting Protocol - CAP) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddCapConnectorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('connectors')
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\CapConnector',
+ 'isEnabled' => 1,
+ 'isVisible' => 1
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20240909114945_add_folder_filter_to_playlist_table_migration.php b/db/migrations/20240909114945_add_folder_filter_to_playlist_table_migration.php
new file mode 100644
index 0000000..2072516
--- /dev/null
+++ b/db/migrations/20240909114945_add_folder_filter_to_playlist_table_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new connector (Open Weather Map) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddFolderFilterToPlaylistTableMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('playlist')
+ ->addColumn('filterFolderId', 'integer', ['after' => 'filterMediaTagsLogicalOperator', 'default' => null,
+ 'null' => true])
+ ->save();
+ }
+}
diff --git a/db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php b/db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php
new file mode 100644
index 0000000..f647e9a
--- /dev/null
+++ b/db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php
@@ -0,0 +1,46 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddDefaultChromeOSDisplayProfileMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // add default display profile for tizen
+ if (!$this->fetchRow('SELECT * FROM displayprofile WHERE type = \'chromeOS\' AND isDefault = 1')) {
+ // Get system user
+ $user = $this->fetchRow('SELECT userId FROM `user` WHERE userTypeId = 1');
+
+ $this->table('displayprofile')->insert([
+ 'name' => 'ChromeOS',
+ 'type' => 'chromeOS',
+ 'config' => '[]',
+ 'userId' => $user['userId'],
+ 'isDefault' => 1
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20250115120000_add_national_weather_service_connector_migration.php b/db/migrations/20250115120000_add_national_weather_service_connector_migration.php
new file mode 100644
index 0000000..f3ac48a
--- /dev/null
+++ b/db/migrations/20250115120000_add_national_weather_service_connector_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add a new connector (National Weather Service - NWS) to connectors table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddNationalWeatherServiceConnectorMigration extends AbstractMigration
+{
+ public function change()
+ {
+ $this->table('connectors')
+ ->insert([
+ 'className' => '\\Xibo\\Connector\\NationalWeatherServiceConnector',
+ 'isEnabled' => 0,
+ 'isVisible' => 1
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20250115151900_add_xmr_ws_setting_migration.php b/db/migrations/20250115151900_add_xmr_ws_setting_migration.php
new file mode 100644
index 0000000..4a1681b
--- /dev/null
+++ b/db/migrations/20250115151900_add_xmr_ws_setting_migration.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AddXmrWsSettingMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // If the setting does not yet exist, add it.
+ if (!$this->fetchRow('SELECT * FROM `setting` WHERE `setting` = \'XMR_WS_ADDRESS\'')) {
+ $this->table('setting')->insert([
+ [
+ 'setting' => 'XMR_WS_ADDRESS',
+ 'value' => '',
+ 'userSee' => 1,
+ 'userChange' => 1
+ ]
+ ])->save();
+ }
+ }
+}
diff --git a/db/migrations/20250121120000_upsert_core_emergency_alert_in_module.php b/db/migrations/20250121120000_upsert_core_emergency_alert_in_module.php
new file mode 100644
index 0000000..5f5163e
--- /dev/null
+++ b/db/migrations/20250121120000_upsert_core_emergency_alert_in_module.php
@@ -0,0 +1,54 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add some additional fields to menu boards
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpsertCoreEmergencyAlertInModule extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Check if the core-emergency-alert row exists
+ $row = $this->fetchRow("SELECT * FROM `module` WHERE `moduleId` = 'core-emergency-alert'");
+
+ if (!$row) {
+ // Row does not exist, insert new row
+ $this->execute("
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`)
+ VALUES ('core-emergency-alert', '1', '1', '60', NULL)
+ ");
+ } else {
+ // Row exists, update existing row
+ $this->execute("
+ UPDATE `module`
+ SET `enabled` = '1',
+ `previewEnabled` = '1',
+ `defaultDuration` = '60',
+ `settings` = NULL
+ WHERE `moduleId` = 'core-emergency-alert'
+ ");
+ }
+ }
+}
diff --git a/db/migrations/20250123160800_update_metric_column_limit_in_schedule_criteria.php b/db/migrations/20250123160800_update_metric_column_limit_in_schedule_criteria.php
new file mode 100644
index 0000000..222f291
--- /dev/null
+++ b/db/migrations/20250123160800_update_metric_column_limit_in_schedule_criteria.php
@@ -0,0 +1,44 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for schedule criteria
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpdateMetricColumnLimitInScheduleCriteria extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('schedule_criteria')
+ ->changeColumn('type', 'string', [
+ 'limit' => 100,
+ 'null' => false,
+ ])
+ ->changeColumn('metric', 'string', [
+ 'limit' => 100,
+ 'null' => false,
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20250430164700_anonymous_usage_enhanced_migration.php b/db/migrations/20250430164700_anonymous_usage_enhanced_migration.php
new file mode 100644
index 0000000..4454cc6
--- /dev/null
+++ b/db/migrations/20250430164700_anonymous_usage_enhanced_migration.php
@@ -0,0 +1,53 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for schedule criteria
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class AnonymousUsageEnhancedMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ try {
+ $myHour = random_int(0, 23);
+ } catch (Exception) {
+ $myHour = 0;
+ }
+
+ $task = $this->table('task');
+ $task->insert([
+ 'name' => 'Anonymous Usage Reporting',
+ 'class' => '\Xibo\XTR\AnonymousUsageTask',
+ 'options' => '[]',
+ 'schedule' => $myHour . ' * * * *',
+ 'isActive' => '1',
+ 'configFile' => '/tasks/anonymous-usage.task'
+ ])
+ ->save();
+
+ // Delete some settings we don't use anymore.
+ $this->execute('DELETE FROM `setting` WHERE `setting` = \'PHONE_HOME_URL\'');
+ }
+}
diff --git a/db/migrations/20250520120000_update_custom_metric_prefix_migration.php b/db/migrations/20250520120000_update_custom_metric_prefix_migration.php
new file mode 100644
index 0000000..7d8c5ab
--- /dev/null
+++ b/db/migrations/20250520120000_update_custom_metric_prefix_migration.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for schedule criteria
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpdateCustomMetricPrefixMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Only update metrics where type is 'custom' and the metric does not already start with 'custom_'
+ $this->execute("
+ UPDATE `schedule_criteria`
+ SET `metric` = CONCAT('custom_', `metric`)
+ WHERE `type` = 'custom'
+ AND `metric` NOT LIKE 'custom_%'
+ ");
+ }
+}
diff --git a/db/migrations/20250602070030_upsert_interactive_button_in_module_table_migration.php b/db/migrations/20250602070030_upsert_interactive_button_in_module_table_migration.php
new file mode 100755
index 0000000..ebf2407
--- /dev/null
+++ b/db/migrations/20250602070030_upsert_interactive_button_in_module_table_migration.php
@@ -0,0 +1,54 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add some additional fields to menu boards
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpsertInteractiveButtonInModuleTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Check if the core-interactive-button row exists
+ $row = $this->fetchRow("SELECT * FROM `module` WHERE `moduleId` = 'core-interactive-button'");
+
+ if (!$row) {
+ // Row does not exist, insert new row
+ $this->execute("
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`)
+ VALUES ('core-interactive-button', '1', '1', '60', NULL)
+ ");
+ } else {
+ // Row exists, update existing row
+ $this->execute("
+ UPDATE `module`
+ SET `enabled` = '1',
+ `previewEnabled` = '1',
+ `defaultDuration` = '60',
+ `settings` = NULL
+ WHERE `moduleId` = 'core-interactive-button'
+ ");
+ }
+ }
+}
diff --git a/db/migrations/20250702145100_fix_oauth_routes_table_migration.php b/db/migrations/20250702145100_fix_oauth_routes_table_migration.php
new file mode 100644
index 0000000..9335768
--- /dev/null
+++ b/db/migrations/20250702145100_fix_oauth_routes_table_migration.php
@@ -0,0 +1,64 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add some additional fields to menu boards
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class FixOauthRoutesTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Remove the Regex column
+ $this->table('oauth_scopes')
+ ->removeColumn('useRegex')
+ ->save();
+
+ // Update the table
+ $oauthRouteScopes = $this->table('oauth_scope_routes');
+ $oauthRouteScopes->truncate();
+
+ $oauthRouteScopes->insert([
+ ['scopeId' => 'datasets', 'route' => '#^/dataset#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'datasetsDelete', 'route' => '#^/dataset#', 'method' => 'DELETE'],
+ ['scopeId' => 'design', 'route' => '#^/library#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'design', 'route' => '#^/layout#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'design', 'route' => '#^/playlist#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'design', 'route' => '#^/resolution#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'designDelete', 'route' => '#^/library#', 'method' => 'DELETE'],
+ ['scopeId' => 'designDelete', 'route' => '#^/layout#', 'method' => 'DELETE'],
+ ['scopeId' => 'designDelete', 'route' => '#^/playlist#', 'method' => 'DELETE'],
+ ['scopeId' => 'designDelete', 'route' => '#^/resolution#', 'method' => 'DELETE'],
+ ['scopeId' => 'displays', 'route' => '#^/display#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'displays', 'route' => '#^/displaygroup#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'displaysDelete', 'route' => '#^/display/{id}#', 'method' => 'DELETE'],
+ ['scopeId' => 'displaysDelete', 'route' => '#^/displaygroup/{id}#', 'method' => 'DELETE'],
+ ['scopeId' => 'mcaas', 'route' => '#^/$#', 'method' => 'GET'],
+ ['scopeId' => 'mcaas', 'route' => '#^/library/download/{id}#', 'method' => 'GET'],
+ ['scopeId' => 'mcaas', 'route' => '#^/library/mcaas/{id}#', 'method' => 'POST'],
+ ['scopeId' => 'schedule', 'route' => '#^/schedule#', 'method' => 'GET,POST,PUT'],
+ ['scopeId' => 'scheduleDelete', 'route' => '#^/schedule#', 'method' => 'DELETE'],
+ ])->saveData();
+ }
+}
diff --git a/db/migrations/20250815103428_upsert_interactive_link_and_zone_in_module_table_migration.php b/db/migrations/20250815103428_upsert_interactive_link_and_zone_in_module_table_migration.php
new file mode 100644
index 0000000..03e4f66
--- /dev/null
+++ b/db/migrations/20250815103428_upsert_interactive_link_and_zone_in_module_table_migration.php
@@ -0,0 +1,61 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Add some additional fields to menu boards
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpsertInteractiveLinkAndZoneInModuleTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $ids = ['core-interactive-link', 'core-interactive-zone'];
+ $pdo = $this->getAdapter()->getConnection();
+
+ foreach ($ids as $id) {
+ // Safely quoted literal
+ $qid = $pdo->quote($id);
+
+ // Check if the core-interactive-link and core-interactive-zone row exists
+ $row = $this->fetchRow('SELECT 1 FROM `module` WHERE `moduleId` = ' . $qid);
+
+ if (!$row) {
+ // Row does not exist, insert new row
+ $this->execute('
+ INSERT INTO `module` (`moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`)
+ VALUES (' . $qid . ', "1", "1", "60", NULL)
+ ');
+ } else {
+ // Row exists, update existing row
+ $this->execute('
+ UPDATE `module`
+ SET `enabled` = "1",
+ `previewEnabled` = "1",
+ `defaultDuration` = "60",
+ `settings` = NULL
+ WHERE `moduleId` = "' . $qid . '"');
+ }
+ }
+ }
+}
diff --git a/db/migrations/20250909081522_update_password_column_limit_in_dataset.php b/db/migrations/20250909081522_update_password_column_limit_in_dataset.php
new file mode 100755
index 0000000..8f648f7
--- /dev/null
+++ b/db/migrations/20250909081522_update_password_column_limit_in_dataset.php
@@ -0,0 +1,41 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Migrations for dataset
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+
+class UpdatePasswordColumnLimitInDataset extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->table('dataset')
+ ->changeColumn('password', 'string', [
+ 'limit' => 1000,
+ 'null' => true,
+ ])
+ ->save();
+ }
+}
diff --git a/db/migrations/20251021074311_update_core_canvas_duration_in_module_table_migration.php b/db/migrations/20251021074311_update_core_canvas_duration_in_module_table_migration.php
new file mode 100755
index 0000000..a6dd9da
--- /dev/null
+++ b/db/migrations/20251021074311_update_core_canvas_duration_in_module_table_migration.php
@@ -0,0 +1,38 @@
+.
+ */
+
+/**
+ * Migrations for core-canvas module duration
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+class UpdateCoreCanvasDurationInModuleTableMigration extends AbstractMigration
+{
+ public function change(): void
+ {
+ $this->execute('UPDATE `module` SET `defaultDuration` = 10 WHERE `moduleId` = :moduleId', [
+ 'moduleId' => 'core-canvas',
+ ]);
+ }
+}
diff --git a/db/migrations/20251118205958_upsert_anonymous_usage_schedule_in_task_table.php b/db/migrations/20251118205958_upsert_anonymous_usage_schedule_in_task_table.php
new file mode 100755
index 0000000..05a127f
--- /dev/null
+++ b/db/migrations/20251118205958_upsert_anonymous_usage_schedule_in_task_table.php
@@ -0,0 +1,70 @@
+.
+ */
+
+use Phinx\Migration\AbstractMigration;
+
+/**
+ * Upsert AnonymousUsageTask Schedule in Task Table
+ * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+ */
+class UpsertAnonymousUsageScheduleInTaskTable extends AbstractMigration
+{
+ public function change(): void
+ {
+ // Check if the anonymous usage task exists in the task table
+ $row = $this->fetchRow("SELECT * FROM `task` WHERE `name` = 'Anonymous Usage Reporting'");
+
+ try {
+ $myHour = random_int(0, 23);
+ $myMinute = random_int(0, 59);
+ } catch (Exception) {
+ $myHour = 0;
+ $myMinute = 0;
+ }
+
+ $schedule = $myMinute . ' ' . $myHour . ' * * *';
+
+ if (!$row) {
+ $this->execute('
+ INSERT INTO `task` (`name`, `class`, `options`, `schedule`, `isActive`, `configFile`)
+ VALUES (:name, :class, :options, :schedule, :isActive, :configFile)
+ ', [
+ 'name' => 'Anonymous Usage Reporting',
+ 'class' => '\\Xibo\\XTR\\AnonymousUsageTask',
+ 'options' => '[]',
+ 'schedule' => $schedule,
+ 'isActive' => '1',
+ 'configFile' => '/tasks/anonymous-usage.task'
+ ]);
+ } else {
+ // Row exists, update existing row
+ $this->execute('
+ UPDATE `task`
+ SET `schedule` = :schedule
+ WHERE `name` = :name
+ ', [
+ 'schedule' => $schedule,
+ 'name' => 'Anonymous Usage Reporting'
+ ]);
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..4cdf5f9
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,46 @@
+version: "3"
+
+services:
+ db:
+ image: mysql:8.0
+ ports:
+ - "3315:3306"
+ volumes:
+ - ./containers/db:/var/lib/mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: "root"
+ MYSQL_DATABASE: "cms"
+
+ xmr:
+ image: ghcr.io/xibosignage/xibo-xmr:develop
+ ports:
+ - "9505:9505"
+ environment:
+ XMR_DEBUG: "true"
+ IPV6PUBSUPPORT: "false"
+
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile.dev
+ volumes:
+ - ./:/var/www/cms
+ ports:
+ - "80:80"
+ environment:
+ CMS_DEV_MODE: "true"
+ MYSQL_DATABASE: "cms"
+
+ memcached:
+ image: memcached:alpine
+ command: memcached -m 15
+
+ swagger:
+ image: swaggerapi/swagger-ui:latest
+ ports:
+ - "8080:8080"
+ environment:
+ - API_URL=http://localhost/swagger.json
+
+ quickchart:
+ image: ianw/quickchart
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..3cbffb8
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,408 @@
+#!/bin/bash
+
+#
+# Copyright (C) 2025 Xibo Signage Ltd
+#
+# Xibo - Digital Signage - https://xibosignage.com
+#
+# This file is part of Xibo.
+#
+# Xibo is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# Xibo is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Xibo. If not, see .
+#
+
+if [ "$CMS_DEV_MODE" == "true" ]
+then
+ # Print MySQL connection details
+ echo "MySQL Connection Details:"
+ echo "Username: $MYSQL_USER"
+ echo "Password: $MYSQL_PASSWORD"
+ echo "DB: $MYSQL_DATABASE"
+ echo "Host: $MYSQL_HOST"
+ echo ""
+ echo "XMR Connection Details:"
+ echo "Host: $XMR_HOST"
+ echo "Player Port: 9505"
+ echo ""
+ echo "Starting Webserver"
+fi
+
+# Sleep for a few seconds to give MySQL time to initialise
+echo "Waiting for MySQL to start - max 300 seconds"
+/usr/local/bin/wait-for-command.sh -q -t 300 -c "nc -z $MYSQL_HOST $MYSQL_PORT"
+
+if [ ! "$?" == 0 ]
+then
+ echo "MySQL didn't start in the allocated time" > /var/www/backup/LOG
+fi
+
+# Safety sleep to give MySQL a moment to settle after coming up
+echo "MySQL started"
+sleep 1
+
+# Write a /root/.my.cnf file
+echo "Configuring MySQL cnf file"
+echo "[client]" > /root/.my.cnf
+echo "host = $MYSQL_HOST" >> /root/.my.cnf
+echo "port = $MYSQL_PORT" >> /root/.my.cnf
+echo "user = $MYSQL_USER" >> /root/.my.cnf
+echo "password = $MYSQL_PASSWORD" >> /root/.my.cnf
+
+if [ ! "$MYSQL_ATTR_SSL_CA" == "none" ]
+then
+ echo "ssl_ca = $MYSQL_ATTR_SSL_CA" >> /root/.my.cnf
+
+ if [ "$MYSQL_ATTR_SSL_VERIFY_SERVER_CERT" == "true" ]
+ then
+ echo "ssl_mode = VERIFY_IDENTITY" >> /root/.my.cnf
+ fi
+fi
+
+# Set permissions on the new cnf file.
+chmod 0600 /root/.my.cnf
+
+# Check to see if we have a settings.php file in this container
+# if we don't, then we will need to create one here (it only contains the $_SERVER environment
+# variables we've already set
+if [ ! -f "/var/www/cms/web/settings.php" ]
+then
+ # Write settings.php
+ echo "Updating settings.php"
+
+ # We won't have a settings.php in place, so we'll need to copy one in
+ cp /tmp/settings.php-template /var/www/cms/web/settings.php
+ chown www-data.www-data /var/www/cms/web/settings.php
+
+ SECRET_KEY=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 8)
+ /bin/sed -i "s/define('SECRET_KEY','');/define('SECRET_KEY','$SECRET_KEY');/" /var/www/cms/web/settings.php
+fi
+
+# Check to see if we have a public/private key pair and encryption key
+if [ ! -f "/var/www/cms/library/certs/private.key" ]
+then
+ # Make the dir
+ mkdir -p /var/www/cms/library/certs
+
+ # Create the Keys
+ openssl genrsa -out /var/www/cms/library/certs/private.key 2048
+ openssl rsa -in /var/www/cms/library/certs/private.key -pubout -out /var/www/cms/library/certs/public.key
+
+ php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;' >> /var/www/cms/library/certs/encryption.key
+fi
+
+# Set the correct permissions on the public/private key
+chmod 600 /var/www/cms/library/certs/private.key
+chmod 660 /var/www/cms/library/certs/public.key
+chown -R www-data.www-data /var/www/cms/library/certs
+
+# Check if there's a database file to import
+if [ -f "/var/www/backup/import.sql" ] && [ "$CMS_DEV_MODE" == "false" ]
+then
+ echo "Attempting to import database"
+
+ echo "Importing Database"
+ mysql -D $MYSQL_DATABASE -e "SOURCE /var/www/backup/import.sql"
+
+ echo "Configuring Database Settings"
+ # Set LIBRARY_LOCATION
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='/var/www/cms/library/', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='LIBRARY_LOCATION' LIMIT 1"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='Apache', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='SENDFILE_MODE' LIMIT 1"
+
+ # Set XMR public/private address
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='http://$XMR_HOST:8081', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='XMR_ADDRESS' LIMIT 1"
+
+ # Configure Maintenance
+ echo "Setting up Maintenance"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='Protected' WHERE \`setting\`='MAINTENANCE_ENABLED' LIMIT 1"
+
+ MAINTENANCE_KEY=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16)
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='$MAINTENANCE_KEY' WHERE \`setting\`='MAINTENANCE_KEY' LIMIT 1"
+
+ # Configure Quick Chart
+ echo "Setting up Quickchart"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='$CMS_QUICK_CHART_URL', userSee=0 WHERE \`setting\`='QUICK_CHART_URL' LIMIT 1"
+
+ mv /var/www/backup/import.sql /var/www/backup/import.sql.done
+fi
+
+DB_EXISTS=0
+# Check if the database exists already
+if mysql -D $MYSQL_DATABASE -e "SELECT settingId FROM \`setting\` LIMIT 1"
+then
+ # Database exists.
+ DB_EXISTS=1
+fi
+
+# Check if we need to run an upgrade
+# if DB_EXISTS then see if the version installed matches
+# only upgrade for production containers
+if [ "$DB_EXISTS" == "1" ] && [ "$CMS_DEV_MODE" == "false" ]
+then
+ echo "Existing Database, checking if we need to upgrade it"
+ # Determine if there are any migrations to be run
+ /var/www/cms/vendor/bin/phinx status -c "/var/www/cms/phinx.php"
+
+ if [ ! "$?" == 0 ]
+ then
+ echo "We will upgrade it, take a backup"
+
+ # We're going to run an upgrade. Make a database backup
+ mysqldump -h $MYSQL_HOST -P $MYSQL_PORT -u $MYSQL_USER -p$MYSQL_PASSWORD \
+ --hex-blob --no-tablespaces $MYSQL_DATABASE | gzip > /var/www/backup/db-$(date +"%Y-%m-%d_%H-%M-%S").sql.gz
+
+ # Drop app cache on upgrade
+ rm -rf /var/www/cms/cache/*
+
+ # Upgrade
+ echo 'Running database migrations'
+ /var/www/cms/vendor/bin/phinx migrate -c /var/www/cms/phinx.php
+ fi
+fi
+
+if [ "$DB_EXISTS" == "0" ]
+then
+ # This is a fresh install so bootstrap the whole
+ # system
+ echo "New install"
+
+ echo "Provisioning Database"
+
+ # Create the database if it doesn't exist
+ mysql -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;"
+
+ # Populate the database
+ php /var/www/cms/vendor/bin/phinx migrate -c "/var/www/cms/phinx.php"
+
+ CMS_KEY=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 8)
+
+ echo "Configuring Database Settings"
+ # Set LIBRARY_LOCATION
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='/var/www/cms/library/', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='LIBRARY_LOCATION' LIMIT 1"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='Apache', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='SENDFILE_MODE' LIMIT 1"
+
+ # Set admin username/password
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`user\` SET \`UserName\`='xibo_admin', \`UserPassword\`='5f4dcc3b5aa765d61d8327deb882cf99' WHERE \`UserID\` = 1 LIMIT 1"
+
+ # Set XMR public address
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='tcp://cms.example.org:9505' WHERE \`setting\`='XMR_PUB_ADDRESS' LIMIT 1"
+
+ # Set CMS Key
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='$CMS_KEY' WHERE \`setting\`='SERVER_KEY' LIMIT 1"
+
+ # Configure Maintenance
+ echo "Setting up Maintenance"
+
+ if [ "$CMS_DEV_MODE" == "false" ]
+ then
+ echo "Protected Maintenance"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='Protected' WHERE \`setting\`='MAINTENANCE_ENABLED' LIMIT 1"
+ fi
+
+ MAINTENANCE_KEY=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16)
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='$MAINTENANCE_KEY' WHERE \`setting\`='MAINTENANCE_KEY' LIMIT 1"
+fi
+
+if [ "$CMS_DEV_MODE" == "false" ]
+then
+ # Import any ca-certificate files that might be needed to use a proxy etc
+ echo "Importing ca-certs"
+ cp -v /var/www/cms/ca-certs/*.pem /usr/local/share/ca-certificates
+ cp -v /var/www/cms/ca-certs/*.crt /usr/local/share/ca-certificates
+ /usr/sbin/update-ca-certificates
+
+ # Configure XMR private API
+ echo "Setting up XMR private API"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='http://$XMR_HOST:8081', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='XMR_ADDRESS' LIMIT 1"
+
+ # Configure Quick Chart
+ echo "Setting up Quickchart"
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='$CMS_QUICK_CHART_URL', userSee=0 WHERE \`setting\`='QUICK_CHART_URL' LIMIT 1"
+
+ # Set the daily maintenance task to run
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`task\` SET \`runNow\`=1 WHERE \`taskId\`='1' LIMIT 1"
+
+ # Update /etc/periodic/15min/cms-db-backup with current environment (for cron)
+ /bin/sed -i "s/^MYSQL_BACKUP_ENABLED=.*$/MYSQL_BACKUP_ENABLED=$MYSQL_BACKUP_ENABLED/" /etc/periodic/15min/cms-db-backup
+ /bin/sed -i "s/^MYSQL_DATABASE=.*$/MYSQL_DATABASE=$MYSQL_DATABASE/" /etc/periodic/15min/cms-db-backup
+
+ echo "*/15 * * * * root /etc/periodic/15min/cms-db-backup > /dev/null 2>&1" > /etc/cron.d/cms_backup_cron
+ echo "" >> /etc/cron.d/cms_backup_cron
+
+ # Update /var/www/maintenance with current environment (for cron)
+ if [ "$XTR_ENABLED" == "true" ]
+ then
+ echo "Configuring Maintenance"
+ echo "#!/bin/bash" > /var/www/maintenance.sh
+ echo "" >> /var/www/maintenance.sh
+ /usr/bin/env | sed 's/^\(.*\)$/export \1/g' | grep -E "^export MYSQL" >> /var/www/maintenance.sh
+ /usr/bin/env | sed 's/^\(.*\)$/export \1/g' | grep -E "^export MEMCACHED" >> /var/www/maintenance.sh
+ echo "export CMS_USE_MEMCACHED=$CMS_USE_MEMCACHED" >> /var/www/maintenance.sh
+ echo "export INSTALL_TYPE=$INSTALL_TYPE" >> /var/www/maintenance.sh
+ echo "cd /var/www/cms && /usr/bin/php bin/xtr.php" >> /var/www/maintenance.sh
+ chmod 755 /var/www/maintenance.sh
+
+ echo "* * * * * www-data /var/www/maintenance.sh > /dev/null 2>&1 " > /etc/cron.d/cms_maintenance_cron
+ echo "" >> /etc/cron.d/cms_maintenance_cron
+ fi
+
+ # Configure MSMTP to send emails if required
+ # Config lives in /etc/msmtprc
+
+ # Split CMS_SMTP_SERVER in to CMS_SMTP_SEVER_HOST : PORT
+ host_port=($(echo $CMS_SMTP_SERVER | tr ":" "\n"))
+
+ /bin/sed -i "s/host .*$/host ${host_port[0]}/" /etc/msmtprc
+ /bin/sed -i "s/port .*$/port ${host_port[1]}/" /etc/msmtprc
+
+ if [ -z "$CMS_SMTP_USERNAME" ] || [ "$CMS_SMTP_USERNAME" == "none" ]
+ then
+ # Use no authentication
+ /bin/sed -i "s/^auth .*$/auth off/" /etc/msmtprc
+ else
+ if [ -z "$CMS_SMTP_OAUTH_CLIENT_ID" ] || [ "$CMS_SMTP_OAUTH_CLIENT_ID" == "none" ]
+ then
+ # Use Username/Password
+ /bin/sed -i "s/^auth .*$/auth on/" /etc/msmtprc
+ /bin/sed -i "s/^user .*$/user $CMS_SMTP_USERNAME/" /etc/msmtprc
+ /bin/sed -i "s/^password .*$/password $CMS_SMTP_PASSWORD/" /etc/msmtprc
+ else
+ # Use OAUTH credentials
+ /bin/sed -i "s/^auth .*$/auth oauthbearer/" /etc/msmtprc
+ /bin/sed -i "s/^user .*$/#user/" /etc/msmtprc
+ /bin/sed -i "s/^password .*$/passwordeval \"/usr/bin/oauth2.py --quiet --user=$CMS_SMTP_USERNAME --client_id=$CMS_SMTP_OAUTH_CLIENT_ID --client_secret=$CMS_SMTP_OAUTH_CLIENT_SECRET --refresh_token=$CMS_SMTP_OAUTH_CLIENT_REFRESH\"/" /etc/msmtprc
+ fi
+ fi
+
+ if [ "$CMS_SMTP_USE_TLS" == "YES" ]
+ then
+ /bin/sed -i "s/tls .*$/tls on/" /etc/msmtprc
+ else
+ /bin/sed -i "s/tls .*$/tls off/" /etc/msmtprc
+ fi
+
+ if [ "$CMS_SMTP_USE_STARTTLS" == "YES" ]
+ then
+ /bin/sed -i "s/tls_starttls .*$/tls_starttls on/" /etc/msmtprc
+ else
+ /bin/sed -i "s/tls_starttls .*$/tls_starttls off/" /etc/msmtprc
+ fi
+
+ /bin/sed -i "s/maildomain .*$/maildomain $CMS_SMTP_REWRITE_DOMAIN/" /etc/msmtprc
+ /bin/sed -i "s/domain .*$/domain $CMS_SMTP_HOSTNAME/" /etc/msmtprc
+
+ if [ "$CMS_SMTP_FROM" == "none" ]
+ then
+ /bin/sed -i "s/from .*$/from cms@$CMS_SMTP_REWRITE_DOMAIN/" /etc/msmtprc
+ else
+ /bin/sed -i "s/from .*$/from $CMS_SMTP_FROM/" /etc/msmtprc
+ fi
+
+ mkdir -p /var/www/cms/library/temp
+ chown www-data:www-data -R /var/www/cms/library
+ chown www-data:www-data -R /var/www/cms/custom
+ chown www-data:www-data -R /var/www/cms/web/theme/custom
+ chown www-data:www-data -R /var/www/cms/web/userscripts
+ chown www-data:www-data -R /var/www/cms/ca-certs
+
+ # If we have a CMS ALIAS environment variable, then configure that in our Apache conf.
+ # this must not be done in DEV mode, as it modifies the .htaccess file, which might then be committed by accident
+ if [ ! "$CMS_ALIAS" == "none" ]
+ then
+ echo "Setting up CMS alias"
+ /bin/sed -i "s|.*Alias.*$|Alias $CMS_ALIAS /var/www/cms/web|" /etc/apache2/sites-enabled/000-default.conf
+
+ echo "Settings up htaccess"
+ /bin/cp /tmp/.htaccess /var/www/cms/web/.htaccess
+ /bin/sed -i "s|REPLACE_ME|$CMS_ALIAS|" /var/www/cms/web/.htaccess
+ fi
+
+ if [ ! -e /var/www/cms/custom/settings-custom.php ]
+ then
+ /bin/cp /tmp/settings-custom.php /var/www/cms/custom
+ fi
+
+ # Remove install.php if it exists
+ if [ -e /var/www/cms/web/install/index.php ]
+ then
+ echo "Removing web/install/index.php from production container"
+ rm /var/www/cms/web/install/index.php
+ fi
+fi
+
+# Configure Anonymous usage reporting
+if [ "$CMS_USAGE_REPORT" == "true" ]
+then
+ # Turn on
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='1', userChange=0 WHERE \`setting\`='PHONE_HOME' LIMIT 1"
+fi
+
+if [ "$CMS_USAGE_REPORT" == "false" ]
+then
+ # Turn off
+ mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='0', userChange=0 WHERE \`setting\`='PHONE_HOME' LIMIT 1"
+fi
+
+echo "Configure PHP"
+
+# Configure PHP
+sed -i "s/session.gc_maxlifetime = .*$/session.gc_maxlifetime = $CMS_PHP_SESSION_GC_MAXLIFETIME/" /etc/php/8.4/apache2/php.ini
+sed -i "s/post_max_size = .*$/post_max_size = $CMS_PHP_POST_MAX_SIZE/" /etc/php/8.4/apache2/php.ini
+sed -i "s/upload_max_filesize = .*$/upload_max_filesize = $CMS_PHP_UPLOAD_MAX_FILESIZE/" /etc/php/8.4/apache2/php.ini
+sed -i "s/max_execution_time = .*$/max_execution_time = $CMS_PHP_MAX_EXECUTION_TIME/" /etc/php/8.4/apache2/php.ini
+sed -i "s/memory_limit = .*$/memory_limit = $CMS_PHP_MEMORY_LIMIT/" /etc/php/8.4/apache2/php.ini
+sed -i "s/session.cookie_httponly =.*$/session.cookie_httponly = $CMS_PHP_COOKIE_HTTP_ONLY/" /etc/php/8.4/apache2/php.ini
+sed -i "s/session.cookie_samesite =.*$/session.cookie_samesite = $CMS_PHP_COOKIE_SAMESITE/" /etc/php/8.4/apache2/php.ini
+sed -i "s/;session.cookie_secure =.*$/session.cookie_secure = $CMS_PHP_COOKIE_SECURE/" /etc/php/8.4/apache2/php.ini
+sed -i "s/session.gc_maxlifetime = .*$/session.gc_maxlifetime = $CMS_PHP_SESSION_GC_MAXLIFETIME/" /etc/php/8.4/cli/php.ini
+sed -i "s/post_max_size = .*$/post_max_size = $CMS_PHP_POST_MAX_SIZE/" /etc/php/8.4/cli/php.ini
+sed -i "s/upload_max_filesize = .*$/upload_max_filesize = $CMS_PHP_UPLOAD_MAX_FILESIZE/" /etc/php/8.4/cli/php.ini
+sed -i "s/max_execution_time = .*$/max_execution_time = $CMS_PHP_CLI_MAX_EXECUTION_TIME/" /etc/php/8.4/cli/php.ini
+sed -i "s/memory_limit = .*$/memory_limit = $CMS_PHP_CLI_MEMORY_LIMIT/" /etc/php/8.4/cli/php.ini
+sed -i "s/session.cookie_httponly =.*$/session.cookie_httponly = $CMS_PHP_COOKIE_HTTP_ONLY/" /etc/php/8.4/cli/php.ini
+sed -i "s/session.cookie_samesite =.*$/session.cookie_samesite = $CMS_PHP_COOKIE_SAMESITE/" /etc/php/8.4/cli/php.ini
+sed -i "s/;session.cookie_secure =.*$/session.cookie_secure = $CMS_PHP_COOKIE_SECURE/" /etc/php/8.4/cli/php.ini
+
+echo "Configure Apache"
+
+# Configure Apache TimeOut
+sed -i "s/\bTimeout\b .*$/Timeout $CMS_APACHE_TIMEOUT/" /etc/apache2/apache2.conf
+
+# Configure Indexes
+if [ "$CMS_APACHE_OPTIONS_INDEXES" == "true" ]
+then
+ sed -i "s/\-Indexes/\+Indexes/" /etc/apache2/sites-enabled/000-default.conf
+fi
+
+# Configure Apache ServerTokens
+if [ "$CMS_APACHE_SERVER_TOKENS" == "Prod" ]
+then
+ sed -i "s/ServerTokens.*$/ServerTokens Prod/" /etc/apache2/sites-enabled/000-default.conf
+fi
+
+# Configure Apache logging
+if [ "$CMS_APACHE_LOG_REQUEST_TIME" == "true" ]
+then
+ sed -i '/combined/s/^/#/' /etc/apache2/sites-enabled/000-default.conf
+else
+ sed -i '/requesttime/s/^/#/' /etc/apache2/sites-enabled/000-default.conf
+fi
+
+# Run CRON in Production mode
+if [ "$CMS_DEV_MODE" == "false" ]
+then
+ echo "Starting cron"
+ /usr/sbin/cron
+fi
+
+echo "Starting webserver"
+exec /usr/local/bin/httpd-foreground
diff --git a/docker/etc/apache2/mods-enabled/mpm_prefork.conf b/docker/etc/apache2/mods-enabled/mpm_prefork.conf
new file mode 100644
index 0000000..1512203
--- /dev/null
+++ b/docker/etc/apache2/mods-enabled/mpm_prefork.conf
@@ -0,0 +1,14 @@
+# prefork MPM
+# StartServers: number of server processes to start
+# MinSpareServers: minimum number of server processes which are kept spare
+# MaxSpareServers: maximum number of server processes which are kept spare
+# MaxRequestWorkers: maximum number of server processes allowed to start
+# MaxConnectionsPerChild: maximum number of requests a server process serves
+
+
+ StartServers ${CMS_APACHE_START_SERVERS}
+ MinSpareServers ${CMS_APACHE_MIN_SPARE_SERVERS}
+ MaxSpareServers ${CMS_APACHE_MAX_SPARE_SERVERS}
+ MaxRequestWorkers ${CMS_APACHE_MAX_REQUEST_WORKERS}
+ MaxConnectionsPerChild ${CMS_APACHE_MAX_CONNECTIONS_PER_CHILD}
+
diff --git a/docker/etc/apache2/sites-available/000-default.conf b/docker/etc/apache2/sites-available/000-default.conf
new file mode 100644
index 0000000..564b072
--- /dev/null
+++ b/docker/etc/apache2/sites-available/000-default.conf
@@ -0,0 +1,66 @@
+TraceEnable Off
+ServerSignature Off
+ServerTokens OS
+
+
+ ServerAdmin me@example.org
+ DocumentRoot /var/www/cms/web/
+
+ PassEnv MYSQL_DATABASE
+ PassEnv MYSQL_HOST
+ PassEnv MYSQL_USER
+ PassEnv MYSQL_PORT
+ PassEnv MYSQL_PASSWORD
+ PassEnv MYSQL_ATTR_SSL_CA
+ PassEnv MYSQL_ATTR_SSL_VERIFY_SERVER_CERT
+ PassEnv CMS_SERVER_NAME
+ PassEnv CMS_DEV_MODE
+ PassEnv INSTALL_TYPE
+ PassEnv GIT_COMMIT
+ PassEnv CMS_USE_MEMCACHED
+ PassEnv MEMCACHED_HOST
+ PassEnv MEMCACHED_PORT
+ PassEnv XMR_HOST
+
+ ServerName ${CMS_SERVER_NAME}
+
+ KeepAlive Off
+ LimitRequestBody 0
+
+ XSendFile on
+ XSendFilePath /var/www/cms/library
+
+
+ DirectoryIndex index.php index.html
+ Options -Indexes +FollowSymLinks -MultiViews
+ AllowOverride All
+ Require all granted
+
+
+ Alias /chromeos /var/www/cms/library/playersoftware/chromeos/latest
+
+ php_admin_value engine Off
+ DirectoryIndex index.html
+ Options -Indexes -FollowSymLinks -MultiViews
+ AllowOverride None
+ Require all granted
+
+
+
+ ProxyPass ws://${XMR_HOST}:8080
+
+
+ ErrorLog /dev/stderr
+
+ LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" *%Ts* *%Dus*" requesttime
+
+ CustomLog /dev/stdout combined
+ CustomLog /dev/stdout requesttime
+
+ # Hardening
+ Header always append X-Frame-Options SAMEORIGIN
+ Header always append X-Content-Type-Options nosniff
+
+ # Alias /xibo /var/www/cms/web
+
+
diff --git a/docker/etc/msmtprc b/docker/etc/msmtprc
new file mode 100644
index 0000000..827b87c
--- /dev/null
+++ b/docker/etc/msmtprc
@@ -0,0 +1,22 @@
+account cms
+
+# CMS_SMTP_SERVER
+host smtp.gmail.com
+# CMS_SMTP_SERVER
+port 587
+auth off
+# CMS_SMTP_USERNAME
+user example
+# CMS_SMTP_PASSWORD
+password password
+# CMS_SMTP_USE_TLS
+tls on
+# CMS_SMTP_USE_STARTTLS
+tls_starttls on
+# CMS_SMTP_REWRITE_DOMAIN
+maildomain gmail.com
+# CMS_SMTP_HOSTNAME
+domain gmail.com
+from cms@example.org
+
+account default : cms
diff --git a/docker/etc/periodic/15min/cms-db-backup b/docker/etc/periodic/15min/cms-db-backup
new file mode 100644
index 0000000..e143b6e
--- /dev/null
+++ b/docker/etc/periodic/15min/cms-db-backup
@@ -0,0 +1,89 @@
+#!/bin/bash
+
+#
+# Copyright (c) 2022 Xibo Signage Ltd
+#
+# Xibo - Digital Signage - http://www.xibo.org.uk
+#
+# This file is part of Xibo.
+#
+# Xibo is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# Xibo is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Xibo. If not, see .
+#
+
+/bin/mkdir -p /var/www/backup/db
+age=86401
+
+MYSQL_BACKUP_ENABLED=
+MYSQL_DATABASE=
+
+# Is this enabled?
+if [ "$MYSQL_BACKUP_ENABLED" == "false" ]
+then
+ echo "Backup not enabled"
+ exit 0
+fi
+
+# See if there is an existing backup, and if so, get its age
+if [ -e /var/www/backup/db/latest.sql.gz ]
+then
+ age=$((`date +%s` - `date -r /var/www/backup/db/latest.sql.gz +%s`))
+
+ # Age can potentially be negative. If it is, make it 0 so that
+ # we run a backup now
+ if [ $age -lt 0 ]
+ then
+ echo "Last backup was in the future. Resetting"
+ age=86401
+ fi
+
+ echo "Existing backup is $age seconds old"
+fi
+
+# Check if mysqldump is running already
+# pgrep exits with 0 if a process is found and 1 otherwise
+pgrep mysqldump > /dev/null 2>&1
+mysqldump_running=$?
+
+# If the backup is older than 1 day, and mysqldump isn't running,
+# then take a new one
+if [ $age -gt 86400 ] && [ $mysqldump_running -ne 0 ]
+then
+ echo "Creating new backup"
+
+ # Tell bash to consider all exit values when evaluating the
+ # exit code of a pipe rather than just the right-most one
+ # That way we can detect if mysqldump errors or is killed etc
+ set -o pipefail
+
+ /usr/bin/mysqldump --single-transaction --hex-blob --no-tablespaces $MYSQL_DATABASE \
+ | gzip > /var/www/backup/db/backup.sql.gz
+
+ RESULT=$?
+
+ if [ $RESULT -eq 0 ] && [ -e /var/www/backup/db/backup.sql.gz ]
+ then
+ echo "Rotating backups"
+ mv /var/www/backup/db/latest.sql.gz /var/www/backup/db/previous.sql.gz
+ mv /var/www/backup/db/backup.sql.gz /var/www/backup/db/latest.sql.gz
+ exit 0
+ else
+ echo "BACKUP FAILED"
+ echo "Not rotating backups"
+ exit 1
+ fi
+elif [ $mysqldump_running -eq 0 ]
+then
+ echo "Backup already in progress. Exiting"
+ exit 0
+fi
diff --git a/docker/mod_xsendfile.c b/docker/mod_xsendfile.c
new file mode 100644
index 0000000..a81479c
--- /dev/null
+++ b/docker/mod_xsendfile.c
@@ -0,0 +1,608 @@
+/* Copyright 2006-2010 by Nils Maier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * mod_xsendfile.c: Process X-SENDFILE header cgi/scripts may set
+ * Written by Nils Maier, testnutzer123 at google mail, March 2006
+ *
+ * Whenever an X-SENDFILE header occures in the response headers drop
+ * the body and send the replacement file idenfitied by this header instead.
+ *
+ * Method inspired by lighttpd
+ * Code inspired by mod_headers, mod_rewrite and such
+ *
+ * Installation:
+ * apxs2 -cia mod_xsendfile.c
+ */
+
+/*
+ * v0.12 (peer-review still required)
+ *
+ * $Id$
+ */
+
+#include "apr.h"
+#include "apr_lib.h"
+#include "apr_strings.h"
+#include "apr_buckets.h"
+#include "apr_file_io.h"
+
+#include "apr_hash.h"
+#define APR_WANT_IOVEC
+#define APR_WANT_STRFUNC
+#include "apr_want.h"
+
+#include "httpd.h"
+#include "http_log.h"
+#include "http_config.h"
+#include "http_log.h"
+#define CORE_PRIVATE
+#include "http_request.h"
+#include "http_core.h" /* needed for per-directory core-config */
+#include "util_filter.h"
+#include "http_protocol.h" /* ap_hook_insert_error_filter */
+
+#define AP_XSENDFILE_HEADER "X-SENDFILE"
+
+module AP_MODULE_DECLARE_DATA xsendfile_module;
+
+typedef enum {
+ XSENDFILE_UNSET = 0,
+ XSENDFILE_ENABLED = 1<<0,
+ XSENDFILE_DISABLED = 1<<1
+} xsendfile_conf_active_t;
+
+typedef struct xsendfile_conf_t {
+ xsendfile_conf_active_t enabled;
+ xsendfile_conf_active_t ignoreETag;
+ xsendfile_conf_active_t ignoreLM;
+ apr_array_header_t *paths;
+} xsendfile_conf_t;
+
+static xsendfile_conf_t *xsendfile_config_create(apr_pool_t *p) {
+ xsendfile_conf_t *conf;
+
+ conf = (xsendfile_conf_t *) apr_pcalloc(p, sizeof(xsendfile_conf_t));
+ conf->ignoreETag =
+ conf->ignoreLM =
+ conf->enabled =
+ XSENDFILE_UNSET;
+
+ conf->paths = apr_array_make(p, 1, sizeof(char*));
+
+ return conf;
+}
+
+static void *xsendfile_config_server_create(apr_pool_t *p, server_rec *s) {
+ return (void*)xsendfile_config_create(p);
+}
+
+#define XSENDFILE_CFLAG(x) conf->x = overrides->x != XSENDFILE_UNSET ? overrides->x : base->x
+
+static void *xsendfile_config_merge(apr_pool_t *p, void *basev, void *overridesv) {
+ xsendfile_conf_t *base = (xsendfile_conf_t *)basev;
+ xsendfile_conf_t *overrides = (xsendfile_conf_t *)overridesv;
+ xsendfile_conf_t *conf;
+
+ conf = (xsendfile_conf_t *) apr_pcalloc(p, sizeof(xsendfile_conf_t));
+
+ XSENDFILE_CFLAG(enabled);
+ XSENDFILE_CFLAG(ignoreETag);
+ XSENDFILE_CFLAG(ignoreLM);
+
+ conf->paths = apr_array_append(p, overrides->paths, base->paths);
+
+ return (void*)conf;
+}
+
+static void *xsendfile_config_perdir_create(apr_pool_t *p, char *path) {
+ return (void*)xsendfile_config_create(p);
+}
+#undef XSENDFILE_CFLAG
+
+static const char *xsendfile_cmd_flag(cmd_parms *cmd, void *perdir_confv, int flag) {
+ xsendfile_conf_t *conf = (xsendfile_conf_t *)perdir_confv;
+ if (cmd->path == NULL) {
+ conf = (xsendfile_conf_t*)ap_get_module_config(
+ cmd->server->module_config,
+ &xsendfile_module
+ );
+ }
+ if (!conf) {
+ return "Cannot get configuration object";
+ }
+ if (!strcasecmp(cmd->cmd->name, "xsendfile")) {
+ conf->enabled = flag ? XSENDFILE_ENABLED : XSENDFILE_DISABLED;
+ }
+ else if (!strcasecmp(cmd->cmd->name, "xsendfileignoreetag")) {
+ conf->ignoreETag = flag ? XSENDFILE_ENABLED: XSENDFILE_DISABLED;
+ }
+ else if (!strcasecmp(cmd->cmd->name, "xsendfileignorelastmodified")) {
+ conf->ignoreLM = flag ? XSENDFILE_ENABLED: XSENDFILE_DISABLED;
+ }
+ else {
+ return apr_psprintf(cmd->pool, "Not a valid command in this context: %s %s", cmd->cmd->name, flag ? "On": "Off");
+ }
+
+ return NULL;
+}
+
+static const char *xsendfile_cmd_path(cmd_parms *cmd, void *pdc, const char *arg) {
+ xsendfile_conf_t *conf = (xsendfile_conf_t*)ap_get_module_config(
+ cmd->server->module_config,
+ &xsendfile_module
+ );
+ char **newpath = (char**)apr_array_push(conf->paths);
+ *newpath = apr_pstrdup(cmd->pool, arg);
+
+ return NULL;
+}
+
+/*
+ little helper function to get the original request path
+ code borrowed from request.c and util_script.c
+*/
+static const char *ap_xsendfile_get_orginal_path(request_rec *rec) {
+ const char
+ *rv = rec->the_request,
+ *last;
+
+ int dir = 0;
+ size_t uri_len;
+
+ /* skip method && spaces */
+ while (*rv && !apr_isspace(*rv)) {
+ ++rv;
+ }
+ while (apr_isspace(*rv)) {
+ ++rv;
+ }
+ /* first space is the request end */
+ last = rv;
+ while (*last && !apr_isspace(*last)) {
+ ++last;
+ }
+ uri_len = last - rv;
+ if (!uri_len) {
+ return NULL;
+ }
+
+ /* alright, lets see if the request_uri changed! */
+ if (strncmp(rv, rec->uri, uri_len) == 0) {
+ rv = apr_pstrdup(rec->pool, rec->filename);
+ dir = rec->finfo.filetype == APR_DIR;
+ }
+ else {
+ /* need to lookup the url again as it changed */
+ request_rec *sr = ap_sub_req_lookup_uri(
+ apr_pstrmemdup(rec->pool, rv, uri_len),
+ rec,
+ NULL
+ );
+ if (!sr) {
+ return NULL;
+ }
+ rv = apr_pstrdup(rec->pool, sr->filename);
+ dir = rec->finfo.filetype == APR_DIR;
+ ap_destroy_sub_req(sr);
+ }
+
+ /* now we need to truncate so we only have the directory */
+ if (!dir && (last = ap_strrchr(rv, '/')) != NULL) {
+ *((char*)last + 1) = '\0';
+ }
+ return rv;
+}
+
+/*
+ little helper function to build the file path if available
+*/
+static apr_status_t ap_xsendfile_get_filepath(request_rec *r, xsendfile_conf_t *conf, const char *file, /* out */ char **path) {
+
+ const char *root = ap_xsendfile_get_orginal_path(r);
+ apr_status_t rv;
+
+ apr_array_header_t *patharr;
+ const char **paths;
+ int i;
+
+
+#ifdef _DEBUG
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, r->server, "xsendfile: path is %s", root);
+#endif
+
+ /* merge the array */
+ if (root) {
+ patharr = apr_array_make(r->pool, conf->paths->nelts + 1, sizeof(char*));
+ *(const char**)(apr_array_push(patharr)) = root;
+ apr_array_cat(patharr, conf->paths);
+ } else {
+ patharr = conf->paths;
+ }
+ if (patharr->nelts == 0) {
+ return APR_EBADPATH;
+ }
+ paths = (const char**)patharr->elts;
+
+ for (i = 0; i < patharr->nelts; ++i) {
+ if ((rv = apr_filepath_merge(
+ path,
+ paths[i],
+ file,
+ APR_FILEPATH_TRUENAME | APR_FILEPATH_NOTABOVEROOT,
+ r->pool
+ )) == OK) {
+ break;
+ }
+ }
+ if (rv != OK) {
+ *path = NULL;
+ }
+ return rv;
+}
+
+static apr_status_t ap_xsendfile_output_filter(ap_filter_t *f, apr_bucket_brigade *in) {
+ request_rec *r = f->r, *sr = NULL;
+
+ xsendfile_conf_t
+ *dconf = (xsendfile_conf_t *)ap_get_module_config(r->per_dir_config, &xsendfile_module),
+ *sconf = (xsendfile_conf_t *)ap_get_module_config(r->server->module_config, &xsendfile_module),
+ *conf = xsendfile_config_merge(r->pool, sconf, dconf);
+
+ core_dir_config *coreconf = (core_dir_config *)ap_get_module_config(r->per_dir_config, &core_module);
+
+ apr_status_t rv;
+ apr_bucket *e;
+
+ apr_file_t *fd = NULL;
+ apr_finfo_t finfo;
+
+ const char *file = NULL;
+ char *translated = NULL;
+
+ int errcode;
+
+#ifdef _DEBUG
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, r->server, "xsendfile: output_filter for %s", r->the_request);
+#endif
+ /*
+ should we proceed with this request?
+
+ * sub-requests suck
+ * furthermore default-handled requests suck, as they actually shouldn't be able to set headers
+ */
+ if (
+ r->status != HTTP_OK
+ || r->main
+ || (r->handler && strcmp(r->handler, "default-handler") == 0) /* those table-keys are lower-case, right? */
+ ) {
+#ifdef _DEBUG
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, r->server, "xsendfile: not met [%d]", r->status);
+#endif
+ ap_remove_output_filter(f);
+ return ap_pass_brigade(f->next, in);
+ }
+
+ /*
+ alright, look for x-sendfile
+ */
+ file = apr_table_get(r->headers_out, AP_XSENDFILE_HEADER);
+ apr_table_unset(r->headers_out, AP_XSENDFILE_HEADER);
+
+ /* cgi/fastcgi will put the stuff into err_headers_out */
+ if (!file || !*file) {
+ file = apr_table_get(r->err_headers_out, AP_XSENDFILE_HEADER);
+ apr_table_unset(r->err_headers_out, AP_XSENDFILE_HEADER);
+ }
+
+ /* nothing there :p */
+ if (!file || !*file) {
+#ifdef _DEBUG
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, r->server, "xsendfile: nothing found");
+#endif
+ ap_remove_output_filter(f);
+ return ap_pass_brigade(f->next, in);
+ }
+
+ /*
+ drop *everything*
+ might be pretty expensive to generate content first that goes straight to the bitbucket,
+ but actually the scripts that might set this flag won't output too much anyway
+ */
+ while (!APR_BRIGADE_EMPTY(in)) {
+ e = APR_BRIGADE_FIRST(in);
+ apr_bucket_delete(e);
+ }
+ r->eos_sent = 0;
+
+ /* as we dropped all the content this field is not valid anymore! */
+ apr_table_unset(r->headers_out, "Content-Length");
+ apr_table_unset(r->err_headers_out, "Content-Length");
+ apr_table_unset(r->headers_out, "Content-Encoding");
+ apr_table_unset(r->err_headers_out, "Content-Encoding");
+
+ rv = ap_xsendfile_get_filepath(r, conf, file, &translated);
+ if (rv != OK) {
+ ap_log_rerror(
+ APLOG_MARK,
+ APLOG_ERR,
+ rv,
+ r,
+ "xsendfile: unable to find file: %s",
+ file
+ );
+ ap_remove_output_filter(f);
+ ap_die(HTTP_NOT_FOUND, r);
+ return HTTP_NOT_FOUND;
+ }
+
+#ifdef _DEBUG
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, r->server, "xsendfile: found %s", translated);
+#endif
+
+ /*
+ try open the file
+ */
+ if ((rv = apr_file_open(
+ &fd,
+ translated,
+ APR_READ | APR_BINARY
+#if APR_HAS_SENDFILE
+ | (coreconf->enable_sendfile != ENABLE_SENDFILE_OFF ? APR_SENDFILE_ENABLED : 0)
+#endif
+ ,
+ 0,
+ r->pool
+ )) != APR_SUCCESS) {
+ ap_log_rerror(
+ APLOG_MARK,
+ APLOG_ERR,
+ rv,
+ r,
+ "xsendfile: cannot open file: %s",
+ translated
+ );
+ ap_remove_output_filter(f);
+ ap_die(HTTP_NOT_FOUND, r);
+ return HTTP_NOT_FOUND;
+ }
+#if APR_HAS_SENDFILE && defined(_DEBUG)
+ if (coreconf->enable_sendfile == ENABLE_SENDFILE_OFF) {
+ ap_log_error(
+ APLOG_MARK,
+ APLOG_WARNING,
+ 0,
+ r->server,
+ "xsendfile: sendfile configured, but not active %d",
+ coreconf->enable_sendfile
+ );
+ }
+#endif
+ /* stat (for etag/cache/content-length stuff) */
+ if ((rv = apr_file_info_get(&finfo, APR_FINFO_NORM, fd)) != APR_SUCCESS) {
+ ap_log_rerror(
+ APLOG_MARK,
+ APLOG_ERR,
+ rv,
+ r,
+ "xsendfile: unable to stat file: %s",
+ translated
+ );
+ apr_file_close(fd);
+ ap_remove_output_filter(f);
+ ap_die(HTTP_FORBIDDEN, r);
+ return HTTP_FORBIDDEN;
+ }
+ /* no inclusion of directories! we're serving files! */
+ if (finfo.filetype != APR_REG) {
+ ap_log_rerror(
+ APLOG_MARK,
+ APLOG_ERR,
+ APR_EBADPATH,
+ r,
+ "xsendfile: not a file %s",
+ translated
+ );
+ apr_file_close(fd);
+ ap_remove_output_filter(f);
+ ap_die(HTTP_NOT_FOUND, r);
+ return HTTP_NOT_FOUND;
+ }
+
+ /*
+ need to cheat here a bit
+ as etag generator will use those ;)
+ and we want local_copy and cache
+ */
+ r->finfo.inode = finfo.inode;
+ r->finfo.size = finfo.size;
+
+ /*
+ caching? why not :p
+ */
+ r->no_cache = r->no_local_copy = 0;
+
+ /* some script (f?cgi) place stuff in err_headers_out */
+ if (
+ conf->ignoreLM == XSENDFILE_ENABLED
+ || (
+ !apr_table_get(r->headers_out, "last-modified")
+ && !apr_table_get(r->headers_out, "last-modified")
+ )
+ ) {
+ apr_table_unset(r->err_headers_out, "last-modified");
+ ap_update_mtime(r, finfo.mtime);
+ ap_set_last_modified(r);
+ }
+ if (
+ conf->ignoreETag == XSENDFILE_ENABLED
+ || (
+ !apr_table_get(r->headers_out, "etag")
+ && !apr_table_get(r->err_headers_out, "etag")
+ )
+ ) {
+ apr_table_unset(r->err_headers_out, "etag");
+ ap_set_etag(r);
+ }
+
+ ap_set_content_length(r, finfo.size);
+
+ /* cache or something? */
+ if ((errcode = ap_meets_conditions(r)) != OK) {
+#ifdef _DEBUG
+ ap_log_error(
+ APLOG_MARK,
+ APLOG_DEBUG,
+ 0,
+ r->server,
+ "xsendfile: met condition %d for %s",
+ errcode,
+ file
+ );
+#endif
+ apr_file_close(fd);
+ r->status = errcode;
+ }
+ else {
+ /* For platforms where the size of the file may be larger than
+ * that which can be stored in a single bucket (where the
+ * length field is an apr_size_t), split it into several
+ * buckets: */
+ if (sizeof(apr_off_t) > sizeof(apr_size_t)
+ && finfo.size > AP_MAX_SENDFILE) {
+ apr_off_t fsize = finfo.size;
+ e = apr_bucket_file_create(fd, 0, AP_MAX_SENDFILE, r->pool,
+ in->bucket_alloc);
+ while (fsize > AP_MAX_SENDFILE) {
+ apr_bucket *ce;
+ apr_bucket_copy(e, &ce);
+ APR_BRIGADE_INSERT_TAIL(in, ce);
+ e->start += AP_MAX_SENDFILE;
+ fsize -= AP_MAX_SENDFILE;
+ }
+ e->length = (apr_size_t)fsize; /* Resize just the last bucket */
+ }
+ else {
+ e = apr_bucket_file_create(fd, 0, (apr_size_t)finfo.size,
+ r->pool, in->bucket_alloc);
+ }
+
+
+#if APR_HAS_MMAP
+ if (coreconf->enable_mmap == ENABLE_MMAP_ON) {
+ apr_bucket_file_enable_mmap(e, 0);
+ }
+#if defined(_DEBUG)
+ else {
+ ap_log_error(
+ APLOG_MARK,
+ APLOG_WARNING,
+ 0,
+ r->server,
+ "xsendfile: mmap configured, but not active %d",
+ coreconf->enable_mmap
+ );
+ }
+#endif /* _DEBUG */
+#endif /* APR_HAS_MMAP */
+ APR_BRIGADE_INSERT_TAIL(in, e);
+ }
+
+ e = apr_bucket_eos_create(in->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(in, e);
+
+ /* remove ourselves from the filter chain */
+ ap_remove_output_filter(f);
+
+#ifdef _DEBUG
+ ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, r->server, "xsendfile: sending %d bytes", (int)finfo.size);
+#endif
+
+ /* send the data up the stack */
+ return ap_pass_brigade(f->next, in);
+}
+
+static void ap_xsendfile_insert_output_filter(request_rec *r) {
+ xsendfile_conf_active_t enabled = ((xsendfile_conf_t *)ap_get_module_config(r->per_dir_config, &xsendfile_module))->enabled;
+ if (XSENDFILE_UNSET == enabled) {
+ enabled = ((xsendfile_conf_t*)ap_get_module_config(r->server->module_config, &xsendfile_module))->enabled;
+ }
+
+ if (XSENDFILE_ENABLED != enabled) {
+ return;
+ }
+
+ ap_add_output_filter(
+ "XSENDFILE",
+ NULL,
+ r,
+ r->connection
+ );
+}
+static const command_rec xsendfile_command_table[] = {
+ AP_INIT_FLAG(
+ "XSendFile",
+ xsendfile_cmd_flag,
+ NULL,
+ OR_FILEINFO,
+ "On|Off - Enable/disable(default) processing"
+ ),
+ AP_INIT_FLAG(
+ "XSendFileIgnoreEtag",
+ xsendfile_cmd_flag,
+ NULL,
+ OR_FILEINFO,
+ "On|Off - Ignore script provided Etag headers (default: Off)"
+ ),
+ AP_INIT_FLAG(
+ "XSendFileIgnoreLastModified",
+ xsendfile_cmd_flag,
+ NULL,
+ OR_FILEINFO,
+ "On|Off - Ignore script provided Last-Modified headers (default: Off)"
+ ),
+ AP_INIT_TAKE1(
+ "XSendFilePath",
+ xsendfile_cmd_path,
+ NULL,
+ RSRC_CONF|ACCESS_CONF,
+ "Allow to serve files from that Path. Must be absolute"
+ ),
+ { NULL }
+};
+static void xsendfile_register_hooks(apr_pool_t *p) {
+ ap_register_output_filter(
+ "XSENDFILE",
+ ap_xsendfile_output_filter,
+ NULL,
+ AP_FTYPE_CONTENT_SET
+ );
+
+ ap_hook_insert_filter(
+ ap_xsendfile_insert_output_filter,
+ NULL,
+ NULL,
+ APR_HOOK_LAST + 1
+ );
+}
+module AP_MODULE_DECLARE_DATA xsendfile_module = {
+ STANDARD20_MODULE_STUFF,
+ xsendfile_config_perdir_create,
+ xsendfile_config_merge,
+ xsendfile_config_server_create,
+ xsendfile_config_merge,
+ xsendfile_command_table,
+ xsendfile_register_hooks
+};
diff --git a/docker/tmp/.htaccess b/docker/tmp/.htaccess
new file mode 100644
index 0000000..cd0376d
--- /dev/null
+++ b/docker/tmp/.htaccess
@@ -0,0 +1,32 @@
+# htaccess file for CMS instances using an Alias.
+# REPLACE_ME gets replaced on container start by entrypoint.sh
+RewriteEngine On
+
+RewriteBase REPLACE_ME/
+
+# fix authorization header
+RewriteCond %{HTTP:Authorization} .+
+RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+
+# requests for api authorize
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_URI} ^REPLACE_ME/api/authorize/.*$
+RewriteRule ^ api/authorize/index.php [QSA,L]
+
+# requests that start with api go down to api/index.php
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_URI} ^REPLACE_ME/api/.*$
+RewriteRule ^ api/index.php [QSA,L]
+
+# install
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_URI} ^REPLACE_ME/install/.*$
+RewriteRule ^ install/index.php [QSA,L]
+
+# all others - i.e. web
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !\.(css|js|png|jpg)$
+RewriteCond %{REQUEST_URI} !^REPLACE_ME/dist/.*$
+RewriteCond %{REQUEST_URI} !^REPLACE_ME/theme/.*$
+RewriteRule ^ index.php [QSA,L]
diff --git a/docker/tmp/settings-custom.php b/docker/tmp/settings-custom.php
new file mode 100644
index 0000000..7d3c7c6
--- /dev/null
+++ b/docker/tmp/settings-custom.php
@@ -0,0 +1,97 @@
+.
+ */
+
+// If you need to add custom configuration settings to the CMS settings.php file,
+// this is the place to do it.
+
+// For example, if you want to configure SAML authentication, you can add the
+// required configuration here
+
+/*
+$authentication = new \Xibo\Middleware\SAMLAuthentication();
+$samlSettings = [
+ 'workflow' => [
+ // Enable/Disable Just-In-Time provisioning
+ 'jit' => true,
+ // Attribute to identify the user
+ 'field_to_identify' => 'UserName', // Alternatives: UserID, UserName or email
+ // Default libraryQuota assigned to the created user by JIT
+ 'libraryQuota' => 1000,
+ // Home Page
+ 'homePage' => 'icondashboard.view',
+ // Enable/Disable Single Logout
+ 'slo' => true,
+ // Attribute mapping between XIBO-CMS and the IdP
+ 'mapping' => [
+ 'UserID' => '',
+ 'usertypeid' => '',
+ 'UserName' => 'uid',
+ 'email' => 'mail',
+ ],
+ // Initial User Group
+ 'group' => 'Users',
+ // Group Assignments
+ 'matchGroups' => [
+ 'enabled' => false,
+ 'attribute' => null,
+ 'extractionRegEx' => null,
+ ],
+ ],
+ // Settings for the PHP-SAML toolkit.
+ // See documentation: https://github.com/onelogin/php-saml#settings
+ 'strict' => false,
+ 'debug' => true,
+ 'idp' => [
+ 'entityId' => 'https://idp.example.com/simplesaml/saml2/idp/metadata.php',
+ 'singleSignOnService' => [
+ 'url' => 'http://idp.example.com/simplesaml/saml2/idp/SSOService.php',
+ ],
+ 'singleLogoutService' => [
+ 'url' => 'http://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php',
+ ],
+ 'x509cert' => '',
+ ],
+ 'sp' => [
+ 'entityId' => 'http://xibo-cms.example.com/saml/metadata',
+ 'assertionConsumerService' => [
+ 'url' => 'http://xibo-cms.example.com/saml/acs',
+ ],
+ 'singleLogoutService' => [
+ 'url' => 'http://xibo-cms.example.com/saml/sls',
+ ],
+ 'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress',
+ 'x509cert' => '',
+ 'privateKey' > '',
+ ,
+ 'security' => [
+ 'nameIdEncrypted' => false,
+ 'authnRequestsSigned' => false,
+ 'logoutRequestSigned' => false,
+ 'logoutResponseSigned' => false,
+ 'signMetadata' => false,
+ 'wantMessagesSigned' => false,
+ 'wantAssertionsSigned' => false,
+ 'wantAssertionsEncrypted' => false,
+ 'wantNameIdEncrypted' => false,
+ ],
+];
+*/
diff --git a/docker/tmp/settings.php-template b/docker/tmp/settings.php-template
new file mode 100644
index 0000000..bb8eeaa
--- /dev/null
+++ b/docker/tmp/settings.php-template
@@ -0,0 +1,47 @@
+" . __("Please press the back button in your browser."));
+
+global $dbhost;
+global $dbuser;
+global $dbpass;
+global $dbname;
+global $dbssl;
+global $dbsslverify;
+
+$dbhost = $_SERVER['MYSQL_HOST'] . ':' . $_SERVER['MYSQL_PORT'];
+$dbuser = $_SERVER['MYSQL_USER'];
+$dbpass = $_SERVER['MYSQL_PASSWORD'];
+$dbname = $_SERVER['MYSQL_DATABASE'];
+$dbssl = $_SERVER['MYSQL_ATTR_SSL_CA'];
+$dbsslverify = $_SERVER['MYSQL_ATTR_SSL_VERIFY_SERVER_CERT'];
+
+if (!defined('SECRET_KEY')) {
+ define('SECRET_KEY','');
+}
+
+if (array_key_exists('CMS_USE_MEMCACHED', $_SERVER)
+ && ($_SERVER['CMS_USE_MEMCACHED'] === true || $_SERVER['CMS_USE_MEMCACHED'] === 'true')
+) {
+ global $cacheDrivers;
+ $cacheDrivers = [
+ new Stash\Driver\Memcache([
+ 'servers' => [$_SERVER['MEMCACHED_HOST'], $_SERVER['MEMCACHED_PORT']],
+ 'CONNECT_TIMEOUT' => 10,
+ ])
+ ];
+}
+
+if (file_exists('/var/www/cms/custom/settings-custom.php')) {
+ include('/var/www/cms/custom/settings-custom.php');
+}
+
+?>
diff --git a/docker/usr/bin/oauth2.py b/docker/usr/bin/oauth2.py
new file mode 100755
index 0000000..e0fc7fb
--- /dev/null
+++ b/docker/usr/bin/oauth2.py
@@ -0,0 +1,347 @@
+#!/usr/bin/python
+#
+# Copyright 2012 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+ # http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Performs client tasks for testing IMAP OAuth2 authentication.
+
+To use this script, you'll need to have registered with Google as an OAuth
+application and obtained an OAuth client ID and client secret.
+See https://developers.google.com/identity/protocols/OAuth2 for instructions on
+registering and for documentation of the APIs invoked by this code.
+
+This script has 3 modes of operation.
+
+1. The first mode is used to generate and authorize an OAuth2 token, the
+first step in logging in via OAuth2.
+
+ oauth2 --user=xxx@gmail.com \
+ --client_id=1038[...].apps.googleusercontent.com \
+ --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
+ --generate_oauth2_token
+
+The script will converse with Google and generate an oauth request
+token, then present you with a URL you should visit in your browser to
+authorize the token. Once you get the verification code from the Google
+website, enter it into the script to get your OAuth access token. The output
+from this command will contain the access token, a refresh token, and some
+metadata about the tokens. The access token can be used until it expires, and
+the refresh token lasts indefinitely, so you should record these values for
+reuse.
+
+2. The script will generate new access tokens using a refresh token.
+
+ oauth2 --user=xxx@gmail.com \
+ --client_id=1038[...].apps.googleusercontent.com \
+ --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
+ --refresh_token=1/Yzm6MRy4q1xi7Dx2DuWXNgT6s37OrP_DW_IoyTum4YA
+
+3. The script will generate an OAuth2 string that can be fed
+directly to IMAP or SMTP. This is triggered with the --generate_oauth2_string
+option.
+
+ oauth2 --generate_oauth2_string --user=xxx@gmail.com \
+ --access_token=ya29.AGy[...]ezLg
+
+The output of this mode will be a base64-encoded string. To use it, connect to a
+IMAPFE and pass it as the second argument to the AUTHENTICATE command.
+
+ a AUTHENTICATE XOAUTH2 a9sha9sfs[...]9dfja929dk==
+"""
+
+import base64
+import imaplib
+import json
+from optparse import OptionParser
+import smtplib
+import sys
+import urllib
+
+
+def SetupOptionParser():
+ # Usage message is the module's docstring.
+ parser = OptionParser(usage=__doc__)
+ parser.add_option('--generate_oauth2_token',
+ action='store_true',
+ dest='generate_oauth2_token',
+ help='generates an OAuth2 token for testing')
+ parser.add_option('--generate_oauth2_string',
+ action='store_true',
+ dest='generate_oauth2_string',
+ help='generates an initial client response string for '
+ 'OAuth2')
+ parser.add_option('--client_id',
+ default=None,
+ help='Client ID of the application that is authenticating. '
+ 'See OAuth2 documentation for details.')
+ parser.add_option('--client_secret',
+ default=None,
+ help='Client secret of the application that is '
+ 'authenticating. See OAuth2 documentation for '
+ 'details.')
+ parser.add_option('--access_token',
+ default=None,
+ help='OAuth2 access token')
+ parser.add_option('--refresh_token',
+ default=None,
+ help='OAuth2 refresh token')
+ parser.add_option('--scope',
+ default='https://mail.google.com/',
+ help='scope for the access token. Multiple scopes can be '
+ 'listed separated by spaces with the whole argument '
+ 'quoted.')
+ parser.add_option('--test_imap_authentication',
+ action='store_true',
+ dest='test_imap_authentication',
+ help='attempts to authenticate to IMAP')
+ parser.add_option('--test_smtp_authentication',
+ action='store_true',
+ dest='test_smtp_authentication',
+ help='attempts to authenticate to SMTP')
+ parser.add_option('--user',
+ default=None,
+ help='email address of user whose account is being '
+ 'accessed')
+ parser.add_option('--quiet',
+ action='store_true',
+ default=False,
+ dest='quiet',
+ help='Omit verbose descriptions and only print '
+ 'machine-readable outputs.')
+ return parser
+
+
+# The URL root for accessing Google Accounts.
+GOOGLE_ACCOUNTS_BASE_URL = 'https://accounts.google.com'
+
+
+# Hardcoded dummy redirect URI for non-web apps.
+REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
+
+
+def AccountsUrl(command):
+ """Generates the Google Accounts URL.
+
+ Args:
+ command: The command to execute.
+
+ Returns:
+ A URL for the given command.
+ """
+ return '%s/%s' % (GOOGLE_ACCOUNTS_BASE_URL, command)
+
+
+def UrlEscape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urllib.quote(text, safe='~-._')
+
+
+def UrlUnescape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urllib.unquote(text)
+
+
+def FormatUrlParams(params):
+ """Formats parameters into a URL query string.
+
+ Args:
+ params: A key-value map.
+
+ Returns:
+ A URL query string version of the given parameters.
+ """
+ param_fragments = []
+ for param in sorted(params.iteritems(), key=lambda x: x[0]):
+ param_fragments.append('%s=%s' % (param[0], UrlEscape(param[1])))
+ return '&'.join(param_fragments)
+
+
+def GeneratePermissionUrl(client_id, scope='https://mail.google.com/'):
+ """Generates the URL for authorizing access.
+
+ This uses the "OAuth2 for Installed Applications" flow described at
+ https://developers.google.com/accounts/docs/OAuth2InstalledApp
+
+ Args:
+ client_id: Client ID obtained by registering your app.
+ scope: scope for access token, e.g. 'https://mail.google.com'
+ Returns:
+ A URL that the user should visit in their browser.
+ """
+ params = {}
+ params['client_id'] = client_id
+ params['redirect_uri'] = REDIRECT_URI
+ params['scope'] = scope
+ params['response_type'] = 'code'
+ return '%s?%s' % (AccountsUrl('o/oauth2/auth'),
+ FormatUrlParams(params))
+
+
+def AuthorizeTokens(client_id, client_secret, authorization_code):
+ """Obtains OAuth access token and refresh token.
+
+ This uses the application portion of the "OAuth2 for Installed Applications"
+ flow at https://developers.google.com/accounts/docs/OAuth2InstalledApp#handlingtheresponse
+
+ Args:
+ client_id: Client ID obtained by registering your app.
+ client_secret: Client secret obtained by registering your app.
+ authorization_code: code generated by Google Accounts after user grants
+ permission.
+ Returns:
+ The decoded response from the Google Accounts server, as a dict. Expected
+ fields include 'access_token', 'expires_in', and 'refresh_token'.
+ """
+ params = {}
+ params['client_id'] = client_id
+ params['client_secret'] = client_secret
+ params['code'] = authorization_code
+ params['redirect_uri'] = REDIRECT_URI
+ params['grant_type'] = 'authorization_code'
+ request_url = AccountsUrl('o/oauth2/token')
+
+ response = urllib.urlopen(request_url, urllib.urlencode(params)).read()
+ return json.loads(response)
+
+
+def RefreshToken(client_id, client_secret, refresh_token):
+ """Obtains a new token given a refresh token.
+
+ See https://developers.google.com/accounts/docs/OAuth2InstalledApp#refresh
+
+ Args:
+ client_id: Client ID obtained by registering your app.
+ client_secret: Client secret obtained by registering your app.
+ refresh_token: A previously-obtained refresh token.
+ Returns:
+ The decoded response from the Google Accounts server, as a dict. Expected
+ fields include 'access_token', 'expires_in', and 'refresh_token'.
+ """
+ params = {}
+ params['client_id'] = client_id
+ params['client_secret'] = client_secret
+ params['refresh_token'] = refresh_token
+ params['grant_type'] = 'refresh_token'
+ request_url = AccountsUrl('o/oauth2/token')
+
+ response = urllib.urlopen(request_url, urllib.urlencode(params)).read()
+ return json.loads(response)
+
+
+def GenerateOAuth2String(username, access_token, base64_encode=True):
+ """Generates an IMAP OAuth2 authentication string.
+
+ See https://developers.google.com/google-apps/gmail/oauth2_overview
+
+ Args:
+ username: the username (email address) of the account to authenticate
+ access_token: An OAuth2 access token.
+ base64_encode: Whether to base64-encode the output.
+
+ Returns:
+ The SASL argument for the OAuth2 mechanism.
+ """
+ auth_string = 'user=%s\1auth=Bearer %s\1\1' % (username, access_token)
+ if base64_encode:
+ auth_string = base64.b64encode(auth_string)
+ return auth_string
+
+
+def TestImapAuthentication(user, auth_string):
+ """Authenticates to IMAP with the given auth_string.
+
+ Prints a debug trace of the attempted IMAP connection.
+
+ Args:
+ user: The Gmail username (full email address)
+ auth_string: A valid OAuth2 string, as returned by GenerateOAuth2String.
+ Must not be base64-encoded, since imaplib does its own base64-encoding.
+ """
+ print
+ imap_conn = imaplib.IMAP4_SSL('imap.gmail.com')
+ imap_conn.debug = 4
+ imap_conn.authenticate('XOAUTH2', lambda x: auth_string)
+ imap_conn.select('INBOX')
+
+
+def TestSmtpAuthentication(user, auth_string):
+ """Authenticates to SMTP with the given auth_string.
+
+ Args:
+ user: The Gmail username (full email address)
+ auth_string: A valid OAuth2 string, not base64-encoded, as returned by
+ GenerateOAuth2String.
+ """
+ print
+ smtp_conn = smtplib.SMTP('smtp.gmail.com', 587)
+ smtp_conn.set_debuglevel(True)
+ smtp_conn.ehlo('test')
+ smtp_conn.starttls()
+ smtp_conn.docmd('AUTH', 'XOAUTH2 ' + base64.b64encode(auth_string))
+
+
+def RequireOptions(options, *args):
+ missing = [arg for arg in args if getattr(options, arg) is None]
+ if missing:
+ print 'Missing options: %s' % ' '.join(missing)
+ sys.exit(-1)
+
+
+def main(argv):
+ options_parser = SetupOptionParser()
+ (options, args) = options_parser.parse_args()
+ if options.refresh_token:
+ RequireOptions(options, 'client_id', 'client_secret')
+ response = RefreshToken(options.client_id, options.client_secret,
+ options.refresh_token)
+ if options.quiet:
+ print response['access_token']
+ else:
+ print 'Access Token: %s' % response['access_token']
+ print 'Access Token Expiration Seconds: %s' % response['expires_in']
+ elif options.generate_oauth2_string:
+ RequireOptions(options, 'user', 'access_token')
+ oauth2_string = GenerateOAuth2String(options.user, options.access_token)
+ if options.quiet:
+ print oauth2_string
+ else:
+ print 'OAuth2 argument:\n' + oauth2_string
+ elif options.generate_oauth2_token:
+ RequireOptions(options, 'client_id', 'client_secret')
+ print 'To authorize token, visit this url and follow the directions:'
+ print ' %s' % GeneratePermissionUrl(options.client_id, options.scope)
+ authorization_code = raw_input('Enter verification code: ')
+ response = AuthorizeTokens(options.client_id, options.client_secret,
+ authorization_code)
+ print 'Refresh Token: %s' % response['refresh_token']
+ print 'Access Token: %s' % response['access_token']
+ print 'Access Token Expiration Seconds: %s' % response['expires_in']
+ elif options.test_imap_authentication:
+ RequireOptions(options, 'user', 'access_token')
+ TestImapAuthentication(options.user,
+ GenerateOAuth2String(options.user, options.access_token,
+ base64_encode=False))
+ elif options.test_smtp_authentication:
+ RequireOptions(options, 'user', 'access_token')
+ TestSmtpAuthentication(options.user,
+ GenerateOAuth2String(options.user, options.access_token,
+ base64_encode=False))
+ else:
+ options_parser.print_help()
+ print 'Nothing to do, exiting.'
+ return
+
+
+if __name__ == '__main__':
+ main(sys.argv)
diff --git a/docker/usr/local/bin/httpd-foreground b/docker/usr/local/bin/httpd-foreground
new file mode 100644
index 0000000..0ed4c61
--- /dev/null
+++ b/docker/usr/local/bin/httpd-foreground
@@ -0,0 +1,30 @@
+#!/bin/bash
+#
+# Copyright (C) 2023 Xibo Signage Ltd
+#
+# Xibo - Digital Signage - https://xibosignage.com
+#
+# This file is part of Xibo.
+#
+# Xibo is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# Xibo is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Xibo. If not, see .
+#
+
+set -e
+
+# Apache gets grumpy about PID files pre-existing
+rm -rf /run/apache2/*
+
+source /etc/apache2/envvars
+
+/usr/sbin/apache2 -DFOREGROUND
diff --git a/docker/usr/local/bin/wait-for-command.sh b/docker/usr/local/bin/wait-for-command.sh
new file mode 100644
index 0000000..bfae12d
--- /dev/null
+++ b/docker/usr/local/bin/wait-for-command.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env sh
+#
+# Compare a command exit status to some given number(s) for a period of time.
+
+CMD_NAME="${0##*/}"
+CMD=""
+STATUS=0
+TIME=10
+TIME_START=0
+QUIET=0
+EXIT_STATUS=1
+
+usage() {
+ cat << EOF >&2
+Usage: ${CMD_NAME} [-c 'COMMAND']
+ ${CMD_NAME} [OPTION]... [-c 'COMMAND']
+ ${CMD_NAME} [-c 'COMMAND'] [OPTION]...
+
+${CMD_NAME} compares a command exit status to some given number(s)
+for a period of time. If comparison is successfully
+${CMD_NAME} returns 0, otherwise 1.
+
+Example: ${CMD_NAME} -c 'echo > /dev/tcp/127.0.0.1/5432'
+ ${CMD_NAME} -s 0 57 -c 'curl 127.0.0.1:5432'
+ ${CMD_NAME} -c 'nc -z 127.0.0.1 5432' -s 0 -t 20 -q
+
+Options:
+ -c, --command ['COMMAND'] execute a COMMAND.
+ -s, --status [NUMBER]... target exit status of COMMAND, default 0.
+ -t, --time [NUMBER] max time to wait in seconds, default 10.
+ -q, --quiet do not make any output, default false.
+ --help display this help.
+
+Notice that quotes are needed after -c/--command for multi-argument
+COMMANDs.
+
+Specifying a same OPTION more than once overrides the previews.
+So "${CMD_NAME} -c 'nothing' -c 'curl 127.0.0.1:5432'" will be
+the same as "${CMD_NAME} -c 'curl 127.0.0.1:5432'".
+It does not apply to option -q/--quiet.
+EOF
+ exit 0
+}
+
+output() {
+ if [ "${QUIET}" -ne 1 ]; then
+ printf "%s\n" "$*" 1>&2;
+ fi
+}
+
+process_command() {
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -c | --command)
+ # allow one shift when no arguments
+ if [ -n "$2" ]; then
+ CMD="$2"
+ shift 1
+ fi
+ shift 1
+ ;;
+ -s | --status)
+ # ensure that a number is provided
+ if ([ "$2" -eq "$2" ]) >/dev/null 2>&1; then
+ unset STATUS
+ # ensure that a number is provided
+ while ([ "$2" -eq "$2" ]) >/dev/null 2>&1; do
+ if [ -z "${STATUS}" ]; then
+ STATUS="$2"
+ shift 1
+ else
+ STATUS="${STATUS} $2"
+ shift 1
+ fi
+ done
+ fi
+ shift 1
+ ;;
+ -t | --time)
+ # ensure that a number is provided
+ if ([ "$2" -eq "$2" ]) >/dev/null 2>&1; then
+ TIME="$2"
+ shift 1
+ fi
+ shift 1
+ ;;
+ -q | --quiet)
+ QUIET=1
+ shift 1
+ ;;
+ --help)
+ usage
+ ;;
+ *)
+ output "Unknown argument: $1"
+ output "Try '${CMD_NAME} --help' for more information."
+ exit "${EXIT_STATUS}"
+ ;;
+ esac
+ done
+
+ if [ -z "${CMD}" ]; then
+ output "Missing command: -c, --command ['COMMAND']"
+ output "Try '${CMD_NAME} --help' for more information."
+ exit "${EXIT_STATUS}"
+ fi
+}
+
+main() {
+ message="failed"
+
+ process_command "$@"
+
+ TIME_START=$(date +%s)
+
+ while [ $(($(date +%s)-TIME_START)) -lt "${TIME}" ]; do
+
+ ($CMD) >/dev/null 2>&1 &
+ pid="$!"
+
+ # while both ps and time are running sleep 1s
+ while kill -0 "${pid}" >/dev/null 2>&1 &&
+ [ $(($(date +%s)-TIME_START)) -lt "${TIME}" ]; do
+ sleep 1
+ done
+
+ # gets CMD status
+ kill "${pid}" >/dev/null 2>&1
+ wait "${pid}" >/dev/null 2>&1
+ cmd_exit_status="$?"
+
+ # looks for equlity in CMD exit status and one of the given status
+ for i in $STATUS; do
+ if ([ "${cmd_exit_status}" -eq "${i}" ]) >/dev/null 2>&1; then
+ message="finished successfully"
+ EXIT_STATUS=0
+ break 2
+ fi
+ done
+
+ done
+
+ output "${CMD_NAME} ${message} after $(($(date +%s)-TIME_START)) second(s)."
+ output "cmd: ${CMD}"
+ output "target exit status: ${STATUS}"
+ output "exited status: ${cmd_exit_status}"
+
+ exit "${EXIT_STATUS}"
+}
+
+main "$@"
\ No newline at end of file
diff --git a/docker/var/www/.gnupg/private-keys-v1.d/.gitkeep b/docker/var/www/.gnupg/private-keys-v1.d/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docker/var/www/.gnupg/pubring.kbx b/docker/var/www/.gnupg/pubring.kbx
new file mode 100644
index 0000000..5ff3118
Binary files /dev/null and b/docker/var/www/.gnupg/pubring.kbx differ
diff --git a/docker/var/www/.gnupg/trustdb.gpg b/docker/var/www/.gnupg/trustdb.gpg
new file mode 100644
index 0000000..1f37dc0
Binary files /dev/null and b/docker/var/www/.gnupg/trustdb.gpg differ
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..76bfca5
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,42 @@
+import globals from 'globals';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import js from '@eslint/js';
+import {FlatCompat} from '@eslint/eslintrc';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+ recommendedConfig: js.configs.recommended,
+ allConfig: js.configs.all,
+});
+
+export default [...compat.extends('google'), {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ },
+
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+
+ settings: {},
+ rules: {
+ indent: ['error', 2, {
+ SwitchCase: 1,
+ }],
+
+ 'quote-props': ['warn', 'as-needed'],
+ 'dot-location': ['warn', 'property'],
+ 'linebreak-style': [0, 'error', 'windows'],
+ 'valid-jsdoc': 'off',
+ 'require-jsdoc': 'off',
+ 'new-cap': 'off',
+ 'no-const-assign': 'error',
+ },
+}, {
+ files: ['**/*.js'],
+ rules: {},
+}];
diff --git a/lib/Connector/AlphaVantageConnector.php b/lib/Connector/AlphaVantageConnector.php
new file mode 100644
index 0000000..8c7c960
--- /dev/null
+++ b/lib/Connector/AlphaVantageConnector.php
@@ -0,0 +1,657 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use GuzzleHttp\Exception\GuzzleException;
+use Stash\Invalidation;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\WidgetDataRequestEvent;
+use Xibo\Event\WidgetEditOptionRequestEvent;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * A connector to get data from the AlphaVantage API for use by the Currencies and Stocks Widgets
+ */
+class AlphaVantageConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(WidgetEditOptionRequestEvent::$NAME, [$this, 'onWidgetEditOption']);
+ $dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'alphavantage';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Alpha Vantage';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Get Currencies and Stocks data';
+ }
+
+ public function getThumbnail(): string
+ {
+ return '';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'alphavantage-form-settings';
+ }
+
+ /**
+ * @param SanitizerInterface $params
+ * @param array $settings
+ * @return array
+ */
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ if (!$this->isProviderSetting('apiKey')) {
+ $settings['apiKey'] = $params->getString('apiKey');
+ $settings['isPaidPlan'] = $params->getCheckbox('isPaidPlan');
+ $settings['cachePeriod'] = $params->getInt('cachePeriod');
+ }
+ return $settings;
+ }
+
+ /**
+ * If the requested dataSource is either Currencies or stocks, get the data, process it and add to dataProvider
+ *
+ * @param WidgetDataRequestEvent $event
+ * @return void
+ */
+ public function onDataRequest(WidgetDataRequestEvent $event)
+ {
+ $dataProvider = $event->getDataProvider();
+ if ($dataProvider->getDataSource() === 'currencies' || $dataProvider->getDataSource() === 'stocks') {
+ if (empty($this->getSetting('apiKey'))) {
+ $this->getLogger()->debug('onDataRequest: Alpha Vantage not configured.');
+ return;
+ }
+
+ $event->stopPropagation();
+
+ try {
+ if ($dataProvider->getDataSource() === 'stocks') {
+ $this->getStockResults($dataProvider);
+ } else if ($dataProvider->getDataSource() === 'currencies') {
+ $this->getCurrenciesResults($dataProvider);
+ }
+
+ // If we've got data, then set our cache period.
+ $event->getDataProvider()->setCacheTtl($this->getSetting('cachePeriod', 3600));
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
+ if ($exception instanceof InvalidArgumentException) {
+ $dataProvider->addError($exception->getMessage());
+ } else {
+ $dataProvider->addError(__('Unable to contact the AlphaVantage API'));
+ }
+ }
+ }
+ }
+
+ /**
+ * If the Widget type is stocks, process it and update options
+ *
+ * @param WidgetEditOptionRequestEvent $event
+ * @return void
+ * @throws NotFoundException
+ */
+ public function onWidgetEditOption(WidgetEditOptionRequestEvent $event): void
+ {
+ $this->getLogger()->debug('onWidgetEditOption');
+
+ // Pull the widget we're working with.
+ $widget = $event->getWidget();
+ if ($widget === null) {
+ throw new NotFoundException();
+ }
+
+ // We handle the stocks widget and the property with id="items"
+ if ($widget->type === 'stocks' && $event->getPropertyId() === 'items') {
+ if (empty($this->getSetting('apiKey'))) {
+ $this->getLogger()->debug('onWidgetEditOption: AlphaVantage API not configured.');
+ return;
+ }
+
+ try {
+ $results = [];
+ $bestMatches = $this->getSearchResults($event->getPropertyValue() ?? '');
+ $this->getLogger()->debug('onWidgetEditOption::getSearchResults => ' . var_export([
+ 'bestMatches' => $bestMatches,
+ ], true));
+
+ if ($bestMatches === false) {
+ $results[] = [
+ 'name' => strtoupper($event->getPropertyValue()),
+ 'type' => strtoupper(trim($event->getPropertyValue())),
+ 'id' => $event->getPropertyId(),
+ ];
+ } else if (count($bestMatches) > 0) {
+ foreach($bestMatches as $match) {
+ $results[] = [
+ 'name' => implode(' ', [$match['1. symbol'], $match['2. name']]),
+ 'type' => $match['1. symbol'],
+ 'id' => $event->getPropertyId(),
+ ];
+ }
+ }
+
+ $event->setOptions($results);
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onWidgetEditOption: Failed to get symbol search results. e = ' . $exception->getMessage());
+ }
+ }
+ }
+
+ /**
+ * Get Stocks data through symbol search
+ *
+ * @param string $keywords
+ * @return array|bool
+ * @throws GeneralException
+ */
+ private function getSearchResults(string $keywords): array|bool
+ {
+ try {
+ $this->getLogger()->debug('AlphaVantage Connector : getSearchResults is served from the API.');
+
+ $request = $this->getClient()->request('GET', 'https://avg.signcdn.com/query', [
+ 'query' => [
+ 'function' => 'SYMBOL_SEARCH',
+ 'keywords' => $keywords,
+ ]
+ ]);
+
+ $data = json_decode($request->getBody(), true);
+
+ if (array_key_exists('bestMatches', $data)) {
+ return $data['bestMatches'];
+ }
+
+ if (array_key_exists('Note', $data)) {
+ return false;
+ }
+
+ return [];
+
+ } catch (GuzzleException $guzzleException) {
+ throw new GeneralException(
+ 'Guzzle exception getting Stocks data . E = '
+ . $guzzleException->getMessage(),
+ $guzzleException->getCode(),
+ $guzzleException
+ );
+ }
+ }
+
+ /**
+ * Get Stocks data, parse it to an array and add each item to the dataProvider
+ *
+ * @throws ConfigurationException
+ * @throws InvalidArgumentException|GeneralException
+ */
+ private function getStockResults(DataProviderInterface $dataProvider): void
+ {
+ // Construct the YQL
+ // process items
+ $items = $dataProvider->getProperty('items');
+
+ if ($items == '') {
+ $this->getLogger()->error('Missing Items for Stocks Module with WidgetId ' . $dataProvider->getWidgetId());
+ throw new InvalidArgumentException(__('Add some stock symbols'), 'items');
+ }
+
+ // Parse items out into an array
+ $items = array_map('trim', explode(',', $items));
+
+ foreach ($items as $symbol) {
+ try {
+ // Does this symbol have any additional data
+ $parsedSymbol = explode('|', $symbol);
+
+ $symbol = $parsedSymbol[0];
+ $name = ($parsedSymbol[1] ?? $symbol);
+ $currency = ($parsedSymbol[2] ?? '');
+
+ $result = $this->getStockQuote($symbol, $this->getSetting('isPaidPlan'));
+
+ $this->getLogger()->debug(
+ 'AlphaVantage Connector : getStockResults data: ' .
+ var_export($result, true)
+ );
+
+ $item = [];
+
+ foreach ($result['Time Series (Daily)'] as $series) {
+ $item = [
+ 'Name' => $name,
+ 'Symbol' => $symbol,
+ 'time' => $result['Meta Data']['3. Last Refreshed'],
+ 'LastTradePriceOnly' => round($series['4. close'], 4),
+ 'RawLastTradePriceOnly' => $series['4. close'],
+ 'YesterdayTradePriceOnly' => round($series['1. open'], 4),
+ 'RawYesterdayTradePriceOnly' => $series['1. open'],
+ 'TimeZone' => $result['Meta Data']['5. Time Zone'],
+ 'Currency' => $currency
+ ];
+
+ $item['Change'] = round($item['RawLastTradePriceOnly'] - $item['RawYesterdayTradePriceOnly'], 4);
+ $item['SymbolTrimmed'] = explode('.', $item['Symbol'])[0];
+ $item = $this->decorateWithReplacements($item);
+ break;
+ }
+
+ // Parse the result and add it to our data array
+ $dataProvider->addItem($item);
+ $dataProvider->setIsHandled();
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ $this->getLogger()->error('Invalid symbol ' . $symbol . ', e: ' . $invalidArgumentException->getMessage());
+ throw new InvalidArgumentException(__('Invalid symbol ' . $symbol), 'items');
+ }
+ }
+ }
+
+ /**
+ * Call Alpha Vantage API to get Stocks data, different endpoint depending on the paidPlan
+ * cache results for cachePeriod defined in the Connector
+ *
+ * @param string $symbol
+ * @param ?int $isPaidPlan
+ * @return array
+ * @throws GeneralException
+ */
+ protected function getStockQuote(string $symbol, ?int $isPaidPlan): array
+ {
+ try {
+ $cache = $this->getPool()->getItem('/widget/stock/api_'.md5($symbol));
+ $cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
+
+ $data = $cache->get();
+
+ if ($cache->isMiss()) {
+ $this->getLogger()->debug('AlphaVantage Connector : getStockQuote is served from the API.');
+
+ $request = $this->getClient()->request('GET', 'https://www.alphavantage.co/query', [
+ 'query' => [
+ 'function' => $isPaidPlan === 1 ? 'TIME_SERIES_DAILY_ADJUSTED' : 'TIME_SERIES_DAILY',
+ 'symbol' => $symbol,
+ 'apikey' => $this->getSetting('apiKey')
+ ]
+ ]);
+
+ $data = json_decode($request->getBody(), true);
+
+ if (!array_key_exists('Time Series (Daily)', $data)) {
+ $this->getLogger()->debug('getStockQuote Data: ' . var_export($data, true));
+ throw new InvalidArgumentException(__('Stocks data invalid'), 'Time Series (Daily)');
+ }
+
+ // Cache this and expire in the cache period
+ $cache->set($data);
+ $cache->expiresAt(Carbon::now()->addSeconds($this->getSetting('cachePeriod', 14400)));
+
+ $this->getPool()->save($cache);
+ } else {
+ $this->getLogger()->debug('AlphaVantage Connector : getStockQuote is served from the cache.');
+ }
+
+ return $data;
+ } catch (GuzzleException $guzzleException) {
+ throw new GeneralException(
+ 'Guzzle exception getting Stocks data . E = ' .
+ $guzzleException->getMessage(),
+ $guzzleException->getCode(),
+ $guzzleException
+ );
+ }
+ }
+
+ /**
+ * Replacements shared between Stocks and Currencies
+ *
+ * @param array $item
+ * @return array
+ */
+ private function decorateWithReplacements(array $item): array
+ {
+ if (($item['Change'] == null || $item['LastTradePriceOnly'] == null)) {
+ $item['ChangePercentage'] = '0';
+ } else {
+ // Calculate the percentage dividing the change by the ( previous value minus the change )
+ $percentage = $item['Change'] / ( $item['LastTradePriceOnly'] - $item['Change'] );
+
+ // Convert the value to percentage and round it
+ $item['ChangePercentage'] = round($percentage*100, 2);
+ }
+
+ if (($item['Change'] != null && $item['LastTradePriceOnly'] != null)) {
+ if ($item['Change'] > 0) {
+ $item['ChangeIcon'] = 'up-arrow';
+ $item['ChangeStyle'] = 'value-up';
+ } else if ($item['Change'] < 0) {
+ $item['ChangeIcon'] = 'down-arrow';
+ $item['ChangeStyle'] = 'value-down';
+ }
+ } else {
+ $item['ChangeStyle'] = 'value-equal';
+ $item['ChangeIcon'] = 'right-arrow';
+ }
+
+ return $item;
+ }
+
+ /**
+ * Get Currencies data from Alpha Vantage, parse it and add to dataProvider
+ *
+ * @param DataProviderInterface $dataProvider
+ * @return void
+ * @throws InvalidArgumentException
+ */
+ private function getCurrenciesResults(DataProviderInterface $dataProvider): void
+ {
+ // What items/base currencies are we interested in?
+ $items = $dataProvider->getProperty('items');
+ $base = $dataProvider->getProperty('base');
+
+ if (empty($items) || empty($base)) {
+ $this->getLogger()->error(
+ 'Missing Items for Currencies Module with WidgetId ' .
+ $dataProvider->getWidgetId()
+ );
+ throw new InvalidArgumentException(
+ __('Missing Items for Currencies Module. Please provide items in order to proceed.'),
+ 'items'
+ );
+ }
+
+ // Does this require a reversed conversion?
+ $reverseConversion = ($dataProvider->getProperty('reverseConversion', 0) == 1);
+
+ // Is this paid plan?
+ $isPaidPlan = ($this->getSetting('isPaidPlan', 0) == 1);
+
+ // Parse items out into an array
+ $items = array_map('trim', explode(',', $items));
+
+ // Ensure base isn't also in the items list (Currencies)
+ if (in_array($base, $items)) {
+ $this->getLogger()->error(
+ 'Invalid Currencies: Base "' . $base . '" also included in Items for ' .
+ 'Currencies Module with WidgetId ' . $dataProvider->getWidgetId()
+ );
+ throw new InvalidArgumentException(
+ __('Base currency must not be included in the Currencies list. Please remove it and try again.'),
+ 'items'
+ );
+ }
+
+ // Each item we want is a call to the results API
+ try {
+ foreach ($items as $currency) {
+ // Remove the multiplier if there's one (this is handled when we substitute the results into
+ // the template)
+ $currency = explode('|', $currency)[0];
+
+ // Do we need to reverse the from/to currency for this comparison?
+ $result = $reverseConversion
+ ? $this->getCurrencyExchangeRate($currency, $base, $isPaidPlan)
+ : $this->getCurrencyExchangeRate($base, $currency, $isPaidPlan);
+
+ $this->getLogger()->debug(
+ 'AlphaVantage Connector : getCurrenciesResults are: ' .
+ var_export($result, true)
+ );
+
+ if ($isPaidPlan) {
+ $item = [
+ 'time' => $result['Realtime Currency Exchange Rate']['6. Last Refreshed'],
+ 'ToName' => $result['Realtime Currency Exchange Rate']['3. To_Currency Code'],
+ 'FromName' => $result['Realtime Currency Exchange Rate']['1. From_Currency Code'],
+ 'Bid' => round($result['Realtime Currency Exchange Rate']['5. Exchange Rate'], 4),
+ 'Ask' => round($result['Realtime Currency Exchange Rate']['5. Exchange Rate'], 4),
+ 'LastTradePriceOnly' => round($result['Realtime Currency Exchange Rate']['5. Exchange Rate'], 4),
+ 'RawLastTradePriceOnly' => $result['Realtime Currency Exchange Rate']['5. Exchange Rate'],
+ 'TimeZone' => $result['Realtime Currency Exchange Rate']['7. Time Zone'],
+ ];
+ } else {
+ $item = [
+ 'time' => $result['Meta Data']['5. Last Refreshed'],
+ 'ToName' => $result['Meta Data']['3. To Symbol'],
+ 'FromName' => $result['Meta Data']['2. From Symbol'],
+ 'Bid' => round(array_values($result['Time Series FX (Daily)'])[0]['1. open'], 4),
+ 'Ask' => round(array_values($result['Time Series FX (Daily)'])[0]['1. open'], 4),
+ 'LastTradePriceOnly' => round(array_values($result['Time Series FX (Daily)'])[0]['1. open'], 4),
+ 'RawLastTradePriceOnly' => array_values($result['Time Series FX (Daily)'])[0]['1. open'],
+ 'TimeZone' => $result['Meta Data']['6. Time Zone'],
+ ];
+ }
+
+ // Set the name/currency to be the full name including the base currency
+ $item['Name'] = $item['FromName'] . '/' . $item['ToName'];
+ $currencyName = ($reverseConversion) ? $item['FromName'] : $item['ToName'];
+ $item['NameShort'] = $currencyName;
+
+ // work out the change when compared to the previous day
+
+ // We need to get the prior day for this pair only (reversed)
+ $priorDay = $reverseConversion
+ ? $this->getCurrencyPriorDay($currency, $base, $isPaidPlan)
+ : $this->getCurrencyPriorDay($base, $currency, $isPaidPlan);
+
+ /*$this->getLog()->debug('Percentage change requested, prior day is '
+ . var_export($priorDay['Time Series FX (Daily)'], true));*/
+
+ $priorDay = count($priorDay['Time Series FX (Daily)']) < 2
+ ? ['1. open' => 1]
+ : array_values($priorDay['Time Series FX (Daily)'])[1];
+
+ $item['YesterdayTradePriceOnly'] = $priorDay['1. open'];
+ $item['Change'] = $item['RawLastTradePriceOnly'] - $item['YesterdayTradePriceOnly'];
+
+
+ $item = $this->decorateWithReplacements($item);
+
+ $this->getLogger()->debug(
+ 'AlphaVantage Connector : Parsed getCurrenciesResults are: ' .
+ var_export($item, true)
+ );
+
+ $dataProvider->addItem($item);
+ $dataProvider->setIsHandled();
+ }
+ } catch (GeneralException $requestException) {
+ $this->getLogger()->error('Problem getting currency information. E = ' . $requestException->getMessage());
+ $this->getLogger()->debug($requestException->getTraceAsString());
+ return;
+ }
+ }
+
+ /**
+ * Call Alpha Vantage API to get Currencies data, different endpoint depending on the paidPlan
+ * cache results for cachePeriod defined on the Connector
+ *
+ * @param string $fromCurrency
+ * @param string $toCurrency
+ * @param bool $isPaidPlan
+ * @return mixed
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ */
+ private function getCurrencyExchangeRate(string $fromCurrency, string $toCurrency, bool $isPaidPlan)
+ {
+ try {
+ $cache = $this->getPool()->getItem('/widget/currency/' . md5($fromCurrency . $toCurrency . $isPaidPlan));
+ $cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
+
+ $data = $cache->get();
+
+ if ($cache->isMiss()) {
+ $this->getLogger()->debug('AlphaVantage Connector : getCurrencyExchangeRate is served from the API.');
+ // Use a different function depending on whether we have a paid plan or not.
+ if ($isPaidPlan) {
+ $query = [
+ 'function' => 'CURRENCY_EXCHANGE_RATE',
+ 'from_currency' => $fromCurrency,
+ 'to_currency' => $toCurrency,
+ ];
+ } else {
+ $query = [
+ 'function' => 'FX_DAILY',
+ 'from_symbol' => $fromCurrency,
+ 'to_symbol' => $toCurrency,
+ ];
+ }
+ $query['apikey'] = $this->getSetting('apiKey');
+
+ $request = $this->getClient()->request('GET', 'https://www.alphavantage.co/query', [
+ 'query' => $query
+ ]);
+
+ $data = json_decode($request->getBody(), true);
+
+ if ($isPaidPlan) {
+ if (!array_key_exists('Realtime Currency Exchange Rate', $data)) {
+ $this->getLogger()->debug('Data: ' . var_export($data, true));
+ throw new InvalidArgumentException(
+ __('Currency data invalid'),
+ 'Realtime Currency Exchange Rate'
+ );
+ }
+ } else {
+ if (!array_key_exists('Meta Data', $data)) {
+ $this->getLogger()->debug('Data: ' . var_export($data, true));
+ throw new InvalidArgumentException(__('Currency data invalid'), 'Meta Data');
+ }
+
+ if (!array_key_exists('Time Series FX (Daily)', $data)) {
+ $this->getLogger()->debug('Data: ' . var_export($data, true));
+ throw new InvalidArgumentException(__('Currency data invalid'), 'Time Series FX (Daily)');
+ }
+ }
+
+ // Cache this and expire in the cache period
+ $cache->set($data);
+ $cache->expiresAt(Carbon::now()->addSeconds($this->getSetting('cachePeriod', 14400)));
+
+ $this->getPool()->save($cache);
+ } else {
+ $this->getLogger()->debug('AlphaVantage Connector : getCurrencyExchangeRate is served from the cache.');
+ }
+
+ return $data;
+ } catch (GuzzleException $guzzleException) {
+ throw new GeneralException(
+ 'Guzzle exception getting currency exchange rate. E = ' .
+ $guzzleException->getMessage(),
+ $guzzleException->getCode(),
+ $guzzleException
+ );
+ }
+ }
+
+ /**
+ * Call Alpha Vantage API to get currencies data, cache results for a day
+ *
+ * @param $fromCurrency
+ * @param $toCurrency
+ * @param $isPaidPlan
+ * @return mixed
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ */
+ private function getCurrencyPriorDay($fromCurrency, $toCurrency, $isPaidPlan)
+ {
+ if ($isPaidPlan) {
+ $key = md5($fromCurrency . $toCurrency . Carbon::yesterday()->format('Y-m-d') . '1');
+ } else {
+ $key = md5($fromCurrency . $toCurrency . '0');
+ }
+
+ try {
+ $cache = $this->getPool()->getItem('/widget/Currencies/' . $key);
+ $cache->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
+
+ $data = $cache->get();
+
+ if ($cache->isMiss()) {
+ $this->getLogger()->debug('AlphaVantage Connector : getPriorDay is served from the API.');
+
+ // Use a web request
+ $request = $this->getClient()->request('GET', 'https://www.alphavantage.co/query', [
+ 'query' => [
+ 'function' => 'FX_DAILY',
+ 'from_symbol' => $fromCurrency,
+ 'to_symbol' => $toCurrency,
+ 'apikey' => $this->getSetting('apiKey')
+ ]
+ ]);
+
+ $data = json_decode($request->getBody(), true);
+
+ if (!array_key_exists('Meta Data', $data)) {
+ $this->getLogger()->debug('Data: ' . var_export($data, true));
+ throw new InvalidArgumentException(__('Currency data invalid'), 'Meta Data');
+ }
+
+ if (!array_key_exists('Time Series FX (Daily)', $data)) {
+ $this->getLogger()->debug('Data: ' . var_export($data, true));
+ throw new InvalidArgumentException(__('Currency data invalid'), 'Time Series FX (Daily)');
+ }
+
+ // Cache this and expire tomorrow (results are valid for the entire day regardless of settings)
+ $cache->set($data);
+ $cache->expiresAt(Carbon::tomorrow());
+
+ $this->getPool()->save($cache);
+ } else {
+ $this->getLogger()->debug('AlphaVantage Connector : getPriorDay is served from the cache.');
+ }
+
+ return $data;
+ } catch (GuzzleException $guzzleException) {
+ throw new GeneralException(
+ 'Guzzle exception getting currency exchange rate. E = ' .
+ $guzzleException->getMessage(),
+ $guzzleException->getCode(),
+ $guzzleException
+ );
+ }
+ }
+}
diff --git a/lib/Connector/CapConnector.php b/lib/Connector/CapConnector.php
new file mode 100644
index 0000000..54f4326
--- /dev/null
+++ b/lib/Connector/CapConnector.php
@@ -0,0 +1,575 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use DOMDocument;
+use DOMElement;
+use Exception;
+use GuzzleHttp\Exception\GuzzleException;
+use GuzzleHttp\Exception\RequestException;
+use Location\Coordinate;
+use Location\Polygon;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\ContainerInterface;
+use Psr\Container\NotFoundExceptionInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\ScheduleCriteriaRequestEvent;
+use Xibo\Event\ScheduleCriteriaRequestInterface;
+use Xibo\Event\WidgetDataRequestEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\XMR\ScheduleCriteriaUpdateAction;
+
+/**
+ * A connector to process Common Alerting Protocol (CAP) Data
+ */
+class CapConnector implements ConnectorInterface, EmergencyAlertInterface
+{
+ use ConnectorTrait;
+
+ /** @var DOMDocument */
+ protected DOMDocument $capXML;
+
+ /** @var DOMElement */
+ protected DOMElement $infoNode;
+
+ /** @var DOMElement */
+ protected DOMElement $areaNode;
+
+ /** @var DisplayFactory */
+ private DisplayFactory $displayFactory;
+
+ /**
+ * @param ContainerInterface $container
+ * @return ConnectorInterface
+ * @throws ContainerExceptionInterface
+ * @throws NotFoundExceptionInterface
+ */
+ public function setFactories(ContainerInterface $container): ConnectorInterface
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ return $this;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ $dispatcher->addListener(ScheduleCriteriaRequestEvent::$NAME, [$this, 'onScheduleCriteriaRequest']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'cap-connector';
+ }
+
+ public function getTitle(): string
+ {
+ return 'CAP Connector';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Common Alerting Protocol';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/xibo-cap.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return '';
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ return [];
+ }
+
+ /**
+ * If the requested dataSource is emergency-alert, get the data, process it and add to dataProvider
+ *
+ * @param WidgetDataRequestEvent $event
+ * @return void
+ * @throws GuzzleException
+ */
+ public function onDataRequest(WidgetDataRequestEvent $event): void
+ {
+ if ($event->getDataProvider()->getDataSource() !== 'emergency-alert') {
+ return;
+ }
+
+ $event->stopPropagation();
+
+ try {
+ // check if CAP URL is present
+ if (empty($event->getDataProvider()->getProperty('emergencyAlertUri'))) {
+ $this->getLogger()->debug('onDataRequest: Emergency alert not configured.');
+ $event->getDataProvider()->addError(__('Missing CAP URL'));
+ return;
+ }
+
+ // Set cache expiry date to 3 minutes from now
+ $cacheExpire = Carbon::now()->addMinutes(3);
+
+ // Fetch the CAP XML content from the given URL
+ $xmlContent = $this->fetchCapAlertFromUrl($event->getDataProvider(), $cacheExpire);
+
+ if ($xmlContent) {
+ // Initialize DOMDocument and load the XML content
+ $this->capXML = new DOMDocument();
+ $this->capXML->loadXML($xmlContent);
+
+ // Process and initialize CAP data
+ $this->processCapData($event->getDataProvider());
+
+ // Initialize update interval
+ $updateIntervalMinute = $event->getDataProvider()->getProperty('updateInterval');
+
+ // Convert the $updateIntervalMinute to seconds
+ $updateInterval = $updateIntervalMinute * 60;
+
+ // If we've got data, then set our cache period.
+ $event->getDataProvider()->setCacheTtl($updateInterval);
+ $event->getDataProvider()->setIsHandled();
+
+ $capStatus = $this->getCapXmlData('status');
+ $category = $this->getCapXmlData('category');
+ } else {
+ $capStatus = 'No Alerts';
+ $category = '';
+ }
+
+ // initialize status for schedule criteria push message
+ if ($capStatus == 'Actual') {
+ $status = self::ACTUAL_ALERT;
+ } elseif ($capStatus == 'No Alerts') {
+ $status = self::NO_ALERT;
+ } else {
+ $status = self::TEST_ALERT;
+ }
+
+ $this->getLogger()->debug('Schedule criteria push message: status = ' . $status
+ . ', category = ' . $category);
+
+ // Set ttl expiry to 180s since widget sync task runs every 180s and add a bit of buffer
+ $ttl = max($updateInterval ?? 180, 180) + 60;
+
+ // Set schedule criteria update
+ $action = new ScheduleCriteriaUpdateAction();
+
+ // Adjust the QOS value lower than the data update QOS to ensure it arrives first
+ $action->setQos(3);
+ $action->setCriteriaUpdates([
+ ['metric' => 'emergency_alert_status', 'value' => $status, 'ttl' => $ttl],
+ ['metric' => 'emergency_alert_category', 'value' => $category, 'ttl' => $ttl]
+ ]);
+
+ // Initialize the display
+ $displayId = $event->getDataProvider()->getDisplayId();
+ $display = $this->displayFactory->getById($displayId);
+
+ // Criteria push message
+ $this->getPlayerActionService()->sendAction($display, $action);
+ } catch (Exception $exception) {
+ $this->getLogger()
+ ->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
+ $event->getDataProvider()->addError(__('Unable to get Common Alerting Protocol (CAP) results.'));
+ }
+ }
+
+ /**
+ * Get and process the CAP data
+ *
+ * @throws Exception
+ */
+ private function processCapData(DataProviderInterface $dataProvider): void
+ {
+ // Array to store configuration data
+ $config = [];
+
+ // Initialize configuration data
+ $config['status'] = $dataProvider->getProperty('status');
+ $config['msgType'] = $dataProvider->getProperty('msgType');
+ $config['scope'] = $dataProvider->getProperty('scope');
+ $config['category'] = $dataProvider->getProperty('category');
+ $config['responseType'] = $dataProvider->getProperty('responseType');
+ $config['urgency'] = $dataProvider->getProperty('urgency');
+ $config['severity'] = $dataProvider->getProperty('severity');
+ $config['certainty'] = $dataProvider->getProperty('certainty');
+ $config['isAreaSpecific'] = $dataProvider->getProperty('isAreaSpecific');
+
+ // Retrieve specific values from the CAP XML for filtering
+ $status = $this->getCapXmlData('status');
+ $msgType = $this->getCapXmlData('msgType');
+ $scope = $this->getCapXmlData('scope');
+
+ // Check if the retrieved CAP data matches the configuration filters
+ if (!$this->matchesFilter($status, $config['status']) ||
+ !$this->matchesFilter($msgType, $config['msgType']) ||
+ !$this->matchesFilter($scope, $config['scope'])) {
+ return;
+ }
+
+ // Array to store CAP values
+ $cap = [];
+
+ // Initialize CAP values
+ $cap['source'] = $this->getCapXmlData('source');
+ $cap['note'] = $this->getCapXmlData('note');
+
+ // Get all elements
+ $infoNodes = $this->capXML->getElementsByTagName('info');
+
+ foreach ($infoNodes as $infoNode) {
+ $this->infoNode = $infoNode;
+
+ // Extract values from the current node for filtering
+ $category = $this->getInfoData('category');
+ $responseType = $this->getInfoData('responseType');
+ $urgency = $this->getInfoData('urgency');
+ $severity = $this->getInfoData('severity');
+ $certainty = $this->getInfoData('certainty');
+
+ // Check if the current node matches all filters
+ if (!$this->matchesFilter($category, $config['category']) ||
+ !$this->matchesFilter($responseType, $config['responseType']) ||
+ !$this->matchesFilter($urgency, $config['urgency']) ||
+ !$this->matchesFilter($severity, $config['severity']) ||
+ !$this->matchesFilter($certainty, $config['certainty'])) {
+ continue;
+ }
+
+ // Initialize the rest of the CAP values
+ $cap['event'] = $this->getInfoData('event');
+ $cap['urgency'] = $this->getInfoData('urgency');
+ $cap['severity'] = $this->getInfoData('severity');
+ $cap['certainty'] = $this->getInfoData('certainty');
+ $cap['dateTimeEffective'] = $this->getInfoData('effective');
+ $cap['dateTimeOnset'] = $this->getInfoData('onset');
+ $cap['dateTimeExpires'] = $this->getInfoData('expires');
+ $cap['senderName'] = $this->getInfoData('senderName');
+ $cap['headline'] = $this->getInfoData('headline');
+ $cap['description'] = $this->getInfoData('description');
+ $cap['instruction'] = $this->getInfoData('instruction');
+ $cap['contact'] = $this->getInfoData('contact');
+
+ // Retrieve all elements within the current element
+ $areaNodes = $this->infoNode->getElementsByTagName('area');
+
+ if (empty($areaNodes->length)) {
+ // If we don't have elements, then provide CAP without the Area
+ $dataProvider->addItem($cap);
+ } else {
+ // Iterate through each element
+ foreach ($areaNodes as $areaNode) {
+ $this->areaNode = $areaNode;
+
+ $circle = $this->getAreaData('circle');
+ $polygon = $this->getAreaData('polygon');
+ $cap['areaDesc'] = $this->getAreaData('areaDesc');
+
+ // Check if the area-specific filter is enabled
+ if ($config['isAreaSpecific']) {
+ if ($circle || $polygon) {
+ // Get the current display coordinates
+ $displayLatitude = $dataProvider->getDisplayLatitude();
+ $displayLongitude = $dataProvider->getDisplayLongitude();
+
+ // Retrieve area coordinates (circle or polygon) from CAP XML
+ $areaCoordinates = $this->getAreaCoordinates();
+
+ // Check if display coordinates matches the CAP alert area
+ if ($this->isWithinArea($displayLatitude, $displayLongitude, $areaCoordinates)) {
+ $dataProvider->addItem($cap);
+ }
+ } else {
+ // Provide CAP data if no coordinate/s is provided
+ $dataProvider->addItem($cap);
+ }
+ } else {
+ // Provide CAP data if area-specific filter is disabled
+ $dataProvider->addItem($cap);
+ }
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Fetches the CAP (Common Alerting Protocol) XML data from the provided emergency alert URL.
+ *
+ * @param DataProviderInterface $dataProvider
+ * @param Carbon $cacheExpiresAt
+ *
+ * @return string|null
+ * @throws GuzzleException
+ */
+ private function fetchCapAlertFromUrl(DataProviderInterface $dataProvider, Carbon $cacheExpiresAt): string|null
+ {
+ $emergencyAlertUrl = $dataProvider->getProperty('emergencyAlertUri');
+
+ $cache = $this->pool->getItem('/emergency-alert/cap/' . md5($emergencyAlertUrl));
+ $data = $cache->get();
+
+ if ($cache->isMiss()) {
+ $cache->lock();
+ $this->getLogger()->debug('Getting CAP data from CAP Feed');
+
+ $httpOptions = [
+ 'timeout' => 20, // Wait no more than 20 seconds
+ ];
+
+ try {
+ // Make a GET request to the CAP URL using Guzzle HTTP client with defined options
+ $response = $dataProvider
+ ->getGuzzleClient($httpOptions)
+ ->get($emergencyAlertUrl);
+
+ $this->getLogger()->debug('CAP Feed: uri: ' . $emergencyAlertUrl . ' httpOptions: '
+ . json_encode($httpOptions));
+
+ // Get the response body as a string
+ $data = $response->getBody()->getContents();
+
+ // Cache
+ $cache->set($data);
+ $cache->expiresAt($cacheExpiresAt);
+ $this->pool->saveDeferred($cache);
+ } catch (RequestException $e) {
+ // Log the error with a message specific to CAP data fetching
+ $this->getLogger()->error('Unable to reach the CAP feed URL: '
+ . $emergencyAlertUrl . ' Error: ' . $e->getMessage());
+
+ // Throw a more specific exception message
+ $dataProvider->addError(__('Failed to retrieve CAP data from the specified URL.'));
+ }
+ } else {
+ $this->getLogger()->debug('Getting CAP data from cache');
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the value of a specified tag from the CAP XML document.
+ *
+ * @param string $tagName
+ * @return string|null
+ */
+ private function getCapXmlData(string $tagName): ?string
+ {
+ // Ensure the XML is loaded and the tag exists
+ $node = $this->capXML->getElementsByTagName($tagName)->item(0);
+
+ // Return the node value if the node exists, otherwise return an empty string
+ return $node ? $node->nodeValue : '';
+ }
+
+ /**
+ * Get the value of a specified tag from the current node.
+ *
+ * @param string $tagName
+ * @return string|null
+ */
+ private function getInfoData(string $tagName): ?string
+ {
+ // Ensure the tag exists within the provided node
+ $node = $this->infoNode->getElementsByTagName($tagName)->item(0);
+
+ // Return the node value if the node exists, otherwise return an empty string
+ return $node ? $node->nodeValue : '';
+ }
+
+ /**
+ * Get the value of a specified tag from the current node.
+ *
+ * @param string $tagName
+ * @return string|null
+ */
+ private function getAreaData(string $tagName): ?string
+ {
+ // Ensure the tag exists within the provided node
+ $node = $this->areaNode->getElementsByTagName($tagName)->item(0);
+
+ // Return the node value if the node exists, otherwise return an empty string
+ return $node ? $node->nodeValue : '';
+ }
+
+ /**
+ * Check if the value of a CAP XML element matches the expected filter value.
+ *
+ * @param string $actualValue
+ * @param string $expectedValue
+ *
+ * @return bool
+ */
+ private function matchesFilter(string $actualValue, string $expectedValue): bool
+ {
+ // If the expected value is 'Any' (empty string) or matches the actual value, the filter passes
+ if (empty($expectedValue) || $expectedValue == $actualValue) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get area coordinates from CAP XML data.
+ *
+ * Determines if the area is defined as a circle or polygon
+ * and returns the relevant data.
+ *
+ * @return array An array with the area type and coordinates.
+ */
+ private function getAreaCoordinates(): array
+ {
+ // array to store coordinates data
+ $area = [];
+
+ // Check for a circle area element
+ $circle = $this->getAreaData('circle');
+ if ($circle) {
+ // Split the circle data into center coordinates and radius
+ $circleParts = explode(' ', $circle);
+ $center = explode(',', $circleParts[0]); // "latitude,longitude"
+ $radius = $circleParts[1];
+
+ $area['type'] = 'circle';
+ $area['center'] = ['lat' => $center[0], 'lon' => $center[1]];
+ $area['radius'] = $radius;
+ return $area;
+ }
+
+ // Check for a polygon area element
+ $polygon = $this->getAreaData('polygon');
+ if ($polygon) {
+ // Split the polygon data into multiple points ("lat1,lon1 lat2,lon2 ...")
+ $points = explode(' ', $polygon);
+
+ // Array to store multiple coordinates
+ $polygonPoints = [];
+
+ foreach ($points as $point) {
+ $coords = explode(',', $point);
+ $polygonPoints[] = ['lat' => $coords[0], 'lon' => $coords[1]];
+ }
+
+ $area['type'] = 'polygon';
+ $area['points'] = $polygonPoints;
+ }
+
+ return $area;
+ }
+
+ /**
+ * Checks if the provided display coordinates are inside a defined area (circle or polygon).
+ * If no area coordinates are available, it returns false.
+ *
+ * @param float $displayLatitude
+ * @param float $displayLongitude
+ * @param array $areaCoordinates The coordinates defining the area (circle or polygon).
+ *
+ * @return bool
+ */
+ private function isWithinArea(float $displayLatitude, float $displayLongitude, array $areaCoordinates): bool
+ {
+ if (empty($areaCoordinates)) {
+ // No area coordinates available
+ return false;
+ }
+
+ // Initialize the display coordinate
+ $displayCoordinate = new Coordinate($displayLatitude, $displayLongitude);
+
+ if ($areaCoordinates['type'] == 'circle') {
+ // Initialize the circle's coordinate and radius
+ $centerCoordinate = new Coordinate($areaCoordinates['center']['lat'], $areaCoordinates['center']['lon']);
+ $radius = $areaCoordinates['radius'];
+
+ // Check if the display is within the specified radius of the center coordinate
+ if ($centerCoordinate->hasSameLocation($displayCoordinate, $radius)) {
+ return true;
+ }
+ } else {
+ // Initialize a new polygon
+ $geofence = new Polygon();
+
+ // Add each point to the polygon
+ foreach ($areaCoordinates['points'] as $point) {
+ $geofence->addPoint(new Coordinate($point['lat'], $point['lon']));
+ }
+
+ // Check if the display is within the polygon
+ if ($geofence->contains($displayCoordinate)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param ScheduleCriteriaRequestInterface $event
+ * @return void
+ * @throws ConfigurationException
+ */
+ public function onScheduleCriteriaRequest(ScheduleCriteriaRequestInterface $event): void
+ {
+ // Initialize Emergency Alerts schedule criteria parameters
+ $event->addType('emergency_alert', __('Emergency Alerts'))
+ ->addMetric('emergency_alert_status', __('Status'))
+ ->addCondition([
+ 'eq' => __('Equal to')
+ ])
+ ->addValues('dropdown', [
+ self::ACTUAL_ALERT => __('Actual Alerts'),
+ self::TEST_ALERT => __('Test Alerts'),
+ self::NO_ALERT => __('No Alerts')
+ ])
+ ->addMetric('emergency_alert_category', __('Category'))
+ ->addCondition([
+ 'eq' => __('Equal to')
+ ])
+ ->addValues('dropdown', [
+ 'Geo' => __('Geo'),
+ 'Met' => __('Met'),
+ 'Safety' => __('Safety'),
+ 'Security' => __('Security'),
+ 'Rescue' => __('Rescue'),
+ 'Fire' => __('Fire'),
+ 'Health' => __('Health'),
+ 'Env' => __('Env'),
+ 'Transport' => __('Transport'),
+ 'Infra' => __('Infra'),
+ 'CBRNE' => __('CBRNE'),
+ 'Other' => __('Other'),
+ ]);
+ }
+}
diff --git a/lib/Connector/ConnectorInterface.php b/lib/Connector/ConnectorInterface.php
new file mode 100644
index 0000000..1c17c15
--- /dev/null
+++ b/lib/Connector/ConnectorInterface.php
@@ -0,0 +1,57 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use GuzzleHttp\Client;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Stash\Interfaces\PoolInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Service\JwtServiceInterface;
+use Xibo\Service\PlayerActionServiceInterface;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Connector Interface
+ */
+interface ConnectorInterface
+{
+ public function setFactories(ContainerInterface $container): ConnectorInterface;
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface;
+ public function useLogger(LoggerInterface $logger): ConnectorInterface;
+ public function useSettings(array $settings, bool $isProvider = true): ConnectorInterface;
+ public function usePool(PoolInterface $pool): ConnectorInterface;
+ public function useHttpOptions(array $httpOptions): ConnectorInterface;
+ public function useJwtService(JwtServiceInterface $jwtService): ConnectorInterface;
+ public function usePlayerActionService(PlayerActionServiceInterface $playerActionService): ConnectorInterface;
+ public function getClient(): Client;
+ public function getSourceName(): string;
+ public function getTitle(): string;
+ public function getDescription(): string;
+ public function getThumbnail(): string;
+ public function getSetting($setting, $default = null);
+ public function isProviderSetting($setting): bool;
+ public function getSettingsFormTwig(): string;
+ public function getSettingsFormJavaScript(): string;
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array;
+}
diff --git a/lib/Connector/ConnectorTrait.php b/lib/Connector/ConnectorTrait.php
new file mode 100644
index 0000000..36d4cc5
--- /dev/null
+++ b/lib/Connector/ConnectorTrait.php
@@ -0,0 +1,202 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use GuzzleHttp\Client;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Service\JwtServiceInterface;
+use Xibo\Service\PlayerActionServiceInterface;
+
+/**
+ * Connector trait to assist with basic scaffolding and utility methods.
+ * we recommend all connectors use this trait.
+ */
+trait ConnectorTrait
+{
+ /** @var \Psr\Log\LoggerInterface */
+ private $logger;
+
+ /** @var array */
+ private $settings = [];
+
+ /** @var array The keys for all provider settings */
+ private $providerSettings = [];
+
+ /** @var PoolInterface|null */
+ private $pool;
+
+ /** @var array */
+ private $httpOptions = [];
+
+ /** @var array */
+ private $keys = [];
+
+ /** @var JwtServiceInterface */
+ private $jwtService;
+
+ /** @var PlayerActionServiceInterface */
+ private $playerActionService;
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return \Xibo\Connector\ConnectorInterface
+ */
+ public function useLogger(LoggerInterface $logger): ConnectorInterface
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ /**
+ * @return \Psr\Log\LoggerInterface|\Psr\Log\NullLogger
+ */
+ private function getLogger(): LoggerInterface
+ {
+ if ($this->logger === null) {
+ return new NullLogger();
+ }
+ return $this->logger;
+ }
+
+ /**
+ * @param array $settings
+ * @param bool $provider
+ * @return ConnectorInterface
+ */
+ public function useSettings(array $settings, bool $provider = false): ConnectorInterface
+ {
+ if ($provider) {
+ $this->providerSettings = array_keys($settings);
+ }
+
+ $this->settings = array_merge($this->settings, $settings);
+ return $this;
+ }
+
+ /**
+ * @param $setting
+ * @return bool
+ */
+ public function isProviderSetting($setting): bool
+ {
+ return in_array($setting, $this->providerSettings);
+ }
+
+ /**
+ * @param $setting
+ * @param null $default
+ * @return string|null
+ */
+ public function getSetting($setting, $default = null)
+ {
+ $this->logger->debug('getSetting: ' . $setting);
+ if (!array_key_exists($setting, $this->settings)) {
+ $this->logger->debug('getSetting: ' . $setting . ' not present.');
+ return $default;
+ }
+
+ return $this->settings[$setting] ?: $default;
+ }
+
+ /**
+ * @param \Stash\Interfaces\PoolInterface $pool
+ * @return \Xibo\Connector\ConnectorInterface
+ */
+ public function usePool(PoolInterface $pool): ConnectorInterface
+ {
+ $this->pool = $pool;
+ return $this;
+ }
+
+ /**
+ * @return \Stash\Interfaces\PoolInterface
+ */
+ private function getPool(): PoolInterface
+ {
+ return $this->pool;
+ }
+
+ /**
+ * @param array $options
+ * @return \Xibo\Connector\ConnectorInterface
+ */
+ public function useHttpOptions(array $options): ConnectorInterface
+ {
+ $this->httpOptions = $options;
+ return $this;
+ }
+
+ public function useJwtService(JwtServiceInterface $jwtService): ConnectorInterface
+ {
+ $this->jwtService = $jwtService;
+ return $this;
+ }
+
+ protected function getJwtService(): JwtServiceInterface
+ {
+ return $this->jwtService;
+ }
+
+ public function usePlayerActionService(PlayerActionServiceInterface $playerActionService): ConnectorInterface
+ {
+ $this->playerActionService = $playerActionService;
+ return $this;
+ }
+
+ protected function getPlayerActionService(): PlayerActionServiceInterface
+ {
+ return $this->playerActionService;
+ }
+
+ public function setFactories($container): ConnectorInterface
+ {
+ return $this;
+ }
+
+ public function getSettingsFormJavaScript(): string
+ {
+ return '';
+ }
+
+ /**
+ * Get an HTTP client with the default proxy settings, etc
+ * @return \GuzzleHttp\Client
+ */
+ public function getClient(): Client
+ {
+ return new Client($this->httpOptions);
+ }
+
+ /**
+ * Return a layout preview URL for the provided connector token
+ * this can be used in a data request and is decorated by the previewing function.
+ * @param string $token
+ * @return string
+ */
+ public function getTokenUrl(string $token): string
+ {
+ return '[[connector='.$token.']]';
+ }
+}
diff --git a/lib/Connector/DataConnectorScriptProviderInterface.php b/lib/Connector/DataConnectorScriptProviderInterface.php
new file mode 100644
index 0000000..48533ff
--- /dev/null
+++ b/lib/Connector/DataConnectorScriptProviderInterface.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+/**
+ * Interface for handling the DataConnectorScriptRequestEvent.
+ *
+ * Provides methods for connectors to supply their data connector JS code.
+ *
+ * These methods should be used together:
+ * - Use getConnectorId() to retrieve the unique identifier of the connector provided in the event.
+ * - Check if the connector's ID matches the ID provided in the event.
+ * - If the IDs match, use setScript() to provide the JavaScript code for the data connector.
+ *
+ * This ensures that the correct script is supplied by the appropriate connector.
+ */
+interface DataConnectorScriptProviderInterface
+{
+ /**
+ * Get the unique identifier of the connector that is selected as the data source for the dataset.
+ *
+ * @return string
+ */
+ public function getConnectorId(): string;
+
+ /**
+ * Set the data connector JavaScript code provided by the connector. Requires real time.
+ *
+ * @param string $script JavaScript code
+ * @return void
+ */
+ public function setScript(string $script): void;
+}
diff --git a/lib/Connector/DataConnectorSourceProviderInterface.php b/lib/Connector/DataConnectorSourceProviderInterface.php
new file mode 100644
index 0000000..0b863b8
--- /dev/null
+++ b/lib/Connector/DataConnectorSourceProviderInterface.php
@@ -0,0 +1,43 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use InvalidArgumentException;
+
+/**
+ * Interface for handling the DataConnectorSourceRequestEvent.
+ *
+ * Registers connectors that provide data connector JavaScript (JS).
+ */
+interface DataConnectorSourceProviderInterface
+{
+ /**
+ * Adds/Registers a connector, that would provide a data connector JS, to the event.
+ * Implementations should use $this->getSourceName() as the $id and $this->getTitle() as the $name.
+ *
+ * @param string $id
+ * @param string $name
+ * @throws InvalidArgumentException if a duplicate ID or name is found.
+ */
+ public function addDataConnectorSource(string $id, string $name): void;
+}
diff --git a/lib/Connector/EmergencyAlertInterface.php b/lib/Connector/EmergencyAlertInterface.php
new file mode 100644
index 0000000..9d42396
--- /dev/null
+++ b/lib/Connector/EmergencyAlertInterface.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+/**
+ * Connector Interface for Emergency Alerts
+ */
+interface EmergencyAlertInterface
+{
+ /**
+ * Represents the status when there is at least one alert of type "Actual".
+ */
+ public const ACTUAL_ALERT = 'actual_alerts';
+
+ /**
+ * Represents the status when there are no alerts of any type.
+ */
+ public const NO_ALERT = 'no_alerts';
+
+ /**
+ * Represents the status when there is at least one test alert
+ * (e.g., Exercise, System, Test, Draft).
+ */
+ public const TEST_ALERT = 'test_alerts';
+}
diff --git a/lib/Connector/NationalWeatherServiceConnector.php b/lib/Connector/NationalWeatherServiceConnector.php
new file mode 100644
index 0000000..f8f2177
--- /dev/null
+++ b/lib/Connector/NationalWeatherServiceConnector.php
@@ -0,0 +1,418 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use DOMDocument;
+use DOMElement;
+use Exception;
+use GuzzleHttp\Exception\GuzzleException;
+use GuzzleHttp\Exception\RequestException;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\ContainerInterface;
+use Psr\Container\NotFoundExceptionInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\ScheduleCriteriaRequestEvent;
+use Xibo\Event\ScheduleCriteriaRequestInterface;
+use Xibo\Event\WidgetDataRequestEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\XMR\ScheduleCriteriaUpdateAction;
+
+/**
+ * A connector to process National Weather Alert (NWS) - Atom feed data
+ */
+class NationalWeatherServiceConnector implements ConnectorInterface, EmergencyAlertInterface
+{
+ use ConnectorTrait;
+
+ /** @var DOMDocument */
+ protected DOMDocument $atomFeedXML;
+
+ /** @var DOMElement */
+ protected DOMElement $feedNode;
+
+ /** @var DOMElement */
+ protected DOMElement $entryNode;
+
+ /** @var DisplayFactory */
+ private DisplayFactory $displayFactory;
+
+ /**
+ * @param ContainerInterface $container
+ * @return ConnectorInterface
+ * @throws ContainerExceptionInterface
+ * @throws NotFoundExceptionInterface
+ */
+ public function setFactories(ContainerInterface $container): ConnectorInterface
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ return $this;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ $dispatcher->addListener(ScheduleCriteriaRequestEvent::$NAME, [$this, 'onScheduleCriteriaRequest']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'national-weather-service-connector';
+ }
+
+ public function getTitle(): string
+ {
+ return 'National Weather Service Connector';
+ }
+
+ public function getDescription(): string
+ {
+ return 'National Weather Service (NWS)';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/xibo-nws.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'national-weather-service-form-settings';
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ if (!$this->isProviderSetting('atomFeedUri')) {
+ $settings['atomFeedUri'] = $params->getString('atomFeedUri');
+ }
+ return $settings;
+ }
+
+ /**
+ * If the requested dataSource is national-weather-service, get the data, process it and add to dataProvider
+ *
+ * @param WidgetDataRequestEvent $event
+ * @return void
+ * @throws GuzzleException
+ */
+ public function onDataRequest(WidgetDataRequestEvent $event): void
+ {
+ if ($event->getDataProvider()->getDataSource() === 'national-weather-service') {
+ if (empty($this->getSetting('atomFeedUri'))) {
+ $this->getLogger()->debug('onDataRequest: National Weather Service Connector not configured.');
+ return;
+ }
+
+ $event->stopPropagation();
+
+ try {
+ // Set cache expiry date to 3 minutes from now
+ $cacheExpire = Carbon::now()->addMinutes(3);
+
+ // Fetch the Atom Feed XML content
+ $xmlContent = $this->getFeedFromUrl($event->getDataProvider(), $cacheExpire);
+
+ // Initialize DOMDocument and load the XML content
+ $this->atomFeedXML = new DOMDocument();
+ $this->atomFeedXML->loadXML($xmlContent);
+
+ // Ensure the root element is
+ $feedNode = $this->atomFeedXML->getElementsByTagName('feed')->item(0);
+ if ($feedNode instanceof DOMElement) {
+ $this->feedNode = $feedNode;
+ } else {
+ throw new \Exception('The root element is missing.');
+ }
+
+ // Get all nodes within the element
+ $entryNodes = $this->feedNode->getElementsByTagName('entry');
+
+ // Are there any?
+ if ($entryNodes->length) {
+ // Process and initialize Atom Feed data
+ $this->processAtomFeedData($event->getDataProvider());
+
+ // Initialize update interval
+ $updateIntervalMinute = $event->getDataProvider()->getProperty('updateInterval');
+
+ // Convert the $updateIntervalMinute to seconds
+ $updateInterval = $updateIntervalMinute * 60;
+
+ // If we've got data, then set our cache period.
+ $event->getDataProvider()->setCacheTtl($updateInterval);
+ $event->getDataProvider()->setIsHandled();
+
+ // Define priority arrays for status (higher priority = lower index)
+ $statusPriority = ['Actual', 'Exercise', 'System', 'Test', 'Draft'];
+
+ $highestStatus = null;
+
+ // Iterate through each node to find the highest-priority status
+ foreach ($entryNodes as $entryNode) {
+ $this->entryNode = $entryNode;
+
+ // Get the status for the current entry
+ $entryStatus = $this->getEntryData('status');
+
+ // Check if the current status has a higher priority
+ if ($entryStatus !== null && (
+ $highestStatus === null ||
+ array_search($entryStatus, $statusPriority) < array_search($highestStatus, $statusPriority)
+ )) {
+ $highestStatus = $entryStatus;
+ }
+ }
+
+ $capStatus = $highestStatus;
+ $category = 'Met';
+ } else {
+ $capStatus = 'No Alerts';
+ $category = '';
+ $event->getDataProvider()->addError(__('No alerts are available for the selected area at the moment.'));//phpcs:ignore
+ }
+
+ // initialize status for schedule criteria push message
+ if ($capStatus == 'Actual') {
+ $status = self::ACTUAL_ALERT;
+ } elseif ($capStatus == 'No Alerts') {
+ $status = self::NO_ALERT;
+ } else {
+ $status = self::TEST_ALERT;
+ }
+
+ $this->getLogger()->debug('Schedule criteria push message: status = ' . $status
+ . ', category = ' . $category);
+
+ // Set schedule criteria update
+ $action = new ScheduleCriteriaUpdateAction();
+ $action->setCriteriaUpdates([
+ ['metric' => 'emergency_alert_status', 'value' => $status, 'ttl' => 60],
+ ['metric' => 'emergency_alert_category', 'value' => $category, 'ttl' => 60]
+ ]);
+
+ // Initialize the display
+ $displayId = $event->getDataProvider()->getDisplayId();
+ $display = $this->displayFactory->getById($displayId);
+
+ // Criteria push message
+ $this->getPlayerActionService()->sendAction($display, $action);
+ } catch (Exception $exception) {
+ $this->getLogger()
+ ->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
+ }
+ }
+ }
+
+ /**
+ * Get and process the NWS Atom Feed data
+ *
+ * @throws Exception
+ */
+ private function processAtomFeedData(DataProviderInterface $dataProvider): void
+ {
+ // Array to store configuration data
+ $config = [];
+
+ // Initialize configuration data
+ $config['status'] = $dataProvider->getProperty('status');
+ $config['msgType'] = $dataProvider->getProperty('msgType');
+ $config['urgency'] = $dataProvider->getProperty('urgency');
+ $config['severity'] = $dataProvider->getProperty('severity');
+ $config['certainty'] = $dataProvider->getProperty('certainty');
+
+ // Get all nodes within the element
+ $entryNodes = $this->feedNode->getElementsByTagName('entry');
+
+ // Iterate through each node
+ foreach ($entryNodes as $entryNode) {
+ $this->entryNode = $entryNode;
+
+ // Retrieve specific values from the CAP XML for filtering
+ $status = $this->getEntryData('status');
+ $msgType = $this->getEntryData('msgType');
+ $urgency = $this->getEntryData('urgency');
+ $severity = $this->getEntryData('severity');
+ $certainty = $this->getEntryData('certainty');
+
+ // Check if the retrieved CAP data matches the configuration filters
+ if (!$this->matchesFilter($status, $config['status']) ||
+ !$this->matchesFilter($msgType, $config['msgType']) ||
+ !$this->matchesFilter($urgency, $config['urgency']) ||
+ !$this->matchesFilter($severity, $config['severity']) ||
+ !$this->matchesFilter($certainty, $config['certainty'])
+ ) {
+ continue;
+ }
+
+ // Array to store CAP values
+ $cap = [];
+
+ // Initialize CAP values
+ $cap['source'] = $this->getEntryData('source');
+ $cap['note'] = $this->getEntryData('note');
+ $cap['event'] = $this->getEntryData('event');
+ $cap['urgency'] = $this->getEntryData('urgency');
+ $cap['severity'] = $this->getEntryData('severity');
+ $cap['certainty'] = $this->getEntryData('certainty');
+ $cap['dateTimeEffective'] = $this->getEntryData('effective');
+ $cap['dateTimeOnset'] = $this->getEntryData('onset');
+ $cap['dateTimeExpires'] = $this->getEntryData('expires');
+ $cap['headline'] = $this->getEntryData('headline');
+ $cap['description'] = $this->getEntryData('summary');
+ $cap['instruction'] = $this->getEntryData('instruction');
+ $cap['contact'] = $this->getEntryData('contact');
+ $cap['areaDesc'] = $this->getEntryData('areaDesc');
+
+ // Add CAP data to data provider
+ $dataProvider->addItem($cap);
+ }
+ }
+
+
+ /**
+ * Fetches the National Weather Service's Atom Feed XML data from the Atom Feed URL provided by the connector.
+ *
+ * @param DataProviderInterface $dataProvider
+ * @param Carbon $cacheExpiresAt
+ *
+ * @return string|null
+ * @throws GuzzleException
+ */
+ private function getFeedFromUrl(DataProviderInterface $dataProvider, Carbon $cacheExpiresAt): string|null
+ {
+ $atomFeedUri = $this->getSetting('atomFeedUri');
+ $area = $dataProvider->getProperty('area');
+
+ // Construct the Atom feed url
+ if (empty($area)) {
+ $url = $atomFeedUri;
+ } else {
+ $url = $atomFeedUri . '?area=' . $area;
+ }
+
+ $cache = $this->pool->getItem('/national-weather-service/alerts/' . md5($url));
+ $data = $cache->get();
+
+ if ($cache->isMiss()) {
+ $cache->lock();
+ $this->getLogger()->debug('Getting alerts from National Weather Service Atom feed');
+
+ $httpOptions = [
+ 'timeout' => 20, // Wait no more than 20 seconds
+ ];
+
+ try {
+ // Make a GET request to the Atom Feed URL using Guzzle HTTP client with defined options
+ $response = $dataProvider
+ ->getGuzzleClient($httpOptions)
+ ->get($url);
+
+ $this->getLogger()->debug('NWS Atom Feed uri: ' . $url . ' httpOptions: '
+ . json_encode($httpOptions));
+
+ // Get the response body as a string
+ $data = $response->getBody()->getContents();
+
+ // Cache
+ $cache->set($data);
+ $cache->expiresAt($cacheExpiresAt);
+ $this->pool->saveDeferred($cache);
+ } catch (RequestException $e) {
+ // Log the error with a message specific to NWS Alert data fetching
+ $this->getLogger()->error('Unable to reach the NWS Atom feed URL: '
+ . $url . ' Error: ' . $e->getMessage());
+
+ // Throw a more specific exception message
+ $dataProvider->addError(__('Failed to retrieve NWS alerts from specified Atom Feed URL.'));
+ }
+ } else {
+ $this->getLogger()->debug('Getting NWS Alert data from cache');
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the value of a specified tag from the current node.
+ *
+ * @param string $tagName
+ * @return string|null
+ */
+ private function getEntryData(string $tagName): ?string
+ {
+ // Ensure the tag exists within the provided node
+ $node = $this->entryNode->getElementsByTagName($tagName)->item(0);
+
+ // Return the node value if the node exists, otherwise return an empty string
+ return $node ? $node->nodeValue : '';
+ }
+
+ /**
+ * Check if the value of XML element matches the expected filter value.
+ *
+ * @param string $actualValue
+ * @param string $expectedValue
+ *
+ * @return bool
+ */
+ private function matchesFilter(string $actualValue, string $expectedValue): bool
+ {
+ // If the expected value is 'Any' (empty string) or matches the actual value, the filter passes
+ if (empty($expectedValue) || $expectedValue == $actualValue) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param ScheduleCriteriaRequestInterface $event
+ * @return void
+ * @throws ConfigurationException
+ */
+ public function onScheduleCriteriaRequest(ScheduleCriteriaRequestInterface $event): void
+ {
+ // Initialize Emergency Alerts schedule criteria parameters but with limited category
+ $event->addType('emergency_alert', __('Emergency Alerts'))
+ ->addMetric('emergency_alert_status', __('Status'))
+ ->addCondition([
+ 'eq' => __('Equal to')
+ ])
+ ->addValues('dropdown', [
+ self::ACTUAL_ALERT => __('Actual Alerts'),
+ self::TEST_ALERT => __('Test Alerts'),
+ self::NO_ALERT => __('No Alerts')
+ ])
+ ->addMetric('emergency_alert_category', __('Category'))
+ ->addCondition([
+ 'eq' => __('Equal to')
+ ])
+ ->addValues('dropdown', [
+ 'Met' => __('Met')
+ ]);
+ }
+}
diff --git a/lib/Connector/OpenWeatherMapConnector.php b/lib/Connector/OpenWeatherMapConnector.php
new file mode 100644
index 0000000..7cb4cfe
--- /dev/null
+++ b/lib/Connector/OpenWeatherMapConnector.php
@@ -0,0 +1,951 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use GuzzleHttp\Exception\RequestException;
+use Illuminate\Support\Str;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\ScheduleCriteriaRequestEvent;
+use Xibo\Event\ScheduleCriteriaRequestInterface;
+use Xibo\Event\WidgetDataRequestEvent;
+use Xibo\Event\XmdsWeatherRequestEvent;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+use Xibo\Widget\DataType\Forecast;
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * A connector to get data from the Open Weather Map API for use by the Weather Widget
+ */
+class OpenWeatherMapConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ private $apiUrl = 'https://api.openweathermap.org/data/';
+ private $forecastCurrent = '2.5/weather';
+ private $forecast3Hourly = '2.5/forecast';
+ private $forecastDaily = '2.5/forecast/daily';
+ private $forecastCombinedV3 = '3.0/onecall';
+
+ /** @var string */
+ protected $timezone;
+
+ /** @var \Xibo\Widget\DataType\Forecast */
+ protected $currentDay;
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(WidgetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ $dispatcher->addListener(ScheduleCriteriaRequestEvent::$NAME, [$this, 'onScheduleCriteriaRequest']);
+ $dispatcher->addListener(XmdsWeatherRequestEvent::$NAME, [$this, 'onXmdsWeatherRequest']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'openweathermap';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Open Weather Map';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Get Weather data from Open Weather Map API';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/owm.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'openweathermap-form-settings';
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ if (!$this->isProviderSetting('owmApiKey')) {
+ $settings['owmApiKey'] = $params->getString('owmApiKey');
+ $settings['owmIsPaidPlan'] = $params->getCheckbox('owmIsPaidPlan');
+ $settings['cachePeriod'] = $params->getInt('cachePeriod');
+ $settings['xmdsCachePeriod'] = $params->getInt('xmdsCachePeriod');
+ }
+ return $settings;
+ }
+
+ /**
+ * If the requested dataSource is forecastio, get the data, process it and add to dataProvider
+ *
+ * @param WidgetDataRequestEvent $event
+ * @return void
+ */
+ public function onDataRequest(WidgetDataRequestEvent $event)
+ {
+ if ($event->getDataProvider()->getDataSource() === 'forecastio') {
+ if (empty($this->getSetting('owmApiKey'))) {
+ $this->getLogger()->debug('onDataRequest: Open Weather Map not configured.');
+ return;
+ }
+
+ $event->stopPropagation();
+
+ if ($this->isProviderSetting('apiUrl')) {
+ $this->apiUrl = $this->getSetting('apiUrl');
+ }
+
+ try {
+ $this->getWeatherData($event->getDataProvider());
+
+ // If we've got data, then set our cache period.
+ $event->getDataProvider()->setCacheTtl($this->getSetting('cachePeriod', 3600));
+ $event->getDataProvider()->setIsHandled();
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onDataRequest: Failed to get results. e = ' . $exception->getMessage());
+ $event->getDataProvider()->addError(__('Unable to get weather results.'));
+ }
+ }
+ }
+
+ /**
+ * Get a combined forecast
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getWeatherData(DataProviderInterface $dataProvider)
+ {
+ // Convert units to an acceptable format
+ $units = in_array($dataProvider->getProperty('units', 'auto'), ['auto', 'us', 'uk2']) ? 'imperial' : 'metric';
+
+ // Temperature and Wind Speed Unit Mappings
+ $unit = $this->getUnit($dataProvider->getProperty('units'));
+
+ if ($dataProvider->getProperty('useDisplayLocation') == 0) {
+ $providedLat = $dataProvider->getProperty('latitude', $dataProvider->getDisplayLatitude());
+ $providedLon = $dataProvider->getProperty('longitude', $dataProvider->getDisplayLongitude());
+ } else {
+ $providedLat = $dataProvider->getDisplayLatitude();
+ $providedLon = $dataProvider->getDisplayLongitude();
+ }
+
+ // Build the URL
+ $url = '?lat=' . $providedLat
+ . '&lon=' . $providedLon
+ . '&units=' . $units
+ . '&lang=' . $dataProvider->getProperty('lang', 'en')
+ . '&appid=[API_KEY]';
+
+ // Cache expiry date
+ $cacheExpire = Carbon::now()->addSeconds($this->getSetting('cachePeriod'));
+
+ if ($this->getSetting('owmIsPaidPlan') ?? 0 == 1) {
+ // We build our data from multiple API calls
+ // Current data first.
+ $data = $this->queryApi($this->apiUrl . $this->forecastCurrent . $url, $cacheExpire);
+ $data['current'] = $this->parseCurrentIntoFormat($data);
+
+ // initialize timezone
+ $timezoneOffset = (int)$data['timezone'];
+
+ // Calculate the number of whole hours in the offset
+ $offsetHours = floor($timezoneOffset / 3600);
+
+ // Calculate the remaining minutes after extracting the whole hours
+ $offsetMinutes = ($timezoneOffset % 3600) / 60;
+
+ // Determine the sign of the offset (positive or negative)
+ $sign = $offsetHours < 0 ? '-' : '+';
+
+ // Ensure the format is as follows: +/-hh:mm
+ $formattedOffset = sprintf("%s%02d:%02d", $sign, abs($offsetHours), abs($offsetMinutes));
+
+ // Get the timezone name
+ $this->timezone = (new \DateTimeZone($formattedOffset))->getName();
+
+ // Pick out the country
+ $country = $data['sys']['country'] ?? null;
+
+ $this->getLogger()->debug('Trying to determine units for Country: ' . $country);
+
+ // If we don't have a unit, then can we base it on the timezone we got back?
+ if ($dataProvider->getProperty('units', 'auto') === 'auto' && $country !== null) {
+ // Pick out some countries to set the units
+ if ($country === 'GB') {
+ $unit = $this->getUnit('uk2');
+ } else if ($country === 'US') {
+ $unit = $this->getUnit('us');
+ } else if ($country === 'CA') {
+ $unit = $this->getUnit('ca');
+ } else {
+ $unit = $this->getUnit('si');
+ }
+ }
+
+ // Then the 16 day forecast API, which we will cache a day
+ $data['daily'] = $this->queryApi(
+ $this->apiUrl . $this->forecastDaily . $url,
+ $cacheExpire->copy()->addDay()->startOfDay()
+ )['list'];
+ } else {
+ // We use one call API 3.0
+ $data = $this->queryApi($this->apiUrl . $this->forecastCombinedV3 . $url, $cacheExpire);
+
+ $this->timezone = $data['timezone'];
+
+ // Country based on timezone (this is harder than using the real country)
+ if ($dataProvider->getProperty('units', 'auto') === 'auto') {
+ if (Str::startsWith($this->timezone, 'America')) {
+ $unit = $this->getUnit('us');
+ } else if ($this->timezone === 'Europe/London') {
+ $unit = $this->getUnit('uk2');
+ } else {
+ $unit = $this->getUnit('si');
+ }
+ }
+ }
+
+ // Using units:
+ $this->getLogger()->debug('Using units: ' . json_encode($unit));
+
+ $forecasts = [];
+
+ // Parse into our forecast.
+ // Load this data into our objects
+ $this->currentDay = new Forecast();
+ $this->currentDay->temperatureUnit = $unit['tempUnit'] ?: 'C';
+ $this->currentDay->windSpeedUnit = $unit['windUnit'] ?: 'KPH';
+ $this->currentDay->visibilityDistanceUnit = $unit['visibilityUnit'] ?: 'km';
+ $this->currentDay->location = $data['name'] ?? '';
+ $this->processItemIntoDay($this->currentDay, $data['current'], $units, true);
+
+ $countForecast = 0;
+ // Process each day into a forecast
+ foreach ($data['daily'] as $dayItem) {
+ // Skip first item as this is the currentDay
+ if ($countForecast++ === 0) {
+ continue;
+ }
+
+ $day = new Forecast();
+ $day->temperatureUnit = $this->currentDay->temperatureUnit;
+ $day->windSpeedUnit = $this->currentDay->windSpeedUnit;
+ $day->visibilityDistanceUnit = $this->currentDay->visibilityDistanceUnit;
+ $day->location = $this->currentDay->location;
+ $this->processItemIntoDay($day, $dayItem, $units);
+
+ $forecasts[] = $day;
+ }
+
+ // Enhance the currently with the high/low from the first daily forecast
+ $this->currentDay->temperatureHigh = $forecasts[0]->temperatureHigh;
+ $this->currentDay->temperatureMaxRound = $forecasts[0]->temperatureMaxRound;
+ $this->currentDay->temperatureLow = $forecasts[0]->temperatureLow;
+ $this->currentDay->temperatureMinRound = $forecasts[0]->temperatureMinRound;
+ $this->currentDay->temperatureMorning = $forecasts[0]->temperatureMorning;
+ $this->currentDay->temperatureMorningRound = $forecasts[0]->temperatureMorningRound;
+ $this->currentDay->temperatureNight = $forecasts[0]->temperatureNight;
+ $this->currentDay->temperatureNightRound = $forecasts[0]->temperatureNightRound;
+ $this->currentDay->temperatureEvening = $forecasts[0]->temperatureEvening;
+ $this->currentDay->temperatureEveningRound = $forecasts[0]->temperatureEveningRound;
+ $this->currentDay->temperatureMean = $forecasts[0]->temperatureMean;
+ $this->currentDay->temperatureMeanRound = $forecasts[0]->temperatureMeanRound;
+
+ if ($dataProvider->getProperty('dayConditionsOnly', 0) == 1) {
+ // Swap the night icons for their day equivalents
+ $this->currentDay->icon = str_replace('-night', '', $this->currentDay->icon);
+ $this->currentDay->wicon = str_replace('-night', '', $this->currentDay->wicon);
+ }
+
+ $dataProvider->addItem($this->currentDay);
+
+ if (count($forecasts) > 0) {
+ foreach ($forecasts as $forecast) {
+ $dataProvider->addItem($forecast);
+ }
+ }
+
+ $dataProvider->addOrUpdateMeta('Attribution', 'Powered by OpenWeather');
+ }
+
+ /**
+ * @param string $url
+ * @param Carbon $cacheExpiresAt
+ * @return array
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function queryApi(string $url, Carbon $cacheExpiresAt): array
+ {
+ $cache = $this->pool->getItem('/weather/owm/' . md5($url));
+ $data = $cache->get();
+
+ if ($cache->isMiss()) {
+ $cache->lock();
+ $this->getLogger()->debug('Getting Forecast from API');
+
+ $url = str_replace('[API_KEY]', $this->getSetting('owmApiKey'), $url);
+
+ try {
+ $response = $this->getClient()->get($url);
+
+ // Success?
+ if ($response->getStatusCode() != 200) {
+ throw new GeneralException('Non-200 response from Open Weather Map');
+ }
+
+ // Parse out header and body
+ $data = json_decode($response->getBody(), true);
+
+ // Cache
+ $cache->set($data);
+ $cache->expiresAt($cacheExpiresAt);
+ $this->pool->saveDeferred($cache);
+
+ } catch (RequestException $e) {
+ $this->getLogger()->error('Unable to reach Open Weather Map API: '
+ . str_replace($this->getSetting('owmApiKey'), '[API_KEY]', $e->getMessage()));
+ throw new GeneralException('API responded with an error.');
+ }
+ } else {
+ $this->getLogger()->debug('Getting Forecast from cache');
+ }
+
+ return $data;
+ }
+
+
+
+ /**
+ * Parse the response from the current API into the format provided by the onecall API
+ * this means easier processing down the line
+ * @param array $source
+ * @return array
+ */
+ private function parseCurrentIntoFormat(array $source): array
+ {
+ return [
+ 'timezone' => $source['timezone'],
+ 'dt' => $source['dt'],
+ 'sunrise' => $source['sys']['sunrise'],
+ 'sunset' => $source['sys']['sunset'],
+ 'temp' => $source['main']['temp'],
+ 'feels_like' => $source['main']['feels_like'],
+ 'pressure' => $source['main']['pressure'],
+ 'humidity' => $source['main']['humidity'],
+ 'dew_point' => null,
+ 'uvi' => null,
+ 'clouds' => $source['clouds']['all'],
+ 'visibility' => $source['visibility'] ?? 0,
+ 'wind_speed' => $source['wind']['speed'],
+ 'wind_deg' => $source['wind']['deg'] ?? 0,
+ 'weather' => $source['weather'],
+ ];
+ }
+
+ /**
+ * @param \Xibo\Weather\Forecast $day
+ * @param array $item
+ * @param $requestUnit
+ * @param bool $isCurrent
+ */
+ private function processItemIntoDay($day, $item, $requestUnit, $isCurrent = false)
+ {
+ $day->time = $item['dt'];
+ $day->sunRise = $item['sunrise'];
+ $day->sunSet = $item['sunset'];
+ $day->summary = ucfirst($item['weather'][0]['description']);
+
+ // Temperature
+ // imperial = F
+ // metric = C
+ if ($isCurrent) {
+ $day->temperature = $item['temp'];
+ $day->apparentTemperature = $item['feels_like'];
+ $day->temperatureHigh = $day->temperature;
+ $day->temperatureLow = $day->temperature;
+ $day->temperatureNight = $day->temperature;
+ $day->temperatureEvening = $day->temperature;
+ $day->temperatureMorning = $day->temperature;
+ } else {
+ $day->temperature = $item['temp']['day'];
+ $day->apparentTemperature = $item['feels_like']['day'];
+ $day->temperatureHigh = $item['temp']['max'] ?? $day->temperature;
+ $day->temperatureLow = $item['temp']['min'] ?? $day->temperature;
+ $day->temperatureNight = $item['temp']['night'];
+ $day->temperatureEvening = $item['temp']['eve'];
+ $day->temperatureMorning = $item['temp']['morn'];
+ }
+
+ if ($requestUnit === 'metric' && $day->temperatureUnit === 'F') {
+ // Convert C to F
+ $day->temperature = ($day->temperature) * 9 / 5 + 32;
+ $day->apparentTemperature = ($day->apparentTemperature) * 9 / 5 + 32;
+ $day->temperatureHigh = ($day->temperatureHigh) * 9 / 5 + 32;
+ $day->temperatureLow = ($day->temperatureLow) * 9 / 5 + 32;
+ $day->temperatureNight = ($day->temperatureNight) * 9 / 5 + 32;
+ $day->temperatureEvening = ($day->temperatureEvening) * 9 / 5 + 32;
+ $day->temperatureMorning = ($day->temperatureMorning) * 9 / 5 + 32;
+
+ } else if ($requestUnit === 'imperial' && $day->temperatureUnit === 'C') {
+ // Convert F to C
+ $day->temperature = ($day->temperature - 32) * 5 / 9;
+ $day->apparentTemperature = ($day->apparentTemperature - 32) * 5 / 9;
+ $day->temperatureHigh = ($day->temperatureHigh - 32) * 5 / 9;
+ $day->temperatureLow = ($day->temperatureLow - 32) * 5 / 9;
+ $day->temperatureNight = ($day->temperatureNight - 32) * 5 / 9;
+ $day->temperatureEvening = ($day->temperatureEvening - 32) * 5 / 9;
+ $day->temperatureMorning = ($day->temperatureMorning - 32) * 5 / 9;
+ }
+
+ // Work out the mean
+ $day->temperatureMean = ($day->temperatureHigh + $day->temperatureLow) / 2;
+
+ // Round those off
+ $day->temperatureRound = round($day->temperature, 0);
+ $day->temperatureNightRound = round($day->temperatureNight, 0);
+ $day->temperatureMorningRound = round($day->temperatureMorning, 0);
+ $day->temperatureEveningRound = round($day->temperatureEvening, 0);
+ $day->apparentTemperatureRound = round($day->apparentTemperature, 0);
+ $day->temperatureMaxRound = round($day->temperatureHigh, 0);
+ $day->temperatureMinRound = round($day->temperatureLow, 0);
+ $day->temperatureMeanRound = round($day->temperatureMean, 0);
+
+ // Humidity
+ $day->humidityPercent = $item['humidity'];
+ $day->humidity = $day->humidityPercent / 100;
+
+ // Pressure
+ // received in hPa, display in mB
+ $day->pressure = $item['pressure'] / 100;
+
+ // Wind
+ // metric = meters per second
+ // imperial = miles per hour
+ $day->windSpeed = $item['wind_speed'] ?? $item['speed'] ?? null;
+ $day->windBearing = $item['wind_deg'] ?? $item['deg'] ?? null;
+
+ if ($requestUnit === 'metric' && $day->windSpeedUnit !== 'MPS') {
+ // We have MPS and need to go to something else
+ if ($day->windSpeedUnit === 'MPH') {
+ // Convert MPS to MPH
+ $day->windSpeed = round($day->windSpeed * 2.237, 2);
+ } else if ($day->windSpeedUnit === 'KPH') {
+ // Convert MPS to KPH
+ $day->windSpeed = round($day->windSpeed * 3.6, 2);
+ }
+ } else if ($requestUnit === 'imperial' && $day->windSpeedUnit !== 'MPH') {
+ if ($day->windSpeedUnit === 'MPS') {
+ // Convert MPH to MPS
+ $day->windSpeed = round($day->windSpeed / 2.237, 2);
+ } else if ($day->windSpeedUnit === 'KPH') {
+ // Convert MPH to KPH
+ $day->windSpeed = round($day->windSpeed * 1.609344, 2);
+ }
+ }
+
+ // Wind direction
+ $day->windDirection = '--';
+ if ($day->windBearing !== null && $day->windBearing !== 0) {
+ foreach (self::cardinalDirections() as $dir => $angles) {
+ if ($day->windBearing >= $angles[0] && $day->windBearing < $angles[1]) {
+ $day->windDirection = $dir;
+ break;
+ }
+ }
+ }
+
+ // Clouds
+ $day->cloudCover = $item['clouds'];
+
+ // Visibility
+ // metric = meters
+ // imperial = meters?
+ $day->visibility = $item['visibility'] ?? '--';
+
+ if ($day->visibility !== '--') {
+ // Always in meters
+ if ($day->visibilityDistanceUnit === 'mi') {
+ // Convert meters to miles
+ $day->visibility = $day->visibility / 1609;
+ } else {
+ if ($day->visibilityDistanceUnit === 'km') {
+ // Convert meters to KM
+ $day->visibility = $day->visibility / 1000;
+ }
+ }
+ }
+
+ // not available
+ $day->dewPoint = $item['dew_point'] ?? '--';
+ $day->uvIndex = $item['uvi'] ?? '--';
+ $day->ozone = '--';
+
+ // Map icon
+ $icons = self::iconMap();
+ $icon = $item['weather'][0]['icon'];
+ $day->icon = $icons['backgrounds'][$icon] ?? 'wi-na';
+ $day->wicon = $icons['weather-icons'][$icon] ?? 'wi-na';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public static function supportedLanguages()
+ {
+ return [
+ ['id' => 'af', 'value' => __('Afrikaans')],
+ ['id' => 'ar', 'value' => __('Arabic')],
+ ['id' => 'az', 'value' => __('Azerbaijani')],
+ ['id' => 'bg', 'value' => __('Bulgarian')],
+ ['id' => 'ca', 'value' => __('Catalan')],
+ ['id' => 'zh_cn', 'value' => __('Chinese Simplified')],
+ ['id' => 'zh_tw', 'value' => __('Chinese Traditional')],
+ ['id' => 'cz', 'value' => __('Czech')],
+ ['id' => 'da', 'value' => __('Danish')],
+ ['id' => 'de', 'value' => __('German')],
+ ['id' => 'el', 'value' => __('Greek')],
+ ['id' => 'en', 'value' => __('English')],
+ ['id' => 'eu', 'value' => __('Basque')],
+ ['id' => 'fa', 'value' => __('Persian (Farsi)')],
+ ['id' => 'fi', 'value' => __('Finnish')],
+ ['id' => 'fr', 'value' => __('French')],
+ ['id' => 'gl', 'value' => __('Galician')],
+ ['id' => 'he', 'value' => __('Hebrew')],
+ ['id' => 'hi', 'value' => __('Hindi')],
+ ['id' => 'hr', 'value' => __('Croatian')],
+ ['id' => 'hu', 'value' => __('Hungarian')],
+ ['id' => 'id', 'value' => __('Indonesian')],
+ ['id' => 'it', 'value' => __('Italian')],
+ ['id' => 'ja', 'value' => __('Japanese')],
+ ['id' => 'kr', 'value' => __('Korean')],
+ ['id' => 'la', 'value' => __('Latvian')],
+ ['id' => 'lt', 'value' => __('Lithuanian')],
+ ['id' => 'mk', 'value' => __('Macedonian')],
+ ['id' => 'no', 'value' => __('Norwegian')],
+ ['id' => 'nl', 'value' => __('Dutch')],
+ ['id' => 'pl', 'value' => __('Polish')],
+ ['id' => 'pt', 'value' => __('Portuguese')],
+ ['id' => 'pt_br', 'value' => __('Português Brasil')],
+ ['id' => 'ro', 'value' => __('Romanian')],
+ ['id' => 'ru', 'value' => __('Russian')],
+ ['id' => 'se', 'value' => __('Swedish')],
+ ['id' => 'sk', 'value' => __('Slovak')],
+ ['id' => 'sl', 'value' => __('Slovenian')],
+ ['id' => 'es', 'value' => __('Spanish')],
+ ['id' => 'sr', 'value' => __('Serbian')],
+ ['id' => 'th', 'value' => __('Thai')],
+ ['id' => 'tr', 'value' => __('Turkish')],
+ ['id' => 'uk', 'value' => __('Ukrainian')],
+ ['id' => 'vi', 'value' => __('Vietnamese')],
+ ['id' => 'zu', 'value' => __('Zulu')]
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function iconMap()
+ {
+ return [
+ 'weather-icons' => [
+ '01d' => 'wi-day-sunny',
+ '01n' => 'wi-night-clear',
+ '02d' => 'wi-day-cloudy',
+ '02n' => 'wi-night-partly-cloudy',
+ '03d' => 'wi-cloudy',
+ '03n' => 'wi-night-cloudy',
+ '04d' => 'wi-day-cloudy',
+ '04n' => 'wi-night-partly-cloudy',
+ '09d' => 'wi-rain',
+ '09n' => 'wi-night-rain',
+ '10d' => 'wi-rain',
+ '10n' => 'wi-night-rain',
+ '11d' => 'wi-day-thunderstorm',
+ '11n' => 'wi-night-thunderstorm',
+ '13d' => 'wi-day-snow',
+ '13n' => 'wi-night-snow',
+ '50d' => 'wi-day-fog',
+ '50n' => 'wi-night-fog'
+ ],
+ 'backgrounds' => [
+ '01d' => 'clear-day',
+ '01n' => 'clear-night',
+ '02d' => 'partly-cloudy-day',
+ '02n' => 'partly-cloudy-night',
+ '03d' => 'cloudy',
+ '03n' => 'cloudy',
+ '04d' => 'partly-cloudy-day',
+ '04n' => 'partly-cloudy-night',
+ '09d' => 'rain',
+ '09n' => 'rain',
+ '10d' => 'rain',
+ '10n' => 'rain',
+ '11d' => 'wind',
+ '11n' => 'wind',
+ '13d' => 'snow',
+ '13n' => 'snow',
+ '50d' => 'fog',
+ '50n' => 'fog'
+ ]
+ ];
+ }
+
+ /** @inheritDoc */
+ public static function unitsAvailable()
+ {
+ return [
+ ['id' => 'auto', 'value' => 'Automatically select based on geographic location', 'tempUnit' => '', 'windUnit' => '', 'visibilityUnit' => ''],
+ ['id' => 'ca', 'value' => 'Canada', 'tempUnit' => 'C', 'windUnit' => 'KPH', 'visibilityUnit' => 'km'],
+ ['id' => 'si', 'value' => 'Standard International Units', 'tempUnit' => 'C', 'windUnit' => 'MPS', 'visibilityUnit' => 'km'],
+ ['id' => 'uk2', 'value' => 'United Kingdom', 'tempUnit' => 'C', 'windUnit' => 'MPH', 'visibilityUnit' => 'mi'],
+ ['id' => 'us', 'value' => 'United States', 'tempUnit' => 'F', 'windUnit' => 'MPH', 'visibilityUnit' => 'mi'],
+ ];
+ }
+
+ /**
+ * @param $code
+ * @return mixed|null
+ */
+ public function getUnit($code)
+ {
+ foreach (self::unitsAvailable() as $unit) {
+ if ($unit['id'] == $code) {
+ return $unit;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ private static function cardinalDirections()
+ {
+ return [
+ 'N' => [337.5, 22.5],
+ 'NE' => [22.5, 67.5],
+ 'E' => [67.5, 112.5],
+ 'SE' => [112.5, 157.5],
+ 'S' => [157.5, 202.5],
+ 'SW' => [202.5, 247.5],
+ 'W' => [247.5, 292.5],
+ 'NW' => [292.5, 337.5]
+ ];
+ }
+
+ /**
+ * @param ScheduleCriteriaRequestInterface $event
+ * @return void
+ * @throws ConfigurationException
+ */
+ public function onScheduleCriteriaRequest(ScheduleCriteriaRequestInterface $event): void
+ {
+ // Initialize Open Weather Schedule Criteria parameters
+ $event->addType('weather', __('Weather'))
+ ->addMetric('weather_condition', __('Weather Condition'))
+ ->addCondition([
+ 'eq' => __('Equal to')
+ ])
+ ->addValues('dropdown', [
+ 'thunderstorm' => __('Thunderstorm'),
+ 'drizzle' => __('Drizzle'),
+ 'rain' => __('Rain'),
+ 'snow' => __('Snow'),
+ 'clear' => __('Clear'),
+ 'clouds' => __('Clouds')
+ ])
+ ->addMetric('weather_temp_imperial', __('Temperature (Imperial)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_temp_metric', __('Temperature (Metric)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_feels_like_imperial', __('Apparent Temperature (Imperial)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_feels_like_metric', __('Apparent Temperature (Metric)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_wind_speed', __('Wind Speed'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_wind_direction', __('Wind Direction'))
+ ->addCondition([
+ 'eq' => __('Equal to')
+ ])
+ ->addValues('dropdown', [
+ 'N' => __('North'),
+ 'NE' => __('Northeast'),
+ 'E' => __('East'),
+ 'SE' => __('Southeast'),
+ 'S' => __('South'),
+ 'SW' => __('Southwest'),
+ 'W' => __('West'),
+ 'NW' => __('Northwest'),
+ ])
+ ->addMetric('weather_wind_degrees', __('Wind Direction (degrees)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_humidity', __('Humidity (Percent)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_pressure', __('Pressure'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', [])
+ ->addMetric('weather_visibility', __('Visibility (meters)'))
+ ->addCondition([
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than')
+ ])
+ ->addValues('number', []);
+ }
+
+ /**
+ * @param $item
+ * @param $unit
+ * @param $requestUnit
+ * @return array
+ */
+ private function processXmdsWeatherData($item, $unit, $requestUnit): array
+ {
+ $windSpeedUnit = $unit['windUnit'] ?? 'KPH';
+ $visibilityDistanceUnit = $unit['visibilityUnit'] ?? 'km';
+
+ // var to store output/response
+ $data = array();
+
+ // format the weather condition
+ $data['weather_condition'] = str_replace(' ', '_', strtolower($item['weather'][0]['main']));
+
+ // Temperature
+ // imperial = F
+ // metric = C
+ $tempImperial = $item['temp'];
+ $apparentTempImperial = $item['feels_like'];
+
+ // Convert F to C
+ $tempMetric = ($tempImperial - 32) * 5 / 9;
+ $apparentTempMetric = ($apparentTempImperial - 32) * 5 / 9;
+
+ // Round those temperature values
+ $data['weather_temp_imperial'] = round($tempImperial, 0);
+ $data['weather_feels_like_imperial'] = round($apparentTempImperial, 0);
+ $data['weather_temp_metric'] = round($tempMetric, 0);
+ $data['weather_feels_like_metric'] = round($apparentTempMetric, 0);
+
+ // Humidity
+ $data['weather_humidity'] = $item['humidity'];
+
+ // Pressure
+ // received in hPa, display in mB
+ $data['weather_pressure'] = $item['pressure'] / 100;
+
+ // Wind
+ // metric = meters per second
+ // imperial = miles per hour
+ $data['weather_wind_speed'] = $item['wind_speed'] ?? $item['speed'] ?? null;
+ $data['weather_wind_degrees'] = $item['wind_deg'] ?? $item['deg'] ?? null;
+
+ if ($requestUnit === 'metric' && $windSpeedUnit !== 'MPS') {
+ // We have MPS and need to go to something else
+ if ($windSpeedUnit === 'MPH') {
+ // Convert MPS to MPH
+ $data['weather_wind_degrees'] = round($data['weather_wind_degrees'] * 2.237, 2);
+ } else if ($windSpeedUnit === 'KPH') {
+ // Convert MPS to KPH
+ $data['weather_wind_degrees'] = round($data['weather_wind_degrees'] * 3.6, 2);
+ }
+ } else if ($requestUnit === 'imperial' && $windSpeedUnit !== 'MPH') {
+ if ($windSpeedUnit === 'MPS') {
+ // Convert MPH to MPS
+ $data['weather_wind_degrees'] = round($data['weather_wind_degrees'] / 2.237, 2);
+ } else if ($windSpeedUnit === 'KPH') {
+ // Convert MPH to KPH
+ $data['weather_wind_degrees'] = round($data['weather_wind_degrees'] * 1.609344, 2);
+ }
+ }
+
+ // Wind direction
+ $data['weather_wind_direction'] = '--';
+ if ($data['weather_wind_degrees'] !== null && $data['weather_wind_degrees'] !== 0) {
+ foreach (self::cardinalDirections() as $dir => $angles) {
+ if ($data['weather_wind_degrees'] >= $angles[0] && $data['weather_wind_degrees'] < $angles[1]) {
+ $data['weather_wind_direction'] = $dir;
+ break;
+ }
+ }
+ }
+
+ // Visibility
+ // metric = meters
+ // imperial = meters?
+ $data['weather_visibility'] = $item['visibility'] ?? '--';
+
+ if ($data['weather_visibility'] !== '--') {
+ // Always in meters
+ if ($visibilityDistanceUnit === 'mi') {
+ // Convert meters to miles
+ $data['weather_visibility'] = $data['weather_visibility'] / 1609;
+ } else {
+ if ($visibilityDistanceUnit === 'km') {
+ // Convert meters to KM
+ $data['weather_visibility'] = $data['weather_visibility'] / 1000;
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param XmdsWeatherRequestEvent $event
+ * @return void
+ * @throws GeneralException|\SoapFault
+ */
+ public function onXmdsWeatherRequest(XmdsWeatherRequestEvent $event): void
+ {
+ // check for API Key
+ if (empty($this->getSetting('owmApiKey'))) {
+ $this->getLogger()->debug('onXmdsWeatherRequest: Open Weather Map not configured.');
+
+ throw new \SoapFault(
+ 'Receiver',
+ 'Open Weather Map API key is not configured'
+ );
+ }
+
+ $latitude = $event->getLatitude();
+ $longitude = $event->getLongitude();
+
+ // Cache expiry date
+ $cacheExpire = Carbon::now()->addHours($this->getSetting('xmdsCachePeriod'));
+
+ // use imperial as the default units, so we can get the right value when converting to metric
+ $units = 'imperial';
+
+ // Temperature and Wind Speed Unit Mappings
+ $unit = $this->getUnit('auto');
+
+ // Build the URL
+ $url = '?lat=' . $latitude
+ . '&lon=' . $longitude
+ . '&units=' . $units
+ . '&appid=[API_KEY]';
+
+ // check API plan
+ if ($this->getSetting('owmIsPaidPlan') ?? 0 == 1) {
+ // use weather data endpoints for Paid Plan
+ $data = $this->queryApi($this->apiUrl . $this->forecastCurrent . $url, $cacheExpire);
+ $data['current'] = $this->parseCurrentIntoFormat($data);
+
+ // Pick out the country
+ $country = $data['sys']['country'] ?? null;
+
+ // If we don't have a unit, then can we base it on the timezone we got back?
+ if ($country !== null) {
+ // Pick out some countries to set the units
+ if ($country === 'GB') {
+ $unit = $this->getUnit('uk2');
+ } else if ($country === 'US') {
+ $unit = $this->getUnit('us');
+ } else if ($country === 'CA') {
+ $unit = $this->getUnit('ca');
+ } else {
+ $unit = $this->getUnit('si');
+ }
+ }
+ } else {
+ // We use one call API 3.0 for Free Plan
+ $data = $this->queryApi($this->apiUrl . $this->forecastCombinedV3 . $url, $cacheExpire);
+
+ // Country based on timezone (this is harder than using the real country)
+ if (Str::startsWith($data['timezone'], 'America')) {
+ $unit = $this->getUnit('us');
+ } else if ($data['timezone'] === 'Europe/London') {
+ $unit = $this->getUnit('uk2');
+ } else {
+ $unit = $this->getUnit('si');
+ }
+ }
+
+ // process weather data
+ $weatherData = $this->processXmdsWeatherData($data['current'], $unit, 'imperial');
+
+ // Set the processed weather data in the event as a JSON-encoded string
+ $event->setWeatherData(json_encode($weatherData));
+ }
+}
diff --git a/lib/Connector/PixabayConnector.php b/lib/Connector/PixabayConnector.php
new file mode 100644
index 0000000..530a179
--- /dev/null
+++ b/lib/Connector/PixabayConnector.php
@@ -0,0 +1,324 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\SearchResult;
+use Xibo\Event\LibraryProviderEvent;
+use Xibo\Event\LibraryProviderImportEvent;
+use Xibo\Event\LibraryProviderListEvent;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Pixabay Connector
+ * This connector acts as a data provider for the Media Toolbar in the Layout/Playlist editor user interface
+ */
+class PixabayConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener('connector.provider.library', [$this, 'onLibraryProvider']);
+ $dispatcher->addListener('connector.provider.library.import', [$this, 'onLibraryImport']);
+ $dispatcher->addListener('connector.provider.library.list', [$this, 'onLibraryList']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'pixabay';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Pixabay';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Show Pixabay images and videos in the Layout editor toolbar and download them to the library for use on your Layouts.';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/pixabay_square_green.png';
+ }
+
+ public function getFilters(): array
+ {
+ return [
+ [
+ 'name' => 'name',
+ 'type' => 'string',
+ 'key' => 'media'
+ ],
+ [
+ 'label' => 'type',
+ 'type' => 'dropdown',
+ 'options' => [
+ [
+ 'name' => 'Image',
+ 'value' => 'image'
+ ],
+ [
+ 'name' => 'Video',
+ 'value' => 'video'
+ ]
+ ]
+ ],
+ [
+ 'label' => 'orientation',
+ 'type' => 'dropdown',
+ 'options' => [
+ [
+ 'name' => 'All',
+ 'value' => ''
+ ],
+ [
+ 'name' => 'Landscape',
+ 'value' => 'landscape'
+ ],
+ [
+ 'name' => 'Portrait',
+ 'value' => 'portrait'
+ ]
+ ],
+ 'visibility' => [
+ 'field' => 'type',
+ 'type' => 'eq',
+ 'value' => 'image'
+ ]
+ ]
+ ];
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'pixabay-form-settings';
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ if (!$this->isProviderSetting('apiKey')) {
+ $settings['apiKey'] = $params->getString('apiKey');
+ }
+ return $settings;
+ }
+
+ /**
+ * @param \Xibo\Event\LibraryProviderEvent $event
+ * @throws \GuzzleHttp\Exception\GuzzleException
+ */
+ public function onLibraryProvider(LibraryProviderEvent $event)
+ {
+ $this->getLogger()->debug('onLibraryProvider');
+
+ // Do we have an alternative URL (we may proxy requests for cache)
+ $baseUrl = $this->getSetting('baseUrl');
+ if (empty($baseUrl)) {
+ $baseUrl = 'https://pixabay.com/api/';
+ }
+
+ // Do we have an API key?
+ $apiKey = $this->getSetting('apiKey');
+ if (empty($apiKey)) {
+ $this->getLogger()->debug('onLibraryProvider: No api key');
+ return;
+ }
+
+ // was Pixabay requested?
+ if ($event->getProviderName() === $this->getSourceName()) {
+ // We do! Let's get some results from Pixabay
+ // first we look at paging
+ $start = $event->getStart();
+ $perPage = $event->getLength();
+ if ($start == 0) {
+ $page = 1;
+ } else {
+ $page = floor($start / $perPage) + 1;
+ }
+
+ $query = [
+ 'key' => $apiKey,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'safesearch' => 'true'
+ ];
+
+ // Now we handle any other search
+ if ($event->getOrientation() === 'landscape') {
+ $query['orientation'] = 'horizontal';
+ } else if ($event->getOrientation() === 'portrait') {
+ $query['orientation'] = 'vertical';
+ }
+
+ if (!empty($event->getSearch())) {
+ $query['q'] = urlencode($event->getSearch());
+ }
+
+ // Pixabay either returns images or videos, not both.
+ if (count($event->getTypes()) !== 1) {
+ return;
+ }
+
+ $type = $event->getTypes()[0];
+ if (!in_array($type, ['image', 'video'])) {
+ return;
+ }
+
+ // Pixabay require a 24-hour cache of each result set.
+ $key = md5($type . '_' . json_encode($query));
+ $cache = $this->getPool()->getItem($key);
+ $body = $cache->get();
+
+ if ($cache->isMiss()) {
+ $this->getLogger()->debug('onLibraryProvider: cache miss, generating.');
+
+ // Make the request
+ $request = $this->getClient()->request('GET', $baseUrl . ($type === 'video' ? 'videos' : ''), [
+ 'query' => $query
+ ]);
+
+ $body = $request->getBody()->getContents();
+ if (empty($body)) {
+ $this->getLogger()->debug('onLibraryProvider: Empty body');
+ return;
+ }
+
+ $body = json_decode($body);
+ if ($body === null || $body === false) {
+ $this->getLogger()->debug('onLibraryProvider: non-json body or empty body returned.');
+ return;
+ }
+
+ // Cache for next time
+ $cache->set($body);
+ $cache->expiresAt(Carbon::now()->addHours(24));
+ $this->getPool()->saveDeferred($cache);
+ } else {
+ $this->getLogger()->debug('onLibraryProvider: serving from cache.');
+ }
+
+ $providerDetails = new ProviderDetails();
+ $providerDetails->id = 'pixabay';
+ $providerDetails->link = 'https://pixabay.com';
+ $providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg';
+ $providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg';
+ $providerDetails->backgroundColor = '';
+ $providerDetails->filters = $this->getFilters();
+
+ // Process each hit into a search result and add it to the overall results we've been given.
+ foreach ($body->hits as $result) {
+ $searchResult = new SearchResult();
+ $searchResult->source = $this->getSourceName();
+ $searchResult->id = $result->id;
+ $searchResult->title = $result->tags;
+ $searchResult->provider = $providerDetails;
+
+ if ($type === 'video') {
+ $searchResult->type = 'video';
+ $searchResult->thumbnail = $result->videos->tiny->url;
+ $searchResult->duration = $result->duration;
+
+ // As per Pixabay, medium videos are usually 1080p but in some cases,
+ // it might be larger (ie 2560x1440) so we need to do an additional validation
+ if (!empty($result->videos->medium) && $result->videos->medium->width <= 1920
+ && $result->videos->medium->height <= 1920
+ ) {
+ $searchResult->download = $result->videos->medium->url;
+ $searchResult->width = $result->videos->medium->width;
+ $searchResult->height = $result->videos->medium->height;
+ $searchResult->fileSize = $result->videos->medium->size;
+ } else if (!empty($result->videos->small)) {
+ $searchResult->download = $result->videos->small->url;
+ $searchResult->width = $result->videos->small->width;
+ $searchResult->height = $result->videos->small->height;
+ $searchResult->fileSize = $result->videos->small->size;
+ } else {
+ $searchResult->download = $result->videos->tiny->url;
+ $searchResult->width = $result->videos->tiny->width;
+ $searchResult->height = $result->videos->tiny->height;
+ $searchResult->fileSize = $result->videos->tiny->size;
+ }
+
+ if (!empty($result->picture_id ?? null)) {
+ // Try the old way (at some point this stopped working and went to the thumbnail approach above
+ $searchResult->videoThumbnailUrl = str_replace(
+ 'pictureId',
+ $result->picture_id,
+ 'https://i.vimeocdn.com/video/pictureId_960x540.png'
+ );
+ } else {
+ // Use the medium thumbnail if we have it, otherwise the tiny one.
+ $searchResult->videoThumbnailUrl = $result->videos->medium->thumbnail
+ ?? $result->videos->tiny->thumbnail;
+ }
+ } else {
+ $searchResult->type = 'image';
+ $searchResult->thumbnail = $result->previewURL;
+ $searchResult->download = $result->fullHDURL ?? $result->largeImageURL;
+ $searchResult->width = $result->imageWidth;
+ $searchResult->height = $result->imageHeight;
+ $searchResult->fileSize = $result->imageSize;
+ }
+ $event->addResult($searchResult);
+ }
+ }
+ }
+
+ /**
+ * @param \Xibo\Event\LibraryProviderImportEvent $event
+ */
+ public function onLibraryImport(LibraryProviderImportEvent $event)
+ {
+ foreach ($event->getItems() as $providerImport) {
+ if ($providerImport->searchResult->provider->id === $this->getSourceName()) {
+ // Configure this import, setting the URL, etc.
+ $providerImport->configureDownload();
+ }
+ }
+ }
+
+ public function onLibraryList(LibraryProviderListEvent $event)
+ {
+ $this->getLogger()->debug('onLibraryList:event');
+
+ if (empty($this->getSetting('apiKey'))) {
+ $this->getLogger()->debug('onLibraryList: No api key');
+ return;
+ }
+
+ $providerDetails = new ProviderDetails();
+ $providerDetails->id = 'pixabay';
+ $providerDetails->link = 'https://pixabay.com';
+ $providerDetails->logoUrl = '/theme/default/img/connectors/pixabay_logo.svg';
+ $providerDetails->iconUrl = '/theme/default/img/connectors/pixabay_logo_square.svg';
+ $providerDetails->backgroundColor = '';
+ $providerDetails->mediaTypes = ['image', 'video'];
+ $providerDetails->filters = $this->getFilters();
+
+ $event->addProvider($providerDetails);
+ }
+}
diff --git a/lib/Connector/ProviderDetails.php b/lib/Connector/ProviderDetails.php
new file mode 100644
index 0000000..488dfd2
--- /dev/null
+++ b/lib/Connector/ProviderDetails.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+/**
+ * Provider Details
+ */
+class ProviderDetails implements \JsonSerializable
+{
+ public $id;
+ public $message;
+ public $link;
+ public $logoUrl;
+ public $iconUrl;
+ public $backgroundColor;
+ public $mediaTypes;
+ public $filters;
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'message' => $this->message,
+ 'link' => $this->link,
+ 'logoUrl' => $this->logoUrl,
+ 'iconUrl' => $this->iconUrl,
+ 'backgroundColor' => $this->backgroundColor,
+ 'mediaTypes' => $this->mediaTypes,
+ 'filters' => $this->filters
+ ];
+ }
+}
diff --git a/lib/Connector/ProviderImport.php b/lib/Connector/ProviderImport.php
new file mode 100644
index 0000000..01016ac
--- /dev/null
+++ b/lib/Connector/ProviderImport.php
@@ -0,0 +1,86 @@
+.
+ */
+namespace Xibo\Connector;
+
+/**
+ * A provider import request/result.
+ * This is used to exchange a search result from a provider for a mediaId in the library.
+ */
+class ProviderImport implements \JsonSerializable
+{
+ /** @var \Xibo\Entity\SearchResult */
+ public $searchResult;
+
+ /** @var \Xibo\Entity\Media media */
+ public $media;
+
+ /** @var bool has this been configured for import */
+ public $isConfigured = false;
+
+ /** @var string the URL to use for the download */
+ public $url;
+
+ /** @var bool has this been uploaded */
+ public $isUploaded = false;
+
+ /** @var bool is error state? */
+ public $isError = false;
+
+ /** @var string error message, if in error state */
+ public $error;
+
+ /**
+ * @return \Xibo\Connector\ProviderImport
+ */
+ public function configureDownload(): ProviderImport
+ {
+ $this->isConfigured = true;
+ $this->url = $this->searchResult->download;
+ return $this;
+ }
+
+ /**
+ * @param $message
+ * @return $this
+ */
+ public function setError($message): ProviderImport
+ {
+ $this->isUploaded = false;
+ $this->isError = true;
+ $this->error = $message;
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'item' => $this->searchResult,
+ 'media' => $this->media,
+ 'isUploaded' => $this->isUploaded,
+ 'isError' => $this->isError,
+ 'error' => $this->error
+ ];
+ }
+}
diff --git a/lib/Connector/XiboAudienceReportingConnector.php b/lib/Connector/XiboAudienceReportingConnector.php
new file mode 100644
index 0000000..958110d
--- /dev/null
+++ b/lib/Connector/XiboAudienceReportingConnector.php
@@ -0,0 +1,977 @@
+.
+ */
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use GuzzleHttp\Exception\ClientException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Exception\ServerException;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\ConnectorReportEvent;
+use Xibo\Event\MaintenanceRegularEvent;
+use Xibo\Event\ReportDataEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+class XiboAudienceReportingConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ /** @var User */
+ private $user;
+
+ /** @var TimeSeriesStoreInterface */
+ private $timeSeriesStore;
+
+ /** @var SanitizerService */
+ private $sanitizer;
+
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /**
+ * @param \Psr\Container\ContainerInterface $container
+ * @return \Xibo\Connector\ConnectorInterface
+ */
+ public function setFactories(ContainerInterface $container): ConnectorInterface
+ {
+ $this->user = $container->get('user');
+ $this->timeSeriesStore = $container->get('timeSeriesStore');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->config = $container->get('configService');
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->displayFactory = $container->get('displayFactory');
+
+ return $this;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(MaintenanceRegularEvent::$NAME, [$this, 'onRegularMaintenance']);
+ $dispatcher->addListener(ReportDataEvent::$NAME, [$this, 'onRequestReportData']);
+ $dispatcher->addListener(ConnectorReportEvent::$NAME, [$this, 'onListReports']);
+
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'xibo-audience-reporting-connector';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Xibo Audience Reporting Connector';
+ }
+
+ /**
+ * Get the service url, either from settings or a default
+ * @return string
+ */
+ private function getServiceUrl(): string
+ {
+ return $this->getSetting('serviceUrl', 'https://exchange.xibo-adspace.com/api');
+ }
+
+ public function getDescription(): string
+ {
+ return 'Enhance your reporting with audience data, impressions and more.';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/xibo-audience-reporting.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'xibo-audience-connector-form-settings';
+ }
+
+ public function getSettingsFormJavaScript(): string
+ {
+ return 'xibo-audience-connector-form-javascript';
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ if (!$this->isProviderSetting('apiKey')) {
+ $settings['apiKey'] = $params->getString('apiKey');
+ }
+
+ // Get this connector settings, etc.
+ $this->getOptionsFromAxe($settings['apiKey'], true);
+
+ return $settings;
+ }
+
+ //
+
+ /**
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function onRegularMaintenance(MaintenanceRegularEvent $event)
+ {
+ // We should only do this if the connector is enabled and if we have an API key
+ $apiKey = $this->getSetting('apiKey');
+ if (empty($apiKey)) {
+ $this->getLogger()->debug('onRegularMaintenance: No api key');
+ return;
+ }
+
+ $event->addMessage('## Audience Connector');
+
+ // Set displays on DMAs
+ foreach ($this->dmaSearch($this->sanitizer->getSanitizer([]))['data'] as $dma) {
+ if ($dma['displayGroupId'] !== null) {
+ $this->setDisplaysForDma($dma['_id'], $dma['displayGroupId']);
+ }
+ }
+
+ // Handle sending stats to the audience connector service API
+ try {
+ $defaultTimezone = $this->config->getSetting('defaultTimezone');
+
+ // Get Watermark (might be null - start from beginning)
+ $watermark = $this->getWatermark();
+
+ // Loop over 5000 stat records
+ // Only interested in layout stats which belong to a parent campaign
+ $params = [
+ 'type' => 'layout',
+ 'start' => 0,
+ 'length' => $this->getSetting('batchSize', 5000),
+ 'mustHaveParentCampaign' => true,
+ ];
+
+ // If the watermark is not empty, we go from this point
+ if (!empty($watermark)) {
+ $params['statId'] = $watermark;
+ }
+
+ $this->getLogger()->debug('onRegularMaintenance: Processing batch of stats with params: '
+ . json_encode($params));
+
+ // Call the time series interface getStats
+ $resultSet = $this->timeSeriesStore->getStats($params, true);
+
+ // Array of campaigns for which we will update the total spend, impresssions, and plays
+ $campaigns = [];
+ $adCampaignCache = [];
+ $listCampaignCache = [];
+ $displayCache = [];
+ $displayIdsDeleted = [];
+ $erroredCampaign = [];
+ $rows = [];
+
+ $updateWatermark = null;
+
+ // Process the stats one by one
+ while ($row = $resultSet->getNextRow()) {
+ try {
+ $sanitizedRow = $this->sanitizer->getSanitizer($row);
+
+ $parentCampaignId = $sanitizedRow->getInt('parentCampaignId', ['default' => 0]);
+ $displayId = $sanitizedRow->getInt('displayId');
+ $statId = $resultSet->getIdFromRow($row);
+
+ // Keep this watermark, so we update it later
+ $updateWatermark = $statId;
+
+ // Skip records we're not interested in, or records that have already been discounted before.
+ if (empty($parentCampaignId)
+ || empty($displayId)
+ || in_array($displayId, $displayIdsDeleted)
+ || array_key_exists($parentCampaignId, $erroredCampaign)
+ || array_key_exists($parentCampaignId, $listCampaignCache)
+ ) {
+ // Comment out this log to save recording messages unless we need to troubleshoot in dev
+ //$this->getLogger()->debug('onRegularMaintenance: Campaign is a list campaign '
+ // . $parentCampaignId);
+ continue;
+ }
+
+ // Build an array to represent the row we want to send.
+ $entry = [
+ 'id' => $statId,
+ 'parentCampaignId' => $parentCampaignId,
+ 'displayId' => $displayId,
+ ];
+
+ // --------
+ // Get Campaign
+ // Campaign start and end date
+ if (array_key_exists($parentCampaignId, $adCampaignCache)) {
+ $entry['campaignStart'] = $adCampaignCache[$parentCampaignId]['start'];
+ $entry['campaignEnd'] = $adCampaignCache[$parentCampaignId]['end'];
+ } else {
+ // Get Campaign
+ try {
+ $parentCampaign = $this->campaignFactory->getById($parentCampaignId);
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onRegularMaintenance: campaign with ID '
+ . $parentCampaignId . ' not found');
+
+ $erroredCampaign[$parentCampaignId] = $entry['id']; // first stat id
+ continue;
+ }
+
+ if ($parentCampaign->type == 'ad') {
+ $adCampaignCache[$parentCampaignId]['type'] = $parentCampaign->type;
+ } else {
+ $this->getLogger()->debug('onRegularMaintenance: campaign is a list '
+ . $parentCampaignId);
+ $listCampaignCache[$parentCampaignId] = $parentCampaignId;
+ continue;
+ }
+
+ if (!empty($parentCampaign->getStartDt()) && !empty($parentCampaign->getEndDt())) {
+ $adCampaignCache[$parentCampaignId]['start'] = $parentCampaign->getStartDt()
+ ->format(DateFormatHelper::getSystemFormat());
+ $adCampaignCache[$parentCampaignId]['end'] = $parentCampaign->getEndDt()
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $entry['campaignStart'] = $adCampaignCache[$parentCampaignId]['start'];
+ $entry['campaignEnd'] = $adCampaignCache[$parentCampaignId]['end'];
+ } else {
+ $this->getLogger()->error('onRegularMaintenance: campaign without dates '
+ . $parentCampaignId);
+
+ $erroredCampaign[$parentCampaignId] = $entry['id']; // first stat id
+ continue;
+ }
+ }
+
+ // Get Display
+ // -----------
+ // Cost per play and impressions per play
+ if (!array_key_exists($displayId, $displayCache)) {
+ try {
+ $display = $this->displayFactory->getById($displayId);
+ $displayCache[$displayId]['costPerPlay'] = $display->costPerPlay;
+ $displayCache[$displayId]['impressionsPerPlay'] = $display->impressionsPerPlay;
+ $displayCache[$displayId]['timeZone'] = empty($display->timeZone) ? $defaultTimezone : $display->timeZone;
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onRegularMaintenance: display not found with ID: '
+ . $displayId);
+ $displayIdsDeleted[] = $displayId;
+ continue;
+ }
+ }
+ $entry['costPerPlay'] = $displayCache[$displayId]['costPerPlay'];
+ $entry['impressionsPerPlay'] = $displayCache[$displayId]['impressionsPerPlay'];
+
+ // Converting the date into the format expected by the API
+
+ // --------
+ // We know that player's local dates were stored in the CMS's configured timezone
+ // Dates were saved in Unix timestamps in MySQL
+ // Dates were saved in UTC format in MongoDB
+ // The main difference is that MySQL stores dates in the timezone of the CMS,
+ // while MongoDB converts those dates to UTC before storing them.
+
+ // -----MySQL
+ // Carbon::createFromTimestamp() always applies the CMS timezone
+
+ // ------MongoDB
+ // $date->toDateTime() returns a PHP DateTime object from MongoDB BSON Date type (UTC)
+ // Carbon::instance() keeps the timezone as UTC
+ try {
+ $start = $resultSet->getDateFromValue($row['start']);
+ $end = $resultSet->getDateFromValue($row['end']);
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onRegularMaintenance: Date convert failed for ID '
+ . $entry['id'] . ' with error: '. $exception->getMessage());
+ continue;
+ }
+
+ // Convert dates to display timezone
+ $entry['start'] = $start->timezone($displayCache[$displayId]['timeZone'])->format(DateFormatHelper::getSystemFormat());
+ $entry['end'] = $end->timezone($displayCache[$displayId]['timeZone'])->format(DateFormatHelper::getSystemFormat());
+
+ $entry['layoutId'] = $sanitizedRow->getInt('layoutId', ['default' => 0]);
+ $entry['numberPlays'] = $sanitizedRow->getInt('count', ['default' => 0]);
+ $entry['duration'] = $sanitizedRow->getInt('duration', ['default' => 0]);
+ $entry['engagements'] = $resultSet->getEngagementsFromRow($row);
+
+ $rows[] = $entry;
+
+ // Campaign list in array
+ if (!in_array($parentCampaignId, $campaigns)) {
+ $campaigns[] = $parentCampaignId;
+ }
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onRegularMaintenance: unexpected exception processing row '
+ . ($entry['id'] ?? null) . ', e: ' . $exception->getMessage());
+ }
+ }
+
+ if (count($erroredCampaign) > 0) {
+ $event->addMessage(sprintf(
+ __('There were %d campaigns which failed. A summary is in the error log.'),
+ count($erroredCampaign)
+ ));
+
+ $this->getLogger()->error('onRegularMaintenance: Failure summary of campaignId and first statId:'
+ . json_encode($erroredCampaign));
+ }
+
+ $this->getLogger()->debug('onRegularMaintenance: Records to send: ' . count($rows)
+ . ', Watermark: ' . $watermark);
+ $this->getLogger()->debug('onRegularMaintenance: Campaigns: ' . json_encode($campaigns));
+
+ // If we have rows, send them.
+ if (count($rows) > 0) {
+ // All outcomes from here are either a break; or an exception to stop the loop.
+ try {
+ $response = $this->getClient()->post($this->getServiceUrl() . '/audience/receiveStats', [
+ 'timeout' => $this->getSetting('receiveStatsTimeout', 300), // 5 minutes
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'json' => $rows
+ ]);
+
+ $statusCode = $response->getStatusCode();
+
+ $this->getLogger()->debug('onRegularMaintenance: Receive Stats StatusCode: ' . $statusCode);
+
+ // Get Campaign Total
+ if ($statusCode == 204) {
+ $this->getAndUpdateCampaignTotal($campaigns);
+ }
+
+ $event->addMessage('Added ' . count($rows) . ' to audience API');
+ } catch (RequestException $requestException) {
+ // If a request fails completely, we should stop and log the error.
+ $this->getLogger()->error('onRegularMaintenance: Audience receiveStats: failed e = '
+ . $requestException->getMessage());
+
+ throw new GeneralException(__('Failed to send stats to audience API'));
+ }
+ }
+
+ // Update the last statId of the block as the watermark
+ if (!empty($updateWatermark)) {
+ $this->setWatermark($updateWatermark);
+ }
+ } catch (GeneralException $exception) {
+ // We should have recorded in the error log already, so we just append to the event message for task
+ // last run status.
+ $event->addMessage($exception->getMessage());
+ }
+ }
+
+ /**
+ * Get the watermark representing how far we've processed already
+ * @return mixed|null
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getWatermark()
+ {
+ // If the watermark request fails, we should error.
+ try {
+ $this->getLogger()->debug('onRegularMaintenance: Get Watermark');
+ $response = $this->getClient()->get($this->getServiceUrl() . '/audience/watermark', [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ ]);
+
+ $body = $response->getBody()->getContents();
+ $json = json_decode($body, true);
+ return $json['watermark'] ?? null;
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('getWatermark: failed e = ' . $requestException->getMessage());
+ throw new GeneralException(__('Cannot get watermark'));
+ }
+ }
+
+ /**
+ * Set the watermark representing how far we've processed already
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function setWatermark($watermark)
+ {
+ // If the watermark set fails, we should error.
+ try {
+ $this->getLogger()->debug('onRegularMaintenance: Set Watermark ' . $watermark);
+ $this->getClient()->post($this->getServiceUrl() . '/audience/watermark', [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'json' => ['watermark' => $watermark]
+ ]);
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('setWatermark: failed e = ' . $requestException->getMessage());
+ throw new GeneralException(__('Cannot set watermark'));
+ }
+ }
+
+ /**
+ * @param array $campaigns
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getAndUpdateCampaignTotal(array $campaigns)
+ {
+ $this->getLogger()->debug('onRegularMaintenance: Get Campaign Total');
+
+ try {
+ $response = $this->getClient()->get($this->getServiceUrl() . '/audience/campaignTotal', [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'query' => [
+ 'campaigns' => $campaigns
+ ]
+ ]);
+
+ $body = $response->getBody()->getContents();
+ $results = json_decode($body, true);
+ $this->getLogger()->debug('onRegularMaintenance: Campaign Total Results: ' . json_encode($results));
+
+ foreach ($results as $item) {
+ try {
+ // Save the total in the campaign
+ $campaign = $this->campaignFactory->getById($item['id']);
+ $this->getLogger()->debug('onRegularMaintenance: Campaign Id: ' . $item['id']
+ . ' Spend: ' . $campaign->spend . ' Impressions: ' . $campaign->impressions);
+
+ $campaign->spend = $item['spend'];
+ $campaign->impressions = $item['impressions'];
+
+ $campaign->overwritePlays();
+
+ $this->getLogger()->debug('onRegularMaintenance: Campaign Id: ' . $item['id']
+ . ' Spend(U): ' . $campaign->spend . ' Impressions(U): ' . $campaign->impressions);
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onRegularMaintenance: campaignId '
+ . $item['id']. ' should have existed, but did not.');
+
+ throw new GeneralException(sprintf(__('Cannot update campaign status for %d'), $item['id']));
+ }
+ }
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('Campaign total: e = ' . $requestException->getMessage());
+
+ throw new GeneralException(__('Failed to update campaign totals.'));
+ }
+ }
+
+ /**
+ * Request Report results from the audience report service
+ */
+ public function onRequestReportData(ReportDataEvent $event)
+ {
+ $this->getLogger()->debug('onRequestReportData');
+
+ $type = $event->getReportType();
+
+ $typeUrl = [
+ 'campaignProofofplay' => $this->getServiceUrl() . '/audience/campaign/proofofplay',
+ 'mobileProofofplay' => $this->getServiceUrl() . '/audience/campaign/proofofplay/mobile',
+ 'displayAdPlay' => $this->getServiceUrl() . '/audience/display/adplays',
+ 'displayPercentage' => $this->getServiceUrl() . '/audience/display/percentage'
+ ];
+
+ if (array_key_exists($type, $typeUrl)) {
+ $json = [];
+ switch ($type) {
+ case 'campaignProofofplay':
+ // Get campaign proofofplay result
+ try {
+ $response = $this->getClient()->get($typeUrl[$type], [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'query' => $event->getParams()
+ ]);
+
+ $body = $response->getBody()->getContents();
+ $json = json_decode($body, true);
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
+ $error = 'Failed to get campaign proofofplay result: '.$requestException->getMessage();
+ }
+ break;
+
+ case 'mobileProofofplay':
+ // Get mobile proofofplay result
+ try {
+ $response = $this->getClient()->get($typeUrl[$type], [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'query' => $event->getParams()
+ ]);
+
+ $body = $response->getBody()->getContents();
+ $json = json_decode($body, true);
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
+ $error = 'Failed to get mobile proofofplay result: '.$requestException->getMessage();
+ }
+ break;
+
+ case 'displayAdPlay':
+ // Get display adplays result
+ try {
+ $response = $this->getClient()->get($typeUrl[$type], [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'query' => $event->getParams()
+ ]);
+
+ $body = $response->getBody()->getContents();
+ $json = json_decode($body, true);
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
+ $error = 'Failed to get display adplays result: '.$requestException->getMessage();
+ }
+ break;
+
+ case 'displayPercentage':
+ // Get display played percentage result
+ try {
+ $response = $this->getClient()->get($typeUrl[$type], [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'query' => $event->getParams()
+ ]);
+
+ $body = $response->getBody()->getContents();
+ $json = json_decode($body, true);
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('Get '. $type.': failed. e = ' . $requestException->getMessage());
+ $error = 'Failed to get display played percentage result: '.$requestException->getMessage();
+ }
+ break;
+
+ default:
+ $this->getLogger()->error('Connector Report not found ');
+ }
+
+ $event->setResults([
+ 'json' => $json,
+ 'error' => $error ?? null
+ ]);
+ }
+ }
+
+ /**
+ * Get this connector reports
+ * @param ConnectorReportEvent $event
+ * @return void
+ */
+ public function onListReports(ConnectorReportEvent $event)
+ {
+ $this->getLogger()->debug('onListReports');
+
+ $connectorReports = [
+ [
+ 'name'=> 'campaignProofOfPlay',
+ 'description'=> 'Campaign Proof of Play',
+ 'class'=> '\\Xibo\\Report\\CampaignProofOfPlay',
+ 'type'=> 'Report',
+ 'output_type'=> 'table',
+ 'color'=> 'gray',
+ 'fa_icon'=> 'fa-th',
+ 'category'=> 'Connector Reports',
+ 'feature'=> 'campaign-proof-of-play',
+ 'adminOnly'=> 0,
+ 'sort_order' => 1
+ ],
+ [
+ 'name'=> 'mobileProofOfPlay',
+ 'description'=> 'Mobile Proof of Play',
+ 'class'=> '\\Xibo\\Report\\MobileProofOfPlay',
+ 'type'=> 'Report',
+ 'output_type'=> 'table',
+ 'color'=> 'green',
+ 'fa_icon'=> 'fa-th',
+ 'category'=> 'Connector Reports',
+ 'feature'=> 'mobile-proof-of-play',
+ 'adminOnly'=> 0,
+ 'sort_order' => 2
+ ],
+ [
+ 'name'=> 'displayPercentage',
+ 'description'=> 'Display Played Percentage',
+ 'class'=> '\\Xibo\\Report\\DisplayPercentage',
+ 'type'=> 'Chart',
+ 'output_type'=> 'both',
+ 'color'=> 'blue',
+ 'fa_icon'=> 'fa-pie-chart',
+ 'category'=> 'Connector Reports',
+ 'feature'=> 'display-report',
+ 'adminOnly'=> 0,
+ 'sort_order' => 3
+ ],
+// [
+// 'name'=> 'revenueByDisplayReport',
+// 'description'=> 'Revenue by Display',
+// 'class'=> '\\Xibo\\Report\\RevenueByDisplay',
+// 'type'=> 'Report',
+// 'output_type'=> 'table',
+// 'color'=> 'green',
+// 'fa_icon'=> 'fa-th',
+// 'category'=> 'Connector Reports',
+// 'feature'=> 'display-report',
+// 'adminOnly'=> 0,
+// 'sort_order' => 4
+// ],
+ [
+ 'name'=> 'displayAdPlay',
+ 'description'=> 'Display Ad Plays',
+ 'class'=> '\\Xibo\\Report\\DisplayAdPlay',
+ 'type'=> 'Chart',
+ 'output_type'=> 'both',
+ 'color'=> 'red',
+ 'fa_icon'=> 'fa-bar-chart',
+ 'category'=> 'Connector Reports',
+ 'feature'=> 'display-report',
+ 'adminOnly'=> 0,
+ 'sort_order' => 5
+ ],
+ ];
+
+ $reports = [];
+ foreach ($connectorReports as $connectorReport) {
+ // Compatibility check
+ if (!isset($connectorReport['feature']) || !isset($connectorReport['category'])) {
+ continue;
+ }
+
+ // Check if only allowed for admin
+ if ($this->user->userTypeId != 1) {
+ if (isset($connectorReport['adminOnly']) && !empty($connectorReport['adminOnly'])) {
+ continue;
+ }
+ }
+
+ $reports[$connectorReport['category']][] = (object) $connectorReport;
+ }
+
+ if (count($reports) > 0) {
+ $event->addReports($reports);
+ }
+ }
+
+ //
+
+ //
+
+ public function dmaSearch(SanitizerInterface $params): array
+ {
+ try {
+ $response = $this->getClient()->get($this->getServiceUrl() . '/dma', [
+ 'timeout' => 120,
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey'),
+ ],
+ ]);
+
+ $body = json_decode($response->getBody()->getContents(), true);
+
+ if (!$body) {
+ throw new GeneralException(__('No response'));
+ }
+
+ return [
+ 'data' => $body,
+ 'recordsTotal' => count($body),
+ ];
+ } catch (\Exception $e) {
+ $this->getLogger()->error('activity: e = ' . $e->getMessage());
+ }
+
+ return [
+ 'data' => [],
+ 'recordsTotal' => 0,
+ ];
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function dmaAdd(SanitizerInterface $params): array
+ {
+ $startDate = $params->getDate('startDate');
+ if ($startDate !== null) {
+ $startDate = $startDate->format('Y-m-d');
+ }
+
+ $endDate = $params->getDate('endDate');
+ if ($endDate !== null) {
+ $endDate = $endDate->format('Y-m-d');
+ }
+
+ try {
+ $response = $this->getClient()->post($this->getServiceUrl() . '/dma', [
+ 'timeout' => 120,
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey'),
+ ],
+ 'json' => [
+ 'name' => $params->getString('name'),
+ 'costPerPlay' => $params->getDouble('costPerPlay'),
+ 'impressionSource' => $params->getString('impressionSource'),
+ 'impressionsPerPlay' => $params->getDouble('impressionsPerPlay'),
+ 'startDate' => $startDate,
+ 'endDate' => $endDate,
+ 'daysOfWeek' => $params->getIntArray('daysOfWeek'),
+ 'startTime' => $params->getString('startTime'),
+ 'endTime' => $params->getString('endTime'),
+ 'geoFence' => json_decode($params->getString('geoFence'), true),
+ 'priority' => $params->getInt('priority'),
+ 'displayGroupId' => $params->getInt('displayGroupId'),
+ ],
+ ]);
+
+ $body = json_decode($response->getBody()->getContents(), true);
+
+ if (!$body) {
+ throw new GeneralException(__('No response'));
+ }
+
+ // Set the displays
+ $this->setDisplaysForDma($body['_id'], $params->getInt('displayGroupId'));
+
+ return $body;
+ } catch (\Exception $e) {
+ $this->handleException($e);
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function dmaEdit(SanitizerInterface $params): array
+ {
+ $startDate = $params->getDate('startDate');
+ if ($startDate !== null) {
+ $startDate = $startDate->format('Y-m-d');
+ }
+
+ $endDate = $params->getDate('endDate');
+ if ($endDate !== null) {
+ $endDate = $endDate->format('Y-m-d');
+ }
+
+ try {
+ $response = $this->getClient()->put($this->getServiceUrl() . '/dma/' . $params->getString('_id'), [
+ 'timeout' => 120,
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey'),
+ ],
+ 'json' => [
+ 'name' => $params->getString('name'),
+ 'costPerPlay' => $params->getDouble('costPerPlay'),
+ 'impressionSource' => $params->getString('impressionSource'),
+ 'impressionsPerPlay' => $params->getDouble('impressionsPerPlay'),
+ 'startDate' => $startDate,
+ 'endDate' => $endDate,
+ 'daysOfWeek' => $params->getIntArray('daysOfWeek'),
+ 'startTime' => $params->getString('startTime'),
+ 'endTime' => $params->getString('endTime'),
+ 'geoFence' => json_decode($params->getString('geoFence'), true),
+ 'priority' => $params->getInt('priority'),
+ 'displayGroupId' => $params->getInt('displayGroupId'),
+ ],
+ ]);
+
+ $body = json_decode($response->getBody()->getContents(), true);
+
+ if (!$body) {
+ throw new GeneralException(__('No response'));
+ }
+
+ // Set the displays
+ $this->setDisplaysForDma($body['_id'], $params->getInt('displayGroupId'));
+
+ return $body;
+ } catch (\Exception $e) {
+ $this->handleException($e);
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function dmaDelete(SanitizerInterface $params)
+ {
+ try {
+ $this->getClient()->delete($this->getServiceUrl() . '/dma/' . $params->getString('_id'), [
+ 'timeout' => 120,
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey'),
+ ],
+ ]);
+
+ return null;
+ } catch (\Exception $e) {
+ $this->handleException($e);
+ }
+ }
+
+ //
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getOptionsFromAxe($apiKey = null, $throw = false)
+ {
+ $apiKey = $apiKey ?? $this->getSetting('apiKey');
+ if (empty($apiKey)) {
+ if ($throw) {
+ throw new InvalidArgumentException(__('Please provide an API key'));
+ } else {
+ return [
+ 'error' => true,
+ 'message' => __('Please provide an API key'),
+ ];
+ }
+ }
+
+ try {
+ $response = $this->getClient()->get($this->getServiceUrl() . '/options', [
+ 'timeout' => 120,
+ 'headers' => [
+ 'X-API-KEY' => $apiKey,
+ ],
+ ]);
+
+ return json_decode($response->getBody()->getContents(), true);
+ } catch (\Exception $e) {
+ try {
+ $this->handleException($e);
+ } catch (\Exception $exception) {
+ if ($throw) {
+ throw $exception;
+ } else {
+ return [
+ 'error' => true,
+ 'message' => $exception->getMessage() ?: __('Unknown Error'),
+ ];
+ }
+ }
+ }
+ }
+
+ private function setDisplaysForDma($dmaId, $displayGroupId)
+ {
+ // Get displays
+ $displayIds = [];
+ foreach ($this->displayFactory->getByDisplayGroupId($displayGroupId) as $display) {
+ $displayIds[] = $display->displayId;
+ }
+
+ // Make a blind call to update this DMA.
+ try {
+ $this->getClient()->post($this->getServiceUrl() . '/dma/' . $dmaId . '/displays', [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'json' => [
+ 'displays' => $displayIds,
+ ]
+ ]);
+ } catch (\Exception $e) {
+ $this->getLogger()->error('Exception updating Displays for dmaId: ' . $dmaId
+ . ', e: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * @param \Exception $exception
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function handleException($exception)
+ {
+ $this->getLogger()->debug('handleException: ' . $exception->getMessage());
+ $this->getLogger()->debug('handleException: ' . $exception->getTraceAsString());
+
+ if ($exception instanceof ClientException) {
+ if ($exception->hasResponse()) {
+ $body = $exception->getResponse()->getBody() ?? null;
+ if (!empty($body)) {
+ $decodedBody = json_decode($body, true);
+ $message = $decodedBody['message'] ?? $body;
+ } else {
+ $message = __('An unknown error has occurred.');
+ }
+
+ switch ($exception->getResponse()->getStatusCode()) {
+ case 422:
+ throw new InvalidArgumentException($message);
+
+ case 404:
+ throw new NotFoundException($message);
+
+ case 401:
+ throw new AccessDeniedException(__('Access denied, please check your API key'));
+
+ default:
+ throw new GeneralException(sprintf(
+ __('Unknown client exception processing your request, error code is %s'),
+ $exception->getResponse()->getStatusCode()
+ ));
+ }
+ } else {
+ throw new InvalidArgumentException(__('Invalid request'));
+ }
+ } elseif ($exception instanceof ServerException) {
+ $this->getLogger()->error('handleException:' . $exception->getMessage());
+ throw new GeneralException(__('There was a problem processing your request, please try again'));
+ } else {
+ throw new GeneralException(__('Unknown Error'));
+ }
+ }
+}
diff --git a/lib/Connector/XiboDashboardConnector.php b/lib/Connector/XiboDashboardConnector.php
new file mode 100644
index 0000000..4dac845
--- /dev/null
+++ b/lib/Connector/XiboDashboardConnector.php
@@ -0,0 +1,551 @@
+.
+ */
+namespace Xibo\Connector;
+
+use GuzzleHttp\Exception\RequestException;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\DashboardDataRequestEvent;
+use Xibo\Event\MaintenanceRegularEvent;
+use Xibo\Event\WidgetEditOptionRequestEvent;
+use Xibo\Event\XmdsConnectorFileEvent;
+use Xibo\Event\XmdsConnectorTokenEvent;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Xibo Dashboard Service connector.
+ * This connector collects credentials and sends them off to the dashboard service
+ */
+class XiboDashboardConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ /** @var float|int The token TTL */
+ const TOKEN_TTL_SECONDS = 3600 * 24 * 2;
+
+ /** @var string Used when rendering the form */
+ private $errorMessage;
+
+ /** @var array Cache of available services */
+ private $availableServices = null;
+
+ /** @var string Cache key for credential states */
+ private $cacheKey = 'connector/xibo_dashboard_connector_statuses';
+
+ /** @var array Cache of error types */
+ private $cachedErrorTypes = null;
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(MaintenanceRegularEvent::$NAME, [$this, 'onRegularMaintenance']);
+ $dispatcher->addListener(XmdsConnectorFileEvent::$NAME, [$this, 'onXmdsFile']);
+ $dispatcher->addListener(XmdsConnectorTokenEvent::$NAME, [$this, 'onXmdsToken']);
+ $dispatcher->addListener(WidgetEditOptionRequestEvent::$NAME, [$this, 'onWidgetEditOption']);
+ $dispatcher->addListener(DashboardDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'xibo-dashboard-connector';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Xibo Dashboard Service';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Add your dashboard credentials for use in the Dashboard widget.';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/xibo-dashboards.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'xibo-dashboard-form-settings';
+ }
+
+ /**
+ * Get the service url, either from settings or a default
+ * @return string
+ */
+ public function getServiceUrl(): string
+ {
+ return $this->getSetting('serviceUrl', 'https://api.dashboards.xibosignage.com');
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ // Remember the old service URL
+ $existingApiKey = $this->getSetting('apiKey');
+
+ if (!$this->isProviderSetting('apiKey')) {
+ $settings['apiKey'] = $params->getString('apiKey');
+ }
+
+ // What if the user changes their API key?
+ // Handle existing credentials
+ if ($existingApiKey !== $settings['apiKey']) {
+ // Test the new API key.
+ $services = $this->getAvailableServices(true, $settings['apiKey']);
+ if (!is_array($services)) {
+ throw new InvalidArgumentException($services);
+ }
+
+ // The new key is valid, clear out the old key's credentials.
+ if (!empty($existingApiKey)) {
+ foreach ($this->getCredentials() as $type => $credential) {
+ try {
+ $this->getClient()->delete(
+ $this->getServiceUrl() . '/services/' . $type . '/' . $credential['id'],
+ [
+ 'headers' => [
+ 'X-API-KEY' => $existingApiKey
+ ]
+ ]
+ );
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('getAvailableServices: delete failed. e = '
+ . $requestException->getMessage());
+ }
+ }
+ }
+ $credentials = [];
+ } else {
+ $credentials = $this->getCredentials();
+ }
+
+ $this->getLogger()->debug('Processing credentials');
+
+ foreach ($this->getAvailableServices(false, $settings['apiKey']) as $service) {
+ // Pull in the parameters for this service.
+ $id = $params->getString($service['type'] . '_id');
+ $isMarkedForRemoval = $params->getCheckbox($service['type'] . '_remove') == 1;
+
+ if (empty($id)) {
+ $userName = $params->getString($service['type'] . '_userName');
+ } else {
+ $userName = $credentials[$service['type']]['userName'] ?? null;
+
+ // This shouldn't happen because we had it when the form opened.
+ if ($userName === null) {
+ $isMarkedForRemoval = true;
+ }
+ }
+ $password = $params->getParam($service['type'] . '_password');
+ $twoFactorSecret = $params->getString($service['type'] . '_twoFactorSecret');
+ $isUrl = isset($service['isUrl']);
+ $url = ($isUrl) ? $params->getString($service['type' ]. '_url') : '';
+
+ if (!empty($id) && $isMarkedForRemoval) {
+ // Existing credential marked for removal
+ try {
+ $this->getClient()->delete($this->getServiceUrl() . '/services/' . $service['type'] . '/' . $id, [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ]
+ ]);
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('getAvailableServices: delete failed. e = '
+ . $requestException->getMessage());
+ }
+ unset($credentials[$service['type']]);
+ } else if (!empty($userName) && !empty($password)) {
+ // A new service or an existing service with a changed password.
+ // Make a request to our service URL.
+ try {
+ $response = $this->getClient()->post(
+ $this->getServiceUrl() . '/services/' . $service['type'],
+ [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'json' => [
+ 'username' => $userName,
+ 'password' => $password,
+ 'totp' => $twoFactorSecret,
+ 'url' => $url
+ ],
+ 'timeout' => 120
+ ]
+ );
+
+ $json = json_decode($response->getBody()->getContents(), true);
+ if (empty($json)) {
+ throw new InvalidArgumentException(__('Empty response from the dashboard service'), $service['type']);
+ }
+ $credentialId = $json['id'];
+
+ $credentials[$service['type']] = [
+ 'userName' => $userName,
+ 'id' => $credentialId,
+ 'status' => true
+ ];
+ } catch (RequestException $requestException) {
+ $this->getLogger()->error('getAvailableServices: e = ' . $requestException->getMessage());
+ throw new InvalidArgumentException(__('Cannot register those credentials.'), $service['type']);
+ }
+ }
+ }
+
+ // Set the credentials
+ $settings['credentials'] = $credentials;
+ return $settings;
+ }
+
+ public function getCredentialForType(string $type)
+ {
+ return $this->settings['credentials'][$type] ?? null;
+ }
+
+ public function getCredentials(): array
+ {
+ return $this->settings['credentials'] ?? [];
+ }
+
+ /**
+ * Used by the Twig template
+ * @param string $type
+ * @return bool
+ */
+ public function isCredentialInErrorState(string $type): bool
+ {
+ if ($this->cachedErrorTypes === null) {
+ $item = $this->getPool()->getItem($this->cacheKey);
+ if ($item->isHit()) {
+ $this->cachedErrorTypes = $item->get();
+ } else {
+ $this->cachedErrorTypes = [];
+ }
+ }
+
+ return in_array($type, $this->cachedErrorTypes);
+ }
+
+ /**
+ * @return array|mixed|string|null
+ */
+ public function getAvailableServices(bool $isReturnError = true, ?string $withApiKey = null)
+ {
+ if ($withApiKey) {
+ $apiKey = $withApiKey;
+ } else {
+ $apiKey = $this->getSetting('apiKey');
+ if (empty($apiKey)) {
+ return [];
+ }
+ }
+
+ if ($this->availableServices === null) {
+ $this->getLogger()->debug('getAvailableServices: Requesting available services.');
+ try {
+ $response = $this->getClient()->get($this->getServiceUrl() . '/services', [
+ 'headers' => [
+ 'X-API-KEY' => $apiKey
+ ]
+ ]);
+ $body = $response->getBody()->getContents();
+
+ $this->getLogger()->debug('getAvailableServices: ' . $body);
+
+ $json = json_decode($body, true);
+ if (empty($json)) {
+ throw new InvalidArgumentException(__('Empty response from the dashboard service'));
+ }
+
+ $this->availableServices = $json;
+ } catch (RequestException $e) {
+ $this->getLogger()->error('getAvailableServices: e = ' . $e->getMessage());
+ $message = json_decode($e->getResponse()->getBody()->getContents(), true);
+
+ if ($isReturnError) {
+ return empty($message)
+ ? __('Cannot contact dashboard service, please try again shortly.')
+ : $message['message'];
+ } else {
+ return [];
+ }
+ } catch (\Exception $e) {
+ $this->getLogger()->error('getAvailableServices: e = ' . $e->getMessage());
+
+ if ($isReturnError) {
+ return __('Cannot contact dashboard service, please try again shortly.');
+ } else {
+ return [];
+ }
+ }
+ }
+
+ return $this->availableServices;
+ }
+
+ public function onRegularMaintenance(MaintenanceRegularEvent $event)
+ {
+ $this->getLogger()->debug('onRegularMaintenance');
+
+ $credentials = $this->getCredentials();
+ if (count($credentials) <= 0) {
+ $this->getLogger()->debug('onRegularMaintenance: No credentials configured, nothing to do.');
+ return;
+ }
+
+ $services = [];
+ foreach ($credentials as $credential) {
+ // Build up a request to ping the service.
+ $services[] = $credential['id'];
+ }
+
+ try {
+ $response = $this->getClient()->post(
+ $this->getServiceUrl() . '/services',
+ [
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ],
+ 'json' => $services
+ ]
+ );
+
+ $body = $response->getBody()->getContents();
+ if (empty($body)) {
+ throw new NotFoundException('Empty response');
+ }
+
+ $json = json_decode($body, true);
+ if (!is_array($json)) {
+ throw new GeneralException('Invalid response body: ' . $body);
+ }
+
+ // Parse the response and activate/deactivate services accordingly.
+ $erroredTypes = [];
+ foreach ($credentials as $type => $credential) {
+ // Get this service from the response.
+ foreach ($json as $item) {
+ if ($item['id'] === $credential['id']) {
+ if ($item['status'] !== true) {
+ $this->getLogger()->error($type . ' credential is in error state');
+ $erroredTypes[] = $type;
+ }
+ continue 2;
+ }
+ }
+ $erroredTypes[] = $type;
+ $this->getLogger()->error($type . ' credential is not present');
+ }
+
+ // Cache the errored types.
+ if (count($erroredTypes) > 0) {
+ $item = $this->getPool()->getItem($this->cacheKey);
+ $item->set($erroredTypes);
+ $item->expiresAfter(3600 * 4);
+ $this->getPool()->save($item);
+ } else {
+ $this->getPool()->deleteItem($this->cacheKey);
+ }
+ } catch (\Exception $e) {
+ $event->addMessage(__('Error calling Dashboard service'));
+ $this->getLogger()->error('onRegularMaintenance: dashboard service e = ' . $e->getMessage());
+ }
+ }
+
+ public function onXmdsToken(XmdsConnectorTokenEvent $event)
+ {
+ $this->getLogger()->debug('onXmdsToken');
+
+ // We are either generating a new token, or verifying an old one.
+ if (empty($event->getToken())) {
+ $this->getLogger()->debug('onXmdsToken: empty token, generate a new one');
+
+ // Generate a new token
+ $token = $this->getJwtService()->generateJwt(
+ $this->getTitle(),
+ $this->getSourceName(),
+ $event->getWidgetId(),
+ $event->getDisplayId(),
+ $event->getTtl()
+ );
+
+ $event->setToken($token->toString());
+ } else {
+ $this->getLogger()->debug('onXmdsToken: Validate the token weve been given');
+
+ try {
+ $token = $this->getJwtService()->validateJwt($event->getToken());
+ if ($token === null) {
+ throw new NotFoundException(__('Cannot decode token'));
+ }
+
+ if ($this->getSourceName() === $token->claims()->get('aud')) {
+ $this->getLogger()->debug('onXmdsToken: Token not for this connector');
+ return;
+ }
+
+ // Configure the event with details from this token
+ $displayId = intval($token->claims()->get('sub'));
+ $widgetId = intval($token->claims()->get('jti'));
+ $event->setTargets($displayId, $widgetId);
+
+ $this->getLogger()->debug('onXmdsToken: Configured event with displayId: ' . $displayId
+ . ', widgetId: ' . $widgetId);
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('onXmdsToken: Invalid token, e = ' . $exception->getMessage());
+ }
+ }
+ }
+
+ public function onXmdsFile(XmdsConnectorFileEvent $event)
+ {
+ $this->getLogger()->debug('onXmdsFile');
+
+ try {
+ // Get the widget
+ $widget = $event->getWidget();
+ if ($widget === null) {
+ throw new NotFoundException();
+ }
+
+ // We want options, so load the widget
+ $widget->load();
+
+ $type = $widget->getOptionValue('type', 'powerbi');
+
+ // Get the credentials for this type.
+ $credentials = $this->getCredentialForType($type);
+ if ($credentials === null) {
+ throw new NotFoundException(sprintf(__('No credentials logged for %s'), $type));
+ }
+
+ // Add headers
+ $headers = [
+ 'X-API-KEY' => $this->getSetting('apiKey')
+ ];
+
+ $response = $this->getClient()->get($this->getServiceUrl() . '/services/' . $type, [
+ 'headers' => $headers,
+ 'query' => [
+ 'credentialId' => $credentials['id'],
+ 'url' => $widget->getOptionValue('url', ''),
+ 'interval' => $widget->getOptionValue('updateInterval', 60) * 60,
+ 'debug' => $event->isDebug()
+ ]
+ ]);
+
+ // Create a response
+ $factory = new Psr17Factory();
+ $event->setResponse(
+ $factory->createResponse(200)
+ ->withHeader('Content-Type', $response->getHeader('Content-Type'))
+ ->withHeader('Cache-Control', $response->getHeader('Cache-Control'))
+ ->withHeader('Last-Modified', $response->getHeader('Last-Modified'))
+ ->withBody($response->getBody())
+ );
+ } catch (\Exception $exception) {
+ // We log any error and return empty
+ $this->getLogger()->error('onXmdsFile: unknown error: ' . $exception->getMessage());
+ }
+ }
+
+ public function onWidgetEditOption(WidgetEditOptionRequestEvent $event)
+ {
+ $this->getLogger()->debug('onWidgetEditOption');
+
+ // Pull the widget we're working with.
+ $widget = $event->getWidget();
+ if ($widget === null) {
+ throw new NotFoundException();
+ }
+
+ // Pull in existing information
+ $existingType = $event->getPropertyValue();
+ $options = $event->getOptions();
+
+ // We handle the dashboard widget and the property with id="type"
+ if ($widget->type === 'dashboard' && $event->getPropertyId() === 'type') {
+ // get available services
+ $services = $this->getAvailableServices(true, $this->getSetting('apiKey'));
+
+ foreach ($services as $option) {
+ // Filter the list of options by the property value provided (if there is one).
+ if (empty($existingType) || $option['type'] === $existingType) {
+ $options[] = $option;
+ }
+ }
+
+ // Set these options on the event.
+ $event->setOptions($options);
+ }
+ }
+
+ public function onDataRequest(DashboardDataRequestEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $this->getLogger()->debug('onDataRequest');
+
+ // Validate that we're configured.
+ if (empty($this->getSetting('apiKey'))) {
+ $event->getDataProvider()->addError(__('Dashboard Connector not configured'));
+ return;
+ }
+
+ // Always generate a token
+ try {
+ $tokenEvent = new XmdsConnectorTokenEvent();
+ $tokenEvent->setTargets($event->getDataProvider()->getDisplayId(), $event->getDataProvider()->getWidgetId());
+ $tokenEvent->setTtl(self::TOKEN_TTL_SECONDS);
+ $dispatcher->dispatch($tokenEvent, XmdsConnectorTokenEvent::$NAME);
+ $token = $tokenEvent->getToken();
+
+ if (empty($token)) {
+ $event->getDataProvider()->addError(__('No token returned'));
+ return;
+ }
+ } catch (\Exception $e) {
+ $this->getLogger()->error('onDataRequest: Failed to get token. e = ' . $e->getMessage());
+ $event->getDataProvider()->addError(__('No token returned'));
+ return;
+ }
+
+ // We return a single data item which contains our URL, token and whether we're a preview
+ $item = [];
+ $item['url'] = $this->getTokenUrl($token);
+ $item['token'] = $token;
+ $item['isPreview'] = $event->getDataProvider()->isPreview();
+
+ // We make sure our data cache expires shortly before the token itself expires (so that we have a new token
+ // generated for it).
+ $event->getDataProvider()->setCacheTtl(self::TOKEN_TTL_SECONDS - 3600);
+
+ // Add our item and set handled
+ $event->getDataProvider()->addItem($item);
+ $event->getDataProvider()->setIsHandled();
+ }
+}
diff --git a/lib/Connector/XiboExchangeConnector.php b/lib/Connector/XiboExchangeConnector.php
new file mode 100644
index 0000000..3827014
--- /dev/null
+++ b/lib/Connector/XiboExchangeConnector.php
@@ -0,0 +1,265 @@
+.
+ */
+
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use Illuminate\Support\Str;
+use Parsedown;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\SearchResult;
+use Xibo\Event\TemplateProviderEvent;
+use Xibo\Event\TemplateProviderImportEvent;
+use Xibo\Event\TemplateProviderListEvent;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * XiboExchangeConnector
+ * ---------------------
+ * This connector will consume the Xibo Layout Exchange API and offer pre-built templates for selection when adding
+ * a new layout.
+ */
+class XiboExchangeConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return ConnectorInterface
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener('connector.provider.template', [$this, 'onTemplateProvider']);
+ $dispatcher->addListener('connector.provider.template.import', [$this, 'onTemplateProviderImport']);
+ $dispatcher->addListener('connector.provider.template.list', [$this, 'onTemplateList']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'xibo-exchange';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Xibo Exchange';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Show Templates provided by the Xibo Exchange in the add new Layout form.';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/xibo-exchange.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'connector-form-edit';
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ return $settings;
+ }
+
+ /**
+ * Get layouts available in Layout exchange and add them to the results
+ * This is triggered in Template Controller search function
+ * @param TemplateProviderEvent $event
+ */
+ public function onTemplateProvider(TemplateProviderEvent $event)
+ {
+ $this->getLogger()->debug('XiboExchangeConnector: onTemplateProvider');
+
+ // Get a cache of the layouts.json file, or request one from download.
+ $uri = 'https://download.xibosignage.com/layouts_v4_1.json';
+ $key = md5($uri);
+ $cache = $this->getPool()->getItem($key);
+ $body = $cache->get();
+
+ if ($cache->isMiss()) {
+ $this->getLogger()->debug('onTemplateProvider: cache miss, generating.');
+
+ // Make the request
+ $request = $this->getClient()->request('GET', $uri);
+
+ $body = $request->getBody()->getContents();
+ if (empty($body)) {
+ $this->getLogger()->debug('onTemplateProvider: Empty body');
+ return;
+ }
+
+ $body = json_decode($body);
+ if ($body === null || $body === false) {
+ $this->getLogger()->debug('onTemplateProvider: non-json body or empty body returned.');
+ return;
+ }
+
+ // Cache for next time
+ $cache->set($body);
+ $cache->expiresAt(Carbon::now()->addHours(24));
+ $this->getPool()->saveDeferred($cache);
+ } else {
+ $this->getLogger()->debug('onTemplateProvider: serving from cache.');
+ }
+
+ // We have the whole file locally, so handle paging
+ $start = $event->getStart();
+ $perPage = $event->getLength();
+
+ // Create a provider to add to each search result
+ $providerDetails = new ProviderDetails();
+ $providerDetails->id = $this->getSourceName();
+ $providerDetails->logoUrl = $this->getThumbnail();
+ $providerDetails->iconUrl = $this->getThumbnail();
+ $providerDetails->message = $this->getTitle();
+ $providerDetails->backgroundColor = '';
+
+ // parse the templates based on orientation filter.
+ if (!empty($event->getOrientation())) {
+ $templates = [];
+ foreach ($body as $template) {
+ if (!empty($template->orientation) &&
+ Str::contains($template->orientation, $event->getOrientation(), true)
+ ) {
+ $templates[] = $template;
+ }
+ }
+ } else {
+ $templates = $body;
+ }
+
+ // Filter the body based on search param.
+ if (!empty($event->getSearch())) {
+ $filtered = [];
+ foreach ($templates as $template) {
+ if (Str::contains($template->title, $event->getSearch(), true)) {
+ $filtered[] = $template;
+ continue;
+ }
+
+ if (!empty($template->description) &&
+ Str::contains($template->description, $event->getSearch(), true)
+ ) {
+ $filtered[] = $template;
+ continue;
+ }
+
+ if (property_exists($template, 'tags') && count($template->tags) > 0) {
+ if (in_array($event->getSearch(), $template->tags)) {
+ $filtered[] = $template;
+ }
+ }
+ }
+ } else {
+ $filtered = $templates;
+ }
+
+ // sort, featured first, otherwise alphabetically.
+ usort($filtered, function ($a, $b) {
+ if (property_exists($a, 'isFeatured') && property_exists($b, 'isFeatured')) {
+ return $b->isFeatured <=> $a->isFeatured;
+ } else {
+ return $a->title <=> $b->title;
+ }
+ });
+
+ for ($i = $start; $i < ($start + $perPage - 1) && $i < count($filtered); $i++) {
+ $searchResult = $this->createSearchResult($filtered[$i]);
+ $searchResult->provider = $providerDetails;
+ $event->addResult($searchResult);
+ }
+ }
+
+ /**
+ * When remote source Template is selected on Layout add,
+ * we need to get the zip file from specified url and import it to the CMS
+ * imported Layout object is set on the Event and retrieved later in Layout controller
+ * @param TemplateProviderImportEvent $event
+ */
+ public function onTemplateProviderImport(TemplateProviderImportEvent $event)
+ {
+ $downloadUrl = $event->getDownloadUrl();
+ $client = new Client();
+ $tempFile = $event->getLibraryLocation() . 'temp/' . $event->getFileName();
+ $client->request('GET', $downloadUrl, ['sink' => $tempFile]);
+ $event->setFilePath($tempFile);
+ }
+
+ /**
+ * @param $template
+ * @return SearchResult
+ */
+ private function createSearchResult($template) : SearchResult
+ {
+ $searchResult = new SearchResult();
+ $searchResult->id = $template->fileName;
+ $searchResult->source = 'remote';
+ $searchResult->title = $template->title;
+ $searchResult->description = empty($template->description)
+ ? null
+ : Parsedown::instance()->setSafeMode(true)->line($template->description);
+
+ // Optional data
+ if (property_exists($template, 'tags') && count($template->tags) > 0) {
+ $searchResult->tags = $template->tags;
+ }
+
+ if (property_exists($template, 'orientation')) {
+ $searchResult->orientation = $template->orientation;
+ }
+
+ if (property_exists($template, 'isFeatured')) {
+ $searchResult->isFeatured = $template->isFeatured;
+ }
+
+ // Thumbnail
+ $searchResult->thumbnail = $template->thumbnailUrl;
+ $searchResult->download = $template->downloadUrl;
+ return $searchResult;
+ }
+
+ /**
+ * Add this connector to the list of providers.
+ * @param \Xibo\Event\TemplateProviderListEvent $event
+ * @return void
+ */
+ public function onTemplateList(TemplateProviderListEvent $event): void
+ {
+ $this->getLogger()->debug('onTemplateList:event');
+
+ $providerDetails = new ProviderDetails();
+ $providerDetails->id = $this->getSourceName();
+ $providerDetails->link = 'https://xibosignage.com';
+ $providerDetails->logoUrl = $this->getThumbnail();
+ $providerDetails->iconUrl = 'exchange-alt';
+ $providerDetails->message = $this->getTitle();
+ $providerDetails->backgroundColor = '';
+ $providerDetails->mediaTypes = ['xlf'];
+
+ $event->addProvider($providerDetails);
+ }
+}
diff --git a/lib/Connector/XiboSspConnector.php b/lib/Connector/XiboSspConnector.php
new file mode 100644
index 0000000..cf7522e
--- /dev/null
+++ b/lib/Connector/XiboSspConnector.php
@@ -0,0 +1,582 @@
+.
+ */
+namespace Xibo\Connector;
+
+use Carbon\Carbon;
+use GuzzleHttp\Exception\RequestException;
+use Illuminate\Support\Str;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\MaintenanceRegularEvent;
+use Xibo\Event\WidgetEditOptionRequestEvent;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Xibo SSP Connector
+ * communicates with the Xibo Ad Exchange to register displays with connected SSPs and manage ad requests
+ */
+class XiboSspConnector implements ConnectorInterface
+{
+ use ConnectorTrait;
+
+ /** @var string */
+ private $formError;
+
+ /** @var array */
+ private $partners;
+
+ /** @var \Xibo\Factory\DisplayFactory */
+ private $displayFactory;
+
+ /**
+ * @param \Psr\Container\ContainerInterface $container
+ * @return \Xibo\Connector\ConnectorInterface
+ */
+ public function setFactories(ContainerInterface $container): ConnectorInterface
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ return $this;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ConnectorInterface
+ {
+ $dispatcher->addListener(MaintenanceRegularEvent::$NAME, [$this, 'onRegularMaintenance']);
+ $dispatcher->addListener(WidgetEditOptionRequestEvent::$NAME, [$this, 'onWidgetEditOption']);
+ return $this;
+ }
+
+ public function getSourceName(): string
+ {
+ return 'xibo-ssp-connector';
+ }
+
+ public function getTitle(): string
+ {
+ return 'Xibo SSP Connector';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Connect to world leading Supply Side Platforms (SSPs) and monetise your network.';
+ }
+
+ public function getThumbnail(): string
+ {
+ return 'theme/default/img/connectors/xibo-ssp.png';
+ }
+
+ public function getSettingsFormTwig(): string
+ {
+ return 'xibo-ssp-connector-form-settings';
+ }
+
+ public function getSettingsFormJavaScript(): string
+ {
+ return 'xibo-ssp-connector-form-javascript';
+ }
+
+ public function getFormError(): string
+ {
+ return $this->formError ?? __('Unknown error');
+ }
+
+ public function processSettingsForm(SanitizerInterface $params, array $settings): array
+ {
+ $existingApiKey = $this->getSetting('apiKey');
+ if (!$this->isProviderSetting('apiKey')) {
+ $settings['apiKey'] = $params->getString('apiKey');
+ }
+
+ $existingCmsUrl = $this->getSetting('cmsUrl');
+ if (!$this->isProviderSetting('cmsUrl')) {
+ $settings['cmsUrl'] = trim($params->getString('cmsUrl'), '/');
+
+ if (empty($settings['cmsUrl']) || !Str::startsWith($settings['cmsUrl'], 'http')) {
+ throw new InvalidArgumentException(
+ __('Please enter a CMS URL, including http(s)://'),
+ 'cmsUrl'
+ );
+ }
+ }
+
+ // If our API key was empty, then do not set partners.
+ if (empty($existingApiKey) || empty($settings['apiKey'])) {
+ return $settings;
+ }
+
+ // Set partners.
+ $partners = [];
+ $available = $this->getAvailablePartners(true, $settings['apiKey']);
+
+ // Pull in expected fields.
+ foreach ($available as $partnerId => $partner) {
+ $partners[] = [
+ 'name' => $partnerId,
+ 'enabled' => $params->getCheckbox($partnerId . '_enabled'),
+ 'isTest' => $params->getCheckbox($partnerId . '_isTest'),
+ 'isUseWidget' => $params->getCheckbox($partnerId . '_isUseWidget'),
+ 'currency' => $params->getString($partnerId . '_currency'),
+ 'key' => $params->getString($partnerId . '_key'),
+ 'sov' => $params->getInt($partnerId . '_sov'),
+ 'mediaTypesAllowed' => $params->getString($partnerId . '_mediaTypesAllowed'),
+ 'duration' => $params->getInt($partnerId . '_duration'),
+ 'minDuration' => $params->getInt($partnerId . '_minDuration'),
+ 'maxDuration' => $params->getInt($partnerId . '_maxDuration'),
+ ];
+
+ // Also grab the displayGroupId if one has been set.
+ $displayGroupId = $params->getInt($partnerId . '_displayGroupId');
+ if (empty($displayGroupId)) {
+ unset($settings[$partnerId . '_displayGroupId']);
+ } else {
+ $settings[$partnerId . '_displayGroupId'] = $displayGroupId;
+ }
+ $settings[$partnerId . '_sspIdField'] = $params->getString($partnerId . '_sspIdField');
+ }
+
+ // Update API config.
+ $this->setPartners($settings['apiKey'], $partners);
+
+ try {
+ // If the API key has changed during this request, clear out displays on the old API key
+ if ($existingApiKey !== $settings['apiKey']) {
+ // Clear all displays for this CMS on the existing key
+ $this->setDisplays($existingApiKey, $existingCmsUrl, [], $settings);
+ } else if (!empty($existingCmsUrl) && $existingCmsUrl !== $settings['cmsUrl']) {
+ // Clear all displays for this CMS on the existing key
+ $this->setDisplays($settings['apiKey'], $existingCmsUrl, [], $settings);
+ }
+ } catch (\Exception $e) {
+ $this->getLogger()->error('Failed to set displays '. $e->getMessage());
+ }
+
+ // Add displays on the new API key (maintenance also does this, but do it now).
+ $this->setDisplays($settings['apiKey'], $settings['cmsUrl'], $partners, $settings);
+
+ return $settings;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws GeneralException
+ */
+ public function getAvailablePartners(bool $isThrowError = false, ?string $withApiKey = null)
+ {
+ if ($this->partners === null) {
+ // Make a call to the API to see what we've currently got configured and what is available.
+ if ($withApiKey) {
+ $apiKey = $withApiKey;
+ } else {
+ $apiKey = $this->getSetting('apiKey');
+ if (empty($apiKey)) {
+ return [];
+ }
+ }
+
+ $this->getLogger()->debug('getAvailablePartners: Requesting available services.');
+
+ try {
+ $response = $this->getClient()->get($this->getServiceUrl() . '/configure', [
+ 'headers' => [
+ 'X-API-KEY' => $apiKey
+ ]
+ ]);
+ $body = $response->getBody()->getContents();
+
+ $this->getLogger()->debug('getAvailablePartners: ' . $body);
+
+ $json = json_decode($body, true);
+ if (empty($json)) {
+ $this->formError = __('Empty response from the dashboard service');
+ throw new InvalidArgumentException($this->formError);
+ }
+
+ $this->partners = $json;
+ } catch (RequestException $e) {
+ $this->getLogger()->error('getAvailablePartners: e = ' . $e->getMessage());
+
+ if ($e->getResponse()->getStatusCode() === 401) {
+ $this->formError = __('API key not valid');
+ if ($isThrowError) {
+ throw new InvalidArgumentException($this->formError, 'apiKey');
+ } else {
+ return null;
+ }
+ }
+
+ $message = json_decode($e->getResponse()->getBody()->getContents(), true);
+
+ $this->formError = empty($message)
+ ? __('Cannot contact SSP service, please try again shortly.')
+ : $message['message'];
+
+ if ($isThrowError) {
+ throw new GeneralException($this->formError);
+ } else {
+ return null;
+ }
+ } catch (\Exception $e) {
+ $this->getLogger()->error('getAvailableServices: e = ' . $e->getMessage());
+
+ $this->formError = __('Cannot contact SSP service, please try again shortly.');
+ if ($isThrowError) {
+ throw new GeneralException($this->formError);
+ } else {
+ return null;
+ }
+ }
+ }
+
+ return $this->partners['available'] ?? [];
+ }
+
+ /**
+ * Get the number of displays that are authorised by this API key.
+ * @return int
+ */
+ public function getAuthorisedDisplayCount(): int
+ {
+ return intval($this->partners['displays'] ?? 0);
+ }
+
+ /**
+ * Get a setting for a partner
+ * @param string $partnerKey
+ * @param string $setting
+ * @param $default
+ * @return mixed|string|null
+ */
+ public function getPartnerSetting(string $partnerKey, string $setting, $default = null)
+ {
+ if (!is_array($this->partners) || !array_key_exists('partners', $this->partners)) {
+ return $default;
+ }
+
+ foreach ($this->partners['partners'] as $partner) {
+ if ($partner['name'] === $partnerKey) {
+ return $partner[$setting] ?? $default;
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws GeneralException
+ */
+ private function setPartners(string $apiKey, array $partners)
+ {
+ $this->getLogger()->debug('setPartners: updating');
+ $this->getLogger()->debug(json_encode($partners));
+
+ try {
+ $this->getClient()->post($this->getServiceUrl() . '/configure', [
+ 'headers' => [
+ 'X-API-KEY' => $apiKey
+ ],
+ 'json' => [
+ 'partners' => $partners
+ ]
+ ]);
+ } catch (RequestException $e) {
+ $this->getLogger()->error('setPartners: e = ' . $e->getMessage());
+ $message = json_decode($e->getResponse()->getBody()->getContents(), true);
+
+ throw new GeneralException(empty($message)
+ ? __('Cannot contact SSP service, please try again shortly.')
+ : $message['message']);
+ } catch (\Exception $e) {
+ $this->getLogger()->error('setPartners: e = ' . $e->getMessage());
+ throw new GeneralException(__('Cannot contact SSP service, please try again shortly.'));
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function setDisplays(string $apiKey, string $cmsUrl, array $partners, array $settings)
+ {
+ $displays = [];
+ foreach ($partners as $partner) {
+ // If this partner is enabled?
+ if (!$partner['enabled']) {
+ continue;
+ }
+
+ // Get displays for this partner
+ $partnerKey = $partner['name'];
+ $sspIdField = $settings[$partnerKey . '_sspIdField'] ?? 'displayId';
+
+ foreach ($this->displayFactory->query(null, [
+ 'disableUserCheck' => 1,
+ 'displayGroupId' => $settings[$partnerKey . '_displayGroupId'] ?? null,
+ 'authorised' => 1,
+ ]) as $display) {
+ if (!array_key_exists($display->displayId, $displays)) {
+ $resolution = explode('x', $display->resolution ?? '');
+ $displays[$display->displayId] = [
+ 'displayId' => $display->displayId,
+ 'hardwareKey' => $display->license,
+ 'width' => trim($resolution[0] ?? 1920),
+ 'height' => trim($resolution[1] ?? 1080),
+ 'partners' => [],
+ ];
+ }
+
+ switch ($sspIdField) {
+ case 'customId':
+ $sspId = $display->customId;
+ break;
+
+ case 'ref1':
+ $sspId = $display->ref1;
+ break;
+
+ case 'ref2':
+ $sspId = $display->ref2;
+ break;
+
+ case 'ref3':
+ $sspId = $display->ref3;
+ break;
+
+ case 'ref4':
+ $sspId = $display->ref4;
+ break;
+
+ case 'ref5':
+ $sspId = $display->ref5;
+ break;
+
+ case 'displayId':
+ default:
+ $sspId = $display->displayId;
+ }
+
+ $displays[$display->displayId]['partners'][] = [
+ 'name' => $partnerKey,
+ 'sspId' => '' . $sspId,
+ ];
+ }
+ }
+
+ try {
+ $this->getClient()->post($this->getServiceUrl() . '/displays', [
+ 'headers' => [
+ 'X-API-KEY' => $apiKey,
+ ],
+ 'json' => [
+ 'cmsUrl' => $cmsUrl,
+ 'displays' => array_values($displays),
+ ],
+ ]);
+ } catch (RequestException $e) {
+ $this->getLogger()->error('setDisplays: e = ' . $e->getMessage());
+ $message = json_decode($e->getResponse()->getBody()->getContents(), true);
+
+ throw new GeneralException(empty($message)
+ ? __('Cannot contact SSP service, please try again shortly.')
+ : $message['message']);
+ } catch (\Exception $e) {
+ $this->getLogger()->error('setDisplays: e = ' . $e->getMessage());
+ throw new GeneralException(__('Cannot contact SSP service, please try again shortly.'));
+ }
+ }
+
+ /**
+ * Get the service url, either from settings or a default
+ * @return string
+ */
+ private function getServiceUrl(): string
+ {
+ return $this->getSetting('serviceUrl', 'https://exchange.xibo-adspace.com/api');
+ }
+
+ //
+
+ /**
+ * Activity data
+ */
+ public function activity(SanitizerInterface $params): array
+ {
+ $fromDt = $params->getDate('activityFromDt', [
+ 'default' => Carbon::now()->startOfHour()
+ ]);
+
+ $toDt = $params->getDate('activityToDt', [
+ 'default' => $fromDt->addHour()
+ ]);
+
+ if ($params->getInt('displayId') == null) {
+ throw new GeneralException(__('Display ID is required'));
+ }
+
+ // Call the api (override the timeout)
+ try {
+ $response = $this->getClient()->get($this->getServiceUrl() . '/activity', [
+ 'timeout' => 120,
+ 'headers' => [
+ 'X-API-KEY' => $this->getSetting('apiKey'),
+ ],
+ 'query' => [
+ 'cmsUrl' => $this->getSetting('cmsUrl'),
+ 'fromDt' => $fromDt->toAtomString(),
+ 'toDt' => $toDt->toAtomString(),
+ 'displayId' => $params->getInt('displayId'),
+ 'campaignId' => $params->getString('partnerId'),
+ ],
+ ]);
+
+ $body = json_decode($response->getBody()->getContents(), true);
+
+ if (!$body) {
+ throw new GeneralException(__('No response'));
+ }
+
+ return $body;
+ } catch (\Exception $e) {
+ $this->getLogger()->error('activity: e = ' . $e->getMessage());
+ }
+
+ return [
+ 'data' => [],
+ 'recordsTotal' => 0,
+ ];
+ }
+
+ /**
+ * Available Partners
+ */
+ public function getAvailablePartnersFilter(SanitizerInterface $params): array
+ {
+ try {
+ return $this->getAvailablePartners() ?? [];
+ } catch (\Exception $e) {
+ $this->getLogger()->error('activity: e = ' . $e->getMessage());
+ }
+
+ return [
+ 'data' => [],
+ 'recordsTotal' => 0,
+ ];
+ }
+ //
+
+ //
+
+ public function onRegularMaintenance(MaintenanceRegularEvent $event)
+ {
+ $this->getLogger()->debug('onRegularMaintenance');
+
+ try {
+ $this->getAvailablePartners();
+ $partners = $this->partners['partners'] ?? [];
+
+ if (count($partners) > 0) {
+ $this->setDisplays(
+ $this->getSetting('apiKey'),
+ $this->getSetting('cmsUrl'),
+ $partners,
+ $this->settings
+ );
+ }
+
+ $event->addMessage('SSP: done');
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('SSP connector: ' . $exception->getMessage());
+ $event->addMessage('Error processing SSP configuration.');
+ }
+ }
+
+ /**
+ * Connector is being deleted
+ * @param \Xibo\Service\ConfigServiceInterface $configService
+ * @return void
+ */
+ public function delete(ConfigServiceInterface $configService): void
+ {
+ $this->getLogger()->debug('delete');
+ $configService->changeSetting('isAdspaceEnabled', 0);
+ }
+
+ /**
+ * Connector is being enabled
+ * @param \Xibo\Service\ConfigServiceInterface $configService
+ * @return void
+ */
+ public function enable(ConfigServiceInterface $configService): void
+ {
+ $this->getLogger()->debug('enable');
+ $configService->changeSetting('isAdspaceEnabled', 1);
+ }
+
+ /**
+ * Connector is being disabled
+ * @param \Xibo\Service\ConfigServiceInterface $configService
+ * @return void
+ */
+ public function disable(ConfigServiceInterface $configService): void
+ {
+ $this->getLogger()->debug('disable');
+ $configService->changeSetting('isAdspaceEnabled', 0);
+ }
+
+ public function onWidgetEditOption(WidgetEditOptionRequestEvent $event)
+ {
+ $this->getLogger()->debug('onWidgetEditOption');
+
+ // Pull the widget we're working with.
+ $widget = $event->getWidget();
+ if ($widget === null) {
+ throw new NotFoundException();
+ }
+
+ // We handle the dashboard widget and the property with id="type"
+ if ($widget->type === 'ssp' && $event->getPropertyId() === 'partnerId') {
+ // Pull in existing information
+ $partnerFilter = $event->getPropertyValue();
+ $options = $event->getOptions();
+
+ foreach ($this->getAvailablePartners() as $partnerId => $partner) {
+ if ((empty($partnerFilter) || $partnerId === $partnerFilter)
+ && $this->getPartnerSetting($partnerId, 'enabled') == 1
+ ) {
+ $options[] = [
+ 'id' => $partnerId,
+ 'type' => $partnerId,
+ 'name' => $partner['name'],
+ ];
+ }
+ }
+
+ $event->setOptions($options);
+ }
+ }
+
+ //
+}
diff --git a/lib/Controller/Action.php b/lib/Controller/Action.php
new file mode 100644
index 0000000..5ad1a41
--- /dev/null
+++ b/lib/Controller/Action.php
@@ -0,0 +1,607 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\ActionFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Action
+ * @package Xibo\Controller
+ */
+class Action extends Base
+{
+
+ /**
+ * @var ActionFactory
+ */
+ private $actionFactory;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var RegionFactory */
+ private $regionFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /**
+ * Set common dependencies.
+ * @param ActionFactory $actionFactory
+ * @param LayoutFactory $layoutFactory
+ * @param RegionFactory $regionFactory
+ * @param WidgetFactory $widgetFactory
+ * @param ModuleFactory $moduleFactory
+ */
+ public function __construct($actionFactory, $layoutFactory, $regionFactory, $widgetFactory, $moduleFactory)
+ {
+ $this->actionFactory = $actionFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->regionFactory = $regionFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->moduleFactory = $moduleFactory;
+ }
+
+
+ /**
+ * Returns a Grid of Actions
+ *
+ * @SWG\Get(
+ * path="/action",
+ * operationId="actionSearch",
+ * tags={"action"},
+ * summary="Search Actions",
+ * description="Search all Actions this user has access to",
+ * @SWG\Parameter(
+ * name="actionId",
+ * in="query",
+ * description="Filter by Action Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ownerId",
+ * in="query",
+ * description="Filter by Owner Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="triggerType",
+ * in="query",
+ * description="Filter by Action trigger type",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="triggerCode",
+ * in="query",
+ * description="Filter by Action trigger code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionType",
+ * in="query",
+ * description="Filter by Action type",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="source",
+ * in="query",
+ * description="Filter by Action source",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="sourceId",
+ * in="query",
+ * description="Filter by Action source Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="target",
+ * in="query",
+ * description="Filter by Action target",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="targetId",
+ * in="query",
+ * description="Filter by Action target Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="query",
+ * description="Return all actions pertaining to a particular Layout",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="sourceOrTargetId",
+ * in="query",
+ * description="Return all actions related to a source or target with the provided ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Action")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function grid(Request $request, Response $response) : Response
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'actionId' => $parsedParams->getInt('actionId'),
+ 'ownerId' => $parsedParams->getInt('ownerId'),
+ 'triggerType' => $parsedParams->getString('triggerType'),
+ 'triggerCode' => $parsedParams->getString('triggerCode'),
+ 'actionType' => $parsedParams->getString('actionType'),
+ 'source' => $parsedParams->getString('source'),
+ 'sourceId' => $parsedParams->getInt('sourceId'),
+ 'target' => $parsedParams->getString('target'),
+ 'targetId' => $parsedParams->getInt('targetId'),
+ 'widgetId' => $parsedParams->getInt('widgetId'),
+ 'layoutCode' => $parsedParams->getString('layoutCode'),
+ 'layoutId' => $parsedParams->getInt('layoutId'),
+ 'sourceOrTargetId' => $parsedParams->getInt('sourceOrTargetId'),
+ ];
+
+ $actions = $this->actionFactory->query(
+ $this->gridRenderSort($parsedParams),
+ $this->gridRenderFilter($filter, $parsedParams)
+ );
+
+ foreach ($actions as $action) {
+ $action->setUnmatchedProperty('widgetName', null);
+ $action->setUnmatchedProperty('regionName', null);
+
+ if ($action->actionType === 'navWidget' && $action->widgetId != null) {
+ try {
+ $widget = $this->widgetFactory->loadByWidgetId($action->widgetId);
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // dynamic field to display in the grid instead of widgetId
+ $action->setUnmatchedProperty('widgetName', $widget->getOptionValue('name', $module->name));
+ } catch (NotFoundException $e) {
+ // Widget not found, leave widgetName as null
+ }
+ }
+
+ if ($action->target === 'region' && $action->targetId != null) {
+ try {
+ $region = $this->regionFactory->getById($action->targetId);
+
+ // dynamic field to display in the grid instead of regionId
+ $action->setUnmatchedProperty('regionName', $region->name);
+ } catch (NotFoundException $e) {
+ // Region not found, leave regionName as null
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->actionFactory->countLast();
+ $this->getState()->setData($actions);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a new Action
+ *
+ * @SWG\Post(
+ * path="/action",
+ * operationId="actionAdd",
+ * tags={"action"},
+ * summary="Add Action",
+ * description="Add a new Action",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="LayoutId associted with this Action",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="actionType",
+ * in="formData",
+ * description="Action type, next, previous, navLayout, navWidget",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="target",
+ * in="formData",
+ * description="Target for this action, screen or region",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="targetId",
+ * in="formData",
+ * description="The id of the target for this action - regionId if the target is set to region",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="source",
+ * in="formData",
+ * description="Source for this action layout, region or widget",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="sourceId",
+ * in="formData",
+ * description="The id of the source object, layoutId, regionId or widgetId",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="triggerType",
+ * in="formData",
+ * description="Action trigger type, touch or webhook",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="triggerCode",
+ * in="formData",
+ * description="Action trigger code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="formData",
+ * description="For navWidget actionType, the WidgetId to navigate to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutCode",
+ * in="formData",
+ * description="For navLayout, the Layout Code identifier to navigate to",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Action"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function add(Request $request, Response $response) : Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $triggerType = $sanitizedParams->getString('triggerType');
+ $triggerCode = $sanitizedParams->getString('triggerCode', ['defaultOnEmptyString' => true]);
+ $actionType = $sanitizedParams->getString('actionType');
+ $target = $sanitizedParams->getString('target');
+ $targetId = $sanitizedParams->getInt('targetId');
+ $widgetId = $sanitizedParams->getInt('widgetId');
+ $layoutCode = $sanitizedParams->getString('layoutCode');
+ $layoutId = $sanitizedParams->getInt('layoutId');
+ $source = $sanitizedParams->getString('source');
+ $sourceId = $sanitizedParams->getInt('sourceId');
+
+ if ($layoutId === null) {
+ throw new InvalidArgumentException(__('Please provide LayoutId'), 'layoutId');
+ }
+
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ // Make sure the Layout is checked out to begin with
+ if (!$layout->isEditable()) {
+ throw new InvalidArgumentException(__('Layout is not checked out'), 'statusId');
+ }
+
+ // restrict to one touch Action per source
+ if (
+ (!empty($source) && $sourceId !== null && !empty($triggerType))
+ && $this->actionFactory->checkIfActionExist($source, $sourceId, $triggerType)
+ ) {
+ throw new InvalidArgumentException(__('Action with specified Trigger Type already exists'), 'triggerType');
+ }
+
+ $action = $this->actionFactory->create(
+ $triggerType,
+ $triggerCode,
+ $actionType,
+ $source,
+ $sourceId,
+ $target,
+ $targetId,
+ $widgetId,
+ $layoutCode,
+ $layoutId
+ );
+
+ $action->save(['notifyLayout' => true]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Added Action'),
+ 'httpStatus' => 201,
+ 'id' => $action->actionId,
+ 'data' => $action,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Action
+ *
+ * @SWG\PUT(
+ * path="/action/{actionId}",
+ * operationId="actionAdd",
+ * tags={"action"},
+ * summary="Add Action",
+ * description="Add a new Action",
+ * @SWG\Parameter(
+ * name="actionId",
+ * in="path",
+ * description="Action ID to edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="LayoutId associted with this Action",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="actionType",
+ * in="formData",
+ * description="Action type, next, previous, navLayout, navWidget",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="target",
+ * in="formData",
+ * description="Target for this action, screen or region",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="targetId",
+ * in="formData",
+ * description="The id of the target for this action - regionId if the target is set to region",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="source",
+ * in="formData",
+ * description="Source for this action layout, region or widget",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="sourceId",
+ * in="formData",
+ * description="The id of the source object, layoutId, regionId or widgetId",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="triggerType",
+ * in="formData",
+ * description="Action trigger type, touch or webhook",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="triggerCode",
+ * in="formData",
+ * description="Action trigger code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="formData",
+ * description="For navWidget actionType, the WidgetId to navigate to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutCode",
+ * in="formData",
+ * description="For navLayout, the Layout Code identifier to navigate to",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Action"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws GeneralException
+ */
+ public function edit(Request $request, Response $response, int $id) : Response
+ {
+ $action = $this->actionFactory->getById($id);
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $layout = $this->layoutFactory->getById($action->layoutId);
+
+ // Make sure the Layout is checked out to begin with
+ if (!$layout->isEditable()) {
+ throw new InvalidArgumentException(__('Layout is not checked out'), 'statusId');
+ }
+
+ $action->source = $sanitizedParams->getString('source');
+ $action->sourceId = $sanitizedParams->getInt('sourceId');
+ $action->triggerType = $sanitizedParams->getString('triggerType');
+ $action->triggerCode = $sanitizedParams->getString('triggerCode', ['defaultOnEmptyString' => true]);
+ $action->actionType = $sanitizedParams->getString('actionType');
+ $action->target = $sanitizedParams->getString('target');
+ $action->targetId = $sanitizedParams->getInt('targetId');
+ $action->widgetId = $sanitizedParams->getInt('widgetId');
+ $action->layoutCode = $sanitizedParams->getString('layoutCode');
+ $action->validate();
+ // restrict to one touch Action per source
+ if ($this->actionFactory->checkIfActionExist($action->source, $action->sourceId, $action->triggerType, $action->actionId)) {
+ throw new InvalidArgumentException(__('Action with specified Trigger Type already exists'), 'triggerType');
+ }
+
+ $action->save(['notifyLayout' => true, 'validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Edited Action'),
+ 'id' => $action->actionId,
+ 'data' => $action
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Action
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ *
+ * @SWG\Delete(
+ * path="/action/{actionId}",
+ * operationId="actionDelete",
+ * tags={"action"},
+ * summary="Delete Action",
+ * description="Delete an existing Action",
+ * @SWG\Parameter(
+ * name="actionId",
+ * in="path",
+ * description="The Action ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, int $id) : Response
+ {
+ $action = $this->actionFactory->getById($id);
+ $layout = $this->layoutFactory->getById($action->layoutId);
+
+ // Make sure the Layout is checked out to begin with
+ if (!$layout->isEditable()) {
+ throw new InvalidArgumentException(__('Layout is not checked out'), 'statusId');
+ }
+
+ $action->notifyLayout($layout->layoutId);
+ $action->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted Action'))
+ ]);
+
+ return $this->render($request, $response);
+
+ }
+
+ /**
+ * @param string $source
+ * @param int $sourceId
+ * @return \Xibo\Entity\Layout|\Xibo\Entity\Region|\Xibo\Entity\Widget
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function checkIfSourceExists(string $source, int $sourceId)
+ {
+ if (strtolower($source) === 'layout') {
+ $object = $this->layoutFactory->getById($sourceId);
+ } elseif (strtolower($source) === 'region') {
+ $object = $this->regionFactory->getById($sourceId);
+ } elseif (strtolower($source) === 'widget') {
+ $object = $this->widgetFactory->getById($sourceId);
+ } else {
+ throw new InvalidArgumentException(__('Provided source is invalid. ') , 'source');
+ }
+
+ return $object;
+ }
+}
diff --git a/lib/Controller/Applications.php b/lib/Controller/Applications.php
new file mode 100644
index 0000000..ca3ee6a
--- /dev/null
+++ b/lib/Controller/Applications.php
@@ -0,0 +1,652 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use League\OAuth2\Server\AuthorizationServer;
+use League\OAuth2\Server\Exception\OAuthServerException;
+use League\OAuth2\Server\Grant\AuthCodeGrant;
+use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\ApplicationScope;
+use Xibo\Factory\ApplicationFactory;
+use Xibo\Factory\ApplicationRedirectUriFactory;
+use Xibo\Factory\ApplicationScopeFactory;
+use Xibo\Factory\ConnectorFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Session;
+use Xibo\OAuth\AuthCodeRepository;
+use Xibo\OAuth\RefreshTokenRepository;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Applications
+ * @package Xibo\Controller
+ */
+class Applications extends Base
+{
+ /**
+ * @var Session
+ */
+ private $session;
+
+ /**
+ * @var ApplicationFactory
+ */
+ private $applicationFactory;
+
+ /**
+ * @var ApplicationRedirectUriFactory
+ */
+ private $applicationRedirectUriFactory;
+
+ /** @var ApplicationScopeFactory */
+ private $applicationScopeFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var \Xibo\Factory\ConnectorFactory */
+ private $connectorFactory;
+
+ /**
+ * Set common dependencies.
+ * @param Session $session
+ * @param ApplicationFactory $applicationFactory
+ * @param ApplicationRedirectUriFactory $applicationRedirectUriFactory
+ * @param $applicationScopeFactory
+ * @param UserFactory $userFactory
+ * @param $pool
+ * @param \Xibo\Factory\ConnectorFactory $connectorFactory
+ */
+ public function __construct(
+ $session,
+ $applicationFactory,
+ $applicationRedirectUriFactory,
+ $applicationScopeFactory,
+ $userFactory,
+ $pool,
+ ConnectorFactory $connectorFactory
+ ) {
+ $this->session = $session;
+ $this->applicationFactory = $applicationFactory;
+ $this->applicationRedirectUriFactory = $applicationRedirectUriFactory;
+ $this->applicationScopeFactory = $applicationScopeFactory;
+ $this->userFactory = $userFactory;
+ $this->pool = $pool;
+ $this->connectorFactory = $connectorFactory;
+ }
+
+ /**
+ * Display Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ // Load all connectors and output any javascript.
+ $connectorJavaScript = [];
+ foreach ($this->connectorFactory->query(['isVisible' => 1]) as $connector) {
+ try {
+ // Create a connector, add in platform settings and register it with the dispatcher.
+ $connectorObject = $this->connectorFactory->create($connector);
+
+ $settingsFormJavaScript = $connectorObject->getSettingsFormJavaScript();
+ if (!empty($settingsFormJavaScript)) {
+ $connectorJavaScript[] = $settingsFormJavaScript;
+ }
+ } catch (\Exception $exception) {
+ // Log and ignore.
+ $this->getLog()->error('Incorrectly configured connector. e=' . $exception->getMessage());
+ }
+ }
+
+ $this->getState()->template = 'applications-page';
+ $this->getState()->setData([
+ 'connectorJavaScript' => $connectorJavaScript,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display page grid
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $this->getState()->template = 'grid';
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $applications = $this->applicationFactory->query(
+ $this->gridRenderSort($sanitizedParams),
+ $this->gridRenderFilter(
+ ['name' => $sanitizedParams->getString('name')],
+ $sanitizedParams
+ )
+ );
+
+ foreach ($applications as $application) {
+ if ($this->isApi($request)) {
+ throw new AccessDeniedException();
+ }
+
+ // Include the buttons property
+ $application->includeProperty('buttons');
+
+ // Add an Edit button (edit form also exposes the secret - not possible to get through the API)
+ $application->buttons = [];
+
+ if ($application->userId == $this->getUser()->userId || $this->getUser()->getUserTypeId() == 1) {
+ // Edit
+ $application->buttons[] = [
+ 'id' => 'application_edit_button',
+ 'url' => $this->urlFor($request, 'application.edit.form', ['id' => $application->key]),
+ 'text' => __('Edit')
+ ];
+
+ // Delete
+ $application->buttons[] = [
+ 'id' => 'application_delete_button',
+ 'url' => $this->urlFor($request, 'application.delete.form', ['id' => $application->key]),
+ 'text' => __('Delete')
+ ];
+ }
+ }
+
+ $this->getState()->setData($applications);
+ $this->getState()->recordsTotal = $this->applicationFactory->countLast();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display the Authorize form.
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function authorizeRequest(Request $request, Response $response)
+ {
+ // Pull authorize params from our session
+ /** @var AuthorizationRequest $authParams */
+ $authParams = $this->session->get('authParams');
+ if (!$authParams) {
+ throw new InvalidArgumentException(__('Authorisation Parameters missing from session.'), 'authParams');
+ }
+
+ if ($this->applicationFactory->checkAuthorised($authParams->getClient()->getIdentifier(), $this->getUser()->userId)) {
+ return $this->authorize($request->withParsedBody(['authorization' => 'Approve']), $response);
+ }
+
+ $client = $this->applicationFactory->getClientEntity($authParams->getClient()->getIdentifier())->load();
+
+ // Process any scopes.
+ $scopes = [];
+ $authScopes = $authParams->getScopes();
+
+ // if we have scopes in the request, make sure we only add the valid ones.
+ // the default scope is all, if it's not set on the Application, $scopes will still be empty here.
+ if ($authScopes !== null) {
+ $validScopes = $this->applicationScopeFactory->finalizeScopes(
+ $authScopes,
+ $authParams->getGrantTypeId(),
+ $client
+ );
+
+ // get all the valid scopes by their ID, we need to do this to present more details on the authorize form.
+ foreach ($validScopes as $scope) {
+ $scopes[] = $this->applicationScopeFactory->getById($scope->getIdentifier());
+ }
+
+ if (count($scopes) <= 0) {
+ throw new InvalidArgumentException(
+ __('This application has not requested access to anything.'),
+ 'authParams'
+ );
+ }
+
+ // update scopes in auth request in session to scopes we actually present for approval
+ $authParams->setScopes($validScopes);
+ }
+
+ // Reasert the auth params.
+ $this->session->set('authParams', $authParams);
+
+ // Get, show page
+ $this->getState()->template = 'applications-authorize-page';
+ $this->getState()->setData([
+ 'forceHide' => true,
+ 'authParams' => $authParams,
+ 'scopes' => $scopes,
+ 'application' => $client
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Authorize an oAuth request
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Exception
+ */
+ public function authorize(Request $request, Response $response)
+ {
+ // Pull authorize params from our session
+ /** @var AuthorizationRequest $authRequest */
+ $authRequest = $this->session->get('authParams');
+ if (!$authRequest) {
+ throw new InvalidArgumentException(__('Authorisation Parameters missing from session.'), 'authParams');
+ }
+
+ $sanitizedQueryParams = $this->getSanitizer($request->getParams());
+
+ $apiKeyPaths = $this->getConfig()->getApiKeyDetails();
+ $privateKey = $apiKeyPaths['privateKeyPath'];
+ $encryptionKey = $apiKeyPaths['encryptionKey'];
+
+ $server = new AuthorizationServer(
+ $this->applicationFactory,
+ new \Xibo\OAuth\AccessTokenRepository($this->getLog(), $this->pool, $this->applicationFactory),
+ $this->applicationScopeFactory,
+ $privateKey,
+ $encryptionKey
+ );
+
+ $server->enableGrantType(
+ new AuthCodeGrant(
+ new AuthCodeRepository(),
+ new RefreshTokenRepository($this->getLog(), $this->pool),
+ new \DateInterval('PT10M')
+ ),
+ new \DateInterval('PT1H')
+ );
+
+ // get oauth User Entity and set the UserId to the current web userId
+ $authRequest->setUser($this->getUser());
+
+ // We are authorized
+ if ($sanitizedQueryParams->getString('authorization') === 'Approve') {
+ $authRequest->setAuthorizationApproved(true);
+
+ $this->applicationFactory->setApplicationApproved(
+ $authRequest->getClient()->getIdentifier(),
+ $authRequest->getUser()->getIdentifier(),
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ $request->getAttribute('ip_address')
+ );
+
+ $this->getLog()->audit(
+ 'Auth',
+ 0,
+ 'Application access approved',
+ [
+ 'Application identifier ends with' => substr($authRequest->getClient()->getIdentifier(), -8),
+ 'Application Name' => $authRequest->getClient()->getName()
+ ]
+ );
+ } else {
+ $authRequest->setAuthorizationApproved(false);
+ }
+
+ // Redirect back to the specified redirect url
+ try {
+ return $server->completeAuthorizationRequest($authRequest, $response);
+ } catch (OAuthServerException $exception) {
+ if ($exception->hasRedirect()) {
+ return $response->withRedirect($exception->getRedirectUri());
+ } else {
+ throw $exception;
+ }
+ }
+ }
+
+ /**
+ * Form to register a new application.
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'applications-form-add';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Application
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ // Get the client
+ $client = $this->applicationFactory->getById($id);
+
+ if ($client->userId != $this->getUser()->userId && $this->getUser()->getUserTypeId() != 1) {
+ throw new AccessDeniedException();
+ }
+
+ // Load this clients details.
+ $client->load();
+
+ $scopes = $this->applicationScopeFactory->query();
+
+ foreach ($scopes as $scope) {
+ /** @var ApplicationScope $scope */
+ $found = false;
+ foreach ($client->scopes as $checked) {
+ if ($checked->id == $scope->id) {
+ $found = true;
+ break;
+ }
+ }
+
+ $scope->setUnmatchedProperty('selected', $found ? 1 : 0);
+ }
+
+ // Render the view
+ $this->getState()->template = 'applications-form-edit';
+ $this->getState()->setData([
+ 'client' => $client,
+ 'scopes' => $scopes,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Application Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ // Get the client
+ $client = $this->applicationFactory->getById($id);
+
+ if ($client->userId != $this->getUser()->userId && $this->getUser()->getUserTypeId() != 1) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'applications-form-delete';
+ $this->getState()->setData([
+ 'client' => $client,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Register a new application with OAuth
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $application = $this->applicationFactory->create();
+ $application->name = $sanitizedParams->getString('name');
+
+ if ($application->name == '') {
+ throw new InvalidArgumentException(__('Please enter Application name'), 'name');
+ }
+
+ $application->userId = $this->getUser()->userId;
+ $application->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Added %s'), $application->name),
+ 'data' => $application,
+ 'id' => $application->key
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Application
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('Editing ' . $id);
+
+ // Get the client
+ $client = $this->applicationFactory->getById($id);
+
+ if ($client->userId != $this->getUser()->userId && $this->getUser()->getUserTypeId() != 1) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $client->name = $sanitizedParams->getString('name');
+ $client->authCode = $sanitizedParams->getCheckbox('authCode');
+ $client->clientCredentials = $sanitizedParams->getCheckbox('clientCredentials');
+ $client->isConfidential = $sanitizedParams->getCheckbox('isConfidential');
+
+ if ($sanitizedParams->getCheckbox('resetKeys') == 1) {
+ $client->resetSecret();
+ $this->pool->getItem('C_' . $client->key)->clear();
+ }
+
+ if ($client->authCode === 1) {
+ $client->description = $sanitizedParams->getString('description');
+ $client->logo = $sanitizedParams->getString('logo');
+ $client->coverImage = $sanitizedParams->getString('coverImage');
+ $client->companyName = $sanitizedParams->getString('companyName');
+ $client->termsUrl = $sanitizedParams->getString('termsUrl');
+ $client->privacyUrl = $sanitizedParams->getString('privacyUrl');
+ }
+
+ // Delete all the redirect urls and add them again
+ $client->load();
+
+ foreach ($client->redirectUris as $uri) {
+ $uri->delete();
+ }
+
+ $client->redirectUris = [];
+
+ // Do we have a redirect?
+ $redirectUris = $sanitizedParams->getArray('redirectUri');
+
+ foreach ($redirectUris as $redirectUri) {
+ if ($redirectUri == '') {
+ continue;
+ }
+
+ $redirect = $this->applicationRedirectUriFactory->create();
+ $redirect->redirectUri = $redirectUri;
+ $client->assignRedirectUri($redirect);
+ }
+
+ // clear scopes
+ $client->scopes = [];
+
+ // API Scopes
+ foreach ($this->applicationScopeFactory->query() as $scope) {
+ /** @var ApplicationScope $scope */
+ // See if this has been checked this time
+ $checked = $sanitizedParams->getCheckbox('scope_' . $scope->id);
+
+ // Assign scopes
+ if ($checked) {
+ $client->assignScope($scope);
+ }
+ }
+
+ // Change the ownership?
+ if ($sanitizedParams->getInt('userId') !== null) {
+ // Check we have permissions to view this user
+ $user = $this->userFactory->getById($sanitizedParams->getInt('userId'));
+
+ $this->getLog()->debug('Attempting to change ownership to ' . $user->userId . ' - ' . $user->userName);
+
+ if (!$this->getUser()->checkViewable($user)) {
+ throw new InvalidArgumentException(__('You do not have permission to assign this user'), 'userId');
+ }
+
+ $client->userId = $user->userId;
+ }
+
+ $client->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $client->name),
+ 'data' => $client,
+ 'id' => $client->key
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete application
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ // Get the client
+ $client = $this->applicationFactory->getById($id);
+
+ if ($client->userId != $this->getUser()->userId && $this->getUser()->getUserTypeId() != 1) {
+ throw new AccessDeniedException();
+ }
+
+ $client->delete();
+ $this->pool->getItem('C_' . $client->key)->clear();
+
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $client->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $userId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ */
+ public function revokeAccess(Request $request, Response $response, $id, $userId)
+ {
+ if ($userId === null) {
+ throw new InvalidArgumentException(__('No User ID provided'));
+ }
+
+ if (empty($id)) {
+ throw new InvalidArgumentException(__('No Client id provided'));
+ }
+
+ $client = $this->applicationFactory->getClientEntity($id);
+
+ if ($this->getUser()->userId != $userId) {
+ throw new InvalidArgumentException(__('Access denied: You do not own this authorization.'));
+ }
+
+ // remove record in lk table
+ $this->applicationFactory->revokeAuthorised($userId, $client->key);
+ // clear cache for this clientId/userId pair, this is how we know the application is no longer approved
+ $this->pool->getItem('C_' . $client->key . '/' . $userId)->clear();
+
+ $this->getLog()->audit(
+ 'Auth',
+ 0,
+ 'Application access revoked',
+ [
+ 'Application identifier ends with' => substr($client->key, -8),
+ 'Application Name' => $client->getName()
+ ]
+ );
+
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Access to %s revoked'), $client->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/AuditLog.php b/lib/Controller/AuditLog.php
new file mode 100644
index 0000000..aa76689
--- /dev/null
+++ b/lib/Controller/AuditLog.php
@@ -0,0 +1,189 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\AuditLogFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Helper\SendFile;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class AuditLog
+ * @package Xibo\Controller
+ */
+class AuditLog extends Base
+{
+ /**
+ * @var AuditLogFactory
+ */
+ private $auditLogFactory;
+
+ /**
+ * Set common dependencies.
+ * @param AuditLogFactory $auditLogFactory
+ */
+ public function __construct($auditLogFactory)
+ {
+ $this->auditLogFactory = $auditLogFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'auditlog-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function grid(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getQueryParams());
+
+ $filterFromDt = $sanitizedParams->getDate('fromDt');
+ $filterToDt = $sanitizedParams->getDate('toDt');
+ $filterUser = $sanitizedParams->getString('user');
+ $filterEntity = $sanitizedParams->getString('entity');
+ $filterEntityId = $sanitizedParams->getString('entityId');
+ $filterMessage = $sanitizedParams->getString('message');
+ $filterIpAddress = $sanitizedParams->getString('ipAddress');
+
+ if ($filterFromDt != null && $filterFromDt == $filterToDt) {
+ $filterToDt->addDay();
+ }
+
+ // Get the dates and times
+ if ($filterFromDt == null) {
+ $filterFromDt = Carbon::now()->sub('1 day');
+ }
+
+ if ($filterToDt == null) {
+ $filterToDt = Carbon::now();
+ }
+
+ $search = [
+ 'fromTimeStamp' => $filterFromDt->format('U'),
+ 'toTimeStamp' => $filterToDt->format('U'),
+ 'userName' => $filterUser,
+ 'entity' => $filterEntity,
+ 'entityId' => $filterEntityId,
+ 'message' => $filterMessage,
+ 'ipAddress' => $filterIpAddress,
+ 'sessionHistoryId' => $sanitizedParams->getInt('sessionHistoryId')
+ ];
+
+ $rows = $this->auditLogFactory->query(
+ $this->gridRenderSort($sanitizedParams),
+ $this->gridRenderFilter($search, $sanitizedParams)
+ );
+
+ // Do some post processing
+ foreach ($rows as $row) {
+ /* @var \Xibo\Entity\AuditLog $row */
+ $row->objectAfter = json_decode($row->objectAfter);
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->auditLogFactory->countLast();
+ $this->getState()->setData($rows);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Output CSV Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function exportForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'auditlog-form-export';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Outputs a CSV of audit trail messages
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function export(Request $request, Response $response) : Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // We are expecting some parameters
+ $filterFromDt = $sanitizedParams->getDate('filterFromDt');
+ $filterToDt = $sanitizedParams->getDate('filterToDt');
+ $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/audittrail_' . Random::generateString();
+
+ if ($filterFromDt == null || $filterToDt == null) {
+ throw new InvalidArgumentException(__('Please provide a from/to date.'), 'filterFromDt');
+ }
+
+ $fromTimeStamp = $filterFromDt->setTime(0, 0, 0)->format('U');
+ $toTimeStamp = $filterToDt->setTime(0, 0, 0)->format('U');
+
+ $rows = $this->auditLogFactory->query('logId', ['fromTimeStamp' => $fromTimeStamp, 'toTimeStamp' => $toTimeStamp]);
+
+ $out = fopen($tempFileName, 'w');
+ fputcsv($out, ['ID', 'Date', 'User', 'Entity', 'EntityId', 'Message', 'Object']);
+
+ // Do some post processing
+ foreach ($rows as $row) {
+ /* @var \Xibo\Entity\AuditLog $row */
+ fputcsv($out, [$row->logId, Carbon::createFromTimestamp($row->logDate)->format(DateFormatHelper::getSystemFormat()), $row->userName, $row->entity, $row->entityId, $row->message, $row->objectAfter]);
+ }
+
+ fclose($out);
+
+ $this->setNoOutput(true);
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $tempFileName,
+ 'audittrail.csv'
+ )->withHeader('Content-Type', 'text/csv;charset=utf-8'));
+ }
+}
diff --git a/lib/Controller/Base.php b/lib/Controller/Base.php
new file mode 100644
index 0000000..dc2bab0
--- /dev/null
+++ b/lib/Controller/Base.php
@@ -0,0 +1,512 @@
+.
+ */
+
+namespace Xibo\Controller;
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Slim\Routing\RouteContext;
+use Slim\Views\Twig;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Twig\Error\LoaderError;
+use Twig\Error\RuntimeError;
+use Twig\Error\SyntaxError;
+use Xibo\Entity\User;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\BaseDependenciesService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\HelpServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Base
+ * @package Xibo\Controller
+ *
+ * Base for all Controllers.
+ *
+ */
+class Base
+{
+ use DataTablesDotNetTrait;
+
+ /**
+ * @var LogServiceInterface
+ */
+ private $log;
+
+ /**
+ * @Inject
+ * @var SanitizerService
+ */
+ private $sanitizerService;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /**
+ * @var HelpServiceInterface
+ */
+ private $helpService;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $configService;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * Automatically output a full page if non-ajax request arrives
+ * @var bool
+ */
+ private $fullPage = true;
+
+ /**
+ * Have we already rendered this controller.
+ * @var bool
+ */
+ private $rendered = false;
+
+ /**
+ * Is this controller expected to output anything?
+ * @var bool
+ */
+ private $noOutput = false;
+
+ /**
+ * @var Twig
+ */
+ private $view;
+
+ /** @var EventDispatcher */
+ private $dispatcher;
+
+ /** @var BaseDependenciesService */
+ private $baseDependenciesService;
+
+ public function useBaseDependenciesService(BaseDependenciesService $baseDependenciesService)
+ {
+ $this->baseDependenciesService = $baseDependenciesService;
+ }
+
+ /**
+ * Get User
+ * @return User
+ */
+ public function getUser()
+ {
+ return $this->baseDependenciesService->getUser();
+ }
+
+ /**
+ * Get the Application State
+ * @return ApplicationState
+ */
+ public function getState()
+ {
+ return $this->baseDependenciesService->getState();
+ }
+
+ /**
+ * Get Log
+ * @return LogServiceInterface
+ */
+ public function getLog()
+ {
+ return $this->baseDependenciesService->getLogger();
+ }
+
+ /**
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ $sanitizerService = $this->getSanitizerService();
+ return $sanitizerService->getSanitizer($array);
+ }
+
+ public function getSanitizerService(): SanitizerService
+ {
+ return $this->baseDependenciesService->getSanitizer();
+ }
+
+ /**
+ * Get Config
+ * @return ConfigServiceInterface
+ */
+ public function getConfig()
+ {
+ return $this->baseDependenciesService->getConfig();
+ }
+
+ /**
+ * @return \Slim\Views\Twig
+ */
+ public function getView()
+ {
+ return $this->baseDependenciesService->getView();
+ }
+
+ /**
+ * @return EventDispatcherInterface
+ */
+ public function getDispatcher(): EventDispatcherInterface
+ {
+ return $this->baseDependenciesService->getDispatcher();
+ }
+
+ /**
+ * Is this the Api?
+ * @param Request $request
+ * @return bool
+ */
+ protected function isApi(Request $request)
+ {
+ return ($request->getAttribute('_entryPoint') != 'web');
+ }
+
+ /**
+ * Get Url For Route
+ * @param Request $request
+ * @param string $route
+ * @param array $data
+ * @param array $params
+ * @return string
+ */
+ protected function urlFor(Request $request, $route, $data = [], $params = [])
+ {
+ $routeParser = RouteContext::fromRequest($request)->getRouteParser();
+ return $routeParser->urlFor($route, $data, $params);
+ }
+
+ /**
+ * Set to not output a full page automatically
+ */
+ public function setNotAutomaticFullPage()
+ {
+ $this->fullPage = false;
+ }
+
+ /**
+ * Set No output
+ * @param bool $bool
+ */
+ public function setNoOutput($bool = true)
+ {
+ $this->noOutput = $bool;
+ }
+
+ /**
+ * End the controller execution, calling render
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented if the controller is not implemented correctly
+ * @throws GeneralException
+ */
+ public function render(Request $request, Response $response)
+ {
+ if ($this->noOutput) {
+ return $response;
+ }
+
+ // State will contain the current ApplicationState, including a success flag that can be used to determine
+ // if we are in error or not.
+ $state = $this->getState();
+ $data = $state->getData();
+
+ // Grid requests require some extra info appended.
+ // they can come from any application, hence being dealt with first
+ $grid = ($state->template === 'grid');
+
+ if ($grid) {
+ $params = $this->getSanitizer($request->getParams());
+ $recordsTotal = ($state->recordsTotal == null) ? count($data) : $state->recordsTotal;
+ $recordsFiltered = ($state->recordsFiltered == null) ? $recordsTotal : $state->recordsFiltered;
+
+ $data = [
+ 'draw' => $params->getInt('draw'),
+ 'recordsTotal' => $recordsTotal,
+ 'recordsFiltered' => $recordsFiltered,
+ 'data' => $data
+ ];
+ }
+
+ // API Request
+ if ($this->isApi($request)) {
+ // Envelope by default - the APIView will un-pack if necessary
+ $this->getState()->setData([
+ 'grid' => $grid,
+ 'success' => $state->success,
+ 'status' => $state->httpStatus,
+ 'message' => $state->message,
+ 'id' => $state->id,
+ 'data' => $data
+ ]);
+
+ return $this->renderApiResponse($request, $response->withStatus($state->httpStatus));
+ } else if ($request->isXhr()) {
+ // WEB Ajax
+ // --------
+ // Are we a template that should be rendered to HTML
+ // and then returned?
+ if ($state->template != '' && $state->template != 'grid') {
+ return $this->renderTwigAjaxReturn($request, $response);
+ }
+
+ // We always return 200's
+ if ($grid) {
+ $json = $data;
+ } else {
+ $json = $state->asArray();
+ }
+
+ return $response->withJson($json, 200);
+ } else {
+ // WEB Normal
+ // ----------
+ if (empty($state->template)) {
+ $this->getLog()->debug(sprintf('Template Missing. State: %s', json_encode($state)));
+ throw new ControllerNotImplemented(__('Template Missing'));
+ }
+
+ // Append the sidebar content
+ $data['clock'] = Carbon::now()->format('H:i T');
+ $data['currentUser'] = $this->getUser();
+
+ try {
+ $response = $this->getView()->render($response, $state->template . '.twig', $data);
+ } catch (LoaderError | RuntimeError | SyntaxError $e) {
+ $this->getLog()->error('Twig Error' . $e->getMessage());
+ throw new GeneralException(__('Unable to view this page'));
+ }
+ }
+ $this->rendered = true;
+ return $response;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function renderTwigAjaxReturn(Request $request, Response $response)
+ {
+ $data = $this->getState()->getData();
+ $state = $this->getState();
+
+ // Supply the current user to the view
+ $data['currentUser'] = $this->getUser();
+
+ // Render the view manually with Twig, parse it and pull out various bits
+ try {
+ $view = $this->getView()->render($response, $state->template . '.twig', $data);
+ } catch (LoaderError | RuntimeError | SyntaxError $e) {
+ $this->getLog()->error('Twig Error' . $e->getMessage());
+ throw new GeneralException(__('Unable to view this page'));
+ }
+
+ $view = $view->getBody();
+
+ // Log Rendered View
+ $this->getLog()->debug(sprintf('%s View: %s', $state->template, $view));
+
+ if (!$view = json_decode($view, true)) {
+ $this->getLog()->error(sprintf('Problem with Template: View = %s, Error = %s ', $state->template, json_last_error_msg()));
+ throw new ControllerNotImplemented(__('Problem with Form Template'));
+ }
+
+ $state->html = $view['html'];
+ $state->dialogTitle = trim($view['title']);
+ $state->callBack = $view['callBack'];
+ $state->extra = $view['extra'];
+
+ // Process the buttons
+ $state->buttons = [];
+ // Expect each button on a new line
+ if (trim($view['buttons']) != '') {
+
+ // Convert to an array
+ $view['buttons'] = str_replace("\n\r", "\n", $view['buttons']);
+ $buttons = explode("\n", $view['buttons']);
+
+ foreach ($buttons as $button) {
+ if ($button == '')
+ continue;
+
+ $this->getLog()->debug('Button is ' . $button);
+
+ $button = explode(',', trim($button));
+
+ if (count($button) != 2) {
+ $this->getLog()->error(sprintf('There is a problem with the buttons in the template: %s. Buttons: %s.', $state->template, var_export($view['buttons'], true)));
+ throw new ControllerNotImplemented(__('Problem with Form Template'));
+ }
+
+ $state->buttons[trim($button[0])] = str_replace('|', ',', trim($button[1]));
+ }
+ }
+
+ // Process the fieldActions
+ if (trim($view['fieldActions']) == '') {
+ $state->fieldActions = [];
+ } else {
+ // Convert to an array
+ $state->fieldActions = json_decode($view['fieldActions']);
+ }
+
+ $json = json_decode($state->asJson());
+ return $response = $response->withJson($json, 200);
+ }
+
+ /**
+ * Render a template to string
+ * @param string $template
+ * @param array $data
+ * @return string
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ */
+ public function renderTemplateToString($template, $data)
+ {
+ return $this->getView()->fetch($template . '.twig', $data);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ */
+ public function renderApiResponse(Request $request, Response $response)
+ {
+ $data = $this->getState()->getData();
+
+ // Don't envelope unless requested
+ if ($request->getParam('envelope', 0) == 1
+ || $request->getAttribute('_entryPoint') === 'test'
+ ) {
+ // Envelope
+ // append error bool
+ if (!$data['success']) {
+ $data['success'] = false;
+ }
+
+ // append status code
+ $data['status'] = $response->getStatusCode();
+
+ // Enveloped responses always return 200
+ $response = $response->withStatus(200);
+ } else {
+ // Don't envelope
+ // Set status
+ $response = $response->withStatus($data['status']);
+
+ // Are we successful?
+ if (!$data['success']) {
+ // Error condition
+ $data = [
+ 'error' => [
+ 'message' => $data['message'],
+ 'code' => $data['status'],
+ 'data' => $data['data']
+ ]
+ ];
+ } else {
+ // Are we a grid?
+ if ($data['grid'] == true) {
+ // Set the response to our data['data'] object
+ $grid = $data['data'];
+ $data = $grid['data'];
+
+ // Total Number of Rows
+ $totalRows = $grid['recordsTotal'];
+
+ // Set some headers indicating our next/previous pages
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $start = $sanitizedParams->getInt('start', ['default' => 0]);
+ $size = $sanitizedParams->getInt('length', ['default' => 10]);
+
+ $linkHeader = '';
+ $url = (new HttpsDetect())->getRootUrl() . $request->getUri()->getPath();
+
+ // Is there a next page?
+ if ($start + $size < $totalRows) {
+ $linkHeader .= '<' . $url . '?start=' . ($start + $size) . '&length=' . $size . '>; rel="next", ';
+ }
+
+ // Is there a previous page?
+ if ($start > 0) {
+ $linkHeader .= '<' . $url . '?start=' . ($start - $size) . '&length=' . $size . '>; rel="prev", ';
+ }
+
+ // The first page
+ $linkHeader .= '<' . $url . '?start=0&length=' . $size . '>; rel="first"';
+
+ $response = $response
+ ->withHeader('X-Total-Count', $totalRows)
+ ->withHeader('Link', $linkHeader);
+ } else {
+ // Set the response to our data object
+ $data = $data['data'];
+ }
+ }
+ }
+
+ return $response->withJson($data);
+ }
+
+ /**
+ * @param string $form The form name
+ * @return bool
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getAutoSubmit(string $form)
+ {
+ return $this->getUser()->getOptionValue('autoSubmit.' . $form, 'false') === 'true';
+ }
+
+ public function checkRootFolderAllowSave()
+ {
+ if ($this->getConfig()->getSetting('FOLDERS_ALLOW_SAVE_IN_ROOT') == 0
+ && !$this->getUser()->isSuperAdmin()
+ ) {
+ throw new InvalidArgumentException(
+ __('Saving into root folder is disabled, please select a different folder')
+ );
+ }
+ }
+}
diff --git a/lib/Controller/Campaign.php b/lib/Controller/Campaign.php
new file mode 100644
index 0000000..bcaa214
--- /dev/null
+++ b/lib/Controller/Campaign.php
@@ -0,0 +1,1538 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Campaign
+ * @package Xibo\Controller
+ */
+class Campaign extends Base
+{
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /** @var \Xibo\Factory\DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /**
+ * Set common dependencies.
+ * @param CampaignFactory $campaignFactory
+ * @param LayoutFactory $layoutFactory
+ * @param TagFactory $tagFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct($campaignFactory, $layoutFactory, $tagFactory, $folderFactory, $displayGroupFactory)
+ {
+ $this->campaignFactory = $campaignFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->tagFactory = $tagFactory;
+ $this->folderFactory = $folderFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'campaign-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display the Campaign Builder
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function displayCampaignBuilder(Request $request, Response $response, $id)
+ {
+ $campaign = $this->campaignFactory->getById($id);
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($campaign->type !== 'ad') {
+ throw new InvalidArgumentException(__('This campaign is not compatible with the Campaign builder'));
+ }
+
+ // Load in our current display groups for the form.
+ $displayGroups = [];
+ $displayGroupIds = $campaign->loadDisplayGroupIds();
+ foreach ($displayGroupIds as $displayGroupId) {
+ $displayGroups[] = $this->displayGroupFactory->getById($displayGroupId);
+ }
+
+ // Work out the percentage complete/target.
+ $progress = $campaign->getProgress();
+
+ $this->getState()->template = 'campaign-builder';
+ $this->getState()->setData([
+ 'campaign' => $campaign,
+ 'displayGroupIds' => $displayGroupIds,
+ 'displayGroups' => $displayGroups,
+ 'stats' => [
+ 'complete' => round($progress->progressTime, 2),
+ 'target' => round($progress->progressTarget, 2),
+ ],
+ ]);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns a Grid of Campaigns
+ *
+ * @SWG\Get(
+ * path="/campaign",
+ * operationId="campaignSearch",
+ * tags={"campaign"},
+ * summary="Search Campaigns",
+ * description="Search all Campaigns this user has access to",
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="query",
+ * description="Filter by Campaign Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="query",
+ * description="Filter by Tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="query",
+ * description="A flag indicating whether to treat the tags filter as an exact match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="query",
+ * description="When filtering by multiple Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="hasLayouts",
+ * in="query",
+ * description="Filter by has layouts",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isLayoutSpecific",
+ * in="query",
+ * description="Filter by whether this Campaign is specific to a Layout or User added",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="query",
+ * description="Filter by retired",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="totalDuration",
+ * in="query",
+ * description="Should we total the duration?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as layouts, permissions, tags and events",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Campaign")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws ControllerNotImplemented
+ * @throws NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+ $filter = [
+ 'campaignId' => $parsedParams->getInt('campaignId'),
+ 'type' => $parsedParams->getString('type'),
+ 'name' => $parsedParams->getString('name'),
+ 'useRegexForName' => $parsedParams->getCheckbox('useRegexForName'),
+ 'tags' => $parsedParams->getString('tags'),
+ 'exactTags' => $parsedParams->getCheckbox('exactTags'),
+ 'hasLayouts' => $parsedParams->getInt('hasLayouts'),
+ 'isLayoutSpecific' => $parsedParams->getInt('isLayoutSpecific'),
+ 'retired' => $parsedParams->getInt('retired'),
+ 'folderId' => $parsedParams->getInt('folderId'),
+ 'totalDuration' => $parsedParams->getInt('totalDuration', ['default' => 1]),
+ 'cyclePlaybackEnabled' => $parsedParams->getInt('cyclePlaybackEnabled'),
+ 'layoutId' => $parsedParams->getInt('layoutId'),
+ 'logicalOperator' => $parsedParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $parsedParams->getString('logicalOperatorName'),
+ 'excludeMedia' => $parsedParams->getInt('excludeMedia'),
+ ];
+
+ $embed = ($parsedParams->getString('embed') !== null) ? explode(',', $parsedParams->getString('embed')) : [];
+
+ $campaigns = $this->campaignFactory->query(
+ $this->gridRenderSort($parsedParams),
+ $this->gridRenderFilter($filter, $parsedParams)
+ );
+
+ foreach ($campaigns as $campaign) {
+ /* @var \Xibo\Entity\Campaign $campaign */
+ if (count($embed) > 0) {
+ if (in_array('layouts', $embed)) {
+ $campaign->loadLayouts();
+ }
+
+ $campaign->load([
+ 'loadPermissions' => in_array('permissions', $embed),
+ 'loadTags' => in_array('tags', $embed),
+ 'loadEvents' => in_array('events', $embed)
+ ]);
+ } else {
+ $campaign->excludeProperty('layouts');
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $campaign->includeProperty('buttons');
+ $campaign->buttons = [];
+
+ // Schedule
+ if ($this->getUser()->featureEnabled('schedule.add') && $campaign->type === 'list') {
+ $campaign->buttons[] = [
+ 'id' => 'campaign_button_schedule',
+ 'url' => $this->urlFor(
+ $request,
+ 'schedule.add.form',
+ ['id' => $campaign->campaignId, 'from' => 'Campaign']
+ ),
+ 'text' => __('Schedule')
+ ];
+ }
+
+ // Preview
+ if ($this->getUser()->featureEnabled(['layout.view', 'campaign.view'], true)
+ && $campaign->type === 'list'
+ ) {
+ $campaign->buttons[] = array(
+ 'id' => 'campaign_button_preview',
+ 'linkType' => '_blank',
+ 'external' => true,
+ 'url' => $this->urlFor($request, 'campaign.preview', ['id' => $campaign->campaignId]),
+ 'text' => __('Preview Campaign')
+ );
+ }
+
+ // Buttons based on permissions
+ if ($this->getUser()->featureEnabled('campaign.modify')
+ && $this->getUser()->checkEditable($campaign)
+ ) {
+ if (count($campaign->buttons) > 0) {
+ $campaign->buttons[] = ['divider' => true];
+ }
+
+ // Edit the Campaign
+ if ($campaign->type === 'list') {
+ $campaign->buttons[] = array(
+ 'id' => 'campaign_button_edit',
+ 'url' => $this->urlFor($request, 'campaign.edit.form', ['id' => $campaign->campaignId]),
+ 'text' => __('Edit'),
+ );
+ } else if ($campaign->type === 'ad' && $this->getUser()->featureEnabled('ad.campaign')) {
+ $campaign->buttons[] = [
+ 'id' => 'campaign_button_edit',
+ 'linkType' => '_self',
+ 'external' => true,
+ 'url' => $this->urlFor($request, 'campaign.builder', ['id' => $campaign->campaignId]),
+ 'text' => __('Edit'),
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $campaign->buttons[] = [
+ 'id' => 'campaign_button_selectfolder',
+ 'url' => $this->urlFor(
+ $request,
+ 'campaign.selectfolder.form',
+ ['id' => $campaign->campaignId]
+ ),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'campaign.selectfolder',
+ ['id' => $campaign->campaignId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'campaign_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $campaign->campaign],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ // Copy the campaign
+ $campaign->buttons[] = [
+ 'id' => 'campaign_button_copy',
+ 'url' => $this->urlFor(
+ $request,
+ 'campaign.copy.form',
+ ['id' => $campaign->campaignId]
+ ),
+ 'text' => __('Copy')
+ ];
+ } else {
+ $campaign->buttons[] = ['divider' => true];
+ }
+
+ if ($this->getUser()->featureEnabled('campaign.modify') &&
+ $this->getUser()->checkDeleteable($campaign)
+ ) {
+ // Delete Campaign
+ $campaign->buttons[] = [
+ 'id' => 'campaign_button_delete',
+ 'url' => $this->urlFor(
+ $request,
+ 'campaign.delete.form',
+ ['id' => $campaign->campaignId]
+ ),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'campaign.delete',
+ ['id' => $campaign->campaignId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'campaign_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $campaign->campaign]
+ ]
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('campaign.modify') &&
+ $this->getUser()->checkPermissionsModifyable($campaign)
+ ) {
+ $campaign->buttons[] = ['divider' => true];
+
+ // Permissions for Campaign
+ $campaign->buttons[] = [
+ 'id' => 'campaign_button_permissions',
+ 'url' => $this->urlFor($request,'user.permissions.form', ['entity' => 'Campaign', 'id' => $campaign->campaignId]),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'user.permissions.multi', ['entity' => 'Campaign', 'id' => $campaign->campaignId])],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'campaign_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $campaign->campaign],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ ['name' => 'custom-handler-url', 'value' => $this->urlFor($request,'user.permissions.multi.form', ['entity' => 'Campaign'])],
+ ['name' => 'content-id-name', 'value' => 'campaignId']
+ ]
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->campaignFactory->countLast();
+ $this->getState()->setData($campaigns);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Campaign Add Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ // Load layouts
+ $layouts = [];
+
+ $this->getState()->template = 'campaign-form-add';
+ $this->getState()->setData([
+ 'layouts' => $layouts,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a Campaign
+ *
+ * @SWG\Post(
+ * path="/campaign",
+ * operationId="campaignAdd",
+ * tags={"campaign"},
+ * summary="Add Campaign",
+ * description="Add a Campaign",
+ * @SWG\Parameter(
+ * name="type",
+ * in="formData",
+ * description="Type of campaign, either list|ad",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Name for this Campaign",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutIds",
+ * in="formData",
+ * description="An array of layoutIds to assign to this Campaign, in order.",
+ * type="array",
+ * @SWG\Items(type="integer"),
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="cyclePlaybackEnabled",
+ * in="formData",
+ * description="When cycle based playback is enabled only 1 Layout from this Campaign will be played each time
+ * it is in a Schedule loop. The same Layout will be shown until the 'Play count' is achieved.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="playCount",
+ * in="formData",
+ * description="In cycle based playback, how many plays should each Layout have before moving on?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="listPlayOrder",
+ * in="formData",
+ * description="In layout list, how should campaigns in the schedule with the same play order be played?",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="targetType",
+ * in="formData",
+ * description="For ad campaigns, how do we measure the target? plays|budget|imp",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="target",
+ * in="formData",
+ * description="For ad campaigns, what is the target count for playback over the entire campaign",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Campaign"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Folders
+ $folderId = $sanitizedParams->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ // Campaign type
+ if ($this->getUser()->featureEnabled('ad.campaign')) {
+ // We use a default to avoid a breaking change in a minor release.
+ $type = $sanitizedParams->getString('type', ['default' => 'list']);
+ } else {
+ $type = 'list';
+ }
+
+ // Create Campaign
+ $campaign = $this->campaignFactory->create(
+ $type,
+ $sanitizedParams->getString('name'),
+ $this->getUser()->userId,
+ $folder->getId()
+ );
+ $campaign->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+
+ $campaign->updateTagLinks($tags);
+ }
+
+ // Cycle based playback
+ if ($campaign->type === 'list') {
+ $campaign->cyclePlaybackEnabled = $sanitizedParams->getCheckbox('cyclePlaybackEnabled');
+ $campaign->playCount = ($campaign->cyclePlaybackEnabled) ? $sanitizedParams->getInt('playCount') : null;
+
+ // For compatibility with existing API implementations we set a default here.
+ $campaign->listPlayOrder = ($campaign->cyclePlaybackEnabled)
+ ? 'block'
+ : $sanitizedParams->getString('listPlayOrder', ['default' => 'round']);
+ } else if ($campaign->type === 'ad') {
+ $campaign->targetType = $sanitizedParams->getString('targetType');
+ $campaign->target = $sanitizedParams->getInt('target');
+ $campaign->listPlayOrder = 'round';
+ }
+
+ // Assign layouts?
+ foreach ($sanitizedParams->getIntArray('layoutIds', ['default' => []]) as $layoutId) {
+ // Can't assign layouts to an ad campaign during creation
+ if ($campaign->type === 'ad') {
+ throw new InvalidArgumentException(
+ __('Cannot assign layouts to an ad campaign during its creation'),
+ 'layoutIds'
+ );
+ }
+
+ // Check permissions.
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException(__('You do not have permission to assign this Layout'));
+ }
+
+ // Make sure we can assign this layout
+ $this->checkLayoutAssignable($layout);
+
+ // Assign.
+ $campaign->assignLayout($layout->layoutId);
+ }
+
+ // All done, save.
+ $campaign->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $campaign->campaign),
+ 'id' => $campaign->campaignId,
+ 'data' => $campaign
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Campaign Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $campaign = $this->campaignFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load layouts
+ $layouts = [];
+ foreach ($campaign->loadLayouts() as $layout) {
+ // TODO: more efficient way than loading an entire layout just to check permissions?
+ if (!$this->getUser()->checkViewable($this->layoutFactory->getById($layout->layoutId))) {
+ // Hide all layout details from the user
+ $layout->layout = __('Layout');
+ $layout->setUnmatchedProperty('locked', true);
+ } else {
+ $layout->setUnmatchedProperty('locked', false);
+ }
+ $layouts[] = $layout;
+ }
+
+ $this->getState()->template = 'campaign-form-edit';
+ $this->getState()->setData([
+ 'campaign' => $campaign,
+ 'layouts' => $layouts,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit a Campaign
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Put(
+ * path="/campaign/{campaignId}",
+ * operationId="campaignEdit",
+ * tags={"campaign"},
+ * summary="Edit Campaign",
+ * description="Edit an existing Campaign",
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="path",
+ * description="The Campaign ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Name for this Campaign",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="manageLayouts",
+ * in="formData",
+ * description="Flag indicating whether to manage layouts or not. Default to no.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutIds",
+ * in="formData",
+ * description="An array of layoutIds to assign to this Campaign, in order.",
+ * type="array",
+ * @SWG\Items(type="integer"),
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="cyclePlaybackEnabled",
+ * in="formData",
+ * description="When cycle based playback is enabled only 1 Layout from this Campaign will be played each time it is in a Schedule loop. The same Layout will be shown until the 'Play count' is achieved.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="playCount",
+ * in="formData",
+ * description="In cycle based playback, how many plays should each Layout have before moving on?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="listPlayOrder",
+ * in="formData",
+ * description="In layout list, how should campaigns in the schedule with the same play order be played?",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="targetType",
+ * in="formData",
+ * description="For ad campaigns, how do we measure the target? plays|budget|imp",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="target",
+ * in="formData",
+ * description="For ad campaigns, what is the target count for playback over the entire campaign",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="startDt",
+ * in="formData",
+ * description="For ad campaigns, what is the start date",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="endDt",
+ * in="formData",
+ * description="For ad campaigns, what is the start date",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupIds[]",
+ * in="formData",
+ * description="For ad campaigns, which display groups should the campaign be run on?",
+ * type="array",
+ * @SWG\Items(type="integer"),
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref1",
+ * in="formData",
+ * description="An optional reference field",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref2",
+ * in="formData",
+ * description="An optional reference field",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref3",
+ * in="formData",
+ * description="An optional reference field",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref4",
+ * in="formData",
+ * description="An optional reference field",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref5",
+ * in="formData",
+ * description="An optional reference field",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Campaign")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $campaign = $this->campaignFactory->getById($id);
+ $parsedRequestParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ $campaign->campaign = $parsedRequestParams->getString('name');
+ $campaign->folderId = $parsedRequestParams->getInt('folderId', ['default' => $campaign->folderId]);
+ $campaign->modifiedBy = $this->getUser()->getId();
+
+ if ($campaign->hasPropertyChanged('folderId')) {
+ if ($campaign->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folder = $this->folderFactory->getById($campaign->folderId);
+ $campaign->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ }
+
+ // Reference fields
+ $campaign->ref1 = $parsedRequestParams->getString('ref1');
+ $campaign->ref2 = $parsedRequestParams->getString('ref2');
+ $campaign->ref3 = $parsedRequestParams->getString('ref3');
+ $campaign->ref4 = $parsedRequestParams->getString('ref4');
+ $campaign->ref5 = $parsedRequestParams->getString('ref5');
+
+ // What type of campaign are we editing?
+ if ($campaign->type === 'ad') {
+ // Ad campaign
+ // -----------
+ $campaign->startDt = $parsedRequestParams->getDate('startDt')?->format('U');
+ $campaign->endDt = $parsedRequestParams->getDate('endDt')?->format('U');
+ $campaign->targetType = $parsedRequestParams->getString('targetType');
+ $campaign->target = $parsedRequestParams->getInt('target');
+
+ // Display groups
+ $displayGroupIds = [];
+ foreach ($parsedRequestParams->getIntArray('displayGroupIds', ['default' => []]) as $displayGroupId) {
+ $displayGroup = $this->displayGroupFactory->getById($displayGroupId);
+ if (!$this->getUser()->checkViewable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+ $displayGroupIds[] = $displayGroup->displayGroupId;
+ }
+
+ $campaign->replaceDisplayGroupIds($displayGroupIds);
+ } else {
+ // Cycle based playback
+ $campaign->cyclePlaybackEnabled = $parsedRequestParams->getCheckbox('cyclePlaybackEnabled');
+ $campaign->playCount = $campaign->cyclePlaybackEnabled ? $parsedRequestParams->getInt('playCount') : null;
+
+ // For compatibility with existing API implementations we keep the current value as default if not provided
+ $campaign->listPlayOrder = ($campaign->cyclePlaybackEnabled)
+ ? 'block'
+ : $parsedRequestParams->getString('listPlayOrder', ['default' => $campaign->listPlayOrder]);
+
+ // Assign layouts?
+ if ($parsedRequestParams->getCheckbox('manageLayouts') === 1) {
+ // Fully decorate our Campaign
+ $campaign->loadLayouts();
+
+ // Remove all we've currently got assigned, keeping track of them for sharing check
+ $originalLayoutAssignments = array_map(function ($element) {
+ return $element->layoutId;
+ }, $campaign->loadLayouts());
+
+ $campaign->unassignAllLayouts();
+
+ foreach ($parsedRequestParams->getIntArray('layoutIds', ['default' => []]) as $layoutId) {
+ // Check permissions.
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ if (!$this->getUser()->checkViewable($layout) && !in_array($layoutId, $originalLayoutAssignments)) {
+ throw new AccessDeniedException(
+ __('You are trying to assign a Layout that is not shared with you.')
+ );
+ }
+
+ // Make sure we can assign this layout
+ $this->checkLayoutAssignable($layout);
+
+ // Assign.
+ $campaign->assignLayout($layout->layoutId);
+ }
+ }
+ }
+
+ // Tags
+ // ----
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($parsedRequestParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($parsedRequestParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($parsedRequestParams->getString('tags'));
+ }
+
+ $campaign->updateTagLinks($tags);
+ }
+
+ // Save the campaign.
+ $campaign->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $campaign->campaign),
+ 'id' => $campaign->campaignId,
+ 'data' => $campaign
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the Delete Group Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $campaign = $this->campaignFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'campaign-form-delete';
+ $this->getState()->setData([
+ 'campaign' => $campaign,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Campaign
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Delete(
+ * path="/campaign/{campaignId}",
+ * operationId="campaignDelete",
+ * tags={"campaign"},
+ * summary="Delete Campaign",
+ * description="Delete an existing Campaign",
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="path",
+ * description="The Campaign ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $campaign = $this->campaignFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ $campaign->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $campaign->campaign)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Assigns a layout to a Campaign
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/campaign/layout/assign/{campaignId}",
+ * operationId="campaignAssignLayout",
+ * tags={"campaign"},
+ * summary="Assign Layout",
+ * description="Assign a Layout to a Campaign. Please note that as of v3.0.0 this API no longer accepts multiple layoutIds.",
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="path",
+ * description="The Campaign ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="Layout ID to Assign: Please note that as of v3.0.0 this API no longer accepts multiple layoutIds.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="daysOfWeek[]",
+ * in="formData",
+ * description="Ad campaigns: restrict this to certain days of the week (iso week)",
+ * type="array",
+ * @SWG\Items(type="integer"),
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dayPartId",
+ * in="formData",
+ * description="Ad campaigns: restrict this to a day part",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="geoFence",
+ * in="formData",
+ * description="Ad campaigns: restrict this to a geofence",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function assignLayout(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('assignLayout with campaignId ' . $id);
+
+ $campaign = $this->campaignFactory->getById($id);
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ // Make sure this is a non-layout specific campaign
+ if ($campaign->isLayoutSpecific == 1) {
+ throw new InvalidArgumentException(
+ __('You cannot change the assignment of a Layout Specific Campaign'),
+ 'campaignId'
+ );
+ }
+
+ // Load our existing layouts
+ $campaign->loadLayouts();
+
+ // Get the layout we want to add
+ $params = $this->getSanitizer($request->getParams());
+ $layout = $this->layoutFactory->getById(
+ $params->getInt('layoutId', [
+ 'throw' => function () {
+ throw new InvalidArgumentException(__('Please select a Layout to assign.'), 'layoutId');
+ }
+ ])
+ );
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException(__('You do not have permission to assign the provided Layout'));
+ }
+
+ // Make sure we can assign this layout
+ $this->checkLayoutAssignable($layout);
+
+ // If we are an ad campaign, then expect some other parameters.
+ $daysOfWeek = $params->getIntArray('daysOfWeek');
+ $daysOfWeek = (empty($daysOfWeek)) ? null : implode(',', $daysOfWeek);
+
+ // Assign to the campaign
+ $campaign->assignLayout(
+ $layout->layoutId,
+ null,
+ $params->getInt('dayPartId'),
+ $daysOfWeek,
+ $params->getString('geoFence')
+ );
+ $campaign->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Assigned Layouts to %s'), $campaign->campaign)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Remove Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function removeLayoutForm(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('removeLayoutForm: ' . $id);
+
+ $campaign = $this->campaignFactory->getById($id);
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+ $campaign->loadLayouts();
+
+ $this->getState()->template = 'campaign-form-layout-delete';
+ $this->getState()->setData([
+ 'campaign' => $campaign,
+ 'layout' => $campaign->getLayoutAt($this->getSanitizer($request->getParams())->getInt('displayOrder')),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Remove a layout from a Campaign
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @SWG\Delete(
+ * path="/campaign/layout/remove/{campaignId}",
+ * operationId="campaignAssignLayout",
+ * tags={"campaign"},
+ * summary="Remove Layout",
+ * description="Remove a Layout from a Campaign.",
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="path",
+ * description="The Campaign ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="Layout ID to remove",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="The display order. Omit to remove all occurences of the layout",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function removeLayout(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('removeLayout with campaignId ' . $id);
+
+ $campaign = $this->campaignFactory->getById($id);
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ // Make sure this is a non-layout specific campaign
+ if ($campaign->isLayoutSpecific == 1) {
+ throw new InvalidArgumentException(
+ __('You cannot change the assignment of a Layout Specific Campaign'),
+ 'campaignId'
+ );
+ }
+
+ $params = $this->getSanitizer($request->getParams());
+ $layoutId = $params->getInt('layoutId', [
+ 'throw' => function () {
+ throw new InvalidArgumentException(__('Please provide a layout'), 'layoutId');
+ },
+ ['rules' => ['notEmpty']],
+ ]);
+ $displayOrder = $params->getInt('displayOrder');
+
+ // Load our existing layouts
+ $campaign->loadLayouts();
+
+ $campaign->unassignLayout($layoutId, $displayOrder);
+ $campaign->save(['validate' => false]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns a Campaign's preview
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function preview(Request $request, Response $response, $id)
+ {
+ $campaign = $this->campaignFactory->getById($id);
+ $layouts = $this->layoutFactory->getByCampaignId($id);
+ $duration = 0 ;
+ $extendedLayouts = [];
+
+ foreach ($layouts as $layout)
+ {
+ $duration += $layout->duration;
+ $extendedLayouts[] = [
+ 'layout' => $layout,
+ 'duration' => $layout->duration,
+ 'previewOptions' => [
+ 'getXlfUrl' => $this->urlFor($request,'layout.getXlf', ['id' => $layout->layoutId]),
+ 'getResourceUrl' => $this->urlFor($request,'module.getResource', ['regionId' => ':regionId', 'id' => ':id']),
+ 'libraryDownloadUrl' => $this->urlFor($request,'library.download', ['id' => ':id']),
+ 'layoutBackgroundDownloadUrl' => $this->urlFor($request,'layout.download.background', ['id' => ':id']),
+ 'loaderUrl' => $this->getConfig()->uri('img/loader.gif')
+ ]
+ ];
+ }
+ $this->getState()->template = 'campaign-preview';
+ $this->getState()->setData([
+ 'campaign' => $campaign,
+ 'layouts' => $layouts,
+ 'duration' => $duration,
+ 'extendedLayouts' => $extendedLayouts
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ // get the Campaign
+ $campaign = $this->campaignFactory->getById($id);
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $campaign->ownerId) {
+ throw new AccessDeniedException(__('You do not have permission to copy this Campaign'));
+ }
+
+ $this->getState()->template = 'campaign-form-copy';
+ $this->getState()->setData([
+ 'campaign' => $campaign
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ // get the Campaign
+ $campaign = $this->campaignFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $campaign->ownerId) {
+ throw new AccessDeniedException(__('You do not have permission to copy this Campaign'));
+ }
+
+ $newCampaign = clone $campaign;
+ $newCampaign->campaign = $sanitizedParams->getString('name');
+
+ // assign the same layouts to the new Campaign
+ foreach ($campaign->loadLayouts() as $layout) {
+ $newCampaign->assignLayout(
+ $layout->layoutId,
+ $layout->displayOrder,
+ $layout->dayPartId,
+ $layout->daysOfWeek,
+ $layout->geoFence
+ );
+ }
+
+ $newCampaign->updateTagLinks($this->tagFactory->tagsFromString($campaign->getTagString()));
+
+ // is the original campaign an ad campaign?
+ if ($campaign->type === 'ad') {
+ // assign the same displays to the new Campaign
+ $newCampaign->replaceDisplayGroupIds($campaign->loadDisplayGroupIds());
+ }
+
+ $newCampaign->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $newCampaign->campaign),
+ 'id' => $newCampaign->campaignId,
+ 'data' => $newCampaign
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Select Folder Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolderForm(Request $request, Response $response, $id)
+ {
+ // Get the Campaign
+ $campaign = $this->campaignFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'campaign' => $campaign
+ ];
+
+ $this->getState()->template = 'campaign-form-selectfolder';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Select Folder
+ *
+ * @SWG\Put(
+ * path="/campaign/{id}/selectfolder",
+ * operationId="campaignSelectFolder",
+ * tags={"campaign"},
+ * summary="Campaign Select folder",
+ * description="Select Folder for Campaign, can also be used with Layout specific Campaign ID",
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="path",
+ * description="The Campaign ID or Layout specific Campaign ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Campaign")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ *
+ */
+ public function selectFolder(Request $request, Response $response, $id)
+ {
+ // Get the Campaign
+ $campaign = $this->campaignFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($campaign)) {
+ throw new AccessDeniedException();
+ }
+
+ $folderId = $this->getSanitizer($request->getParams())->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ $campaign->folderId = $folderId;
+ $folder = $this->folderFactory->getById($campaign->folderId);
+ $campaign->permissionsFolderId = ($folder->getPermissionFolderId() == null)
+ ? $folder->id
+ : $folder->getPermissionFolderId();
+
+ if ($campaign->isLayoutSpecific === 1) {
+ $layouts = $this->layoutFactory->getByCampaignId($campaign->campaignId, true, true);
+
+ foreach ($layouts as $layout) {
+ $layout->load();
+ $allRegions = array_merge($layout->regions, $layout->drawers);
+
+ foreach ($allRegions as $region) {
+ $playlist = $region->getPlaylist();
+ $playlist->folderId = $campaign->folderId;
+ $playlist->permissionsFolderId = $campaign->permissionsFolderId;
+ $playlist->save();
+ }
+ }
+ }
+
+ // Save
+ $campaign->save([
+ 'validate' => false,
+ 'notify' => false,
+ 'collectNow' => false,
+ 'saveTags' => false
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Layout %s moved to Folder %s'), $campaign->campaign, $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function checkLayoutAssignable(\Xibo\Entity\Layout $layout)
+ {
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot assign a Draft Layout to a Campaign'), 'layoutId');
+ }
+
+ // Make sure this layout is not a template - for API, in web ui templates are not available for assignment
+ if ($layout->isTemplate()) {
+ throw new InvalidArgumentException(__('Cannot assign a Template to a Campaign'), 'layoutId');
+ }
+ }
+}
diff --git a/lib/Controller/Clock.php b/lib/Controller/Clock.php
new file mode 100644
index 0000000..bdd8a62
--- /dev/null
+++ b/lib/Controller/Clock.php
@@ -0,0 +1,93 @@
+.
+ */
+namespace Xibo\Controller;
+
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Helper\Session;
+
+/**
+ * Class Clock
+ * @package Xibo\Controller
+ */
+class Clock extends Base
+{
+ /**
+ * @var Session
+ */
+ private $session;
+
+ /**
+ * Set common dependencies.
+ * @param Session $session
+ */
+ public function __construct($session)
+ {
+ $this->session = $session;
+ }
+
+ /**
+ * Gets the Time
+ *
+ * @SWG\Get(
+ * path="/clock",
+ * operationId="clock",
+ * tags={"misc"},
+ * description="The Time",
+ * summary="The current CMS time",
+ * @SWG\Response(
+ * response=200,
+ * description="successful response",
+ * @SWG\Schema(
+ * type="object",
+ * additionalProperties={"title":"time", "type":"string"}
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function clock(Request $request, Response $response)
+ {
+ $this->session->refreshExpiry = false;
+
+ if ($request->isXhr() || $this->isApi($request)) {
+ $output = Carbon::now()->format('H:i T');
+
+ $this->getState()->setData(array('time' => $output));
+ $this->getState()->html = $output;
+ $this->getState()->clockUpdate = true;
+ $this->getState()->success = true;
+ return $this->render($request, $response);
+ } else {
+ // We are returning the response directly, so write the body.
+ $response->getBody()->write(Carbon::now()->format('c'));
+ return $response;
+ }
+ }
+}
diff --git a/lib/Controller/Command.php b/lib/Controller/Command.php
new file mode 100644
index 0000000..a9bfcba
--- /dev/null
+++ b/lib/Controller/Command.php
@@ -0,0 +1,585 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\CommandDeleteEvent;
+use Xibo\Factory\CommandFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Command
+ * Command Controller
+ * @package Xibo\Controller
+ */
+class Command extends Base
+{
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ /**
+ * Set common dependencies.
+ * @param CommandFactory $commandFactory
+ */
+ public function __construct($commandFactory)
+ {
+ $this->commandFactory = $commandFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'command-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/command",
+ * operationId="commandSearch",
+ * tags={"command"},
+ * summary="Command Search",
+ * description="Search this users Commands",
+ * @SWG\Parameter(
+ * name="commandId",
+ * in="query",
+ * description="Filter by Command Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="command",
+ * in="query",
+ * description="Filter by Command Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="query",
+ * description="Filter by Command Code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="useRegexForName",
+ * in="query",
+ * description="Flag (0,1). When filtering by multiple commands in command filter, should we use regex?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="useRegexForCode",
+ * in="query",
+ * description="Flag (0,1). When filtering by multiple codes in code filter, should we use regex?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperatorName",
+ * in="query",
+ * description="When filtering by multiple commands in command filter,
+ * which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperatorCode",
+ * in="query",
+ * description="When filtering by multiple codes in code filter,
+ * which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Command")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $filter = [
+ 'commandId' => $sanitizedParams->getInt('commandId'),
+ 'command' => $sanitizedParams->getString('command'),
+ 'code' => $sanitizedParams->getString('code'),
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'),
+ 'useRegexForCode' => $sanitizedParams->getCheckbox('useRegexForCode'),
+ 'logicalOperatorName' => $sanitizedParams->getString('logicalOperatorName'),
+ 'logicalOperatorCode' => $sanitizedParams->getString('logicalOperatorCode'),
+ ];
+
+ $commands = $this->commandFactory->query(
+ $this->gridRenderSort($sanitizedParams),
+ $this->gridRenderFilter($filter, $sanitizedParams)
+ );
+
+ foreach ($commands as $command) {
+ /* @var \Xibo\Entity\Command $command */
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $command->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('command.modify')) {
+ // Command edit
+ if ($this->getUser()->checkEditable($command)) {
+ $command->buttons[] = array(
+ 'id' => 'command_button_edit',
+ 'url' => $this->urlFor($request, 'command.edit.form', ['id' => $command->commandId]),
+ 'text' => __('Edit')
+ );
+ }
+
+ // Command delete
+ if ($this->getUser()->checkDeleteable($command)) {
+ $command->buttons[] = [
+ 'id' => 'command_button_delete',
+ 'url' => $this->urlFor($request, 'command.delete.form', ['id' => $command->commandId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'command.delete', ['id' => $command->commandId])
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'command_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $command->command]
+ ]
+ ];
+ }
+
+ // Command Permissions
+ if ($this->getUser()->checkPermissionsModifyable($command)) {
+ // Permissions button
+ $command->buttons[] = [
+ 'id' => 'command_button_permissions',
+ 'url' => $this->urlFor(
+ $request,
+ 'user.permissions.form',
+ ['entity' => 'Command', 'id' => $command->commandId]
+ ),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi',
+ ['entity' => 'Command', 'id' => $command->commandId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'command_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $command->command],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi.form',
+ ['entity' => 'Command']
+ )
+ ],
+ ['name' => 'content-id-name', 'value' => 'commandId']
+ ]
+ ];
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->commandFactory->countLast();
+ $this->getState()->setData($commands);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Command Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'command-form-add';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Command
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $command = $this->commandFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($command)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'command-form-edit';
+ $this->getState()->setData([
+ 'command' => $command
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Command
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $command = $this->commandFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($command)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'command-form-delete';
+ $this->getState()->setData([
+ 'command' => $command
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Command
+ *
+ * @SWG\Post(
+ * path="/command",
+ * operationId="commandAdd",
+ * tags={"command"},
+ * summary="Command Add",
+ * description="Add a Command",
+ * @SWG\Parameter(
+ * name="command",
+ * in="formData",
+ * description="The Command Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description for the command",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="A unique code for this command",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="commandString",
+ * in="formData",
+ * description="The Command String for this Command. Can be overridden on Display Settings.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="validationString",
+ * in="formData",
+ * description="The Validation String for this Command. Can be overridden on Display Settings.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="availableOn",
+ * in="formData",
+ * description="An array of Player types this Command is available on, empty for all.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="createAlertOn",
+ * in="formData",
+ * description="On command execution, when should a Display alert be created?
+ * success, failure, always or never",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Command"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $command = $this->commandFactory->create();
+ $command->command = $sanitizedParams->getString('command');
+ $command->description = $sanitizedParams->getString('description');
+ $command->code = $sanitizedParams->getString('code');
+ $command->userId = $this->getUser()->userId;
+ $command->commandString = $sanitizedParams->getString('commandString');
+ $command->validationString = $sanitizedParams->getString('validationString');
+ $command->createAlertOn = $sanitizedParams->getString('createAlertOn', ['default' => 'never']);
+ $availableOn = $sanitizedParams->getArray('availableOn');
+ if (empty($availableOn)) {
+ $command->availableOn = null;
+ } else {
+ $command->availableOn = implode(',', $availableOn);
+ }
+ $command->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $command->command),
+ 'id' => $command->commandId,
+ 'data' => $command
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Command
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ *
+ * @SWG\Put(
+ * path="/command/{commandId}",
+ * operationId="commandEdit",
+ * tags={"command"},
+ * summary="Edit Command",
+ * description="Edit the provided command",
+ * @SWG\Parameter(
+ * name="commandId",
+ * in="path",
+ * description="The Command Id to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="command",
+ * in="formData",
+ * description="The Command Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description for the command",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="commandString",
+ * in="formData",
+ * description="The Command String for this Command. Can be overridden on Display Settings.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="validationString",
+ * in="formData",
+ * description="The Validation String for this Command. Can be overridden on Display Settings.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="availableOn",
+ * in="formData",
+ * description="An array of Player types this Command is available on, empty for all.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="createAlertOn",
+ * in="formData",
+ * description="On command execution, when should a Display alert be created?
+ * success, failure, always or never",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Command")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $command = $this->commandFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($command)) {
+ throw new AccessDeniedException();
+ }
+
+ $command->command = $sanitizedParams->getString('command');
+ $command->description = $sanitizedParams->getString('description');
+ $command->commandString = $sanitizedParams->getString('commandString');
+ $command->validationString = $sanitizedParams->getString('validationString');
+ $command->createAlertOn = $sanitizedParams->getString('createAlertOn', ['default' => 'never']);
+ $availableOn = $sanitizedParams->getArray('availableOn');
+ if (empty($availableOn)) {
+ $command->availableOn = null;
+ } else {
+ $command->availableOn = implode(',', $availableOn);
+ }
+ $command->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $command->command),
+ 'id' => $command->commandId,
+ 'data' => $command
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Command
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @SWG\Delete(
+ * path="/command/{commandId}",
+ * operationId="commandDelete",
+ * tags={"command"},
+ * summary="Delete Command",
+ * description="Delete the provided command",
+ * @SWG\Parameter(
+ * name="commandId",
+ * in="path",
+ * description="The Command Id to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $command = $this->commandFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($command)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getDispatcher()->dispatch(new CommandDeleteEvent($command), CommandDeleteEvent::$NAME);
+
+ $command->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $command->command)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/Connector.php b/lib/Controller/Connector.php
new file mode 100644
index 0000000..0213096
--- /dev/null
+++ b/lib/Controller/Connector.php
@@ -0,0 +1,261 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Exception\HttpMethodNotAllowedException;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\ConnectorDeletingEvent;
+use Xibo\Event\ConnectorEnabledChangeEvent;
+use Xibo\Factory\ConnectorFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Connector controller to view, activate and install connectors.
+ */
+class Connector extends Base
+{
+ /** @var \Xibo\Factory\ConnectorFactory */
+ private $connectorFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ public function __construct(ConnectorFactory $connectorFactory, WidgetFactory $widgetFactory)
+ {
+ $this->connectorFactory = $connectorFactory;
+ $this->widgetFactory = $widgetFactory;
+ }
+
+ /**
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ $connectors = $this->connectorFactory->query($request->getParams());
+
+ // Should we show uninstalled connectors?
+ if ($params->getCheckbox('showUninstalled')) {
+ $connectors = array_merge($connectors, $this->connectorFactory->getUninstalled());
+ }
+
+ foreach ($connectors as $connector) {
+ // Instantiate and decorate the entity
+ try {
+ $connector->decorate($this->connectorFactory->create($connector));
+ } catch (NotFoundException) {
+ $this->getLog()->info('Connector installed which is not found in this CMS. ' . $connector->className);
+ $connector->setUnmatchedProperty('isHidden', 1);
+ } catch (\Exception $e) {
+ $this->getLog()->error('Incorrectly configured connector '
+ . $connector->className . '. e=' . $e->getMessage());
+ $connector->setUnmatchedProperty('isHidden', 1);
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = count($connectors);
+ $this->getState()->setData($connectors);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Connector Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ // Is this an installed connector, or not.
+ if (is_numeric($id)) {
+ $connector = $this->connectorFactory->getById($id);
+ } else {
+ $connector = $this->connectorFactory->getUninstalledById($id);
+ }
+ $interface = $this->connectorFactory->create($connector);
+
+ $this->getState()->template = $interface->getSettingsFormTwig() ?: 'connector-form-edit';
+ $this->getState()->setData([
+ 'connector' => $connector,
+ 'interface' => $interface
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Connector Form Proxy
+ * this is a magic method used to call a connector method which returns some JSON data
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $method
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Slim\Exception\HttpMethodNotAllowedException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editFormProxy(Request $request, Response $response, $id, $method)
+ {
+ $connector = $this->connectorFactory->getById($id);
+ $interface = $this->connectorFactory->create($connector);
+
+ if (method_exists($interface, $method)) {
+ return $response->withJson($interface->{$method}($this->getSanitizer($request->getParams())));
+ } else {
+ throw new HttpMethodNotAllowedException($request);
+ }
+ }
+
+ /**
+ * Edit Connector
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ if (is_numeric($id)) {
+ $connector = $this->connectorFactory->getById($id);
+ } else {
+ $connector = $this->connectorFactory->getUninstalledById($id);
+
+ // Null the connectorId so that we add this to the database.
+ $connector->connectorId = null;
+ }
+ $interface = $this->connectorFactory->create($connector);
+
+ // Is this an uninstallation request
+ if ($params->getCheckbox('shouldUninstall')) {
+ // Others
+ $this->getDispatcher()->dispatch(
+ new ConnectorDeletingEvent($connector, $this->getConfig()),
+ ConnectorDeletingEvent::$NAME
+ );
+
+ // Ourselves
+ if (method_exists($interface, 'delete')) {
+ $interface->delete($this->getConfig());
+ }
+
+ $connector->delete();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Uninstalled %s'), $interface->getTitle())
+ ]);
+ } else {
+ // Core properties
+ $connector->isEnabled = $params->getCheckbox('isEnabled');
+
+ // Enabled state change.
+ // Update ourselves, and any others that might be interested.
+ if ($connector->hasPropertyChanged('isEnabled')) {
+ // Others
+ $this->getDispatcher()->dispatch(
+ new ConnectorEnabledChangeEvent($connector, $this->getConfig()),
+ ConnectorEnabledChangeEvent::$NAME
+ );
+
+ // Ourselves
+ if ($connector->isEnabled && method_exists($interface, 'enable')) {
+ $interface->enable($this->getConfig());
+ } else if (!$connector->isEnabled && method_exists($interface, 'disable')) {
+ $interface->disable($this->getConfig());
+ }
+ }
+
+ $connector->settings = $interface->processSettingsForm($params, $connector->settings);
+ $connector->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $interface->getTitle()),
+ 'id' => $id,
+ 'data' => $connector
+ ]);
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $token
+ * @return \Psr\Http\Message\ResponseInterface
+ * @throws AccessDeniedException
+ */
+ public function connectorPreview(Request $request, Response $response)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $token = $params->getString('token');
+ $isDebug = $params->getCheckbox('isDebug');
+
+ if (empty($token)) {
+ throw new AccessDeniedException();
+ }
+
+ // Dispatch an event to check the token
+ $tokenEvent = new \Xibo\Event\XmdsConnectorTokenEvent();
+ $tokenEvent->setToken($token);
+ $this->getDispatcher()->dispatch($tokenEvent, \Xibo\Event\XmdsConnectorTokenEvent::$NAME);
+
+ if (empty($tokenEvent->getWidgetId())) {
+ throw new AccessDeniedException();
+ }
+
+ // Get the widget
+ $widget = $this->widgetFactory->getById($tokenEvent->getWidgetId());
+
+ // It has been found, so we raise an event here to see if any connector can provide a file for it.
+ $event = new \Xibo\Event\XmdsConnectorFileEvent($widget, $isDebug);
+ $this->getDispatcher()->dispatch($event, \Xibo\Event\XmdsConnectorFileEvent::$NAME);
+
+ // What now?
+ return $event->getResponse();
+ }
+}
diff --git a/lib/Controller/CypressTest.php b/lib/Controller/CypressTest.php
new file mode 100644
index 0000000..99f9c8c
--- /dev/null
+++ b/lib/Controller/CypressTest.php
@@ -0,0 +1,345 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\Display;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\CommandFactory;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Helper\Session;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class CypressTest
+ * @package Xibo\Controller
+ */
+class CypressTest extends Base
+{
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /**
+ * @var Session
+ */
+ private $session;
+
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param Session $session
+ * @param ScheduleFactory $scheduleFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param CampaignFactory $campaignFactory
+ * @param DisplayFactory $displayFactory
+ * @param LayoutFactory $layoutFactory
+ * @param DayPartFactory $dayPartFactory
+ */
+
+ public function __construct(
+ $store,
+ $session,
+ $scheduleFactory,
+ $displayGroupFactory,
+ $campaignFactory,
+ $displayFactory,
+ $layoutFactory,
+ $dayPartFactory,
+ $folderFactory,
+ $commandFactory
+ ) {
+ $this->store = $store;
+ $this->session = $session;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->displayFactory = $displayFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ $this->folderFactory = $folderFactory;
+ $this->commandFactory = $commandFactory;
+ }
+
+ //
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws ControllerNotImplemented
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function scheduleCampaign(Request $request, Response $response): Response|ResponseInterface
+ {
+ $this->getLog()->debug('Add Schedule');
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $schedule = $this->scheduleFactory->createEmpty();
+ $schedule->userId = $this->getUser()->userId;
+ $schedule->eventTypeId = 5;
+ $schedule->campaignId = $sanitizedParams->getInt('campaignId');
+ $schedule->commandId = $sanitizedParams->getInt('commandId');
+ $schedule->displayOrder = $sanitizedParams->getInt('displayOrder', ['default' => 0]);
+ $schedule->isPriority = $sanitizedParams->getInt('isPriority', ['default' => 0]);
+ $schedule->isGeoAware = $sanitizedParams->getCheckbox('isGeoAware');
+ $schedule->actionType = $sanitizedParams->getString('actionType');
+ $schedule->actionTriggerCode = $sanitizedParams->getString('actionTriggerCode');
+ $schedule->actionLayoutCode = $sanitizedParams->getString('actionLayoutCode');
+ $schedule->maxPlaysPerHour = $sanitizedParams->getInt('maxPlaysPerHour', ['default' => 0]);
+ $schedule->syncGroupId = $sanitizedParams->getInt('syncGroupId');
+
+ // Set the parentCampaignId for campaign events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$CAMPAIGN_EVENT) {
+ $schedule->parentCampaignId = $schedule->campaignId;
+
+ // Make sure we're not directly scheduling an ad campaign
+ $campaign = $this->campaignFactory->getById($schedule->campaignId);
+ if ($campaign->type === 'ad') {
+ throw new InvalidArgumentException(
+ __('Direct scheduling of an Ad Campaign is not allowed'),
+ 'campaignId'
+ );
+ }
+ }
+
+ // Fields only collected for interrupt events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$INTERRUPT_EVENT) {
+ $schedule->shareOfVoice = $sanitizedParams->getInt('shareOfVoice', [
+ 'throw' => function () {
+ new InvalidArgumentException(
+ __('Share of Voice must be a whole number between 0 and 3600'),
+ 'shareOfVoice'
+ );
+ }
+ ]);
+ } else {
+ $schedule->shareOfVoice = null;
+ }
+
+ $schedule->dayPartId = 2;
+ $schedule->syncTimezone = 0;
+
+ $displays = $this->displayFactory->query(null, ['display' => $sanitizedParams->getString('displayName')]);
+ $display = $displays[0];
+ $schedule->assignDisplayGroup($this->displayGroupFactory->getById($display->displayGroupId));
+
+ // Ready to do the add
+ $schedule->setDisplayNotifyService($this->displayFactory->getDisplayNotifyService());
+ if ($schedule->campaignId != null) {
+ $schedule->setCampaignFactory($this->campaignFactory);
+ }
+ $schedule->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Added Event'),
+ 'id' => $schedule->eventId,
+ 'data' => $schedule
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function displaySetStatus(Request $request, Response $response): Response|ResponseInterface
+ {
+ $this->getLog()->debug('Set display status');
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $displays = $this->displayFactory->query(null, ['display' => $sanitizedParams->getString('displayName')]);
+ $display = $displays[0];
+
+ // Get the display
+ $status = $sanitizedParams->getInt('statusId');
+
+ // Set display status
+ $display->mediaInventoryStatus = $status;
+
+ $this->store->update('UPDATE `display` SET MediaInventoryStatus = :status, auditingUntil = :auditingUntil
+ WHERE displayId = :displayId', [
+ 'displayId' => $display->displayId,
+ 'auditingUntil' => Carbon::now()->addSeconds(86400)->format('U'),
+ 'status' => Display::$STATUS_DONE
+ ]);
+ $this->store->commitIfNecessary();
+ $this->store->close();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function displayStatusEquals(Request $request, Response $response): Response|ResponseInterface
+ {
+ $this->getLog()->debug('Check display status');
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get the display
+ $displays = $this->displayFactory->query(null, ['display' => $sanitizedParams->getString('displayName')]);
+ $display = $displays[0];
+ $status = $sanitizedParams->getInt('statusId');
+
+ // Check display status
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'data' => $display->mediaInventoryStatus === $status
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ //
+
+ public function createCommand(Request $request, Response $response): Response|ResponseInterface
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $command = $this->commandFactory->create();
+ $command->command = $sanitizedParams->getString('command');
+ $command->description = $sanitizedParams->getString('description');
+ $command->code = $sanitizedParams->getString('code');
+ $command->userId = $this->getUser()->userId;
+ $command->commandString = $sanitizedParams->getString('commandString');
+ $command->validationString = $sanitizedParams->getString('validationString');
+ $availableOn = $sanitizedParams->getArray('availableOn');
+ if (empty($availableOn)) {
+ $command->availableOn = null;
+ } else {
+ $command->availableOn = implode(',', $availableOn);
+ }
+ $command->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $command->command),
+ 'id' => $command->commandId,
+ 'data' => $command
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws ControllerNotImplemented
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function createCampaign(Request $request, Response $response): Response|ResponseInterface
+ {
+ $this->getLog()->debug('Creating campaign');
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $folder = $this->folderFactory->getById($this->getUser()->homeFolderId, 0);
+
+ // Create Campaign
+ $campaign = $this->campaignFactory->create(
+ 'list',
+ $sanitizedParams->getString('name'),
+ $this->getUser()->userId,
+ $folder->getId()
+ );
+
+ // Cycle based playback
+ if ($campaign->type === 'list') {
+ $campaign->cyclePlaybackEnabled = $sanitizedParams->getCheckbox('cyclePlaybackEnabled');
+ $campaign->playCount = ($campaign->cyclePlaybackEnabled) ? $sanitizedParams->getInt('playCount') : null;
+
+ // For compatibility with existing API implementations we set a default here.
+ $campaign->listPlayOrder = ($campaign->cyclePlaybackEnabled)
+ ? 'block'
+ : $sanitizedParams->getString('listPlayOrder', ['default' => 'round']);
+ } else if ($campaign->type === 'ad') {
+ $campaign->targetType = $sanitizedParams->getString('targetType');
+ $campaign->target = $sanitizedParams->getInt('target');
+ $campaign->listPlayOrder = 'round';
+ }
+
+ // All done, save.
+ $campaign->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Added campaign'),
+ 'id' => $campaign->campaignId,
+ 'data' => $campaign
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ //
+
+ //
+}
diff --git a/lib/Controller/DataSet.php b/lib/Controller/DataSet.php
new file mode 100644
index 0000000..0ebff53
--- /dev/null
+++ b/lib/Controller/DataSet.php
@@ -0,0 +1,1990 @@
+.
+ */
+namespace Xibo\Controller;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use Illuminate\Support\Str;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\DataConnectorScriptRequestEvent;
+use Xibo\Event\DataConnectorSourceRequestEvent;
+use Xibo\Factory\DataSetColumnFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Helper\DataSetUploadHandler;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Helper\SendFile;
+use Xibo\Service\MediaService;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DataSet
+ * @package Xibo\Controller
+ */
+class DataSet extends Base
+{
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var DataSetColumnFactory */
+ private $dataSetColumnFactory;
+
+ /** @var \Xibo\Factory\UserFactory */
+ private $userFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /**
+ * Set common dependencies.
+ * @param DataSetFactory $dataSetFactory
+ * @param DataSetColumnFactory $dataSetColumnFactory
+ * @param \Xibo\Factory\UserFactory $userFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct($dataSetFactory, $dataSetColumnFactory, $userFactory, $folderFactory)
+ {
+ $this->dataSetFactory = $dataSetFactory;
+ $this->dataSetColumnFactory = $dataSetColumnFactory;
+ $this->userFactory = $userFactory;
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * @return DataSetFactory
+ */
+ public function getDataSetFactory()
+ {
+ return $this->dataSetFactory;
+ }
+
+ /**
+ * View Route
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'dataset-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Search Data
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Get(
+ * path="/dataset",
+ * operationId="dataSetSearch",
+ * tags={"dataset"},
+ * summary="DataSet Search",
+ * description="Search this users DataSets",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="query",
+ * description="Filter by DataSet Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataSet",
+ * in="query",
+ * description="Filter by DataSet Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="query",
+ * description="Filter by DataSet Code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRealTime",
+ * in="query",
+ * description="Filter by real time",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userId",
+ * in="query",
+ * description="Filter by user Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as columns",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DataSet")
+ * )
+ * )
+ * )
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+ $sanitizedParams = $this->getSanitizer($request->getQueryParams());
+
+ // Embed?
+ $embed = ($sanitizedParams->getString('embed') != null) ? explode(',', $sanitizedParams->getString('embed')) : [];
+
+ $filter = [
+ 'dataSetId' => $sanitizedParams->getInt('dataSetId'),
+ 'dataSet' => $sanitizedParams->getString('dataSet'),
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'),
+ 'code' => $sanitizedParams->getString('code'),
+ 'isRealTime' => $sanitizedParams->getInt('isRealTime'),
+ 'userId' => $sanitizedParams->getInt('userId'),
+ 'folderId' => $sanitizedParams->getInt('folderId'),
+ 'logicalOperatorName' => $sanitizedParams->getString('logicalOperatorName'),
+ ];
+
+ $dataSets = $this->dataSetFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter($filter, $sanitizedParams));
+
+ foreach ($dataSets as $dataSet) {
+ /* @var \Xibo\Entity\DataSet $dataSet */
+ if (in_array('columns', $embed)) {
+ $dataSet->load();
+ }
+ if ($this->isApi($request)) {
+ break;
+ }
+
+ $dataSet->includeProperty('buttons');
+ $dataSet->buttons = [];
+
+ // Load the dataSet to get the columns
+ $dataSet->load();
+
+ if ($this->getUser()->featureEnabled('dataset.data') && $user->checkEditable($dataSet)) {
+ // View Data
+ $dataSet->buttons[] = array(
+ 'id' => 'dataset_button_viewdata',
+ 'class' => 'XiboRedirectButton',
+ 'url' => $this->urlFor($request, 'dataSet.view.data', ['id' => $dataSet->dataSetId]),
+ 'text' => __('View Data')
+ );
+ }
+
+ if ($this->getUser()->featureEnabled('dataset.modify')) {
+ if ($user->checkEditable($dataSet)) {
+ // View Columns
+ $dataSet->buttons[] = array(
+ 'id' => 'dataset_button_viewcolumns',
+ 'url' => $this->urlFor($request, 'dataSet.column.view', ['id' => $dataSet->dataSetId]),
+ 'class' => 'XiboRedirectButton',
+ 'text' => __('View Columns')
+ );
+
+ // View RSS
+ $dataSet->buttons[] = array(
+ 'id' => 'dataset_button_viewrss',
+ 'url' => $this->urlFor($request, 'dataSet.rss.view', ['id' => $dataSet->dataSetId]),
+ 'class' => 'XiboRedirectButton',
+ 'text' => __('View RSS')
+ );
+
+ if ($this->getUser()->featureEnabled('dataset.realtime') && $dataSet->isRealTime === 1) {
+ $dataSet->buttons[] = [
+ 'id' => 'dataset_button_view_data_connector',
+ 'url' => $this->urlFor($request, 'dataSet.dataConnector.view', [
+ 'id' => $dataSet->dataSetId
+ ]),
+ 'class' => 'XiboRedirectButton',
+ 'text' => __('View Data Connector'),
+ ];
+ }
+
+ // Divider
+ $dataSet->buttons[] = ['divider' => true];
+
+ // Import DataSet
+ if ($dataSet->isRemote !== 1) {
+ $dataSet->buttons[] = array(
+ 'id' => 'dataset_button_import',
+ 'class' => 'dataSetImportForm',
+ 'text' => __('Import CSV')
+ );
+ }
+
+ // Copy
+ $dataSet->buttons[] = array(
+ 'id' => 'dataset_button_copy',
+ 'url' => $this->urlFor($request, 'dataSet.copy.form', ['id' => $dataSet->dataSetId]),
+ 'text' => __('Copy')
+ );
+
+ // Divider
+ $dataSet->buttons[] = ['divider' => true];
+
+ // Edit DataSet
+ $dataSet->buttons[] = array(
+ 'id' => 'dataset_button_edit',
+ 'url' => $this->urlFor($request, 'dataSet.edit.form', ['id' => $dataSet->dataSetId]),
+ 'text' => __('Edit')
+ );
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $dataSet->buttons[] = [
+ 'id' => 'dataSet_button_selectfolder',
+ 'url' => $this->urlFor($request, 'dataSet.selectfolder.form', ['id' => $dataSet->dataSetId]),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'dataSet.selectfolder', ['id' => $dataSet->dataSetId])
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'dataSet_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $dataSet->dataSet],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ $dataSet->buttons[] = [
+ 'id' => 'dataset_button_csv_export',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'dataSet.export.csv', ['id' => $dataSet->dataSetId]),
+ 'text' => __('Export (CSV)')
+ ];
+
+ if ($dataSet->isRemote === 1) {
+ $dataSet->buttons[] = [
+ 'id' => 'dataset_button_clear_cache',
+ 'url' => $this->urlFor($request, 'dataSet.clear.cache.form', ['id' => $dataSet->dataSetId]),
+ 'text' => __('Clear Cache'),
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'dataSet.clear.cache', ['id' => $dataSet->dataSetId])],
+ ['name' => 'commit-method', 'value' => 'POST']
+ ]
+ ];
+ }
+ }
+
+ if ($user->checkDeleteable($dataSet)
+ && $dataSet->isLookup == 0
+ && ($dataSet->isRealTime === 0 || $this->getUser()->featureEnabled('dataset.realtime'))
+ ) {
+ $dataSet->buttons[] = ['divider' => true];
+ // Delete DataSet
+ $dataSet->buttons[] = [
+ 'id' => 'dataset_button_delete',
+ 'url' => $this->urlFor($request, 'dataSet.delete.form', ['id' => $dataSet->dataSetId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'dataSet.delete', ['id' => $dataSet->dataSetId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'dataset_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'rowtitle', 'value' => $dataSet->dataSet],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'form-callback', 'value' => 'deleteMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ // Divider
+ $dataSet->buttons[] = ['divider' => true];
+
+ if ($user->checkPermissionsModifyable($dataSet)) {
+ // Edit Permissions
+ $dataSet->buttons[] = [
+ 'id' => 'dataset_button_permissions',
+ 'url' => $this->urlFor($request,'user.permissions.form', ['entity' => 'DataSet', 'id' => $dataSet->dataSetId]),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'user.permissions.multi', ['entity' => 'DataSet', 'id' => $dataSet->dataSetId])],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'dataset_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $dataSet->dataSet],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ ['name' => 'custom-handler-url', 'value' => $this->urlFor($request,'user.permissions.multi.form', ['entity' => 'DataSet'])],
+ ['name' => 'content-id-name', 'value' => 'dataSetId']
+ ]
+ ];
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->dataSetFactory->countLast();
+ $this->getState()->setData($dataSets);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add DataSet Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addForm(Request $request, Response $response)
+ {
+
+ // Dispatch an event to initialize list of data sources for data connectors
+ $event = new DataConnectorSourceRequestEvent();
+ $this->getDispatcher()->dispatch($event, DataConnectorSourceRequestEvent::$NAME);
+
+ // Retrieve data connector sources from the event
+ $dataConnectorSources = $event->getDataConnectorSources();
+
+ $this->getState()->template = 'dataset-form-add';
+ $this->getState()->setData([
+ 'dataSets' => $this->dataSetFactory->query(),
+ 'dataConnectorSources' => $dataConnectorSources,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add dataSet
+ *
+ * @SWG\Post(
+ * path="/dataset",
+ * operationId="dataSetAdd",
+ * tags={"dataset"},
+ * summary="Add DataSet",
+ * description="Add a DataSet",
+ * @SWG\Parameter(
+ * name="dataSet",
+ * in="formData",
+ * description="The DataSet Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description of this DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="A code for this DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRemote",
+ * in="formData",
+ * description="Is this a remote DataSet?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isRealTime",
+ * in="formData",
+ * description="Is this a real time DataSet?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataConnectorSource",
+ * in="formData",
+ * description="Source of the data connector",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="method",
+ * in="formData",
+ * description="The Request Method GET or POST",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="uri",
+ * in="formData",
+ * description="The URI, without query parameters",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="postData",
+ * in="formData",
+ * description="query parameter encoded data to add to the request",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="authentication",
+ * in="formData",
+ * description="HTTP Authentication method None|Basic|Digest",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="username",
+ * in="formData",
+ * description="HTTP Authentication User Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="password",
+ * in="formData",
+ * description="HTTP Authentication Password",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="customHeaders",
+ * in="formData",
+ * description="Comma separated string of custom HTTP headers",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userAgent",
+ * in="formData",
+ * description="Custom user Agent value",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="refreshRate",
+ * in="formData",
+ * description="How often in seconds should this remote DataSet be refreshed",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="clearRate",
+ * in="formData",
+ * description="How often in seconds should this remote DataSet be truncated",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="truncateOnEmpty",
+ * in="formData",
+ * description="Should the DataSet data be truncated even if no new data is pulled from the source?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="runsAfter",
+ * in="formData",
+ * description="An optional dataSetId which should be run before this Remote DataSet",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataRoot",
+ * in="formData",
+ * description="The root of the data in the Remote source which is used as the base for all remote columns",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="summarize",
+ * in="formData",
+ * description="Should the data be aggregated? None|Summarize|Count",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="summarizeField",
+ * in="formData",
+ * description="Which field should be used to summarize",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="sourceId",
+ * in="formData",
+ * description="For remote DataSet, what type data is it? 1 - json, 2 - csv",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ignoreFirstRow",
+ * in="formData",
+ * description="For remote DataSet with sourceId 2 (CSV), should we ignore first row?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="rowLimit",
+ * in="formData",
+ * description="For remote DataSet, maximum number of rows this DataSet can hold, if left empty the CMS Setting for DataSet row limit will be used.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="limitPolicy",
+ * in="formData",
+ * description="For remote DataSet, what should happen when the DataSet row limit is reached? stop, fifo or truncate",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="csvSeparator",
+ * in="formData",
+ * description="Separator that should be used when using Remote DataSets with CSV source, comma will be used by default.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataConnectorScript",
+ * in="formData",
+ * description="If isRealTime then provide a script to connect to the data source",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSet"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $dataSet = $this->dataSetFactory->createEmpty();
+ $dataSet->dataSet = $sanitizedParams->getString('dataSet');
+ $dataSet->description = $sanitizedParams->getString('description');
+ $dataSet->code = $sanitizedParams->getString('code');
+ $dataSet->isRemote = $sanitizedParams->getCheckbox('isRemote');
+ $dataSet->isRealTime = $sanitizedParams->getCheckbox('isRealTime');
+ $dataSet->dataConnectorSource = $sanitizedParams->getString('dataConnectorSource');
+ $dataSet->userId = $this->getUser()->userId;
+
+ // Folders
+ $folderId = $sanitizedParams->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+ $dataSet->folderId = $folder->getId();
+ $dataSet->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ // Fields for remote
+ if ($dataSet->isRemote === 1) {
+ $dataSet->method = $sanitizedParams->getString('method');
+ $dataSet->uri = $sanitizedParams->getString('uri');
+ $dataSet->postData = trim($sanitizedParams->getString('postData'));
+ $dataSet->authentication = $sanitizedParams->getString('authentication');
+ $dataSet->username = $sanitizedParams->getString('username');
+ $dataSet->password = $sanitizedParams->getString('password');
+ $dataSet->customHeaders = $sanitizedParams->getString('customHeaders');
+ $dataSet->userAgent = $sanitizedParams->getString('userAgent');
+ $dataSet->refreshRate = $sanitizedParams->getInt('refreshRate');
+ $dataSet->clearRate = $sanitizedParams->getInt('clearRate');
+ $dataSet->truncateOnEmpty = $sanitizedParams->getCheckbox('truncateOnEmpty');
+ $dataSet->runsAfter = $sanitizedParams->getInt('runsAfter');
+ $dataSet->dataRoot = $sanitizedParams->getString('dataRoot');
+ $dataSet->summarize = $sanitizedParams->getString('summarize');
+ $dataSet->summarizeField = $sanitizedParams->getString('summarizeField');
+ $dataSet->sourceId = $sanitizedParams->getInt('sourceId');
+ $dataSet->ignoreFirstRow = $sanitizedParams->getCheckbox('ignoreFirstRow');
+ $dataSet->rowLimit = $sanitizedParams->getInt('rowLimit');
+ $dataSet->limitPolicy = $sanitizedParams->getString('limitPolicy') ?? 'stop';
+ $dataSet->csvSeparator = ($dataSet->sourceId === 2) ? $sanitizedParams->getString('csvSeparator') ?? ',' : null;
+ }
+
+ // Also add one column
+ $dataSetColumn = $this->dataSetColumnFactory->createEmpty();
+ $dataSetColumn->columnOrder = 1;
+ $dataSetColumn->heading = 'Col1';
+ $dataSetColumn->dataSetColumnTypeId = 1;
+ $dataSetColumn->dataTypeId = 1;
+
+ // Add Column
+ // only when we are not routing through the API
+ if (!$this->isApi($request)) {
+ $dataSet->assignColumn($dataSetColumn);
+ }
+
+ // Save
+ $dataSet->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $dataSet->dataSet),
+ 'id' => $dataSet->dataSetId,
+ 'data' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit DataSet Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Dispatch an event to initialize list of data sources for data connectors
+ $event = new DataConnectorSourceRequestEvent();
+ $this->getDispatcher()->dispatch($event, DataConnectorSourceRequestEvent::$NAME);
+
+ // Retrieve data sources from the event
+ $dataConnectorSources = $event->getDataConnectorSources();
+
+ // retrieve the columns of the selected dataset
+ $dataSet->getColumn();
+
+ // Set the form
+ $this->getState()->template = 'dataset-form-edit';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'dataSets' => $this->dataSetFactory->query(),
+ 'script' => $dataSet->getScript(),
+ 'dataConnectorSources' => $dataConnectorSources
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit DataSet
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Put(
+ * path="/dataset/{dataSetId}",
+ * operationId="dataSetEdit",
+ * tags={"dataset"},
+ * summary="Edit DataSet",
+ * description="Edit a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSet",
+ * in="formData",
+ * description="The DataSet Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description of this DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="A code for this DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRemote",
+ * in="formData",
+ * description="Is this a remote DataSet?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isRealTime",
+ * in="formData",
+ * description="Is this a real time DataSet?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataConnectorSource",
+ * in="formData",
+ * description="Source of the data connector",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="method",
+ * in="formData",
+ * description="The Request Method GET or POST",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="uri",
+ * in="formData",
+ * description="The URI, without query parameters",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="postData",
+ * in="formData",
+ * description="query parameter encoded data to add to the request",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="authentication",
+ * in="formData",
+ * description="HTTP Authentication method None|Basic|Digest",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="username",
+ * in="formData",
+ * description="HTTP Authentication User Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="password",
+ * in="formData",
+ * description="HTTP Authentication Password",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="customHeaders",
+ * in="formData",
+ * description="Comma separated string of custom HTTP headers",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userAgent",
+ * in="formData",
+ * description="Custom user Agent value",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="refreshRate",
+ * in="formData",
+ * description="How often in seconds should this remote DataSet be refreshed",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="clearRate",
+ * in="formData",
+ * description="How often in seconds should this remote DataSet be truncated",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="truncateOnEmpty",
+ * in="formData",
+ * description="Should the DataSet data be truncated even if no new data is pulled from the source?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="runsAfter",
+ * in="formData",
+ * description="An optional dataSetId which should be run before this Remote DataSet",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataRoot",
+ * in="formData",
+ * description="The root of the data in the Remote source which is used as the base for all remote columns",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="summarize",
+ * in="formData",
+ * description="Should the data be aggregated? None|Summarize|Count",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="summarizeField",
+ * in="formData",
+ * description="Which field should be used to summarize",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="sourceId",
+ * in="formData",
+ * description="For remote DataSet, what type data is it? 1 - json, 2 - csv",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ignoreFirstRow",
+ * in="formData",
+ * description="For remote DataSet with sourceId 2 (CSV), should we ignore first row?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="rowLimit",
+ * in="formData",
+ * description="For remote DataSet, maximum number of rows this DataSet can hold, if left empty the CMS Setting for DataSet row limit will be used.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="limitPolicy",
+ * in="formData",
+ * description="For remote DataSet, what should happen when the DataSet row limit is reached? stop, fifo or truncate",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="csvSeparator",
+ * in="formData",
+ * description="Separator that should be used when using Remote DataSets with CSV source, comma will be used by default.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataConnectorScript",
+ * in="formData",
+ * description="If isRealTime then provide a script to connect to the data source",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSet")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->dataSet = $sanitizedParams->getString('dataSet');
+ $dataSet->description = $sanitizedParams->getString('description');
+ $dataSet->code = $sanitizedParams->getString('code');
+ $dataSet->isRemote = $sanitizedParams->getCheckbox('isRemote');
+ $dataSet->isRealTime = $sanitizedParams->getCheckbox('isRealTime');
+ $dataSet->dataConnectorSource = $sanitizedParams->getString('dataConnectorSource');
+ $dataSet->folderId = $sanitizedParams->getInt('folderId', ['default' => $dataSet->folderId]);
+
+ if ($dataSet->hasPropertyChanged('folderId')) {
+ if ($dataSet->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folder = $this->folderFactory->getById($dataSet->folderId);
+ $dataSet->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+ }
+
+ if ($dataSet->isRemote === 1) {
+ $dataSet->method = $sanitizedParams->getString('method');
+ $dataSet->uri = $sanitizedParams->getString('uri');
+ $dataSet->postData = trim($sanitizedParams->getString('postData'));
+ $dataSet->authentication = $sanitizedParams->getString('authentication');
+ $dataSet->username = $sanitizedParams->getString('username');
+ $dataSet->password = $sanitizedParams->getString('password');
+ $dataSet->customHeaders = $sanitizedParams->getString('customHeaders');
+ $dataSet->userAgent = $sanitizedParams->getString('userAgent');
+ $dataSet->refreshRate = $sanitizedParams->getInt('refreshRate');
+ $dataSet->clearRate = $sanitizedParams->getInt('clearRate');
+ $dataSet->truncateOnEmpty = $sanitizedParams->getCheckbox('truncateOnEmpty');
+ $dataSet->runsAfter = $sanitizedParams->getInt('runsAfter');
+ $dataSet->dataRoot = $sanitizedParams->getString('dataRoot');
+ $dataSet->summarize = $sanitizedParams->getString('summarize');
+ $dataSet->summarizeField = $sanitizedParams->getString('summarizeField');
+ $dataSet->sourceId = $sanitizedParams->getInt('sourceId');
+ $dataSet->ignoreFirstRow = $sanitizedParams->getCheckbox('ignoreFirstRow');
+ $dataSet->rowLimit = $sanitizedParams->getInt('rowLimit');
+ $dataSet->limitPolicy = $sanitizedParams->getString('limitPolicy') ?? 'stop';
+ $dataSet->csvSeparator = ($dataSet->sourceId === 2)
+ ? $sanitizedParams->getString('csvSeparator') ?? ','
+ : null;
+ }
+
+ $dataSet->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $dataSet->dataSet),
+ 'id' => $dataSet->dataSetId,
+ 'data' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit DataSet Data Connector
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ *
+ * @SWG\Put(
+ * path="/dataset/dataConnector/{dataSetId}",
+ * operationId="dataSetDataConnectorEdit",
+ * tags={"dataset"},
+ * summary="Edit DataSet Data Connector",
+ * description="Edit a DataSet Data Connector",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataConnectorScript",
+ * in="formData",
+ * description="If isRealTime then provide a script to connect to the data source",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSet")
+ * )
+ * )
+ */
+ public function updateDataConnector(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($dataSet->isRealTime === 1) {
+ // Set the script.
+ $dataSet->saveScript($sanitizedParams->getParam('dataConnectorScript'));
+ $dataSet->notify();
+ } else {
+ throw new InvalidArgumentException(__('This DataSet does not have a data connector'), 'isRealTime');
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $dataSet->dataSet),
+ 'id' => $dataSet->dataSetId,
+ 'data' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * DataSet Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($dataSet->isLookup) {
+ throw new InvalidArgumentException(__('Lookup Tables cannot be deleted'));
+ }
+
+ // Set the form
+ $this->getState()->template = 'dataset-form-delete';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * DataSet Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/dataset/{dataSetId}",
+ * operationId="dataSetDelete",
+ * tags={"dataset"},
+ * summary="Delete DataSet",
+ * description="Delete a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkDeleteable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Is there existing data?
+ if ($sanitizedParams->getCheckbox('deleteData') == 0 && $dataSet->hasData())
+ throw new InvalidArgumentException(__('There is data assigned to this data set, cannot delete.'), 'dataSetId');
+
+ // Otherwise delete
+ $dataSet->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $dataSet->dataSet)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Select Folder Form
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolderForm(Request $request, Response $response, $id)
+ {
+ // Get the data set
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'dataSet' => $dataSet
+ ];
+
+ $this->getState()->template = 'dataset-form-selectfolder';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/dataset/{id}/selectfolder",
+ * operationId="dataSetSelectFolder",
+ * tags={"dataSet"},
+ * summary="DataSet Select folder",
+ * description="Select Folder for DataSet",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSet")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolder(Request $request, Response $response, $id)
+ {
+ // Get the DataSet
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $folderId = $this->getSanitizer($request->getParams())->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ $dataSet->folderId = $folderId;
+ $folder = $this->folderFactory->getById($dataSet->folderId);
+ $dataSet->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+
+ // Save
+ $dataSet->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('DataSet %s moved to Folder %s'), $dataSet->dataSet, $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy DataSet Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Set the form
+ $this->getState()->template = 'dataset-form-copy';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy DataSet
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/dataset/copy/{dataSetId}",
+ * operationId="dataSetCopy",
+ * tags={"dataset"},
+ * summary="Copy DataSet",
+ * description="Copy a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSet",
+ * in="formData",
+ * description="The DataSet Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description of this DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="A code for this DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="copyRows",
+ * in="formData",
+ * description="Flag whether to copy all the row data from the original dataSet",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSet")
+ * )
+ * )
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $copyRows = $sanitizedParams->getCheckbox('copyRows');
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load for the Copy
+ $dataSet->load();
+ $oldName = $dataSet->dataSet;
+
+ // Clone and reset parameters
+ $dataSet = clone $dataSet;
+ $dataSet->dataSet = $sanitizedParams->getString('dataSet');
+ $dataSet->description = $sanitizedParams->getString('description');
+ $dataSet->code = $sanitizedParams->getString('code');
+ $dataSet->userId = $this->getUser()->userId;
+
+ $dataSet->save();
+
+ if ($copyRows === 1) {
+ $dataSet->copyRows($id, $dataSet->dataSetId);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Copied %s as %s'), $oldName, $dataSet->dataSet),
+ 'id' => $dataSet->dataSetId,
+ 'data' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Import CSV
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/dataset/import/{dataSetId}",
+ * operationId="dataSetImport",
+ * tags={"dataset"},
+ * summary="Import CSV",
+ * description="Import a CSV into a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID to import into.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="files",
+ * in="formData",
+ * description="The file",
+ * type="file",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="csvImport_{dataSetColumnId}",
+ * in="formData",
+ * description="You need to provide dataSetColumnId after csvImport_, to know your dataSet columns Ids, you will need to use the GET /dataset/{dataSetId}/column call first. The value of this parameter is the index of the column in your csv file, where the first column is 1",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="overwrite",
+ * in="formData",
+ * description="flag (0,1) Set to 1 to erase all content in the dataSet and overwrite it with new content in this import",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ignorefirstrow",
+ * in="formData",
+ * description="flag (0,1), Set to 1 to Ignore first row, useful if the CSV file has headings",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function import(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('Import DataSet');
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($this->getConfig()->getSetting('LIBRARY_LOCATION'));
+
+ $sanitizer = $this->getSanitizer($request->getParams());
+
+ $options = array(
+ 'userId' => $this->getUser()->userId,
+ 'dataSetId' => $id,
+ 'controller' => $this,
+ 'accept_file_types' => '/\.csv/i',
+ 'sanitizer' => $sanitizer
+ );
+
+ try {
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ new DataSetUploadHandler($libraryFolder . 'temp/', $this->getLog()->getLoggerInterface(), $options);
+ } catch (\Exception $e) {
+ // We must not issue an error, the file upload return should have the error object already
+ $this->getState()->setCommitState(false);
+ }
+
+ $this->setNoOutput(true);
+
+ // Explicitly set the Content-Type header to application/json
+ $response = $response->withHeader('Content-Type', 'application/json');
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * Import Json schema
+ *
+ * @SWG\Definition(definition="importJsonSchema", type="object",
+ * @SWG\Property(property="uniqueKeys", type="array", description="A name of the unique column", @SWG\Items(type="string", @SWG\Property(property="colName", type="string"))),
+ * @SWG\Property(property="truncate", type="array", description="Flag True or False, whether to truncate existing data on import", @SWG\Items(type="string", @SWG\Property(property="truncate", type="string"))),
+ * @SWG\Property(property="rows", type="array", description="An array of objects with pairs: ColumnName:Value", @SWG\Items(type="object", @SWG\Property(property="colName", type="string"))),
+ * )
+ */
+
+
+ /**
+ * Import JSON
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/dataset/importjson/{dataSetId}",
+ * operationId="dataSetImportJson",
+ * tags={"dataset"},
+ * summary="Import JSON",
+ * description="Import JSON into a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID to import into.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="data",
+ * in="body",
+ * description="The row data, field name vs field data format. e.g. { uniqueKeys: [col1], rows: [{col1: value1}]}",
+ * required=true,
+ * @SWG\Schema(ref="#/definitions/importJsonSchema")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function importJson(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $body = json_encode($request->getParsedBody());
+
+ if (empty($body)) {
+ throw new InvalidArgumentException(__('Missing JSON Body'));
+ }
+
+ // Expect 2 parameters
+ $data = json_decode($body, true);
+
+ if (!isset($data['rows']) || !isset($data['uniqueKeys'])) {
+ throw new InvalidArgumentException(__('Malformed JSON body, rows and uniqueKeys are required'));
+ }
+
+ $this->getLog()->debug('Import JSON into DataSet with ' . count($data['rows']) . ' and unique keys ' . json_encode($data['uniqueKeys']));
+
+ // Should we truncate?
+ if (isset($data['truncate']) && $data['truncate']) {
+ $dataSet->deleteData();
+ }
+
+ // Get the columns for this dataset
+ $columns = [];
+ foreach ($dataSet->getColumn() as $column) {
+ /* @var \Xibo\Entity\DataSetColumn $column */
+ if ($column->dataSetColumnTypeId == 1) {
+ $columns[$column->heading] = $column->dataTypeId;
+ }
+ }
+
+ $takenSomeAction = false;
+
+ // Parse and validate each data row we've been provided
+ foreach ($data['rows'] as $row) {
+ // Parse each property
+ $sanitizedRow = $this->getSanitizer($row);
+ $rowToAdd = null;
+ foreach ($row as $key => $value) {
+ // Does the property in the provided row exist as a column?
+ if (isset($columns[$key])) {
+ // Sanitize accordingly
+ if ($columns[$key] == 2) {
+ // Number
+ $value = $sanitizedRow->getDouble($key);
+ } elseif ($columns[$key] == 3) {
+ // Date
+ try {
+ $date = $sanitizedRow->getDate($key);
+ $value = $date->format(DateFormatHelper::getSystemFormat());
+ } catch (\Exception $e) {
+ $this->getLog()->error(sprintf('Incorrect date provided %s, expected date format Y-m-d H:i:s ', $value));
+ throw new InvalidArgumentException(sprintf(__('Incorrect date provided %s, expected date format Y-m-d H:i:s '), $value), 'date');
+ }
+ } elseif ($columns[$key] == 5) {
+ // Media Id
+ $value = $sanitizedRow->getInt($key);
+ } else {
+ // String
+ $value = $sanitizedRow->getString($key);
+ }
+
+ // Data is sanitized, add to the sanitized row
+ $rowToAdd[$key] = $value;
+ }
+ }
+
+ if (count($rowToAdd) > 0) {
+ $takenSomeAction = true;
+
+ // Check unique keys to see if this is an update
+ if (!empty($data['uniqueKeys']) && is_array($data['uniqueKeys'])) {
+ // Build a filter to select existing records
+ $filter = '';
+ $params = [];
+ $i = 0;
+ foreach ($data['uniqueKeys'] as $uniqueKey) {
+ if (isset($rowToAdd[$uniqueKey])) {
+ $i++;
+ $filter .= 'AND `' . $uniqueKey . '` = :uniqueKey_' . $i . ' ';
+ $params['uniqueKey_' . $i] = $rowToAdd[$uniqueKey];
+ }
+ }
+ $filter = trim($filter, 'AND');
+
+ // Use the unique keys to look up this row and see if it exists
+ $existingRows = $dataSet->getData(
+ ['filter' => $filter],
+ ['includeFormulaColumns' => false, 'requireTotal' => false],
+ $params,
+ );
+
+ if (count($existingRows) > 0) {
+ foreach ($existingRows as $existingRow) {
+ $dataSet->editRow($existingRow['id'], array_merge($existingRow, $rowToAdd));
+ }
+ } else {
+ $dataSet->addRow($rowToAdd);
+ }
+ } else {
+ $dataSet->addRow($rowToAdd);
+ }
+ }
+ }
+
+ if (!$takenSomeAction)
+ throw new NotFoundException(__('No data found in request body'));
+
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Imported JSON into %s'), $dataSet->dataSet)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Sends out a Test Request and returns the Data as JSON to the Client so it can be shown in the Dialog
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function testRemoteRequest(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $testDataSetId = $sanitizedParams->getInt('testDataSetId');
+
+ if ($testDataSetId !== null) {
+ $dataSet = $this->dataSetFactory->getById($testDataSetId);
+ } else {
+ $dataSet = $this->dataSetFactory->createEmpty();
+ }
+
+ $dataSet->dataSet = $sanitizedParams->getString('dataSet');
+ $dataSet->method = $sanitizedParams->getString('method');
+ $dataSet->uri = $sanitizedParams->getString('uri');
+ $dataSet->postData = $sanitizedParams->getString('postData');
+ $dataSet->authentication = $sanitizedParams->getString('authentication');
+ $dataSet->username = $sanitizedParams->getString('username');
+ $dataSet->password = $sanitizedParams->getString('password');
+ $dataSet->dataRoot = $sanitizedParams->getString('dataRoot');
+ $dataSet->sourceId = $sanitizedParams->getInt('sourceId');
+ $dataSet->ignoreFirstRow = $sanitizedParams->getCheckbox('ignoreFirstRow');
+
+ // Before running the test, check if the length is within the current URI character limit
+ if (strlen($dataSet->uri) > 250) {
+ throw new InvalidArgumentException(__('URI can not be longer than 250 characters'), 'uri');
+ }
+
+ // Set this DataSet as active.
+ $dataSet->setActive();
+
+ // Getting the dependant DataSet to process the current DataSet on
+ $dependant = null;
+ if ($dataSet->runsAfter != null && $dataSet->runsAfter != $dataSet->dataSetId) {
+ $dependant = $this->dataSetFactory->getById($dataSet->runsAfter);
+ }
+
+ // Call the remote service requested
+ $data = $this->dataSetFactory->callRemoteService($dataSet, $dependant, false);
+
+ if ($data->number > 0) {
+ // Process the results, but don't record them
+ if ($dataSet->sourceId === 1) {
+ $this->dataSetFactory->processResults($dataSet, $data, false);
+ } else {
+ $this->dataSetFactory->processCsvEntries($dataSet, $data, false);
+ }
+ }
+
+ $this->getLog()->debug('Results: ' . var_export($data, true));
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Run Test-Request for %s', $dataSet->dataSet),
+ 'id' => $dataSet->dataSetId,
+ 'data' => $data
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Export DataSet to csv
+ *
+ * @SWG\GET(
+ * path="/dataset/export/csv/{dataSetId}",
+ * operationId="dataSetExportCsv",
+ * tags={"dataset"},
+ * summary="Export to CSV",
+ * description="Export DataSet data to a csv file",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID to export.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function exportToCsv(Request $request, Response $response, $id)
+ {
+ $this->setNoOutput();
+ $i = 0;
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ // Create a CSV file
+ $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . Random::generateString() .'.csv';
+
+ $out = fopen($tempFileName, 'w');
+
+ foreach ($dataSet->getData() as $row) {
+ $columnHeaders = [];
+ $rowData = [];
+
+ foreach ($dataSet->columns as $column) {
+ if ($i === 0) {
+ $columnHeaders[] = $column->heading;
+ }
+
+ $rowData[] = $row[$column->heading];
+ }
+
+ if (!empty($columnHeaders)) {
+ fputcsv($out, $columnHeaders);
+ }
+
+ fputcsv($out, $rowData);
+ $i++;
+ }
+
+ fclose($out);
+ $this->getLog()->debug('Exported DataSet ' . $dataSet->dataSet . ' with ' . $i . ' rows of data');
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $tempFileName,
+ $dataSet->dataSet.'.csv'
+ )->withHeader('Content-Type', 'text/csv;charset=utf-8'));
+ }
+
+ public function clearCacheForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ $this->getState()->template = 'dataset-form-clear-cache';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('dataSetClearCacheForm');
+ $this->getState()->setData([
+ 'dataSet' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Clear cache for remote dataSet, only available via web interface
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function clearCache(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->clearCache();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Cache cleared for %s', $dataSet->dataSet),
+ 'id' => $dataSet->dataSetId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Real-time data script editor
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws GeneralException
+ */
+ public function dataConnectorView(Request $request, Response $response, $id): Response
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->load();
+
+ if ($dataSet->dataConnectorSource == 'user_defined') {
+ // retrieve the user defined javascript
+ $script = $dataSet->getScript();
+ } else {
+ // Dispatch the event to get the script from the connector
+ $event = new DataConnectorScriptRequestEvent($dataSet);
+ $this->getDispatcher()->dispatch($event, DataConnectorScriptRequestEvent::$NAME);
+ $script = $dataSet->getScript();
+ }
+
+ $this->getState()->template = 'dataset-data-connector-page';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'script' => $script,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Real-time data script test
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws GeneralException
+ */
+ public function dataConnectorTest(Request $request, Response $response, $id): Response
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->load();
+
+ $this->getState()->template = 'dataset-data-connector-test-page';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'script' => $dataSet->getScript(),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Real-time data script test
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws GeneralException
+ */
+ public function dataConnectorRequest(Request $request, Response $response, $id): Response
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $params = $this->getSanitizer($request->getParams());
+ $url = $params->getString('url');
+ $method = $params->getString('method', ['default' => 'GET']);
+ $headers = $params->getArray('headers');
+ $body = $params->getArray('body');
+
+ // Verify that the requested URL appears in the script somewhere.
+ $script = $dataSet->getScript();
+
+ if (!Str::contains($script, $url)) {
+ throw new InvalidArgumentException(__('URL not found in data connector script'), 'url');
+ }
+
+ // Make the request
+ $options = [];
+ if (is_array($headers)) {
+ $options['headers'] = $headers;
+ }
+
+ if ($method === 'GET') {
+ $options['query'] = $body;
+ } else {
+ $options['body'] = $body;
+ }
+
+ $this->getLog()->debug('dataConnectorRequest: making request with options ' . var_export($options, true));
+
+ // Use guzzle to make the request
+ try {
+ $client = new Client();
+ $remoteResponse = $client->request($method, $url, $options);
+
+ // Format the response
+ $response->getBody()->write($remoteResponse->getBody()->getContents());
+ $response = $response->withAddedHeader('Content-Type', $remoteResponse->getHeader('Content-Type')[0]);
+ $response = $response->withStatus($remoteResponse->getStatusCode());
+ } catch (RequestException $exception) {
+ $this->getLog()->error('dataConnectorRequest: error with request: ' . $exception->getMessage());
+
+ if ($exception->hasResponse()) {
+ $remoteResponse = $exception->getResponse();
+ $response = $response->withStatus($remoteResponse->getStatusCode());
+ $response->getBody()->write($remoteResponse->getBody()->getContents());
+ } else {
+ $response = $response->withStatus(500);
+ }
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/Controller/DataSetColumn.php b/lib/Controller/DataSetColumn.php
new file mode 100644
index 0000000..3d983b6
--- /dev/null
+++ b/lib/Controller/DataSetColumn.php
@@ -0,0 +1,690 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\DataSetColumnFactory;
+use Xibo\Factory\DataSetColumnTypeFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DataTypeFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class DataSetColumn
+ * @package Xibo\Controller
+ */
+class DataSetColumn extends Base
+{
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var DataSetColumnFactory */
+ private $dataSetColumnFactory;
+
+ /** @var DataSetColumnTypeFactory */
+ private $dataSetColumnTypeFactory;
+
+ /** @var DataTypeFactory */
+ private $dataTypeFactory;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /**
+ * Set common dependencies.
+ * @param DataSetFactory $dataSetFactory
+ * @param DataSetColumnFactory $dataSetColumnFactory
+ * @param DataSetColumnTypeFactory $dataSetColumnTypeFactory
+ * @param DataTypeFactory $dataTypeFactory
+ * @param PoolInterface $pool
+ */
+ public function __construct($dataSetFactory, $dataSetColumnFactory, $dataSetColumnTypeFactory, $dataTypeFactory, $pool)
+ {
+ $this->dataSetFactory = $dataSetFactory;
+ $this->dataSetColumnFactory = $dataSetColumnFactory;
+ $this->dataSetColumnTypeFactory = $dataSetColumnTypeFactory;
+ $this->dataTypeFactory = $dataTypeFactory;
+ $this->pool = $pool;
+ }
+
+ /**
+ * Column Page
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function displayPage(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'dataset-column-page';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Column Search
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Get(
+ * path="/dataset/{dataSetId}/column",
+ * operationId="dataSetColumnSearch",
+ * tags={"dataset"},
+ * summary="Search Columns",
+ * description="Search Columns for DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnId",
+ * in="query",
+ * description="Filter by DataSet ColumnID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DataSetColumn")
+ * )
+ * )
+ * )
+ */
+ public function grid(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $parsedRequestParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSetColumns = $this->dataSetColumnFactory->query(
+ $this->gridRenderSort($parsedRequestParams),
+ $this->gridRenderFilter(
+ ['dataSetId' => $id, 'dataSetColumnId' => $parsedRequestParams->getInt('dataSetColumnId')],
+ $parsedRequestParams
+ )
+ );
+
+ foreach ($dataSetColumns as $column) {
+ /* @var \Xibo\Entity\DataSetColumn $column */
+
+ $column->dataType = __($column->dataType);
+ $column->dataSetColumnType = __($column->dataSetColumnType);
+
+ if ($this->isApi($request))
+ break;
+
+ $column->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('dataset.modify')) {
+ // Edit
+ $column->buttons[] = array(
+ 'id' => 'dataset_button_edit',
+ 'url' => $this->urlFor($request,'dataSet.column.edit.form', ['id' => $id, 'colId' => $column->dataSetColumnId]),
+ 'text' => __('Edit')
+ );
+
+ if ($this->getUser()->checkDeleteable($dataSet)) {
+ // Delete
+ $column->buttons[] = array(
+ 'id' => 'dataset_button_delete',
+ 'url' => $this->urlFor($request,'dataSet.column.delete.form', ['id' => $id, 'colId' => $column->dataSetColumnId]),
+ 'text' => __('Delete')
+ );
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($dataSetColumns);
+ $this->getState()->recordsTotal = $this->dataSetColumnFactory->countLast();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function addForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'dataset-column-form-add';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'dataTypes' => $this->dataTypeFactory->query(),
+ 'dataSetColumnTypes' => $this->dataSetColumnTypeFactory->query(),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Post(
+ * path="/dataset/{dataSetId}/column",
+ * operationId="dataSetColumnAdd",
+ * tags={"dataset"},
+ * summary="Add Column",
+ * description="Add a Column to a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="heading",
+ * in="formData",
+ * description="The heading for the Column",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="listContent",
+ * in="formData",
+ * description="A comma separated list of content for drop downs",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="columnOrder",
+ * in="formData",
+ * description="The display order for this column",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataTypeId",
+ * in="formData",
+ * description="The data type ID for this column",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnTypeId",
+ * in="formData",
+ * description="The column type for this column",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="formula",
+ * in="formData",
+ * description="MySQL SELECT syntax formula for this Column if the column type is formula",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="remoteField",
+ * in="formData",
+ * description="JSON-String to select Data from the Remote DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="showFilter",
+ * in="formData",
+ * description="Flag indicating whether this column should present a filter on DataEntry",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="showSort",
+ * in="formData",
+ * description="Flag indicating whether this column should allow sorting on DataEntry",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tooltip",
+ * in="formData",
+ * description="Help text that should be displayed when entering data for this Column.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRequired",
+ * in="formData",
+ * description="Flag indicating whether value must be provided for this Column.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dateFormat",
+ * in="formData",
+ * description="PHP date format for the dates in the source of the remote DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSetColumn"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function add(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Create a Column
+ $column = $this->dataSetColumnFactory->createEmpty();
+ $column->heading = $sanitizedParams->getString('heading');
+ $column->listContent = $sanitizedParams->getString('listContent');
+ $column->columnOrder = $sanitizedParams->getInt('columnOrder');
+ $column->dataTypeId = $sanitizedParams->getInt('dataTypeId');
+ $column->dataSetColumnTypeId = $sanitizedParams->getInt('dataSetColumnTypeId');
+ $column->formula = $request->getParam('formula', null);
+ $column->remoteField = $request->getParam('remoteField', null);
+ $column->showFilter = $sanitizedParams->getCheckbox('showFilter');
+ $column->showSort = $sanitizedParams->getCheckbox('showSort');
+ $column->tooltip = $sanitizedParams->getString('tooltip');
+ $column->isRequired = $sanitizedParams->getCheckbox('isRequired', ['default' => 0]);
+ $column->dateFormat = $sanitizedParams->getString('dateFormat', ['default' => null]);
+
+ if ($column->dataSetColumnTypeId == 3) {
+ $this->pool->deleteItem('/dataset/cache/' . $dataSet->dataSetId);
+ $this->getLog()->debug('New remote column detected, clear cache for remote dataSet ID ' . $dataSet->dataSetId);
+ }
+
+ // Assign the column to set the column order if necessary
+ $dataSet->assignColumn($column);
+
+ // client side formula disable sort
+ if (substr($column->formula, 0, 1) === '$') {
+ $column->showSort = 0;
+ }
+
+ // Save the column
+ $column->save();
+
+ // Notify the change
+ $dataSet->notify();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $column->heading),
+ 'id' => $column->dataSetColumnId,
+ 'data' => $column
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $colId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id, $colId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'dataset-column-form-edit';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'dataSetColumn' => $this->dataSetColumnFactory->getById($colId),
+ 'dataTypes' => $this->dataTypeFactory->query(),
+ 'dataSetColumnTypes' => $this->dataSetColumnTypeFactory->query(),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $colId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Put(
+ * path="/dataset/{dataSetId}/column/{dataSetColumnId}",
+ * operationId="dataSetColumnEdit",
+ * tags={"dataset"},
+ * summary="Edit Column",
+ * description="Edit a Column to a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnId",
+ * in="path",
+ * description="The Column ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="heading",
+ * in="formData",
+ * description="The heading for the Column",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="listContent",
+ * in="formData",
+ * description="A comma separated list of content for drop downs",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="columnOrder",
+ * in="formData",
+ * description="The display order for this column",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataTypeId",
+ * in="formData",
+ * description="The data type ID for this column",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnTypeId",
+ * in="formData",
+ * description="The column type for this column",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="formula",
+ * in="formData",
+ * description="MySQL SELECT syntax formula for this Column if the column type is formula",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="remoteField",
+ * in="formData",
+ * description="JSON-String to select Data from the Remote DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="showFilter",
+ * in="formData",
+ * description="Flag indicating whether this column should present a filter on DataEntry",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="showSort",
+ * in="formData",
+ * description="Flag indicating whether this column should allow sorting on DataEntry",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tooltip",
+ * in="formData",
+ * description="Help text that should be displayed when entering data for this Column.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRequired",
+ * in="formData",
+ * description="Flag indicating whether value must be provided for this Column.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dateFormat",
+ * in="formData",
+ * description="PHP date format for the dates in the source of the remote DataSet",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSetColumn"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id, $colId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Column
+ $column = $this->dataSetColumnFactory->getById($colId);
+ $column->heading = $sanitizedParams->getString('heading');
+ $column->listContent = $sanitizedParams->getString('listContent');
+ $column->columnOrder = $sanitizedParams->getInt('columnOrder');
+ $column->dataTypeId = $sanitizedParams->getInt('dataTypeId');
+ $column->dataSetColumnTypeId = $sanitizedParams->getInt('dataSetColumnTypeId');
+ $column->formula = $request->getParam('formula', null);
+ $column->remoteField = $request->getParam('remoteField', null);
+ $column->showFilter = $sanitizedParams->getCheckbox('showFilter');
+ $column->showSort = $sanitizedParams->getCheckbox('showSort');
+ $column->tooltip = $sanitizedParams->getString('tooltip');
+ $column->isRequired = $sanitizedParams->getCheckbox('isRequired');
+ $column->dateFormat = $sanitizedParams->getString('dateFormat', ['default' => null]);
+
+ // client side formula disable sort
+ if (substr($column->formula, 0, 1) === '$') {
+ $column->showSort = 0;
+ }
+
+ $column->save();
+
+ if ($column->dataSetColumnTypeId == 3 && $column->hasPropertyChanged('remoteField')) {
+ $this->pool->deleteItem('/dataset/cache/' . $dataSet->dataSetId);
+ $this->getLog()->debug('Edited remoteField detected, clear cache for remote dataSet ID ' . $dataSet->dataSetId);
+ }
+
+ $dataSet->notify();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $column->heading),
+ 'id' => $column->dataSetColumnId,
+ 'data' => $column
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $colId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id, $colId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'dataset-column-form-delete';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'dataSetColumn' => $this->dataSetColumnFactory->getById($colId),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $colId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Delete(
+ * path="/dataset/{dataSetId}/column/{dataSetColumnId}",
+ * operationId="dataSetColumnDelete",
+ * tags={"dataset"},
+ * summary="Delete Column",
+ * description="Delete DataSet Column",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnId",
+ * in="path",
+ * description="The Column ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id, $colId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get the column
+ $column = $this->dataSetColumnFactory->getById($colId);
+ $column->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $column->heading)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/DataSetData.php b/lib/Controller/DataSetData.php
new file mode 100644
index 0000000..94cfbca
--- /dev/null
+++ b/lib/Controller/DataSetData.php
@@ -0,0 +1,593 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DataSetData
+ * @package Xibo\Controller
+ */
+class DataSetData extends Base
+{
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /**
+ * Set common dependencies.
+ * @param DataSetFactory $dataSetFactory
+ * @param MediaFactory $mediaFactory
+ */
+ public function __construct($dataSetFactory, $mediaFactory)
+ {
+ $this->dataSetFactory = $dataSetFactory;
+ $this->mediaFactory = $mediaFactory;
+ }
+
+ /**
+ * Display Page
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load data set
+ $dataSet->load();
+
+ $this->getState()->template = 'dataset-dataentry-page';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Grid
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Get(
+ * path="/dataset/data/{dataSetId}",
+ * operationId="dataSetData",
+ * tags={"dataset"},
+ * summary="DataSet Data",
+ * description="Get Data for DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function grid(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $sorting = $this->gridRenderSort($sanitizedParams);
+
+ if ($sorting != null) {
+ $sorting = implode(',', $sorting);
+ }
+
+ // Filter criteria
+ $filter = '';
+ $params = [];
+ $i = 0;
+ foreach ($dataSet->getColumn() as $column) {
+ /* @var \Xibo\Entity\DataSetColumn $column */
+ if ($column->dataSetColumnTypeId == 1) {
+ $i++;
+ if ($sanitizedParams->getString($column->heading) != null) {
+ $filter .= 'AND `' . $column->heading . '` LIKE :heading_' . $i . ' ';
+ $params['heading_' . $i] = '%' . $sanitizedParams->getString($column->heading) . '%';
+ }
+ }
+ }
+ $filter = trim($filter, 'AND');
+
+ // Work out the limits
+ $filter = $this->gridRenderFilter(['filter' => $request->getParam('filter', $filter)], $sanitizedParams);
+
+ try {
+ $data = $dataSet->getData(
+ [
+ 'order' => $sorting,
+ 'start' => $filter['start'],
+ 'size' => $filter['length'],
+ 'filter' => $filter['filter']
+ ],
+ [],
+ $params,
+ );
+ } catch (\Exception $e) {
+ $data = ['exception' => __('Error getting DataSet data, failed with following message: ') . $e->getMessage()];
+ $this->getLog()->error('Error getting DataSet data, failed with following message: ' . $e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($data);
+
+ // Output the count of records for paging purposes
+ if ($dataSet->countLast() != 0)
+ $this->getState()->recordsTotal = $dataSet->countLast();
+
+ // Set this dataSet as being active
+ $dataSet->setActive();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->load();
+
+ $this->getState()->template = 'dataset-data-form-add';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/dataset/data/{dataSetId}",
+ * operationId="dataSetDataAdd",
+ * tags={"dataset"},
+ * summary="Add Row",
+ * description="Add a row of Data to a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnId_ID",
+ * in="formData",
+ * description="Parameter for each dataSetColumnId in the DataSet",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function add(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $row = [];
+
+ // Expect input for each value-column
+ foreach ($dataSet->getColumn() as $column) {
+ /* @var \Xibo\Entity\DataSetColumn $column */
+ if ($column->dataSetColumnTypeId == 1) {
+ // Sanitize accordingly
+ if ($column->dataTypeId == 2) {
+ // Number
+ $value = $sanitizedParams->getDouble('dataSetColumnId_' . $column->dataSetColumnId);
+ } else if ($column->dataTypeId == 3) {
+ // Date
+ $date = $sanitizedParams->getDate('dataSetColumnId_' . $column->dataSetColumnId);
+ // format only if we have the date provided.
+ $value = $date === null ? $date : $date->format(DateFormatHelper::getSystemFormat());
+ } else if ($column->dataTypeId == 5) {
+ // Media Id
+ $value = $sanitizedParams->getInt('dataSetColumnId_' . $column->dataSetColumnId);
+ } else if ($column->dataTypeId === 6) {
+ // HTML
+ $value = $sanitizedParams->getHtml('dataSetColumnId_' . $column->dataSetColumnId);
+ } else {
+ // String
+ $value = $sanitizedParams->getString('dataSetColumnId_' . $column->dataSetColumnId);
+ }
+
+ $row[$column->heading] = $value;
+ } elseif ($column->dataSetColumnTypeId == 3) {
+ throw new InvalidArgumentException(__('Cannot add new rows to remote dataSet'), 'dataSetColumnTypeId');
+ }
+ }
+
+ // Use the data set object to add a row
+ $rowId = $dataSet->addRow($row);
+
+
+ // Save the dataSet
+ $dataSet->save(['validate' => false, 'saveColumns' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Added Row'),
+ 'id' => $rowId,
+ 'data' => [
+ 'id' => $rowId
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $rowId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id, $rowId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->load();
+
+ $row = $dataSet->getData(['id' => $rowId])[0];
+
+ // Augment my row with any already selected library image
+ foreach ($dataSet->getColumn() as $dataSetColumn) {
+ if ($dataSetColumn->dataTypeId === 5) {
+ // Add this image object to my row
+ try {
+ if (isset($row[$dataSetColumn->heading])) {
+ $row['__images'][$dataSetColumn->dataSetColumnId] = $this->mediaFactory->getById($row[$dataSetColumn->heading]);
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->debug('DataSet ' . $id . ' references an image that no longer exists. ID is ' . $row[$dataSetColumn->heading]);
+ }
+ }
+ }
+
+ $this->getState()->template = 'dataset-data-form-edit';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'row' => $row
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Row
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param int $rowId
+ *
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Put(
+ * path="/dataset/data/{dataSetId}/{rowId}",
+ * operationId="dataSetDataEdit",
+ * tags={"dataset"},
+ * summary="Edit Row",
+ * description="Edit a row of Data to a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="rowId",
+ * in="path",
+ * description="The Row ID of the Data to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetColumnId_ID",
+ * in="formData",
+ * description="Parameter for each dataSetColumnId in the DataSet",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id, $rowId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $existingRow = $dataSet->getData(['id' => $rowId])[0];
+ $row = [];
+
+ // Expect input for each value-column
+ foreach ($dataSet->getColumn() as $column) {
+ $existingValue = $existingRow[$column->heading];
+ /* @var \Xibo\Entity\DataSetColumn $column */
+ if ($column->dataSetColumnTypeId == 1) {
+ // Pull out the value
+ $value = $request->getParam('dataSetColumnId_' . $column->dataSetColumnId, null);
+
+ $this->getLog()->debug('Value is: ' . var_export($value, true)
+ . ', existing value is ' . var_export($existingValue, true));
+
+ // Sanitize accordingly
+ if ($column->dataTypeId == 2) {
+ // Number
+ if (isset($value)) {
+ $value = $sanitizedParams->getDouble('dataSetColumnId_' . $column->dataSetColumnId);
+ } else {
+ $value = $existingValue;
+ }
+ } else if ($column->dataTypeId == 3) {
+ // Date
+ if (isset($value)) {
+ $value = $sanitizedParams->getDate('dataSetColumnId_' . $column->dataSetColumnId);
+ } else {
+ $value = $existingValue;
+ }
+ } else if ($column->dataTypeId == 5) {
+ // Media Id
+ if (isset($value)) {
+ $value = $sanitizedParams->getInt('dataSetColumnId_' . $column->dataSetColumnId);
+ } else {
+ $value = null;
+ }
+ } else if ($column->dataTypeId === 6) {
+ // HTML
+ if (isset($value)) {
+ $value = $sanitizedParams->getHtml('dataSetColumnId_' . $column->dataSetColumnId);
+ } else {
+ $value = null;
+ }
+ } else {
+ // String
+ if (isset($value)) {
+ $value = $sanitizedParams->getString('dataSetColumnId_' . $column->dataSetColumnId);
+ } else {
+ $value = $existingValue;
+ }
+ }
+
+ $row[$column->heading] = $value;
+ }
+ }
+
+ // Use the data set object to edit a row
+ if ($row != []) {
+ $dataSet->editRow($rowId, $row);
+ } else {
+ throw new InvalidArgumentException(__('Cannot edit data of remote columns'), 'dataSetColumnTypeId');
+ }
+ // Save the dataSet
+ $dataSet->save(['validate' => false, 'saveColumns' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Edited Row'),
+ 'id' => $rowId,
+ 'data' => [
+ 'id' => $rowId
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param int $rowId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id, $rowId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $dataSet->load();
+
+ $this->getState()->template = 'dataset-data-form-delete';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'row' => $dataSet->getData(['id' => $rowId])[0]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Row
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $rowId
+ *
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Delete(
+ * path="/dataset/data/{dataSetId}/{rowId}",
+ * operationId="dataSetDataDelete",
+ * tags={"dataset"},
+ * summary="Delete Row",
+ * description="Delete a row of Data to a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="rowId",
+ * in="path",
+ * description="The Row ID of the Data to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id, $rowId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ if (empty($dataSet->getData(['id' => $rowId])[0])) {
+ throw new NotFoundException(__('row not found'), 'dataset');
+ }
+
+ // Delete the row
+ $dataSet->deleteRow($rowId);
+
+ // Save the dataSet
+ $dataSet->save(['validate' => false, 'saveColumns' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Deleted Row'),
+ 'id' => $rowId
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/DataSetRss.php b/lib/Controller/DataSetRss.php
new file mode 100644
index 0000000..f293a23
--- /dev/null
+++ b/lib/Controller/DataSetRss.php
@@ -0,0 +1,913 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Carbon\Exceptions\InvalidDateException;
+use PicoFeed\Syndication\Rss20FeedBuilder;
+use PicoFeed\Syndication\Rss20ItemBuilder;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\DataSetColumnFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DataSetRssFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+class DataSetRss extends Base
+{
+ /** @var DataSetRssFactory */
+ private $dataSetRssFactory;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var DataSetColumnFactory */
+ private $dataSetColumnFactory;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /**
+ * Set common dependencies.
+ * @param DataSetRssFactory $dataSetRssFactory
+ * @param DataSetFactory $dataSetFactory
+ * @param DataSetColumnFactory $dataSetColumnFactory
+ * @param PoolInterface $pool
+ * @param StorageServiceInterface $store
+ */
+ public function __construct($dataSetRssFactory, $dataSetFactory, $dataSetColumnFactory, $pool, $store)
+ {
+ $this->dataSetRssFactory = $dataSetRssFactory;
+ $this->dataSetFactory = $dataSetFactory;
+ $this->dataSetColumnFactory = $dataSetColumnFactory;
+ $this->pool = $pool;
+ $this->store = $store;
+ }
+
+ /**
+ * Display Page
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'dataset-rss-page';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Search
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Get(
+ * path="/dataset/{dataSetId}/rss",
+ * operationId="dataSetRSSSearch",
+ * tags={"dataset"},
+ * summary="Search RSSs",
+ * description="Search RSSs for DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DataSetRss")
+ * )
+ * )
+ * )
+ */
+ public function grid(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getQueryParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $feeds = $this->dataSetRssFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter([
+ 'dataSetId' => $id,
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName')
+ ], $sanitizedParams));
+
+ foreach ($feeds as $feed) {
+
+ if ($this->isApi($request))
+ continue;
+
+ $feed->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('dataset.data')) {
+ // Edit
+ $feed->buttons[] = array(
+ 'id' => 'datasetrss_button_edit',
+ 'url' => $this->urlFor($request,'dataSet.rss.edit.form', ['id' => $id, 'rssId' => $feed->id]),
+ 'text' => __('Edit')
+ );
+
+ if ($this->getUser()->checkDeleteable($dataSet)) {
+ // Delete
+ $feed->buttons[] = array(
+ 'id' => 'datasetrss_button_delete',
+ 'url' => $this->urlFor($request,'dataSet.rss.delete.form', ['id' => $id, 'rssId' => $feed->id]),
+ 'text' => __('Delete')
+ );
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($feeds);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addForm(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $columns = $dataSet->getColumn();
+ $dateColumns = [];
+
+ foreach ($columns as $column) {
+ if ($column->dataTypeId === 3)
+ $dateColumns[] = $column;
+ }
+
+ $this->getState()->template = 'dataset-rss-form-add';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'extra' => [
+ 'orderClauses' => [],
+ 'filterClauses' => [],
+ 'columns' => $columns,
+ 'dateColumns' => $dateColumns
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/dataset/{dataSetId}/rss",
+ * operationId="dataSetRssAdd",
+ * tags={"dataset"},
+ * summary="Add RSS",
+ * description="Add a RSS to a DataSet",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="title",
+ * in="formData",
+ * description="The title for the RSS",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="title",
+ * in="formData",
+ * description="The author for the RSS",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="summaryColumnId",
+ * in="formData",
+ * description="The columnId to be used as each item summary",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="contentColumnId",
+ * in="formData",
+ * description="The columnId to be used as each item content",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="publishedDateColumnId",
+ * in="formData",
+ * description="The columnId to be used as each item published date",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DataSetRss"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function add(Request $request, Response $response, $id)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($sanitizedParams->getString('title') == '') {
+ throw new InvalidArgumentException(__('Please enter title'), 'title');
+ }
+
+ if ($sanitizedParams->getString('author') == '') {
+ throw new InvalidArgumentException(__('Please enter author name'), 'author');
+ }
+
+ // Create RSS
+ $feed = $this->dataSetRssFactory->createEmpty();
+ $feed->dataSetId = $id;
+ $feed->title = $sanitizedParams->getString('title');
+ $feed->author = $sanitizedParams->getString('author');
+ $feed->titleColumnId = $sanitizedParams->getInt('titleColumnId');
+ $feed->summaryColumnId = $sanitizedParams->getInt('summaryColumnId');
+ $feed->contentColumnId = $sanitizedParams->getInt('contentColumnId');
+ $feed->publishedDateColumnId = $sanitizedParams->getInt('publishedDateColumnId');
+ $this->handleFormFilterAndOrder($request, $response, $feed);
+
+ // New feed needs a PSK
+ $feed->setNewPsk();
+
+ // Save
+ $feed->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $feed->title),
+ 'id' => $feed->id,
+ 'data' => $feed
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param \Xibo\Entity\DataSetRss $feed
+ */
+ private function handleFormFilterAndOrder(Request $request, Response $response, $feed)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Order criteria
+ $orderClauses = $sanitizedParams->getArray('orderClause');
+ $orderClauseDirections = $sanitizedParams->getArray('orderClauseDirection');
+ $orderClauseMapping = [];
+
+ $i = -1;
+ foreach ($orderClauses as $orderClause) {
+ $i++;
+
+ if ($orderClause == '')
+ continue;
+
+ // Map the stop code received to the stop ref (if there is one)
+ $orderClauseMapping[] = [
+ 'orderClause' => $orderClause,
+ 'orderClauseDirection' => isset($orderClauseDirections[$i]) ? $orderClauseDirections[$i] : '',
+ ];
+ }
+
+ $feed->sort = json_encode([
+ 'sort' => $sanitizedParams->getString('sort'),
+ 'useOrderingClause' => $sanitizedParams->getCheckbox('useOrderingClause'),
+ 'orderClauses' => $orderClauseMapping
+ ]);
+
+ // Filter criteria
+ $filterClauses = $sanitizedParams->getArray('filterClause');
+ $filterClauseOperator = $sanitizedParams->getArray('filterClauseOperator');
+ $filterClauseCriteria = $sanitizedParams->getArray('filterClauseCriteria');
+ $filterClauseValue = $sanitizedParams->getArray('filterClauseValue');
+ $filterClauseMapping = [];
+
+ $i = -1;
+ foreach ($filterClauses as $filterClause) {
+ $i++;
+
+ if ($filterClause == '')
+ continue;
+
+ // Map the stop code received to the stop ref (if there is one)
+ $filterClauseMapping[] = [
+ 'filterClause' => $filterClause,
+ 'filterClauseOperator' => isset($filterClauseOperator[$i]) ? $filterClauseOperator[$i] : '',
+ 'filterClauseCriteria' => isset($filterClauseCriteria[$i]) ? $filterClauseCriteria[$i] : '',
+ 'filterClauseValue' => isset($filterClauseValue[$i]) ? $filterClauseValue[$i] : '',
+ ];
+ }
+
+ $feed->filter = json_encode([
+ 'filter' => $sanitizedParams->getString('filter'),
+ 'useFilteringClause' => $sanitizedParams->getCheckbox('useFilteringClause'),
+ 'filterClauses' => $filterClauseMapping
+ ]);
+ }
+
+ /**
+ * Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $rssId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id, $rssId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $feed = $this->dataSetRssFactory->getById($rssId);
+
+ $columns = $dataSet->getColumn();
+ $dateColumns = [];
+
+ foreach ($columns as $column) {
+ if ($column->dataTypeId === 3)
+ $dateColumns[] = $column;
+ }
+
+ $this->getState()->template = 'dataset-rss-form-edit';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'feed' => $feed,
+ 'extra' => array_merge($feed->getSort(), $feed->getFilter(), ['columns' => $columns, 'dateColumns' => $dateColumns])
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $rssId
+ *
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/dataset/{dataSetId}/rss/{rssId}",
+ * operationId="dataSetRssEdit",
+ * tags={"dataset"},
+ * summary="Edit Rss",
+ * description="Edit DataSet Rss Feed",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="rssId",
+ * in="path",
+ * description="The RSS ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="title",
+ * in="formData",
+ * description="The title for the RSS",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="title",
+ * in="formData",
+ * description="The author for the RSS",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="summaryColumnId",
+ * in="formData",
+ * description="The rssId to be used as each item summary",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="contentColumnId",
+ * in="formData",
+ * description="The columnId to be used as each item content",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="publishedDateColumnId",
+ * in="formData",
+ * description="The columnId to be used as each item published date",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="regeneratePsk",
+ * in="formData",
+ * description="Regenerate the PSK?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id, $rssId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($sanitizedParams->getString('title') == '') {
+ throw new InvalidArgumentException(__('Please enter title'), 'title');
+ }
+
+ if ($sanitizedParams->getString('author') == '') {
+ throw new InvalidArgumentException(__('Please enter author name'), 'author');
+ }
+
+ $feed = $this->dataSetRssFactory->getById($rssId);
+ $feed->title = $sanitizedParams->getString('title');
+ $feed->author = $sanitizedParams->getString('author');
+ $feed->titleColumnId = $sanitizedParams->getInt('titleColumnId');
+ $feed->summaryColumnId = $sanitizedParams->getInt('summaryColumnId');
+ $feed->contentColumnId = $sanitizedParams->getInt('contentColumnId');
+ $feed->publishedDateColumnId = $sanitizedParams->getInt('publishedDateColumnId');
+ $this->handleFormFilterAndOrder($request, $response, $feed);
+
+ if ($sanitizedParams->getCheckbox('regeneratePsk')) {
+ $feed->setNewPsk();
+ }
+
+ $feed->save();
+
+ // Delete from the cache
+ $this->pool->deleteItem('/dataset/rss/' . $feed->id);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $feed->title),
+ 'id' => $feed->id,
+ 'data' => $feed
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $rssId
+ *
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id, $rssId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $feed = $this->dataSetRssFactory->getById($rssId);
+
+ $this->getState()->template = 'dataset-rss-form-delete';
+ $this->getState()->setData([
+ 'dataSet' => $dataSet,
+ 'feed' => $feed
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $rssId
+ *
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/dataset/{dataSetId}/rss/{rssId}",
+ * operationId="dataSetRSSDelete",
+ * tags={"dataset"},
+ * summary="Delete RSS",
+ * description="Delete DataSet RSS",
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="path",
+ * description="The DataSet ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="rssId",
+ * in="path",
+ * description="The RSS ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id, $rssId)
+ {
+ $dataSet = $this->dataSetFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ $feed = $this->dataSetRssFactory->getById($rssId);
+ $feed->delete();
+
+ // Delete from the cache
+ $this->pool->deleteItem('/dataset/rss/' . $feed->id);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $feed->title)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Output feed
+ * this is a public route (no authentication requried)
+ * @param Request $request
+ * @param Response $response
+ * @param $psk
+ * @throws \Exception
+ */
+ public function feed(Request $request, Response $response, $psk)
+ {
+ $this->setNoOutput();
+
+ $this->getLog()->debug('RSS Feed Request with PSK ' . $psk);
+
+ // Try and get the feed using the PSK
+ try {
+ $feed = $this->dataSetRssFactory->getByPsk($psk);
+
+ // Get the DataSet out
+ $dataSet = $this->dataSetFactory->getById($feed->dataSetId);
+
+ // What is the edit date of this data set
+ $dataSetEditDate = ($dataSet->lastDataEdit == 0)
+ ? Carbon::now()->subMonths(2)
+ : Carbon::createFromTimestamp($dataSet->lastDataEdit);
+
+ // Do we have this feed in the cache?
+ $cache = $this->pool->getItem('/dataset/rss/' . $feed->id);
+
+ $output = $cache->get();
+
+ if ($cache->isMiss() || $cache->getCreation() < $dataSetEditDate) {
+ // We need to recache
+ $this->getLog()->debug('Generating RSS feed and saving to cache. Created on '
+ . ($cache->getCreation()
+ ? $cache->getCreation()->format(DateFormatHelper::getSystemFormat())
+ : 'never'));
+
+ $output = $this->generateFeed($feed, $dataSetEditDate, $dataSet);
+
+ $cache->set($output);
+ $cache->expiresAfter(new \DateInterval('PT5M'));
+ $this->pool->saveDeferred($cache);
+ } else {
+ $this->getLog()->debug('Serving from Cache');
+ }
+
+ $response->withHeader('Content-Type', 'application/rss+xml');
+ echo $output;
+ } catch (NotFoundException) {
+ $this->getState()->httpStatus = 404;
+ }
+ return $response;
+ }
+
+ /**
+ * @param \Xibo\Entity\DataSetRss $feed
+ * @param Carbon $dataSetEditDate
+ * @param \Xibo\Entity\DataSet $dataSet
+ * @return string
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function generateFeed($feed, $dataSetEditDate, $dataSet): string
+ {
+ // Create the start of our feed, its description, etc.
+ $builder = Rss20FeedBuilder::create()
+ ->withTitle($feed->title)
+ ->withAuthor($feed->author)
+ ->withFeedUrl('')
+ ->withSiteUrl('')
+ ->withDate($dataSetEditDate);
+
+ $sort = $feed->getSort();
+ $filter = $feed->getFilter();
+
+ // Get results, using the filter criteria
+ // Ordering
+ $ordering = '';
+
+ if ($sort['useOrderingClause'] == 1) {
+ $ordering = $sort['sort'];
+ } else {
+ // Build an order string
+ foreach ($sort['orderClauses'] as $clause) {
+ $ordering .= $clause['orderClause'] . ' ' . $clause['orderClauseDirection'] . ',';
+ }
+
+ $ordering = rtrim($ordering, ',');
+ }
+
+ // Filtering
+ $filtering = '';
+
+ if ($filter['useFilteringClause'] == 1) {
+ $filtering = $filter['filter'];
+ } else {
+ // Build
+ $i = 0;
+ foreach ($filter['filterClauses'] as $clause) {
+ $i++;
+ $criteria = '';
+
+ switch ($clause['filterClauseCriteria']) {
+
+ case 'starts-with':
+ $criteria = 'LIKE \'' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'ends-with':
+ $criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'contains':
+ $criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'equals':
+ $criteria = '= \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'not-contains':
+ $criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'not-starts-with':
+ $criteria = 'NOT LIKE \'' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'not-ends-with':
+ $criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'not-equals':
+ $criteria = '<> \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'greater-than':
+ $criteria = '> \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'less-than':
+ $criteria = '< \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ default:
+ // Continue out of the switch and the loop (this takes us back to our foreach)
+ continue 2;
+ }
+
+ if ($i > 1)
+ $filtering .= ' ' . $clause['filterClauseOperator'] . ' ';
+
+ // Ability to filter by not-empty and empty
+ if ($clause['filterClauseCriteria'] == 'is-empty') {
+ $filtering .= 'IFNULL(`' . $clause['filterClause'] . '`, \'\') = \'\'';
+ } else if ($clause['filterClauseCriteria'] == 'is-not-empty') {
+ $filtering .= 'IFNULL(`' . $clause['filterClause'] . '`, \'\') <> \'\'';
+ } else {
+ $filtering .= $clause['filterClause'] . ' ' . $criteria;
+ }
+ }
+ }
+
+ // Get an array representing the id->heading mappings
+ $mappings = [];
+ $columns = [];
+
+ if ($feed->titleColumnId != 0)
+ $columns[] = $feed->titleColumnId;
+
+ if ($feed->summaryColumnId != 0)
+ $columns[] = $feed->summaryColumnId;
+
+ if ($feed->contentColumnId != 0)
+ $columns[] = $feed->contentColumnId;
+
+ if ($feed->publishedDateColumnId != 0)
+ $columns[] = $feed->publishedDateColumnId;
+
+ foreach ($columns as $dataSetColumnId) {
+ // Get the column definition this represents
+ $column = $dataSet->getColumn($dataSetColumnId);
+ /* @var \Xibo\Entity\DataSetColumn $column */
+
+ $mappings[$column->heading] = [
+ 'dataSetColumnId' => $dataSetColumnId,
+ 'heading' => $column->heading,
+ 'dataTypeId' => $column->dataTypeId
+ ];
+ }
+
+ $filter = [
+ 'filter' => $filtering,
+ 'order' => $ordering
+ ];
+
+ // Set the timezone for SQL
+ $dateNow = Carbon::now();
+
+ $this->store->setTimeZone($dateNow->format('P'));
+
+ // Get the data (complete table, filtered)
+ $dataSetResults = $dataSet->getData($filter);
+
+ foreach ($dataSetResults as $row) {
+ $item = Rss20ItemBuilder::create($builder);
+ $item->withUrl('');
+
+ $hasContent = false;
+ $hasDate = false;
+
+ // Go through the columns of each row
+ foreach ($row as $key => $value) {
+ // Is this one of the columns we're interested in?
+ if (isset($mappings[$key])) {
+ // Yes it is - which one?
+ $hasContent = true;
+
+ if ($mappings[$key]['dataSetColumnId'] === $feed->titleColumnId) {
+ $item->withTitle($value);
+ } else if ($mappings[$key]['dataSetColumnId'] === $feed->summaryColumnId) {
+ $item->withSummary($value);
+ } else if ($mappings[$key]['dataSetColumnId'] === $feed->contentColumnId) {
+ $item->withContent($value);
+ } else if ($mappings[$key]['dataSetColumnId'] === $feed->publishedDateColumnId) {
+ try {
+ $date = Carbon::createFromTimestamp($value);
+ } catch (InvalidDateException) {
+ $date = $dataSetEditDate;
+ }
+
+ if ($date !== null) {
+ $item->withPublishedDate($date);
+ $hasDate = true;
+ }
+ }
+ }
+ }
+
+ if (!$hasDate) {
+ $item->withPublishedDate($dataSetEditDate);
+ }
+
+ if ($hasContent) {
+ $builder->withItem($item);
+ }
+ }
+
+ // Found, do things
+ return $builder->build();
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/DataTablesDotNetTrait.php b/lib/Controller/DataTablesDotNetTrait.php
new file mode 100644
index 0000000..b793337
--- /dev/null
+++ b/lib/Controller/DataTablesDotNetTrait.php
@@ -0,0 +1,100 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Trait DataTablesDotNetTrait
+ * @package Xibo\Controller
+ *
+ * Methods which implement the particular sorting/filtering requirements of DataTables.Net
+ */
+trait DataTablesDotNetTrait
+{
+ /**
+ * Set the filter
+ * @param array $extraFilter
+ * @param SanitizerInterface|null $sanitizedRequestParams
+ * @return array
+ */
+ protected function gridRenderFilter(array $extraFilter, $sanitizedRequestParams = null)
+ {
+ if ($sanitizedRequestParams === null) {
+ return $extraFilter;
+ }
+
+ // Handle filtering
+ $filter = [];
+ if ($sanitizedRequestParams->getInt('disablePaging') != 1) {
+ $filter['start'] = $sanitizedRequestParams->getInt('start', ['default' => 0]);
+ $filter['length'] = $sanitizedRequestParams->getInt('length', ['default' => 10]);
+ }
+
+ $search = $sanitizedRequestParams->getArray('search', ['default' => []]);
+ if (is_array($search) && isset($search['value'])) {
+ $filter['search'] = $search['value'];
+ } else if ($search != '') {
+ $filter['search'] = $search;
+ }
+
+ // Merge with any extra filter items that have been provided
+ $filter = array_merge($extraFilter, $filter);
+
+ return $filter;
+ }
+
+ /**
+ * Set the sort order
+ * @param SanitizerInterface|array $sanitizedRequestParams
+ * @return array
+ */
+ protected function gridRenderSort($sanitizedRequestParams)
+ {
+ if ($sanitizedRequestParams instanceof SanitizerInterface) {
+ $columns = $sanitizedRequestParams->getArray('columns');
+ $order = $sanitizedRequestParams->getArray('order');
+ } else {
+ $columns = $sanitizedRequestParams['columns'] ?? null;
+ $order = $sanitizedRequestParams['order'] ?? null;
+ }
+
+ if ($columns === null
+ || !is_array($columns)
+ || count($columns) <= 0
+ || $order === null
+ || !is_array($order)
+ || count($order) <= 0
+ ) {
+ return null;
+ }
+
+ return array_map(function ($element) use ($columns) {
+ $val = (isset($columns[$element['column']]['name']) && $columns[$element['column']]['name'] != '')
+ ? $columns[$element['column']]['name']
+ : $columns[$element['column']]['data'];
+ $val = preg_replace('/[^A-Za-z0-9_]/', '', $val);
+ return '`' . $val . '`' . (($element['dir'] == 'desc') ? ' DESC' : '');
+ }, $order);
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/DayPart.php b/lib/Controller/DayPart.php
new file mode 100644
index 0000000..807d1d5
--- /dev/null
+++ b/lib/Controller/DayPart.php
@@ -0,0 +1,621 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class DayPart
+ * @package Xibo\Controller
+ */
+class DayPart extends Base
+{
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Set common dependencies.
+ * @param DayPartFactory $dayPartFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param \Xibo\Service\DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct($dayPartFactory, $scheduleFactory, DisplayNotifyServiceInterface $displayNotifyService)
+ {
+ $this->dayPartFactory = $dayPartFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * View Route
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'daypart-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Search
+ *
+ * @SWG\Get(
+ * path="/daypart",
+ * operationId="dayPartSearch",
+ * tags={"dayPart"},
+ * summary="Daypart Search",
+ * description="Search dayparts",
+ * @SWG\Parameter(
+ * name="dayPartId",
+ * in="query",
+ * description="The dayPart ID to Search",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="The name of the dayPart to Search",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as exceptions",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DayPart")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'dayPartId' => $sanitizedParams->getInt('dayPartId'),
+ 'name' => $sanitizedParams->getString('name'),
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'),
+ 'isAlways' => $sanitizedParams->getInt('isAlways'),
+ 'isCustom' => $sanitizedParams->getInt('isCustom'),
+ 'isRetired' => $sanitizedParams->getInt('isRetired')
+ ];
+
+ $dayParts = $this->dayPartFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter($filter, $sanitizedParams));
+ $embed = ($sanitizedParams->getString('embed') != null) ? explode(',', $sanitizedParams->getString('embed')) : [];
+
+ foreach ($dayParts as $dayPart) {
+ /* @var \Xibo\Entity\DayPart $dayPart */
+ if (!in_array('exceptions', $embed)){
+ $dayPart->excludeProperty('exceptions');
+ }
+ if ($this->isApi($request))
+ continue;
+
+ $dayPart->includeProperty('buttons');
+
+ if ($dayPart->isCustom !== 1
+ && $dayPart->isAlways !== 1
+ && $this->getUser()->featureEnabled('daypart.modify')
+ ) {
+ // CRUD
+ $dayPart->buttons[] = array(
+ 'id' => 'daypart_button_edit',
+ 'url' => $this->urlFor($request,'daypart.edit.form', ['id' => $dayPart->dayPartId]),
+ 'text' => __('Edit')
+ );
+
+ if ($this->getUser()->checkDeleteable($dayPart)) {
+ $dayPart->buttons[] = [
+ 'id' => 'daypart_button_delete',
+ 'url' => $this->urlFor($request,'daypart.delete.form', ['id' => $dayPart->dayPartId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'daypart.delete', ['id' => $dayPart->dayPartId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'daypart_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $dayPart->name]
+ ]
+ ];
+ }
+ }
+
+ if ($this->getUser()->checkPermissionsModifyable($dayPart)
+ && $this->getUser()->featureEnabled('daypart.modify')
+ ) {
+ if (count($dayPart->buttons) > 0)
+ $dayPart->buttons[] = ['divider' => true];
+
+ // Edit Permissions
+ $dayPart->buttons[] = [
+ 'id' => 'daypart_button_permissions',
+ 'url' => $this->urlFor($request,'user.permissions.form', ['entity' => 'DayPart', 'id' => $dayPart->dayPartId]),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'user.permissions.multi', ['entity' => 'DayPart', 'id' => $dayPart->dayPartId])],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'daypart_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $dayPart->name],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ ['name' => 'custom-handler-url', 'value' => $this->urlFor($request,'user.permissions.multi.form', ['entity' => 'DayPart'])],
+ ['name' => 'content-id-name', 'value' => 'dayPartId']
+ ]
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->dayPartFactory->countLast();
+ $this->getState()->setData($dayParts);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Daypart Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'daypart-form-add';
+ $this->getState()->setData([
+ 'extra' => [
+ 'exceptions' => []
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Daypart
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $dayPart = $this->dayPartFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($dayPart)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($dayPart->isAlways === 1 || $dayPart->isCustom === 1) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'daypart-form-edit';
+ $this->getState()->setData([
+ 'dayPart' => $dayPart,
+ 'extra' => [
+ 'exceptions' => $dayPart->exceptions
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Daypart
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $dayPart = $this->dayPartFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dayPart)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($dayPart->isAlways === 1 || $dayPart->isCustom === 1) {
+ throw new AccessDeniedException();
+ }
+
+ // Get a count of schedules for this day part
+ $schedules = $this->scheduleFactory->getByDayPartId($id);
+
+ $this->getState()->template = 'daypart-form-delete';
+ $this->getState()->setData([
+ 'countSchedules' => count($schedules),
+ 'dayPart' => $dayPart
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add
+ * @SWG\Post(
+ * path="/daypart",
+ * operationId="dayPartAdd",
+ * tags={"dayPart"},
+ * summary="Daypart Add",
+ * description="Add a Daypart",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Daypart Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description for the dayPart",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="startTime",
+ * in="formData",
+ * description="The start time for this day part",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="endTime",
+ * in="formData",
+ * description="The end time for this day part",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="exceptionDays",
+ * in="formData",
+ * description="String array of exception days",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="exceptionStartTimes",
+ * in="formData",
+ * description="String array of exception start times to match the exception days",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="exceptionEndTimes",
+ * in="formData",
+ * description="String array of exception end times to match the exception days",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DayPart"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $dayPart = $this->dayPartFactory->createEmpty();
+ $this->handleCommonInputs($dayPart, $request);
+
+ $dayPart
+ ->setScheduleFactory($this->scheduleFactory, $this->displayNotifyService)
+ ->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $dayPart->name),
+ 'id' => $dayPart->dayPartId,
+ 'data' => $dayPart
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Put(
+ * path="/daypart/{dayPartId}",
+ * operationId="dayPartEdit",
+ * tags={"dayPart"},
+ * summary="Daypart Edit",
+ * description="Edit a Daypart",
+ * @SWG\Parameter(
+ * name="dayPartId",
+ * in="path",
+ * description="The Daypart Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Daypart Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description for the dayPart",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="startTime",
+ * in="formData",
+ * description="The start time for this day part",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="endTime",
+ * in="formData",
+ * description="The end time for this day part",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="exceptionDays",
+ * in="formData",
+ * description="String array of exception days",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="exceptionStartTimes",
+ * in="formData",
+ * description="String array of exception start times to match the exception days",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="exceptionEndTimes",
+ * in="formData",
+ * description="String array of exception end times to match the exception days",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DayPart")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $dayPart = $this->dayPartFactory->getById($id)
+ ->load();
+
+ if (!$this->getUser()->checkEditable($dayPart)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($dayPart->isAlways === 1 || $dayPart->isCustom === 1) {
+ throw new AccessDeniedException();
+ }
+
+ $this->handleCommonInputs($dayPart, $request);
+ $dayPart
+ ->setScheduleFactory($this->scheduleFactory, $this->displayNotifyService)
+ ->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $dayPart->name),
+ 'id' => $dayPart->dayPartId,
+ 'data' => $dayPart
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Handle common inputs
+ * @param \Xibo\Entity\DayPart $dayPart
+ * @param Request $request
+ */
+ private function handleCommonInputs($dayPart, Request $request)
+ {
+ $dayPart->userId = $this->getUser()->userId;
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $dayPart->name = $sanitizedParams->getString('name');
+ $dayPart->description = $sanitizedParams->getString('description');
+ $dayPart->isRetired = $sanitizedParams->getCheckbox('isRetired');
+ $dayPart->startTime = $sanitizedParams->getString('startTime');
+ $dayPart->endTime = $sanitizedParams->getString('endTime');
+
+ // Exceptions
+ $exceptionDays = $sanitizedParams->getArray('exceptionDays', ['default' => []]);
+ $exceptionStartTimes = $sanitizedParams->getArray('exceptionStartTimes', ['default' => []]);
+ $exceptionEndTimes = $sanitizedParams->getArray('exceptionEndTimes', ['default' => []]);
+
+ // Clear down existing exceptions
+ $dayPart->exceptions = [];
+
+ $i = -1;
+ foreach ($exceptionDays as $exceptionDay) {
+ // Pull the corrisponding start/end time out of the same position in the array
+ $i++;
+
+ $exceptionDayStartTime = isset($exceptionStartTimes[$i]) ? $exceptionStartTimes[$i] : '';
+ $exceptionDayEndTime = isset($exceptionEndTimes[$i]) ? $exceptionEndTimes[$i] : '';
+
+ if ($exceptionDay == '' || $exceptionDayStartTime == '' || $exceptionDayEndTime == '')
+ continue;
+
+ // Is this already set?
+ $found = false;
+ foreach ($dayPart->exceptions as $exception) {
+
+ if ($exception['day'] == $exceptionDay) {
+ $exception['start'] = $exceptionDayStartTime;
+ $exception['end'] = $exceptionDayEndTime;
+
+ $found = true;
+ break;
+ }
+ }
+
+ // Otherwise add it
+ if (!$found) {
+ $dayPart->exceptions[] = [
+ 'day' => $exceptionDay,
+ 'start' => $exceptionDayStartTime,
+ 'end' => $exceptionDayEndTime
+ ];
+ }
+ }
+ }
+
+ /**
+ * Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Delete(
+ * path="/daypart/{dayPartId}",
+ * operationId="dayPartDelete",
+ * tags={"dayPart"},
+ * summary="Delete DayPart",
+ * description="Delete the provided dayPart",
+ * @SWG\Parameter(
+ * name="dayPartId",
+ * in="path",
+ * description="The Daypart Id to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $dayPart = $this->dayPartFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($dayPart)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($dayPart->isSystemDayPart()) {
+ throw new InvalidArgumentException(__('Cannot Delete system specific DayParts'));
+ }
+
+ $dayPart
+ ->setScheduleFactory($this->scheduleFactory, $this->displayNotifyService)
+ ->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $dayPart->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/Developer.php b/lib/Controller/Developer.php
new file mode 100644
index 0000000..ba43d13
--- /dev/null
+++ b/lib/Controller/Developer.php
@@ -0,0 +1,698 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\ModuleTemplateFactory;
+use Xibo\Helper\SendFile;
+use Xibo\Service\MediaService;
+use Xibo\Service\UploadService;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Module
+ * @package Xibo\Controller
+ */
+class Developer extends Base
+{
+ public function __construct(
+ private readonly ModuleFactory $moduleFactory,
+ private readonly ModuleTemplateFactory $moduleTemplateFactory
+ ) {
+ }
+
+ /**
+ * Display the module templates page
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayTemplatePage(Request $request, Response $response): Response
+ {
+ $this->getState()->template = 'developer-template-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Show Module templates in a grid
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function templateGrid(Request $request, Response $response): Response
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ $templates = $this->moduleTemplateFactory->loadUserTemplates(
+ $this->gridRenderSort($params),
+ $this->gridRenderFilter(
+ [
+ 'id' => $params->getInt('id'),
+ 'templateId' => $params->getString('templateId'),
+ 'dataType' => $params->getString('dataType'),
+ ],
+ $params
+ )
+ );
+
+ foreach ($templates as $template) {
+ if ($this->isApi($request)) {
+ break;
+ }
+
+ $template->includeProperty('buttons');
+
+ if ($this->getUser()->checkEditable($template) &&
+ $this->getUser()->featureEnabled('developer.edit')
+ ) {
+ // Edit button
+ $template->buttons[] = [
+ 'id' => 'template_button_edit',
+ 'url' => $this->urlFor($request, 'developer.templates.view.edit', ['id' => $template->id]),
+ 'text' => __('Edit'),
+ 'class' => 'XiboRedirectButton',
+ ];
+
+ $template->buttons[] = [
+ 'id' => 'template_button_export',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'developer.templates.export', ['id' => $template->id]),
+ 'text' => __('Export XML'),
+ ];
+
+ $template->buttons[] = [
+ 'id' => 'template_button_copy',
+ 'url' => $this->urlFor($request, 'developer.templates.form.copy', ['id' => $template->id]),
+ 'text' => __('Copy'),
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('developer.edit') &&
+ $this->getUser()->checkPermissionsModifyable($template)
+ ) {
+ $template->buttons[] = ['divider' => true];
+ // Permissions for Module Template
+ $template->buttons[] = [
+ 'id' => 'template_button_permissions',
+ 'url' => $this->urlFor(
+ $request,
+ 'user.permissions.form',
+ ['entity' => 'ModuleTemplate', 'id' => $template->id]
+ ),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi',
+ ['entity' => 'ModuleTemplate', 'id' => $template->id]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'template_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $template->templateId],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi.form',
+ ['entity' => 'ModuleTemplate']
+ )
+ ],
+ ['name' => 'content-id-name', 'value' => 'id']
+ ]
+ ];
+ }
+
+ if ($this->getUser()->checkDeleteable($template) &&
+ $this->getUser()->featureEnabled('developer.delete')
+ ) {
+ $template->buttons[] = ['divider' => true];
+ // Delete button
+ $template->buttons[] = [
+ 'id' => 'template_button_delete',
+ 'url' => $this->urlFor($request, 'developer.templates.form.delete', ['id' => $template->id]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'developer.templates.delete',
+ ['id' => $template->id]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'template_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $template->templateId]
+ ]
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->moduleTemplateFactory->countLast();
+ $this->getState()->setData($templates);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows an add form for a module template
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function templateAddForm(Request $request, Response $response): Response
+ {
+ $this->getState()->template = 'developer-template-form-add';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display the module template page
+ * @param Request $request
+ * @param Response $response
+ * @param mixed $id The template ID to edit.
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayTemplateEditPage(Request $request, Response $response, $id): Response
+ {
+ $template = $this->moduleTemplateFactory->getUserTemplateById($id);
+ if ($template->ownership !== 'user') {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'developer-template-edit-page';
+ $this->getState()->setData([
+ 'template' => $template,
+ 'propertiesJSON' => json_encode($template->properties),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a module template
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function templateAdd(Request $request, Response $response): Response
+ {
+ // When adding a template we just save the XML
+ $params = $this->getSanitizer($request->getParams());
+
+ $templateId = $params->getString('templateId', ['throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a unique template ID'), 'templateId');
+ }]);
+ $title = $params->getString('title', ['throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a title'), 'title');
+ }]);
+ $dataType = $params->getString('dataType', ['throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a data type'), 'dataType');
+ }]);
+ $showIn = $params->getString('showIn', ['throw' => function () {
+ throw new InvalidArgumentException(
+ __('Please select relevant editor which should show this Template'),
+ 'showIn'
+ );
+ }]);
+
+ // do we have a template selected?
+ if (!empty($params->getString('copyTemplateId'))) {
+ // get the selected template
+ $copyTemplate = $this->moduleTemplateFactory->getByDataTypeAndId(
+ $dataType,
+ $params->getString('copyTemplateId')
+ );
+
+ // get the template xml and load to document.
+ $xml = new \DOMDocument();
+ $xml->loadXML($copyTemplate->getXml());
+
+ // get template node, make adjustments from the form
+ $templateNode = $xml->getElementsByTagName('template')[0];
+ $this->setNode($xml, 'id', $templateId, false, $templateNode);
+ $this->setNode($xml, 'title', $title, false, $templateNode);
+ $this->setNode($xml, 'showIn', $showIn, false, $templateNode);
+
+ // create template with updated xml.
+ $template = $this->moduleTemplateFactory->createUserTemplate($xml->saveXML());
+ } else {
+ // The most basic template possible.
+ $template = $this->moduleTemplateFactory->createUserTemplate('
+
+ ' . $templateId . '
+ ' . $title . '
+ static
+ ' . $dataType . '
+ '. $showIn . '
+
+ ');
+ }
+
+ $template->ownerId = $this->getUser()->userId;
+ $template->save();
+
+ $this->getState()->hydrate([
+ 'httpState' => 201,
+ 'message' => __('Added'),
+ 'id' => $template->id,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit a module template
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function templateEdit(Request $request, Response $response, $id): Response
+ {
+ $template = $this->moduleTemplateFactory->getUserTemplateById($id);
+
+ $params = $this->getSanitizer($request->getParams());
+ $templateId = $params->getString('templateId', ['throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a unique template ID'), 'templateId');
+ }]);
+ $title = $params->getString('title', ['throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a title'), 'title');
+ }]);
+ $dataType = $params->getString('dataType', ['throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a data type'), 'dataType');
+ }]);
+ $showIn = $params->getString('showIn', ['throw' => function () {
+ throw new InvalidArgumentException(
+ __('Please select relevant editor which should show this Template'),
+ 'showIn'
+ );
+ }]);
+
+ $template->dataType = $dataType;
+ $template->isEnabled = $params->getCheckbox('enabled');
+
+ // TODO: validate?
+ $twig = $params->getParam('twig');
+ $hbs = $params->getParam('hbs');
+ $style = $params->getParam('style');
+ $head = $params->getParam('head');
+ $properties = $params->getParam('properties');
+ $onTemplateRender = $params->getParam('onTemplateRender');
+ $onTemplateVisible = $params->getParam('onTemplateVisible');
+
+ // We need to edit the XML we have for this template.
+ $document = $template->getDocument();
+
+ // Root nodes
+ $template->templateId = $templateId;
+ $this->setNode($document, 'id', $templateId, false);
+ $this->setNode($document, 'title', $title, false);
+ $this->setNode($document, 'showIn', $showIn, false);
+ $this->setNode($document, 'dataType', $dataType, false);
+ $this->setNode($document, 'onTemplateRender', $onTemplateRender);
+ $this->setNode($document, 'onTemplateVisible', $onTemplateVisible);
+
+ // Stencil nodes.
+ $stencilNodes = $document->getElementsByTagName('stencil');
+ if ($stencilNodes->count() <= 0) {
+ $stencilNode = $document->createElement('stencil');
+ $document->documentElement->appendChild($stencilNode);
+ } else {
+ $stencilNode = $stencilNodes[0];
+ }
+
+ $this->setNode($document, 'twig', $twig, true, $stencilNode);
+ $this->setNode($document, 'hbs', $hbs, true, $stencilNode);
+ $this->setNode($document, 'style', $style, true, $stencilNode);
+ $this->setNode($document, 'head', $head, true, $stencilNode);
+
+ // Properties.
+ // this is different because we want to replace the properties node with a new one.
+ if (!empty($properties)) {
+ // parse json and create a new properties node
+ $newPropertiesXml = $this->moduleTemplateFactory->parseJsonPropertiesToXml($properties);
+
+ $propertiesNodes = $document->getElementsByTagName('properties');
+
+ if ($propertiesNodes->count() <= 0) {
+ $document->documentElement->appendChild(
+ $document->importNode($newPropertiesXml->documentElement, true)
+ );
+ } else {
+ $document->documentElement->replaceChild(
+ $document->importNode($newPropertiesXml->documentElement, true),
+ $propertiesNodes[0]
+ );
+ }
+ }
+
+ // All done.
+ $template->setXml($document->saveXML());
+ $template->save();
+
+ if ($params->getCheckbox('isInvalidateWidget')) {
+ $template->invalidate();
+ }
+
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $template->title),
+ 'id' => $template->id,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Helper function to set a node.
+ * @param \DOMDocument $document
+ * @param string $node
+ * @param string $value
+ * @param bool $cdata
+ * @param \DOMElement|null $childNode
+ * @return void
+ * @throws \DOMException
+ */
+ private function setNode(
+ \DOMDocument $document,
+ string $node,
+ string $value,
+ bool $cdata = true,
+ ?\DOMElement $childNode = null
+ ): void {
+ $addTo = $childNode ?? $document->documentElement;
+
+ $nodes = $addTo->getElementsByTagName($node);
+ if ($nodes->count() <= 0) {
+ if ($cdata) {
+ $element = $document->createElement($node);
+ $cdata = $document->createCDATASection($value);
+ $element->appendChild($cdata);
+ } else {
+ $element = $document->createElement($node, $value);
+ }
+
+ $addTo->appendChild($element);
+ } else {
+ /** @var \DOMElement $element */
+ $element = $nodes[0];
+ if ($cdata) {
+ $cdata = $document->createCDATASection($value);
+ $element->textContent = $value;
+
+ if ($element->firstChild !== null) {
+ $element->replaceChild($cdata, $element->firstChild);
+ } else {
+ //$element->textContent = '';
+ $element->appendChild($cdata);
+ }
+ } else {
+ $element->textContent = $value;
+ }
+ }
+ }
+
+ public function getAvailableDataTypes(Request $request, Response $response)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $dataTypes = $this->moduleFactory->getAllDataTypes();
+
+ if ($params->getString('dataType') !== null) {
+ foreach ($dataTypes as $dataType) {
+ if ($dataType->id === $params->getString('dataType')) {
+ $dataTypes = [$dataType];
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = 0;
+ $this->getState()->setData($dataTypes);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Export module template
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function templateExport(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $template = $this->moduleTemplateFactory->getUserTemplateById($id);
+
+ if ($template->ownership !== 'user') {
+ throw new AccessDeniedException();
+ }
+
+ $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $template->templateId . '.xml';
+
+ $template->getDocument()->save($tempFileName);
+
+ $this->setNoOutput(true);
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $tempFileName,
+ $template->templateId . '.xml'
+ )->withHeader('Content-Type', 'text/xml;charset=utf-8'));
+ }
+
+ /**
+ * Import xml file and create module template
+ * @param Request $request
+ * @param Response $response
+ * @return ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws ConfigurationException
+ */
+ public function templateImport(Request $request, Response $response): Response|ResponseInterface
+ {
+ $this->getLog()->debug('Import Module Template');
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($libraryFolder);
+
+ $options = [
+ 'upload_dir' => $libraryFolder . 'temp/',
+ 'accept_file_types' => '/\.xml/i',
+ 'libraryQuotaFull' => false,
+ ];
+
+ $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options));
+
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ $uploadService = new UploadService($libraryFolder . 'temp/', $options, $this->getLog(), $this->getState());
+ $uploadHandler = $uploadService->createUploadHandler();
+
+ $uploadHandler->setPostProcessor(function ($file, $uploadHandler) use ($libraryFolder) {
+ // Return right away if the file already has an error.
+ if (!empty($file->error)) {
+ return $file;
+ }
+
+ $this->getUser()->isQuotaFullByUser(true);
+
+ $filePath = $libraryFolder . 'temp/' . $file->fileName;
+
+ // load the xml from uploaded file
+ $xml = new \DOMDocument();
+ $xml->load($filePath);
+
+ // Add the Template
+ $moduleTemplate = $this->moduleTemplateFactory->createUserTemplate($xml->saveXML());
+ $moduleTemplate->ownerId = $this->getUser()->userId;
+ $moduleTemplate->save();
+
+ // Tidy up the temporary file
+ @unlink($filePath);
+
+ return $file;
+ });
+
+ $uploadHandler->post();
+
+ $this->setNoOutput(true);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Show module template copy form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function templateCopyForm(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id);
+
+ if (!$this->getUser()->checkViewable($moduleTemplate)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'developer-template-form-copy';
+ $this->getState()->setData([
+ 'template' => $moduleTemplate,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy module template
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function templateCopy(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id);
+
+ if (!$this->getUser()->checkViewable($moduleTemplate)) {
+ throw new AccessDeniedException();
+ }
+
+ $params = $this->getSanitizer($request->getParams());
+
+ $newTemplate = clone $moduleTemplate;
+ $newTemplate->templateId = $params->getString('templateId');
+ $newTemplate->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Copied as %s'), $newTemplate->templateId),
+ 'id' => $newTemplate->id,
+ 'data' => $newTemplate
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Show module template delete form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function templateDeleteForm(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id);
+
+ if (!$this->getUser()->checkDeleteable($moduleTemplate)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'developer-template-form-delete';
+ $this->getState()->setData([
+ 'template' => $moduleTemplate,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete module template
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function templateDelete(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $moduleTemplate = $this->moduleTemplateFactory->getUserTemplateById($id);
+
+ if (!$this->getUser()->checkDeleteable($moduleTemplate)) {
+ throw new AccessDeniedException();
+ }
+
+ $moduleTemplate->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $moduleTemplate->templateId)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Display.php b/lib/Controller/Display.php
new file mode 100644
index 0000000..85f44a2
--- /dev/null
+++ b/lib/Controller/Display.php
@@ -0,0 +1,3196 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use GeoJson\Feature\Feature;
+use GeoJson\Feature\FeatureCollection;
+use GeoJson\Geometry\Point;
+use GuzzleHttp\Client;
+use Intervention\Image\ImageManagerStatic as Img;
+use Respect\Validation\Validator as v;
+use RobThree\Auth\TwoFactorAuth;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayEventFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\DisplayProfileFactory;
+use Xibo\Factory\DisplayTypeFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Factory\RequiredFileFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\Random;
+use Xibo\Helper\WakeOnLan;
+use Xibo\Service\PlayerActionServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+use Xibo\XMR\LicenceCheckAction;
+use Xibo\XMR\PurgeAllAction;
+use Xibo\XMR\RekeyAction;
+use Xibo\XMR\ScreenShotAction;
+
+/**
+ * Class Display
+ * @package Xibo\Controller
+ */
+class Display extends Base
+{
+ use DisplayProfileConfigFields;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var PoolInterface
+ */
+ private $pool;
+
+ /**
+ * @var PlayerActionServiceInterface
+ */
+ private $playerAction;
+
+ /**
+ * @var DayPartFactory
+ */
+ private $dayPartFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ /**
+ * @var DisplayTypeFactory
+ */
+ private $displayTypeFactory;
+
+ /** @var DisplayEventFactory */
+ private $displayEventFactory;
+
+ /** @var PlayerVersionFactory */
+ private $playerVersionFactory;
+
+ /** @var RequiredFileFactory */
+ private $requiredFileFactory;
+
+ /** @var TagFactory */
+ private $tagFactory;
+
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param PoolInterface $pool
+ * @param PlayerActionServiceInterface $playerAction
+ * @param DisplayFactory $displayFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param DisplayTypeFactory $displayTypeFactory
+ * @param LayoutFactory $layoutFactory
+ * @param DisplayProfileFactory $displayProfileFactory
+ * @param DisplayEventFactory $displayEventFactory
+ * @param RequiredFileFactory $requiredFileFactory
+ * @param TagFactory $tagFactory
+ * @param NotificationFactory $notificationFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param PlayerVersionFactory $playerVersionFactory
+ * @param DayPartFactory $dayPartFactory
+ */
+ public function __construct($store, $pool, $playerAction, $displayFactory, $displayGroupFactory, $displayTypeFactory, $layoutFactory, $displayProfileFactory, $displayEventFactory, $requiredFileFactory, $tagFactory, $notificationFactory, $userGroupFactory, $playerVersionFactory, $dayPartFactory)
+ {
+ $this->store = $store;
+ $this->pool = $pool;
+ $this->playerAction = $playerAction;
+ $this->displayFactory = $displayFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->displayTypeFactory = $displayTypeFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->displayProfileFactory = $displayProfileFactory;
+ $this->displayEventFactory = $displayEventFactory;
+ $this->requiredFileFactory = $requiredFileFactory;
+ $this->tagFactory = $tagFactory;
+ $this->notificationFactory = $notificationFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->playerVersionFactory = $playerVersionFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/displayvenue",
+ * summary="Get Display Venues",
+ * tags={"displayVenue"},
+ * operationId="displayVenueSearch",
+ * @SWG\Response(
+ * response=200,
+ * description="a successful response",
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayVenue(Request $request, Response $response)
+ {
+ if (!file_exists(PROJECT_ROOT . '/openooh/specification.json')) {
+ throw new GeneralException(__('OpenOOH specification missing'));
+ }
+
+ $content = file_get_contents(PROJECT_ROOT . '/openooh/specification.json');
+ $data = json_decode($content, true);
+
+ $taxonomy = [];
+ $i = 0;
+ foreach ($data['openooh_venue_taxonomy']['specification']['categories'] as $categories) {
+ $taxonomy[$i]['venueId'] = $categories['enumeration_id'];
+ $taxonomy[$i]['venueName'] = $categories['name'];
+
+ $i++;
+ foreach ($categories['children'] as $children) {
+ $taxonomy[$i]['venueId'] = $children['enumeration_id'];
+ $taxonomy[$i]['venueName'] = $categories['name'] . ' -> ' . $children['name'];
+ $i++;
+
+ if (isset($children['children'])) {
+ foreach ($children['children'] as $grandchildren) {
+ $taxonomy[$i]['venueId'] = $grandchildren['enumeration_id'] ;
+ $taxonomy[$i]['venueName'] = $categories['name'] . ' -> ' . $children['name'] . ' -> ' . $grandchildren['name'] ;
+ $i++;
+ }
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = count($taxonomy);
+ $this->getState()->setData($taxonomy);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Include display page template page based on sub page selected
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ // Build a list of display profiles
+ $displayProfiles = $this->displayProfileFactory->query();
+ $displayProfiles[] = ['displayProfileId' => -1, 'name' => __('Default')];
+
+ // Call to render the template
+ $this->getState()->template = 'display-page';
+
+ $mapConfig = [
+ 'setArea' => [
+ 'lat' => $this->getConfig()->getSetting('DEFAULT_LAT'),
+ 'long' => $this->getConfig()->getSetting('DEFAULT_LONG'),
+ 'zoom' => 7
+ ]
+ ];
+
+ $this->getState()->setData([
+ 'mapConfig' => $mapConfig,
+ 'displayProfiles' => $displayProfiles
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display Management Page for an Individual Display
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function displayManage(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // Zero out some variables
+ $dependencies = [];
+ $layouts = [];
+ $widgets = [];
+ $widgetData = [];
+ $media = [];
+ $totalCount = 0;
+ $completeCount = 0;
+ $totalSize = 0;
+ $completeSize = 0;
+
+ // Show 4 widgets
+ // Dependencies
+ $sql = '
+ SELECT `requiredfile`.*
+ FROM `requiredfile`
+ WHERE `requiredfile`.displayId = :displayId
+ AND `requiredfile`.type = :type
+ ORDER BY fileType, path
+ ';
+
+ foreach ($this->store->select($sql, ['displayId' => $id, 'type' => 'P']) as $row) {
+ $totalSize = $totalSize + $row['size'];
+ $totalCount++;
+
+ if (intval($row['complete']) === 1) {
+ $completeSize = $completeSize + $row['size'];
+ $completeCount = $completeCount + 1;
+ }
+
+ $row = $this->getSanitizer($row);
+
+ $dependencies[] = [
+ 'path' => $row->getString('path'),
+ 'fileType' => $row->getString('fileType'),
+ 'bytesRequested' => $row->getInt('bytesRequested'),
+ 'complete' => $row->getInt('complete'),
+ ];
+ }
+
+ // Layouts
+ $sql = '
+ SELECT layoutId, layout, `requiredfile`.*
+ FROM `layout`
+ INNER JOIN `requiredfile`
+ ON `requiredfile`.itemId = `layout`.layoutId
+ WHERE `requiredfile`.displayId = :displayId
+ AND `requiredfile`.type = :type
+ ORDER BY layout
+ ';
+
+ foreach ($this->store->select($sql, ['displayId' => $id, 'type' => 'L']) as $row) {
+ $rf = $this->requiredFileFactory->getByDisplayAndLayout($id, $row['layoutId']);
+
+ $totalCount++;
+
+ if ($rf->complete) {
+ $completeCount = $completeCount + 1;
+ }
+
+ $rf = $rf->toArray();
+ $rf['layout'] = $row['layout'];
+ $layouts[] = $rf;
+ }
+
+ // Media
+ $sql = '
+ SELECT mediaId, `name`, fileSize, media.type AS mediaType, storedAs, `requiredfile`.*
+ FROM `media`
+ INNER JOIN `requiredfile`
+ ON `requiredfile`.itemId = `media`.mediaId
+ WHERE `requiredfile`.displayId = :displayId
+ AND `requiredfile`.type = :type
+ ORDER BY `name`
+ ';
+
+ foreach ($this->store->select($sql, ['displayId' => $id, 'type' => 'M']) as $row) {
+ $rf = $this->requiredFileFactory->getByDisplayAndMedia($id, $row['mediaId']);
+
+ $totalSize = $totalSize + $row['fileSize'];
+ $totalCount++;
+
+ if ($rf->complete) {
+ $completeSize = $completeSize + $row['fileSize'];
+ $completeCount = $completeCount + 1;
+ }
+
+ $rf = $rf->toArray();
+ $rf['name'] = $row['name'];
+ $rf['type'] = $row['mediaType'];
+ $rf['storedAs'] = $row['storedAs'];
+ $rf['size'] = $row['fileSize'];
+ $media[] = $rf;
+ }
+
+ // Widgets
+ $sql = '
+ SELECT `widget`.`type` AS widgetType,
+ `widgetoption`.`value` AS widgetName,
+ `widget`.`widgetId`,
+ `requiredfile`.*
+ FROM `widget`
+ INNER JOIN `requiredfile`
+ ON `requiredfile`.itemId = `widget`.widgetId
+ LEFT OUTER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.option = \'name\'
+ WHERE `requiredfile`.`displayId` = :displayId
+ AND `requiredfile`.`type` IN (\'W\', \'D\')
+ ORDER BY `widgetoption`.value, `widget`.type, `widget`.widgetId
+ ';
+
+ foreach ($this->store->select($sql, ['displayId' => $id]) as $row) {
+ $row = $this->getSanitizer($row);
+ $entry = [];
+ $entry['type'] = $row->getString('widgetType');
+ $entry['widgetName'] = $row->getString('widgetName');
+ $entry['widgetType'] = $row->getString('widgetType');
+
+ if ($row->getString('type') === 'W') {
+ $rf = $this->requiredFileFactory->getByDisplayAndWidget($id, $row->getInt('widgetId'));
+
+ $totalCount++;
+
+ if ($rf->complete) {
+ $completeCount = $completeCount + 1;
+ }
+
+ $widgets[] = array_merge($entry, $rf->toArray());
+ } else {
+ $entry['widgetId'] = $row->getInt('widgetId');
+ $entry['bytesRequested'] = $row->getInt('bytesRequested');
+ $widgetData[] = $entry;
+ }
+ }
+
+ // Widget for file status
+ // Decide what our units are going to be, based on the size
+ $suffixes = array('bytes', 'k', 'M', 'G', 'T');
+ $base = (int)floor(log($totalSize) / log(1024));
+
+ if ($base < 0) {
+ $base = 0;
+ }
+
+ $units = $suffixes[$base] ?? '';
+ $this->getLog()->debug(sprintf('Base for size is %d and suffix is %s', $base, $units));
+
+
+ // Call to render the template
+ $this->getState()->template = 'display-page-manage';
+ $this->getState()->setData([
+ 'requiredFiles' => [],
+ 'display' => $display,
+ 'timeAgo' => Carbon::createFromTimestamp($display->lastAccessed)->diffForHumans(),
+ 'errorSearch' => http_build_query([
+ 'displayId' => $display->displayId,
+ 'type' => 'ERROR',
+ 'fromDt' => Carbon::now()->subHours(12)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]),
+ 'inventory' => [
+ 'dependencies' => $dependencies,
+ 'layouts' => $layouts,
+ 'media' => $media,
+ 'widgets' => $widgets,
+ 'widgetData' => $widgetData,
+ ],
+ 'status' => [
+ 'units' => $units,
+ 'countComplete' => $completeCount,
+ 'countRemaining' => $totalCount - $completeCount,
+ 'sizeComplete' => round((double)$completeSize / (pow(1024, $base)), 2),
+ 'sizeRemaining' => round((double)($totalSize - $completeSize) / (pow(1024, $base)), 2),
+ ],
+ 'defaults' => [
+ 'fromDate' => Carbon::now()->startOfMonth()->format(DateFormatHelper::getSystemFormat()),
+ 'fromDateOneDay' => Carbon::now()->subDay()->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->endOfMonth()->format(DateFormatHelper::getSystemFormat())
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Get display filters
+ * @param SanitizerInterface $parsedQueryParams
+ * @return array
+ */
+ public function getFilters(SanitizerInterface $parsedQueryParams): array
+ {
+ return [
+ 'displayId' => $parsedQueryParams->getInt('displayId'),
+ 'display' => $parsedQueryParams->getString('display'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'macAddress' => $parsedQueryParams->getString('macAddress'),
+ 'license' => $parsedQueryParams->getString('hardwareKey'),
+ 'displayGroupId' => $parsedQueryParams->getInt('displayGroupId'),
+ 'clientVersion' => $parsedQueryParams->getString('clientVersion'),
+ 'clientType' => $parsedQueryParams->getString('clientType'),
+ 'clientCode' => $parsedQueryParams->getString('clientCode'),
+ 'customId' => $parsedQueryParams->getString('customId'),
+ 'authorised' => $parsedQueryParams->getInt('authorised'),
+ 'displayProfileId' => $parsedQueryParams->getInt('displayProfileId'),
+ 'tags' => $parsedQueryParams->getString('tags'),
+ 'exactTags' => $parsedQueryParams->getCheckbox('exactTags'),
+ 'showTags' => true,
+ 'clientAddress' => $parsedQueryParams->getString('clientAddress'),
+ 'mediaInventoryStatus' => $parsedQueryParams->getInt('mediaInventoryStatus'),
+ 'loggedIn' => $parsedQueryParams->getInt('loggedIn'),
+ 'lastAccessed' => $parsedQueryParams->getDate('lastAccessed')?->format('U'),
+ 'displayGroupIdMembers' => $parsedQueryParams->getInt('displayGroupIdMembers'),
+ 'orientation' => $parsedQueryParams->getString('orientation'),
+ 'commercialLicence' => $parsedQueryParams->getInt('commercialLicence'),
+ 'folderId' => $parsedQueryParams->getInt('folderId'),
+ 'logicalOperator' => $parsedQueryParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $parsedQueryParams->getString('logicalOperatorName'),
+ 'bounds' => $parsedQueryParams->getString('bounds'),
+ 'syncGroupId' => $parsedQueryParams->getInt('syncGroupId'),
+ 'syncGroupIdMembers' => $parsedQueryParams->getInt('syncGroupIdMembers'),
+ 'xmrRegistered' => $parsedQueryParams->getInt('xmrRegistered'),
+ 'isPlayerSupported' => $parsedQueryParams->getInt('isPlayerSupported'),
+ 'displayGroupIds' => $parsedQueryParams->getIntArray('displayGroupIds'),
+ ];
+ }
+
+ /**
+ * Grid of Displays
+ *
+ * @SWG\Get(
+ * path="/display",
+ * operationId="displaySearch",
+ * tags={"display"},
+ * summary="Display Search",
+ * description="Search Displays for this User",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="query",
+ * description="Filter by Display Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="query",
+ * description="Filter by DisplayGroup Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="display",
+ * in="query",
+ * description="Filter by Display Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="query",
+ * description="Filter by tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="query",
+ * description="A flag indicating whether to treat the tags filter as an exact match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="query",
+ * description="When filtering by multiple Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="macAddress",
+ * in="query",
+ * description="Filter by Mac Address",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="hardwareKey",
+ * in="query",
+ * description="Filter by Hardware Key",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="clientVersion",
+ * in="query",
+ * description="Filter by Client Version",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="clientType",
+ * in="query",
+ * description="Filter by Client Type",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="clientCode",
+ * in="query",
+ * description="Filter by Client Code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data, namely displaygroups. A comma separated list of child objects to embed.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="authorised",
+ * in="query",
+ * description="Filter by authorised flag",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayProfileId",
+ * in="query",
+ * description="Filter by Display Profile",
+ * type="integer",
+ * required=false
+ * ),
+ * * @SWG\Parameter(
+ * name="mediaInventoryStatus",
+ * in="query",
+ * description="Filter by Display Status ( 1 - up to date, 2 - downloading, 3 - Out of date)",
+ * type="integer",
+ * required=false
+ * ),
+ * * @SWG\Parameter(
+ * name="loggedIn",
+ * in="query",
+ * description="Filter by Logged In flag",
+ * type="integer",
+ * required=false
+ * ),
+ * * @SWG\Parameter(
+ * name="lastAccessed",
+ * in="query",
+ * description="Filter by Display Last Accessed date, expects date in Y-m-d H:i:s format",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="xmrRegistered",
+ * in="query",
+ * description="Filter by whether XMR is registed (1 or 0)",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isPlayerSupported",
+ * in="query",
+ * description="Filter by whether the player is supported (1 or 0)",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Display")
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+ // Embed?
+ $embed = ($parsedQueryParams->getString('embed') != null)
+ ? explode(',', $parsedQueryParams->getString('embed'))
+ : [];
+
+ $filter = $this->getFilters($parsedQueryParams);
+
+ // Get a list of displays
+ $displays = $this->displayFactory->query(
+ $this->gridRenderSort($parsedQueryParams),
+ $this->gridRenderFilter($filter, $parsedQueryParams)
+ );
+
+ // Get all Display Profiles
+ $displayProfiles = [];
+ foreach ($this->displayProfileFactory->query() as $displayProfile) {
+ $displayProfiles[$displayProfile->displayProfileId] = $displayProfile->name;
+ }
+
+ // validate displays so we get a realistic view of the table
+ $this->validateDisplays($displays);
+
+ foreach ($displays as $display) {
+ /* @var \Xibo\Entity\Display $display */
+ if (in_array('displaygroups', $embed)) {
+ $display->load();
+ } else {
+ $display->excludeProperty('displayGroups');
+ }
+
+ if (in_array('overrideconfig', $embed)) {
+ $display->includeProperty('overrideConfig');
+ }
+
+ $display->setUnmatchedProperty(
+ 'bandwidthLimitFormatted',
+ ByteFormatter::format($display->bandwidthLimit * 1024)
+ );
+
+ // Current layout from cache
+ $display->getCurrentLayoutId($this->pool, $this->layoutFactory);
+
+ if ($this->isApi($request)) {
+ $display->lastAccessed =
+ Carbon::createFromTimestamp($display->lastAccessed)->format(DateFormatHelper::getSystemFormat());
+ $display->auditingUntil = ($display->auditingUntil == 0)
+ ? 0
+ : Carbon::createFromTimestamp($display->auditingUntil)->format(DateFormatHelper::getSystemFormat());
+ continue;
+ }
+
+ // use try and catch here to cover scenario
+ // when there is no default display profile set for any of the existing display types.
+ $displayProfileName = '';
+ try {
+ $defaultDisplayProfile = $this->displayProfileFactory->getDefaultByType($display->clientType);
+ $displayProfileName = $defaultDisplayProfile->name;
+ } catch (NotFoundException) {
+ $this->getLog()->debug('No default Display Profile set for Display type ' . $display->clientType);
+ }
+
+ // Add in the display profile information
+ $display->setUnmatchedProperty(
+ 'displayProfile',
+ (!array_key_exists($display->displayProfileId, $displayProfiles))
+ ? $displayProfileName . __(' (Default)')
+ : $displayProfiles[$display->displayProfileId]
+ );
+
+ $display->includeProperty('buttons');
+
+ // Format the storage available / total space
+ $display->setUnmatchedProperty(
+ 'storageAvailableSpaceFormatted',
+ ByteFormatter::format($display->storageAvailableSpace)
+ );
+ $display->setUnmatchedProperty(
+ 'storageTotalSpaceFormatted',
+ ByteFormatter::format($display->storageTotalSpace)
+ );
+ $display->setUnmatchedProperty(
+ 'storagePercentage',
+ ($display->storageTotalSpace == 0)
+ ? 0
+ : round($display->storageAvailableSpace / $display->storageTotalSpace * 100.0, 2)
+ );
+
+ // Set some text for the display status
+ $display->setUnmatchedProperty('statusDescription', match ($display->mediaInventoryStatus) {
+ 1 => __('Display is up to date'),
+ 2 => __('Display is downloading new files'),
+ 3 => __('Display is out of date but has not yet checked in with the server'),
+ default => __('Unknown Display Status'),
+ });
+
+ // Commercial Licence
+ $display->setUnmatchedProperty('commercialLicenceDescription', match ($display->commercialLicence) {
+ 1 => __('Display is fully licensed'),
+ 2 => __('Display is on a trial licence'),
+ default => __('Display is not licensed'),
+ });
+
+ if ($display->clientCode < 400) {
+ $commercialLicenceDescription = $display->getUnmatchedProperty('commercialLicenceDescription');
+ $commercialLicenceDescription .= ' ('
+ . __('The status will be updated with each Commercial Licence check') . ')';
+ $display->setUnmatchedProperty('commercialLicenceDescription', $commercialLicenceDescription);
+ }
+
+ // Thumbnail
+ $display->setUnmatchedProperty('thumbnail', '');
+ // If we aren't logged in, and we are showThumbnail == 2, then show a circle
+ if (file_exists($this->getConfig()->getSetting('LIBRARY_LOCATION') . 'screenshots/'
+ . $display->displayId . '_screenshot.jpg')) {
+ $display->setUnmatchedProperty(
+ 'thumbnail',
+ $this->urlFor($request, 'display.screenShot', [
+ 'id' => $display->displayId
+ ]) . '?' . Random::generateString()
+ );
+ }
+
+ $display->setUnmatchedProperty(
+ 'teamViewerLink',
+ (!empty($display->teamViewerSerial))
+ ? 'https://start.teamviewer.com/' . $display->teamViewerSerial
+ : ''
+ );
+ $display->setUnmatchedProperty(
+ 'webkeyLink',
+ (!empty($display->webkeySerial))
+ ? 'https://device.webkeyapp.com/phone?publicid=' . $display->webkeySerial
+ : ''
+ );
+
+ // Is a transfer to another CMS in progress?
+ $display->setUnmatchedProperty('isCmsTransferInProgress', (!empty($display->newCmsAddress)));
+
+ // Edit and Delete buttons first
+ if ($this->getUser()->featureEnabled('displays.modify')
+ && $this->getUser()->checkEditable($display)
+ ) {
+ // Manage
+ $display->buttons[] = [
+ 'id' => 'display_button_manage',
+ 'url' => $this->urlFor($request, 'display.manage', ['id' => $display->displayId]),
+ 'text' => __('Manage'),
+ 'external' => true
+ ];
+
+ $display->buttons[] = ['divider' => true];
+
+ // Edit
+ $display->buttons[] = [
+ 'id' => 'display_button_edit',
+ 'url' => $this->urlFor($request, 'display.edit.form', ['id' => $display->displayId]),
+ 'text' => __('Edit')
+ ];
+ }
+
+ // Delete
+ if ($this->getUser()->featureEnabled('displays.modify')
+ && $this->getUser()->checkDeleteable($display)
+ ) {
+ $deleteButton = [
+ 'id' => 'display_button_delete',
+ 'url' => $this->urlFor($request, 'display.delete.form', ['id' => $display->displayId]),
+ 'text' => __('Delete')
+ ];
+
+ // We only include this in dev mode, because users have complained that it is too powerful a feature
+ // to have in the core product.
+ if (Environment::isDevMode()) {
+ $deleteButton['multi-select'] = true;
+ $deleteButton['dataAttributes'] = [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.delete',
+ ['id' => $display->displayId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'display_button_delete'],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'rowtitle', 'value' => $display->display]
+ ];
+ }
+
+ $display->buttons[] = $deleteButton;
+ }
+
+ if ($this->getUser()->featureEnabled('displays.modify')
+ && ($this->getUser()->checkEditable($display) || $this->getUser()->checkDeleteable($display))
+ ) {
+ $display->buttons[] = ['divider' => true];
+ }
+
+ if ($this->getUser()->featureEnabled('displays.modify')
+ && $this->getUser()->checkEditable($display)
+ ) {
+ // Authorise
+ $display->buttons[] = [
+ 'id' => 'display_button_authorise',
+ 'url' => $this->urlFor($request, 'display.authorise.form', ['id' => $display->displayId]),
+ 'text' => __('Authorise'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.authorise',
+ ['id' => $display->displayId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'display_button_authorise'],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'text', 'value' => __('Toggle Authorise')],
+ ['name' => 'rowtitle', 'value' => $display->display]
+ ]
+ ];
+
+ // Default Layout
+ $display->buttons[] = [
+ 'id' => 'display_button_defaultlayout',
+ 'url' => $this->urlFor($request, 'display.defaultlayout.form', ['id' => $display->displayId]),
+ 'text' => __('Default Layout'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.defaultlayout',
+ ['id' => $display->displayId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'display_button_defaultlayout'],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'text', 'value' => __('Set Default Layout')],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'form-callback', 'value' => 'setDefaultMultiSelectFormOpen']
+ ]
+ ];
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $display->buttons[] = [
+ 'id' => 'displaygroup_button_selectfolder',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayGroup.selectfolder.form',
+ ['id' => $display->displayGroupId]
+ ),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.selectfolder',
+ ['id' => $display->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'displaygroup_button_selectfolder'],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ if (in_array($display->clientType, ['android', 'lg', 'sssp', 'chromeOS'])) {
+ $display->buttons[] = array(
+ 'id' => 'display_button_checkLicence',
+ 'url' => $this->urlFor($request, 'display.licencecheck.form', ['id' => $display->displayId]),
+ 'text' => __('Check Licence'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.licencecheck',
+ ['id' => $display->displayId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'display_button_checkLicence'],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'text', 'value' => __('Check Licence')],
+ ['name' => 'rowtitle', 'value' => $display->display]
+ ]
+ );
+ }
+
+ $display->buttons[] = ['divider' => true];
+ }
+
+ // Schedule
+ if ($this->getUser()->featureEnabled('schedule.add')
+ && ($this->getUser()->checkEditable($display)
+ || $this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION') == 1)
+ ) {
+ $display->buttons[] = array(
+ 'id' => 'display_button_schedule',
+ 'url' => $this->urlFor(
+ $request,
+ 'schedule.add.form',
+ ['id' => $display->displayGroupId, 'from' => 'DisplayGroup']
+ ),
+ 'text' => __('Schedule')
+ );
+ }
+
+ // Check if limited view access is allowed
+ if (($this->getUser()->featureEnabled('displays.modify') && $this->getUser()->checkEditable($display))
+ || $this->getUser()->featureEnabled('displays.limitedView')
+ ) {
+ if ($this->getUser()->checkEditable($display)) {
+ if ($this->getUser()->featureEnabled('layout.view')) {
+ $display->buttons[] = [
+ 'id' => 'display_button_layouts_jump',
+ 'linkType' => '_self',
+ 'external' => true,
+ 'url' => $this->urlFor($request, 'layout.view')
+ . '?activeDisplayGroupId=' . $display->displayGroupId,
+ 'text' => __('Jump to Scheduled Layouts')
+ ];
+ }
+
+ // File Associations
+ $display->buttons[] = array(
+ 'id' => 'displaygroup_button_fileassociations',
+ 'url' => $this->urlFor($request, 'displayGroup.media.form', ['id' => $display->displayGroupId]),
+ 'text' => __('Assign Files')
+ );
+
+ // Layout Assignments
+ $display->buttons[] = array(
+ 'id' => 'displaygroup_button_layout_associations',
+ 'url' => $this->urlFor($request, 'displayGroup.layout.form', ['id' => $display->displayGroupId]),
+ 'text' => __('Assign Layouts')
+ );
+ }
+
+ // Screen Shot
+ $display->buttons[] = [
+ 'id' => 'display_button_requestScreenShot',
+ 'url' => $this->urlFor($request, 'display.screenshot.form', ['id' => $display->displayId]),
+ 'text' => __('Request Screen Shot'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.requestscreenshot',
+ ['id' => $display->displayId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'sort-group', 'value' => 3],
+ ['name' => 'id', 'value' => 'display_button_requestScreenShot'],
+ ['name' => 'text', 'value' => __('Request Screen Shot')],
+ ['name' => 'rowtitle', 'value' => $display->display]
+ ]
+ ];
+
+ // Collect Now
+ $display->buttons[] = [
+ 'id' => 'display_button_collectNow',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayGroup.collectNow.form',
+ ['id' => $display->displayGroupId]
+ ),
+ 'text' => __('Collect Now'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.action.collectNow',
+ ['id' => $display->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'sort-group', 'value' => 3],
+ ['name' => 'id', 'value' => 'display_button_collectNow'],
+ ['name' => 'text', 'value' => __('Collect Now')],
+ ['name' => 'rowtitle', 'value' => $display->display]
+ ]
+ ];
+
+ if ($this->getUser()->checkEditable($display)) {
+ // Trigger webhook
+ $display->buttons[] = [
+ 'id' => 'display_button_trigger_webhook',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayGroup.trigger.webhook.form',
+ ['id' => $display->displayGroupId]
+ ),
+ 'text' => __('Trigger a web hook'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.action.trigger.webhook',
+ ['id' => $display->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'display_button_trigger_webhook'],
+ ['name' => 'sort-group', 'value' => 3],
+ ['name' => 'text', 'value' => __('Trigger a web hook')],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'form-callback', 'value' => 'triggerWebhookMultiSelectFormOpen']
+ ]
+ ];
+
+ if ($this->getUser()->isSuperAdmin()) {
+ $display->buttons[] = [
+ 'id' => 'display_button_purgeAll',
+ 'url' => $this->urlFor($request, 'display.purge.all.form', ['id' => $display->displayId]),
+ 'text' => __('Purge All')
+ ];
+ }
+
+ $display->buttons[] = ['divider' => true];
+ }
+ }
+
+ if ($this->getUser()->featureEnabled('displays.modify')
+ && $this->getUser()->checkPermissionsModifyable($display)
+ ) {
+ // Display Groups
+ $display->buttons[] = array(
+ 'id' => 'display_button_group_membership',
+ 'url' => $this->urlFor($request, 'display.membership.form', ['id' => $display->displayId]),
+ 'text' => __('Display Groups')
+ );
+
+ // Permissions
+ $display->buttons[] = [
+ 'id' => 'display_button_group_permissions',
+ 'url' => $this->urlFor(
+ $request,
+ 'user.permissions.form',
+ ['entity' => 'DisplayGroup', 'id' => $display->displayGroupId]
+ ),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi',
+ ['entity' => 'DisplayGroup', 'id' => $display->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'display_button_group_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'sort-group', 'value' => 4],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi.form',
+ ['entity' => 'DisplayGroup']
+ )
+ ],
+ ['name' => 'content-id-name', 'value' => 'displayGroupId']
+ ]
+ ];
+ }
+
+ // Check if limited view access is allowed
+ if (($this->getUser()->featureEnabled('displays.modify') && $this->getUser()->checkEditable($display))
+ || $this->getUser()->featureEnabled('displays.limitedView')
+ ) {
+ if ($this->getUser()->checkPermissionsModifyable($display)) {
+ $display->buttons[] = ['divider' => true];
+ }
+
+ if ($this->getUser()->checkEditable($display)) {
+ // Wake On LAN
+ $display->buttons[] = array(
+ 'id' => 'display_button_wol',
+ 'url' => $this->urlFor($request, 'display.wol.form', ['id' => $display->displayId]),
+ 'text' => __('Wake on LAN')
+ );
+ }
+
+ // Send Command
+ $display->buttons[] = [
+ 'id' => 'displaygroup_button_command',
+ 'url' => $this->urlFor($request, 'displayGroup.command.form', ['id' => $display->displayGroupId]),
+ 'text' => __('Send Command'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.action.command',
+ ['id' => $display->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'displaygroup_button_command'],
+ ['name' => 'text', 'value' => __('Send Command')],
+ ['name' => 'sort-group', 'value' => 3],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'form-callback', 'value' => 'sendCommandMultiSelectFormOpen']
+ ]
+ ];
+
+ if ($this->getUser()->checkEditable($display)) {
+ $display->buttons[] = ['divider' => true];
+
+ $display->buttons[] = [
+ 'id' => 'display_button_move_cms',
+ 'url' => $this->urlFor($request, 'display.moveCms.form', ['id' => $display->displayId]),
+ 'text' => __('Transfer to another CMS'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.moveCms',
+ ['id' => $display->displayId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'display_button_move_cms'],
+ ['name' => 'text', 'value' => __('Transfer to another CMS')],
+ ['name' => 'sort-group', 'value' => 5],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'form-callback', 'value' => 'setMoveCmsMultiSelectFormOpen']
+ ]
+ ];
+
+ $display->buttons[] = [
+ 'multi-select' => true,
+ 'multiSelectOnly' => true, // Show button only on multi-select menu
+ 'id' => 'display_button_set_bandwidth',
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'display.setBandwidthLimitMultiple'
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'display_button_set_bandwidth'],
+ ['name' => 'text', 'value' => __('Set Bandwidth')],
+ ['name' => 'rowtitle', 'value' => $display->display],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor($request, 'display.setBandwidthLimitMultiple.form')
+ ],
+ ['name' => 'content-id-name', 'value' => 'displayId']
+ ]
+ ];
+
+ if ($display->getUnmatchedProperty('isCmsTransferInProgress', false)) {
+ $display->buttons[] = [
+ 'id' => 'display_button_move_cancel',
+ 'url' => $this->urlFor($request, 'display.moveCmsCancel.form', ['id' => $display->displayId]),
+ 'text' => __('Cancel CMS Transfer'),
+ ];
+ }
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->displayFactory->countLast();
+ $this->getState()->setData($displays);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Displays on map
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ */
+ public function displayMap(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = $this->getFilters($parsedQueryParams);
+
+ // Get a list of displays
+ $displays = $this->displayFactory->query(null, $filter);
+ $results = [];
+ $status = [
+ '1' => __('Up to date'),
+ '2' => __('Downloading'),
+ '3' => __('Out of date')
+ ];
+
+ // Get all Display Profiles
+ $displayProfiles = [];
+ foreach ($this->displayProfileFactory->query() as $displayProfile) {
+ $displayProfiles[$displayProfile->displayProfileId] = $displayProfile->name;
+ }
+
+ foreach ($displays as $display) {
+ // use try and catch here to cover scenario when there is no default display profile set for any of the existing display types.
+ $displayProfileName = '';
+ try {
+ $defaultDisplayProfile = $this->displayProfileFactory->getDefaultByType($display->clientType);
+ $displayProfileName = $defaultDisplayProfile->name;
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('No default Display Profile set for Display type ' . $display->clientType);
+ }
+
+ // Add in the display profile information
+ $display->setUnmatchedProperty(
+ 'displayProfile',
+ (!array_key_exists($display->displayProfileId, $displayProfiles))
+ ? $displayProfileName . __(' (Default)')
+ : $displayProfiles[$display->displayProfileId]
+ );
+
+ $properties = [
+ 'display' => $display->display,
+ 'status' => $display->mediaInventoryStatus ? $status[$display->mediaInventoryStatus] : __('Unknown'),
+ 'mediaInventoryStatus' => $display->mediaInventoryStatus,
+ 'orientation' => ucwords($display->orientation ?: __('Unknown')),
+ 'displayId' => $display->getId(),
+ 'licensed' => $display->licensed,
+ 'loggedIn' => $display->loggedIn,
+ 'displayProfile' => $display->getUnmatchedProperty('displayProfile'),
+ 'resolution' => $display->resolution,
+ 'lastAccessed' => $display->lastAccessed,
+ ];
+
+ if (file_exists($this->getConfig()->getSetting('LIBRARY_LOCATION') . 'screenshots/' . $display->displayId . '_screenshot.jpg')) {
+ $properties['thumbnail'] = $this->urlFor($request, 'display.screenShot', ['id' => $display->displayId]) . '?' . Random::generateString();
+ }
+
+ $longitude = ($display->longitude) ?: $this->getConfig()->getSetting('DEFAULT_LONG');
+ $latitude = ($display->latitude) ?: $this->getConfig()->getSetting('DEFAULT_LAT');
+
+ $geo = new Point([(double)$longitude, (double)$latitude]);
+
+ $results[] = new Feature($geo, $properties);
+ }
+
+ return $response->withJson(new FeatureCollection($results));
+ }
+
+ /**
+ * Edit Display Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function editForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id, true);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // We have permission - load
+ $display->load();
+
+ // Dates
+ $auditingUntilIso = !empty($display->auditingUntil)
+ ? Carbon::createFromTimestamp($display->auditingUntil)->format(DateFormatHelper::getSystemFormat())
+ : null;
+ $display->setUnmatchedProperty('auditingUntilIso', $auditingUntilIso);
+
+ // display profile dates
+ $displayProfile = $display->getDisplayProfile();
+
+ // Get the settings from the profile
+ $profile = $display->getSettings();
+ $displayTypes = $this->displayTypeFactory->query();
+
+ $elevateLogsUntil = $displayProfile->getSetting('elevateLogsUntil');
+ $elevateLogsUntilIso = !empty($elevateLogsUntil)
+ ? Carbon::createFromTimestamp($elevateLogsUntil)->format(DateFormatHelper::getSystemFormat())
+ : null;
+ $displayProfile->setUnmatchedProperty('elevateLogsUntilIso', $elevateLogsUntilIso);
+
+ // Get a list of timezones
+ $timeZones = [];
+ foreach (DateFormatHelper::timezoneList() as $key => $value) {
+ $timeZones[] = ['id' => $key, 'value' => $value];
+ }
+
+ // Get the currently assigned default layout
+ try {
+ $layouts = (($display->defaultLayoutId != null) ? [$this->layoutFactory->getById($display->defaultLayoutId)] : []);
+ } catch (NotFoundException $notFoundException) {
+ $layouts = [];
+ }
+
+ // Player Version Setting
+ $versionId = $display->getSetting('versionMediaId', null, ['displayOnly' => true]);
+ $profileVersionId = $display->getDisplayProfile()->getSetting('versionMediaId');
+ $playerVersions = [];
+
+ // Daypart - Operating Hours
+ $dayPartId = $display->getSetting('dayPartId', null, ['displayOnly' => true]);
+ $profileDayPartId = $display->getDisplayProfile()->getSetting('dayPartId');
+ $dayparts = [];
+
+ // Get the Player Version for this display profile type
+ if ($versionId !== null) {
+ try {
+ $playerVersions[] = $this->playerVersionFactory->getById($versionId);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('Unknown versionId set on Display Profile for displayId ' . $display->displayId);
+ }
+ }
+
+ if ($versionId !== $profileVersionId && $profileVersionId !== null) {
+ try {
+ $playerVersions[] = $this->playerVersionFactory->getById($profileVersionId);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('Unknown versionId set on Display Profile for displayId ' . $display->displayId);
+ }
+ }
+
+ if ($dayPartId !== null) {
+ try {
+ $dayparts[] = $this->dayPartFactory->getById($dayPartId);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('Unknown dayPartId set on Display Profile for displayId ' . $display->displayId);
+ }
+ }
+
+ if ($dayPartId !== $profileDayPartId && $profileDayPartId !== null) {
+ try {
+ $dayparts[] = $this->dayPartFactory->getById($profileDayPartId);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('Unknown dayPartId set on Display Profile for displayId ' . $display->displayId);
+ }
+ }
+
+ // A list of languages
+ // Build an array of supported languages
+ $languages = [];
+ $localeDir = PROJECT_ROOT . '/locale';
+ foreach (array_map('basename', glob($localeDir . '/*.mo')) as $lang) {
+ // Trim the .mo off the end
+ $lang = str_replace('.mo', '', $lang);
+ $languages[] = ['id' => $lang, 'value' => $lang];
+ }
+
+ $this->getState()->template = 'display-form-edit';
+ $this->getState()->setData([
+ 'display' => $display,
+ 'displayProfile' => $displayProfile,
+ 'lockOptions' => json_decode($display->getDisplayProfile()->getSetting('lockOptions', '[]'), true),
+ 'layouts' => $layouts,
+ 'profiles' => $this->displayProfileFactory->query(null, array('type' => $display->clientType)),
+ 'settings' => $profile,
+ 'timeZones' => $timeZones,
+ 'displayLockName' => ($this->getConfig()->getSetting('DISPLAY_LOCK_NAME_TO_DEVICENAME') == 1),
+ 'versions' => $playerVersions,
+ 'displayTypes' => $displayTypes,
+ 'dayParts' => $dayparts,
+ 'languages' => $languages,
+ 'isWolDisabled' => defined('ACCOUNT_ID'),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'display-form-delete';
+ $this->getState()->setData([
+ 'display' => $display,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display Edit
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/display/{displayId}",
+ * operationId="displayEdit",
+ * tags={"display"},
+ * summary="Display Edit",
+ * description="Edit a Display",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="display",
+ * in="formData",
+ * description="The Display Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description of the Display",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="A comma separated list of tags for this item",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="auditingUntil",
+ * in="formData",
+ * description="A date this Display records auditing information until.",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="defaultLayoutId",
+ * in="formData",
+ * description="A Layout ID representing the Default Layout for this Display.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="licensed",
+ * in="formData",
+ * description="Flag indicating whether this display is licensed.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="license",
+ * in="formData",
+ * description="The hardwareKey to use as the licence key for this Display",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="incSchedule",
+ * in="formData",
+ * description="Flag indicating whether the Default Layout should be included in the Schedule",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="emailAlert",
+ * in="formData",
+ * description="Flag indicating whether the Display generates up/down email alerts.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="alertTimeout",
+ * in="formData",
+ * description="How long in seconds should this display wait before alerting when it hasn't connected. Override for the collection interval.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="wakeOnLanEnabled",
+ * in="formData",
+ * description="Flag indicating if Wake On LAN is enabled for this Display",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="wakeOnLanTime",
+ * in="formData",
+ * description="A h:i string representing the time that the Display should receive its Wake on LAN command",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="broadCastAddress",
+ * in="formData",
+ * description="The BroadCast Address for this Display - used by Wake On LAN",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="secureOn",
+ * in="formData",
+ * description="The secure on configuration for this Display",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="cidr",
+ * in="formData",
+ * description="The CIDR configuration for this Display",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="latitude",
+ * in="formData",
+ * description="The Latitude of this Display",
+ * type="number",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="longitude",
+ * in="formData",
+ * description="The Longitude of this Display",
+ * type="number",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="timeZone",
+ * in="formData",
+ * description="The timezone for this display, or empty to use the CMS timezone",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="languages",
+ * in="formData",
+ * description="An array of languages supported in this display location",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayProfileId",
+ * in="formData",
+ * description="The Display Settings Profile ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayTypeId",
+ * in="formData",
+ * description="The Display Type ID of this Display",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="screenSize",
+ * in="formData",
+ * description="The screen size of this Display",
+ * type="number",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="venueId",
+ * in="formData",
+ * description="The Venue ID of this Display",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="address",
+ * in="formData",
+ * description="The Location Address of this Display",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isMobile",
+ * in="formData",
+ * description="Is this Display mobile?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isOutdoor",
+ * in="formData",
+ * description="Is this Display Outdoor?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="costPerPlay",
+ * in="formData",
+ * description="The Cost Per Play of this Display",
+ * type="number",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="impressionsPerPlay",
+ * in="formData",
+ * description="The Impressions Per Play of this Display",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="customId",
+ * in="formData",
+ * description="The custom ID (an Id of any external system) of this Display",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref1",
+ * in="formData",
+ * description="Reference 1",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref2",
+ * in="formData",
+ * description="Reference 2",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref3",
+ * in="formData",
+ * description="Reference 3",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref4",
+ * in="formData",
+ * description="Reference 4",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref5",
+ * in="formData",
+ * description="Reference 5",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="clearCachedData",
+ * in="formData",
+ * description="Clear all Cached data for this display",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="rekeyXmr",
+ * in="formData",
+ * description="Clear the cached XMR configuration and send a rekey",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="teamViewerSerial",
+ * in="formData",
+ * description="The TeamViewer serial number for this Display, if applicable",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="webkeySerial",
+ * in="formData",
+ * description="The Webkey serial number for this Display, if applicable",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Display")
+ * )
+ * )
+ */
+ function edit(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id, true);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // Update properties
+ if ($this->getConfig()->getSetting('DISPLAY_LOCK_NAME_TO_DEVICENAME') == 0) {
+ $display->display = $sanitizedParams->getString('display');
+ }
+
+ $display->load();
+
+ $display->description = $sanitizedParams->getString('description');
+ $display->displayTypeId = $sanitizedParams->getInt('displayTypeId');
+ $display->venueId = $sanitizedParams->getInt('venueId');
+ $display->address = $sanitizedParams->getString('address');
+ $display->isMobile = $sanitizedParams->getCheckbox('isMobile');
+ $languages = $sanitizedParams->getArray('languages');
+ if (empty($languages)) {
+ $display->languages = null;
+ } else {
+ $display->languages = implode(',', $languages);
+ }
+ $display->screenSize = $sanitizedParams->getInt('screenSize');
+ $display->auditingUntil = $sanitizedParams->getDate('auditingUntil')?->format('U');
+ $display->defaultLayoutId = $sanitizedParams->getInt('defaultLayoutId');
+ $display->licensed = $sanitizedParams->getInt('licensed');
+ $display->license = $sanitizedParams->getString('license');
+ $display->incSchedule = $sanitizedParams->getInt('incSchedule');
+ $display->emailAlert = $sanitizedParams->getInt('emailAlert');
+ $display->alertTimeout = $sanitizedParams->getCheckbox('alertTimeout');
+ $display->latitude = $sanitizedParams->getDouble('latitude');
+ $display->longitude = $sanitizedParams->getDouble('longitude');
+ $display->timeZone = $sanitizedParams->getString('timeZone');
+ $display->displayProfileId = $sanitizedParams->getInt('displayProfileId');
+ $display->bandwidthLimit = $sanitizedParams->getInt('bandwidthLimit', ['default' => 0]);
+ $display->teamViewerSerial = $sanitizedParams->getString('teamViewerSerial');
+ $display->webkeySerial = $sanitizedParams->getString('webkeySerial');
+ $display->folderId = $sanitizedParams->getInt('folderId', ['default' => $display->folderId]);
+ $display->isOutdoor = $sanitizedParams->getCheckbox('isOutdoor');
+ $display->costPerPlay = $sanitizedParams->getDouble('costPerPlay');
+ $display->impressionsPerPlay = $sanitizedParams->getDouble('impressionsPerPlay');
+ $display->customId = $sanitizedParams->getString('customId');
+ $display->ref1 = $sanitizedParams->getString('ref1');
+ $display->ref2 = $sanitizedParams->getString('ref2');
+ $display->ref3 = $sanitizedParams->getString('ref3');
+ $display->ref4 = $sanitizedParams->getString('ref4');
+ $display->ref5 = $sanitizedParams->getString('ref5');
+
+ // Wake on Lan
+ if (defined('ACCOUNT_ID')) {
+ // WOL is not allowed on a Xibo Cloud CMS
+ // Force disable, but leave the other settings as they are.
+ $display->wakeOnLanEnabled = 0;
+ } else {
+ $display->wakeOnLanEnabled = $sanitizedParams->getCheckbox('wakeOnLanEnabled');
+ $display->wakeOnLanTime = $sanitizedParams->getString('wakeOnLanTime');
+ $display->broadCastAddress = $sanitizedParams->getString('broadCastAddress');
+ $display->secureOn = $sanitizedParams->getString('secureOn');
+ $display->cidr = $sanitizedParams->getString('cidr');
+ }
+
+ // Get the display profile and use that to pull in any overrides
+ // start with an empty config
+ $display->overrideConfig = $this->editConfigFields(
+ $display->getDisplayProfile(),
+ $sanitizedParams,
+ [],
+ $display
+ );
+
+ // Tags are stored on the displaygroup, we're just passing through here
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+
+ $display->tags = $tags;
+ }
+
+ // Should we invalidate this display?
+ if ($display->hasPropertyChanged('defaultLayoutId')) {
+ $display->notify();
+ } elseif ($sanitizedParams->getCheckbox('clearCachedData', ['default' => 1]) == 1) {
+ // Remove the cache if the display licenced state has changed
+ $this->pool->deleteItem($display->getCacheKey());
+ }
+
+ // Should we rekey?
+ if ($sanitizedParams->getCheckbox('rekeyXmr', ['default' => 0]) == 1) {
+ // Queue the rekey action first (before we clear the channel and key)
+ $this->playerAction->sendAction($display, new RekeyAction());
+
+ // Clear the config.
+ $display->xmrChannel = null;
+ $display->xmrPubKey = null;
+ }
+
+ $display->save();
+
+ if ($this->isApi($request)) {
+ $display->lastAccessed = Carbon::createFromTimestamp($display->lastAccessed)
+ ->format(DateFormatHelper::getSystemFormat());
+ $display->auditingUntil = ($display->auditingUntil == 0)
+ ? 0
+ : Carbon::createFromTimestamp($display->auditingUntil)->format(DateFormatHelper::getSystemFormat());
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $display->display),
+ 'id' => $display->displayId,
+ 'data' => $display
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete a display
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/display/{displayId}",
+ * operationId="displayDelete",
+ * tags={"display"},
+ * summary="Display Delete",
+ * description="Delete a Display",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function delete(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($display->isLead()) {
+ throw new InvalidArgumentException(
+ __('Cannot delete a Lead Display of a Sync Group'),
+ );
+ }
+
+ $display->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $display->display),
+ 'id' => $display->displayId,
+ 'data' => $display
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Member of Display Groups Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function membershipForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // Groups we are assigned to
+ $groupsAssigned = $this->displayGroupFactory->getByDisplayId($display->displayId);
+
+ $this->getState()->template = 'display-form-membership';
+ $this->getState()->setData([
+ 'display' => $display,
+ 'extra' => [
+ 'displayGroupsAssigned' => $groupsAssigned
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Bandwidth to one or more displays
+ * @param Request $request
+ * @param Response $response
+ * @param $ids
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function setBandwidthLimitMultipleForm(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check if the array of ids is passed
+ if ($sanitizedParams->getString('ids') == '') {
+ throw new InvalidArgumentException(__('The array of ids is empty!'));
+ }
+
+ // Get array of ids
+ $ids = $sanitizedParams->getString('ids');
+
+ $this->getState()->template = 'display-form-set-bandwidth';
+ $this->getState()->setData([
+ 'ids' => $ids,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Bandwidth to one or more displays
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function setBandwidthLimitMultiple(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get array of ids
+ $ids = ($sanitizedParams->getString('ids') != '') ? explode(',', $sanitizedParams->getString('ids')) : [];
+ $bandwidthLimit = intval($sanitizedParams->getString('bandwidthLimit'));
+ $bandwidthLimitUnits = $sanitizedParams->getString('bandwidthLimitUnits');
+
+ // Check if the array of ids is passed
+ if (count($ids) == 0) {
+ throw new InvalidArgumentException(__('The array of ids is empty!'));
+ }
+
+ // Check if the bandwidth value has something
+ if ($bandwidthLimit == '') {
+ throw new InvalidArgumentException(__('The array of ids is empty!'));
+ }
+
+ // convert bandwidth to kb based on form units
+ if ($bandwidthLimitUnits == 'mb') {
+ $bandwidthLimit = $bandwidthLimit * 1024;
+ } elseif ($bandwidthLimitUnits == 'gb') {
+ $bandwidthLimit = $bandwidthLimit * 1024 * 1024;
+ }
+
+ // display group ids to be updated
+ $displayGroupIds = [];
+
+ foreach ($ids as $id) {
+ // get display
+ $display = $this->displayFactory->getById($id);
+
+ // check if the display is accessible by user
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $displayGroupIds[] = $display->displayGroupId;
+ }
+
+ // update bandwidth limit to the array of ids
+ $this->displayGroupFactory->setBandwidth($bandwidthLimit, $displayGroupIds);
+
+ // Audit Log message
+ $this->getLog()->audit('DisplayGroup', 0, 'Batch update of bandwidth limit for ' . count($displayGroupIds) . ' items', [
+ 'bandwidthLimit' => $bandwidthLimit,
+ 'displayGroupIds' => $displayGroupIds
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpCode' => 204,
+ 'message' => __('Displays Updated')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * Assign Display to Display Groups
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function assignDisplayGroup(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // Go through each ID to assign
+ foreach ($sanitizedParams->getIntArray('displayGroupId', ['default' => []]) as $displayGroupId) {
+ $displayGroup = $this->displayGroupFactory->getById($displayGroupId);
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException(__('Access Denied to DisplayGroup'));
+ }
+
+ $displayGroup->assignDisplay($display);
+ $displayGroup->save(['validate' => false]);
+ }
+
+ // Have we been provided with unassign id's as well?
+ foreach ($sanitizedParams->getIntArray('unassignDisplayGroupId', ['default' => []]) as $displayGroupId) {
+ $displayGroup = $this->displayGroupFactory->getById($displayGroupId);
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException(__('Access Denied to DisplayGroup'));
+ }
+
+ $displayGroup->unassignDisplay($display);
+ $displayGroup->save(['validate' => false]);
+ }
+
+ // Queue display to check for cache updates
+ $display->notify();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('%s assigned to Display Groups'), $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Output a screen shot
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function screenShot(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ // Allow limited view access
+ if (!$this->getUser()->checkViewable($display) && !$this->getUser()->featureEnabled('displays.limitedView')) {
+ throw new AccessDeniedException();
+ }
+
+ // The request will output its own content, disable framework
+ $this->setNoOutput(true);
+
+ // Output an image if present, otherwise not found image.
+ $file = 'screenshots/' . $id . '_screenshot.jpg';
+
+ // File upload directory.. get this from the settings object
+ $library = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ $fileName = $library . $file;
+
+ if (!file_exists($fileName)) {
+ $fileName = $this->getConfig()->uri('forms/filenotfound.gif');
+ }
+
+ Img::configure(array('driver' => 'gd'));
+ $img = Img::make($fileName);
+
+ $date = $display->getCurrentScreenShotTime($this->pool);
+
+ if ($date != '') {
+ $img
+ ->rectangle(0, 0, 110, 15, function ($draw) {
+ $draw->background('#ffffff');
+ })
+ ->text($date, 10, 10);
+ }
+
+ // Cache headers
+ header('Cache-Control: no-store, no-cache, must-revalidate');
+ header('Pragma: no-cache');
+ header('Expires: 0');
+
+ // Disable any buffering to prevent OOM errors.
+ while (ob_get_level() > 0) {
+ ob_end_clean();
+ }
+
+ $response->write($img->encode());
+ $response = $response->withHeader('Content-Type', $img->mime());
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Request ScreenShot form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function requestScreenShotForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ // Allow limited view access
+ if (!$this->getUser()->checkViewable($display) && !$this->getUser()->featureEnabled('displays.limitedView')) {
+ throw new AccessDeniedException();
+ }
+
+ // Work out the next collection time based on the last accessed date/time and the collection interval
+ if ($display->lastAccessed == 0) {
+ $nextCollect = __('once it has connected for the first time');
+ } else {
+ $collectionInterval = $display->getSetting('collectInterval', 300);
+ $nextCollect = Carbon::createFromTimestamp($display->lastAccessed)
+ ->addSeconds($collectionInterval)
+ ->diffForHumans();
+ }
+
+ $this->getState()->template = 'display-form-request-screenshot';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('displayRequestScreenshotForm');
+ $this->getState()->setData([
+ 'display' => $display,
+ 'nextCollect' => $nextCollect,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Request ScreenShot
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/display/requestscreenshot/{displayId}",
+ * operationId="displayRequestScreenshot",
+ * tags={"display"},
+ * summary="Request Screen Shot",
+ * description="Notify the display that the CMS would like a screen shot to be sent.",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Display")
+ * )
+ * )
+ */
+ public function requestScreenShot(Request $request, Response $response, $id): Response
+ {
+ $display = $this->displayFactory->getById($id);
+
+ // Allow limited view access
+ if (!$this->getUser()->checkViewable($display) && !$this->getUser()->featureEnabled('displays.limitedView')) {
+ throw new AccessDeniedException();
+ }
+
+ $display->screenShotRequested = 1;
+ $display->save(['validate' => false, 'audit' => false]);
+
+ if (!empty($display->xmrChannel)) {
+ $this->playerAction->sendAction($display, new ScreenShotAction());
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Request sent for %s'), $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Form for wake on Lan
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function wakeOnLanForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($display->macAddress == '') {
+ throw new InvalidArgumentException(
+ __('This display has no mac address recorded against it yet. Make sure the display is running.'),
+ 'macAddress'
+ );
+ }
+
+ $this->getState()->template = 'display-form-wakeonlan';
+ $this->getState()->setData([
+ 'display' => $display,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Wake this display using a WOL command
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/display/wol/{displayId}",
+ * operationId="displayWakeOnLan",
+ * tags={"display"},
+ * summary="Issue WOL",
+ * description="Send a Wake On LAN packet to this Display",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function wakeOnLan(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($display->macAddress == '' || $display->broadCastAddress == '') {
+ throw new InvalidArgumentException(
+ __('This display has no mac address recorded against it yet. Make sure the display is running.')
+ );
+ }
+
+ $this->getLog()->notice(
+ 'About to send WOL packet to '
+ . $display->broadCastAddress . ' with Mac Address ' . $display->macAddress
+ );
+
+ WakeOnLan::TransmitWakeOnLan(
+ $display->macAddress,
+ $display->secureOn,
+ $display->broadCastAddress,
+ $display->cidr,
+ '9',
+ $this->getLog()
+ );
+
+ $display->lastWakeOnLanCommandSent = Carbon::now()->format('U');
+ $display->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Wake on Lan sent for %s'), $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Validate the display list
+ * @param \Xibo\Entity\Display[] $displays
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function validateDisplays(array $displays): void
+ {
+ // Get the global time out (overrides the alert time out on the display if 0)
+ $globalTimeout = $this->getConfig()->getSetting('MAINTENANCE_ALERT_TOUT') * 60;
+ $emailAlerts = ($this->getConfig()->getSetting('MAINTENANCE_EMAIL_ALERTS') == 1);
+ $alwaysAlert = ($this->getConfig()->getSetting('MAINTENANCE_ALWAYS_ALERT') == 1);
+
+ foreach ($displays as $display) {
+ // Should we test against the collection interval or the preset alert timeout?
+ if ($display->alertTimeout == 0 && $display->clientType != '') {
+ $timeoutToTestAgainst = ((double)$display->getSetting('collectInterval', $globalTimeout)) * 1.1;
+ } else {
+ $timeoutToTestAgainst = $globalTimeout;
+ }
+
+ // Store the timeout to test against
+ $timeOut = $display->lastAccessed + $timeoutToTestAgainst;
+
+ // If the last time we accessed is less than now minus the timeout
+ if ($timeOut < Carbon::now()->format('U')) {
+ $this->getLog()->debug('Timed out display. Last Accessed: '
+ . date('Y-m-d h:i:s', $display->lastAccessed) . '. Time out: ' . date('Y-m-d h:i:s', $timeOut));
+
+ // Is this the first time this display has gone "off-line"
+ $displayOffline = ($display->loggedIn == 1);
+
+ // If this is the first switch (i.e. the row was logged in before)
+ if ($displayOffline) {
+ // Update the display and set it as logged out
+ $display->loggedIn = 0;
+ $display->save(\Xibo\Entity\Display::$saveOptionsMinimum);
+
+ // Log the down event
+ $event = $this->displayEventFactory->createEmpty();
+ $event->displayId = $display->displayId;
+ $event->start = $display->lastAccessed;
+ // eventTypeId 1 is for Display up/down events.
+ $event->eventTypeId = 1;
+ $event->save();
+ }
+
+ $dayPartId = $display->getSetting('dayPartId');
+ $operatingHours = true;
+
+ if ($dayPartId !== null) {
+ try {
+ $dayPart = $this->dayPartFactory->getById($dayPartId);
+
+ $startTimeArray = explode(':', $dayPart->startTime);
+ $startTime = Carbon::now()->setTime(intval($startTimeArray[0]), intval($startTimeArray[1]));
+
+ $endTimeArray = explode(':', $dayPart->endTime);
+ $endTime = Carbon::now()->setTime(intval($endTimeArray[0]), intval($endTimeArray[1]));
+
+ $now = Carbon::now();
+
+ // exceptions
+ foreach ($dayPart->exceptions as $exception) {
+ // check if we are on exception day and if so override the start and endtime accordingly
+ if ($exception['day'] == Carbon::now()->format('D')) {
+ $exceptionsStartTime = explode(':', $exception['start']);
+ $startTime = Carbon::now()->setTime(
+ intval($exceptionsStartTime[0]),
+ intval($exceptionsStartTime[1])
+ );
+
+ $exceptionsEndTime = explode(':', $exception['end']);
+ $endTime = Carbon::now()->setTime(
+ intval($exceptionsEndTime[0]),
+ intval($exceptionsEndTime[1])
+ );
+ }
+ }
+
+ // check if we are inside the operating hours for this display -
+ // we use that flag to decide if we need to create a notification and send an email.
+ if (($now >= $startTime && $now <= $endTime)) {
+ $operatingHours = true;
+ } else {
+ $operatingHours = false;
+ }
+ } catch (NotFoundException) {
+ $this->getLog()->debug(
+ 'Unknown dayPartId set on Display Profile for displayId ' . $display->displayId
+ );
+ }
+ }
+
+ // Should we create a notification
+ if ($emailAlerts && $display->emailAlert == 1 && ($displayOffline || $alwaysAlert)) {
+ // Alerts enabled for this display
+ // Display just gone offline, or always alert
+ // Fields for email
+
+ // for displays without dayPartId set, this is always true,
+ // otherwise we check if we are inside the operating hours set for this display
+ if ($operatingHours) {
+ $subject = sprintf(__('Alert for Display %s'), $display->display);
+ $body = sprintf(
+ __('Display ID %d is offline since %s.'),
+ $display->displayId,
+ Carbon::createFromTimestamp($display->lastAccessed)
+ ->format(DateFormatHelper::getSystemFormat())
+ );
+
+ // Add to system
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'display'
+ );
+
+ // Add in any displayNotificationGroups, with permissions
+ foreach ($this->userGroupFactory
+ ->getDisplayNotificationGroups($display->displayGroupId) as $group) {
+ $notification->assignUserGroup($group);
+ }
+
+ $notification->save();
+ } else {
+ $this->getLog()->info('Not sending email down alert for Display - ' . $display->display
+ . ' we are outside of its operating hours');
+ }
+ } elseif ($displayOffline) {
+ $this->getLog()->info('Not sending an email for offline display - emailAlert = '
+ . $display->emailAlert . ', alwaysAlert = ' . $alwaysAlert);
+ }
+ }
+ }
+ }
+
+ /**
+ * Show the authorise form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function authoriseForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'display-form-authorise';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('displayAuthoriseForm');
+ $this->getState()->setData([
+ 'display' => $display
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Toggle Authorise on this Display
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/display/authorise/{displayId}",
+ * operationId="displayToggleAuthorise",
+ * tags={"display"},
+ * summary="Toggle authorised",
+ * description="Toggle authorised for the Display.",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function toggleAuthorise(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $display->licensed = ($display->licensed == 1) ? 0 : 1;
+ $display->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Authorised set to %d for %s'), $display->licensed, $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function defaultLayoutForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get the currently assigned default layout
+ try {
+ $layouts = (($display->defaultLayoutId != null) ? [$this->layoutFactory->getById($display->defaultLayoutId)] : []);
+ } catch (NotFoundException $notFoundException) {
+ $layouts = [];
+ }
+
+ $this->getState()->template = 'display-form-defaultlayout';
+ $this->getState()->setData([
+ 'display' => $display,
+ 'layouts' => $layouts
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set the Default Layout for this Display
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/display/defaultlayout/{displayId}",
+ * operationId="displayDefaultLayout",
+ * tags={"display"},
+ * summary="Set Default Layout",
+ * description="Set the default Layout on this Display",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function setDefaultLayout(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $layoutId = $this->getSanitizer($request->getParams())->getInt('layoutId');
+
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ $display->defaultLayoutId = $layoutId;
+ $display->save(['validate' => false]);
+ if ($display->hasPropertyChanged('defaultLayoutId')) {
+ $display->notify();
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Default Layout with name %s set for %s'), $layout->layout, $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function moveCmsForm(Request $request, Response $response, $id)
+ {
+ if ($this->getUser()->twoFactorTypeId != 2) {
+ throw new AccessDeniedException('This action requires active Google Authenticator Two Factor authentication');
+ }
+
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'display-form-moveCms';
+ $this->getState()->setData([
+ 'display' => $display,
+ 'newCmsAddress' => $display->newCmsAddress,
+ 'newCmsKey' => $display->newCmsKey
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \RobThree\Auth\TwoFactorAuthException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function moveCms(Request $request, Response $response, $id)
+ {
+ if ($this->getUser()->twoFactorTypeId != 2) {
+ throw new AccessDeniedException('This action requires active Google Authenticator Two Factor authentication');
+ }
+
+ $display = $this->displayFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ // Two Factor Auth
+ $issuerSettings = $this->getConfig()->getSetting('TWOFACTOR_ISSUER');
+ $appName = $this->getConfig()->getThemeConfig('app_name');
+
+ if ($issuerSettings !== '') {
+ $issuer = $issuerSettings;
+ } else {
+ $issuer = $appName;
+ }
+
+ $authenticationCode = $sanitizedParams->getString('twoFactorCode');
+
+ $tfa = new TwoFactorAuth($issuer);
+ $result = $tfa->verifyCode($this->getUser()->twoFactorSecret, $authenticationCode, 3);
+
+ if ($result) {
+ // get the new CMS Address and Key from the form.
+ $newCmsAddress = $sanitizedParams->getString('newCmsAddress');
+ $newCmsKey = $sanitizedParams->getString('newCmsKey');
+
+ // validate the URL
+ if (!v::url()->notEmpty()->validate(urldecode($newCmsAddress)) || !filter_var($newCmsAddress, FILTER_VALIDATE_URL)) {
+ throw new InvalidArgumentException(__('Provided CMS URL is invalid'), 'newCmsUrl');
+ }
+
+ if (!v::stringType()->length(1, 1000)->validate($newCmsAddress)) {
+ throw new InvalidArgumentException(__('New CMS URL can have maximum of 1000 characters'), 'newCmsUrl');
+ }
+
+ if ($newCmsKey == '') {
+ throw new InvalidArgumentException(__('Provided CMS Key is invalid'), 'newCmsKey');
+ }
+
+ // we are successfully authenticated, get new CMS address and Key and save the Display record.
+ $display->newCmsAddress = $newCmsAddress;
+ $display->newCmsKey = $newCmsKey;
+ $display->save();
+ } else {
+ throw new InvalidArgumentException(__('Invalid Two Factor Authentication Code'), 'twoFactorCode');
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ */
+ public function moveCmsCancelForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'display-form-moveCmsCancel';
+ $this->getState()->setData([
+ 'display' => $display
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @param $id
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function moveCmsCancel(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $display->newCmsAddress = '';
+ $display->newCmsKey = '';
+ $display->save();
+
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Cancelled CMS Transfer for %s'), $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addViaCodeForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'display-form-addViaCode';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addViaCode(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $user_code = $sanitizedParams->getString('user_code');
+ $cmsAddress = (new HttpsDetect())->getBaseUrl($request);
+ $cmsKey = $this->getConfig()->getSetting('SERVER_KEY');
+
+ if ($user_code == '') {
+ throw new InvalidArgumentException(__('Code cannot be empty'), 'code');
+ }
+
+ $guzzle = new Client();
+
+ try {
+ // When the valid code is submitted, it will be sent along with CMS Address and Key to Authentication Service maintained by Xibo Signage Ltd.
+ // The Player will then call the service with the same code to retrieve the CMS details.
+ // On success, the details will be removed from the Authentication Service.
+ $guzzleRequest = $guzzle->request(
+ 'POST',
+ 'https://auth.signlicence.co.uk/addDetails',
+ $this->getConfig()->getGuzzleProxy([
+ 'form_params' => [
+ 'user_code' => $user_code,
+ 'cmsAddress' => $cmsAddress,
+ 'cmsKey' => $cmsKey,
+ ]
+ ])
+ );
+
+ $data = json_decode($guzzleRequest->getBody(), true);
+
+ $this->getState()->hydrate([
+ 'message' => $data['message']
+ ]);
+ } catch (\Exception $e) {
+ $this->getLog()->debug($e->getMessage());
+ throw new InvalidArgumentException(__('The code provided does not match. Please double-check the code shown on the device you are trying to connect.'), 'user_code');
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Check commercial licence form
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function checkLicenceForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'display-form-licence-check';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('displayLicenceCheckForm');
+ $this->getState()->setData([
+ 'display' => $display
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Check commercial licence
+ *
+ * @SWG\Put(
+ * summary="Licence Check",
+ * path="/display/licenceCheck/{displayId}",
+ * operationId="displayLicenceCheck",
+ * tags={"display"},
+ * description="Ask this Player to check its Commercial Licence",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function checkLicence(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ if (empty($display->xmrChannel)) {
+ throw new InvalidArgumentException(__('XMR is not configured for this Display'), 'xmrChannel');
+ }
+
+ $this->playerAction->sendAction($display, new LicenceCheckAction());
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Request sent for %s'), $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/display/status/{id}",
+ * operationId="displayStatus",
+ * tags={"display"},
+ * summary="Display Status",
+ * description="Get the display status window for this Display.",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="Display Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(type="string")
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id displayId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function statusWindow(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display)) {
+ throw new AccessDeniedException();
+ }
+
+ return $response->withJson($display->getStatusWindow($this->pool));
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function purgeAllForm(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display) || !$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'display-form-purge-all';
+ $this->getState()->setData([
+ 'display' => $display
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * Purge All
+ *
+ * @SWG\Put(
+ * summary="Purge All",
+ * path="/display/purgeAll/{displayId}",
+ * operationId="displayPurgeAll",
+ * tags={"display"},
+ * description="Ask this Player to purge all Media from its local storage and request fresh files from CMS.",
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="path",
+ * description="The Display ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function purgeAll(Request $request, Response $response, $id)
+ {
+ $display = $this->displayFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($display) || !$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ if (empty($display->xmrChannel)) {
+ throw new InvalidArgumentException(__('XMR is not configured for this Display'), 'xmrChannel');
+ }
+
+ $this->playerAction->sendAction($display, new PurgeAllAction());
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Request sent for %s'), $display->display),
+ 'id' => $display->displayId
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/DisplayGroup.php b/lib/Controller/DisplayGroup.php
new file mode 100644
index 0000000..1f0c4c1
--- /dev/null
+++ b/lib/Controller/DisplayGroup.php
@@ -0,0 +1,3029 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\Display;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\CommandFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Service\PlayerActionServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\XMR\ChangeLayoutAction;
+use Xibo\XMR\CollectNowAction;
+use Xibo\XMR\CommandAction;
+use Xibo\XMR\OverlayLayoutAction;
+use Xibo\XMR\PlayerActionException;
+use Xibo\XMR\RevertToSchedule;
+use Xibo\XMR\ScheduleCriteriaUpdateAction;
+use Xibo\XMR\TriggerWebhookAction;
+
+/**
+ * Class DisplayGroup
+ * @package Xibo\Controller
+ */
+class DisplayGroup extends Base
+{
+ /**
+ * @var PlayerActionServiceInterface
+ */
+ private $playerAction;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var ModuleFactory
+ */
+ private $moduleFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /**
+ * Set common dependencies.
+ * @param PlayerActionServiceInterface $playerAction
+ * @param DisplayFactory $displayFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param LayoutFactory $layoutFactory
+ * @param ModuleFactory $moduleFactory
+ * @param MediaFactory $mediaFactory
+ * @param CommandFactory $commandFactory
+ * @param TagFactory $tagFactory
+ * @param CampaignFactory $campaignFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct(
+ $playerAction,
+ $displayFactory,
+ $displayGroupFactory,
+ $layoutFactory,
+ $moduleFactory,
+ $mediaFactory,
+ $commandFactory,
+ $tagFactory,
+ $campaignFactory,
+ $folderFactory
+ ) {
+ $this->playerAction = $playerAction;
+ $this->displayFactory = $displayFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->commandFactory = $commandFactory;
+ $this->tagFactory = $tagFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * Display Group Page Render
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'displaygroup-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/displaygroup",
+ * summary="Get Display Groups",
+ * tags={"displayGroup"},
+ * operationId="displayGroupSearch",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="query",
+ * description="Filter by DisplayGroup Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroup",
+ * in="query",
+ * description="Filter by DisplayGroup Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="query",
+ * description="Filter by DisplayGroups containing a specific display",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="nestedDisplayId",
+ * in="query",
+ * description="Filter by DisplayGroups containing a specific display in there nesting",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dynamicCriteria",
+ * in="query",
+ * description="Filter by DisplayGroups containing a specific dynamic criteria",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="query",
+ * description="Filter by tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="query",
+ * description="A flag indicating whether to treat the tags filter as an exact match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="query",
+ * description="When filtering by multiple Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDisplaySpecific",
+ * in="query",
+ * description="Filter by whether the Display Group belongs to a Display or is user created",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="forSchedule",
+ * in="query",
+ * description="Should the list be refined for only those groups the User can Schedule against?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="a successful response",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DisplayGroup")
+ * ),
+ * @SWG\Header(
+ * header="X-Total-Count",
+ * description="The total number of records",
+ * type="integer"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'displayGroupId' => $parsedQueryParams->getInt('displayGroupId'),
+ 'displayGroupIds' => $parsedQueryParams->getIntArray('displayGroupIds'),
+ 'displayGroup' => $parsedQueryParams->getString('displayGroup'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'displayId' => $parsedQueryParams->getInt('displayId'),
+ 'nestedDisplayId' => $parsedQueryParams->getInt('nestedDisplayId'),
+ 'dynamicCriteria' => $parsedQueryParams->getString('dynamicCriteria'),
+ 'tags' => $parsedQueryParams->getString('tags'),
+ 'exactTags' => $parsedQueryParams->getCheckbox('exactTags'),
+ 'isDisplaySpecific' => $parsedQueryParams->getInt('isDisplaySpecific'),
+ 'displayGroupIdMembers' => $parsedQueryParams->getInt('displayGroupIdMembers'),
+ 'userId' => $parsedQueryParams->getInt('userId'),
+ 'isDynamic' => $parsedQueryParams->getInt('isDynamic'),
+ 'folderId' => $parsedQueryParams->getInt('folderId'),
+ 'logicalOperator' => $parsedQueryParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $parsedQueryParams->getString('logicalOperatorName'),
+ 'displayIdMember' => $parsedQueryParams->getInt('displayIdMember'),
+ ];
+
+ $scheduleWithView = ($this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION') == 1);
+
+ $displayGroups = $this->displayGroupFactory->query(
+ $this->gridRenderSort($parsedQueryParams),
+ $this->gridRenderFilter($filter, $parsedQueryParams)
+ );
+
+ foreach ($displayGroups as $group) {
+ /* @var \Xibo\Entity\DisplayGroup $group */
+
+ // Check to see if we're getting this data for a Schedule attempt, or for a general list
+ if ($parsedQueryParams->getCheckbox('forSchedule') == 1) {
+ // Can't schedule with view, but no edit permissions
+ if (!$scheduleWithView && !$this->getUser()->checkEditable($group)) {
+ continue;
+ }
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $group->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('displaygroup.modify')
+ && $this->getUser()->checkEditable($group)
+ ) {
+ // Show the edit button, members button
+ if ($group->isDynamic == 0) {
+ // Group Members
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_group_members',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayGroup.members.form',
+ ['id' => $group->displayGroupId]
+ ),
+ 'text' => __('Members')
+ ];
+
+ $group->buttons[] = ['divider' => true];
+ }
+
+ // Edit
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_edit',
+ 'url' => $this->urlFor($request, 'displayGroup.edit.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Edit')
+ ];
+
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_copy',
+ 'url' => $this->urlFor($request, 'displayGroup.copy.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Copy')
+ ];
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_selectfolder',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayGroup.selectfolder.form',
+ ['id' => $group->displayGroupId]
+ ),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.selectfolder',
+ ['id' => $group->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'displaygroup_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $group->displayGroup],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+ }
+
+ if ($this->getUser()->featureEnabled('displaygroup.modify')
+ && $this->getUser()->checkDeleteable($group)
+ ) {
+ // Show the delete button
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_delete',
+ 'url' => $this->urlFor($request, 'displayGroup.delete.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.delete',
+ ['id' => $group->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'displaygroup_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $group->displayGroup],
+ ['name' => 'form-callback', 'value' => 'setDeleteMultiSelectFormOpen'],
+ ['name' => 'form-confirm', 'value' => true]
+ ]
+ ];
+ }
+
+ // Schedule
+ if ($this->getUser()->featureEnabled('schedule.add')
+ && ($this->getUser()->checkEditable($group)
+ || $this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION') == 1)
+ ) {
+ $group->buttons[] = ['divider' => true];
+
+ $group->buttons[] = array(
+ 'id' => 'displaygroup_button_schedule',
+ 'url' => $this->urlFor(
+ $request,
+ 'schedule.add.form',
+ ['id' => $group->displayGroupId, 'from' => 'DisplayGroup']
+ ),
+ 'text' => __('Schedule')
+ );
+ }
+
+ if ($this->getUser()->featureEnabled('displaygroup.modify')
+ && $this->getUser()->checkEditable($group)
+ ) {
+ $group->buttons[] = ['divider' => true];
+
+ // File Associations
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_fileassociations',
+ 'url' => $this->urlFor($request, 'displayGroup.media.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Assign Files')
+ ];
+
+ // Layout Assignments
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_layout_associations',
+ 'url' => $this->urlFor($request, 'displayGroup.layout.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Assign Layouts')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('displaygroup.modify')
+ && $this->getUser()->checkPermissionsModifyable($group)
+ ) {
+ // Show the modify permissions button
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_permissions',
+ 'url' => $this->urlFor(
+ $request,
+ 'user.permissions.form',
+ ['entity' => 'DisplayGroup', 'id' => $group->displayGroupId]
+ ),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi',
+ ['entity' => 'DisplayGroup', 'id' => $group->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'displaygroup_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $group->displayGroup],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi.form',
+ ['entity' => 'DisplayGroup']
+ )
+ ],
+ ['name' => 'content-id-name', 'value' => 'displayGroupId']
+ ]
+ ];
+ }
+
+ // Check if limited view access is allowed
+ if (($this->getUser()->featureEnabled('displaygroup.modify') && $this->getUser()->checkEditable($group))
+ || $this->getUser()->featureEnabled('displaygroup.limitedView')
+ ) {
+
+ if ($this->getUser()->checkEditable($group)) {
+ $group->buttons[] = ['divider' => true];
+ }
+
+ // Send command
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_command',
+ 'url' => $this->urlFor($request, 'displayGroup.command.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Send Command'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.action.command',
+ ['id' => $group->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'displaygroup_button_command'],
+ ['name' => 'text', 'value' => __('Send Command')],
+ ['name' => 'sort-group', 'value' => 3],
+ ['name' => 'rowtitle', 'value' => $group->displayGroup],
+ ['name' => 'form-callback', 'value' => 'sendCommandMultiSelectFormOpen']
+ ]
+ ];
+
+ // Collect Now
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_collectNow',
+ 'url' => $this->urlFor($request, 'displayGroup.collectNow.form', ['id' => $group->displayGroupId]),
+ 'text' => __('Collect Now'),
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.action.collectNow',
+ ['id' => $group->displayGroupId]
+ )
+ ],
+ ]
+ ];
+
+ if ($this->getUser()->checkEditable($group)) {
+ // Trigger webhook
+ $group->buttons[] = [
+ 'id' => 'displaygroup_button_trigger_webhook',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayGroup.trigger.webhook.form',
+ ['id' => $group->displayGroupId]
+ ),
+ 'text' => __('Trigger a web hook'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'displayGroup.action.trigger.webhook',
+ ['id' => $group->displayGroupId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'displaygroup_button_trigger_webhook'],
+ ['name' => 'text', 'value' => __('Trigger a web hook')],
+ ['name' => 'rowtitle', 'value' => $group->displayGroup],
+ ['name' => 'form-callback', 'value' => 'triggerWebhookMultiSelectFormOpen']
+ ]
+ ];
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->displayGroupFactory->countLast();
+ $this->getState()->setData($displayGroups);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows an add form for a display group
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'displaygroup-form-add';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows an edit form for a display group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'displaygroup-form-edit';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the Delete Group Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'displaygroup-form-delete';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display Group Members form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function membersForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Displays in Group
+ $displaysAssigned = $this->displayFactory->getByDisplayGroupId($displayGroup->displayGroupId);
+ // Get all the DisplayGroups assigned to this Group directly
+ $groupsAssigned = $this->displayGroupFactory->getByParentId($displayGroup->displayGroupId);
+
+ $this->getState()->template = 'displaygroup-form-members';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup,
+ 'extra' => [
+ 'displaysAssigned' => $displaysAssigned,
+ 'displayGroupsAssigned' => $groupsAssigned
+ ],
+ 'tree' => $this->displayGroupFactory->getRelationShipTree($id),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Adds a Display Group
+ * @SWG\Post(
+ * path="/displaygroup",
+ * operationId="displayGroupAdd",
+ * tags={"displayGroup"},
+ * summary="Add a Display Group",
+ * description="Add a new Display Group to the CMS",
+ * @SWG\Parameter(
+ * name="displayGroup",
+ * in="formData",
+ * description="The Display Group Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The Display Group Description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="A comma separated list of tags for this item",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDynamic",
+ * in="formData",
+ * description="Flag indicating whether this DisplayGroup is Dynamic",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dynamicCriteria",
+ * in="formData",
+ * description="The filter criteria for this dynamic group.
+ * A comma separated set of regular expressions to apply",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperatorName",
+ * in="formData",
+ * description="When filtering by multiple dynamic criteria, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dynamicCriteriaTags",
+ * in="formData",
+ * description="The filter criteria for this dynamic group.
+ * A comma separated set of regular expressions to apply",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="formData",
+ * description="When filtering by Tags, should we use exact match?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="formData",
+ * description="When filtering by Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayGroup"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new DisplayGroup",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function add(Request $request, Response $response)
+ {
+ $displayGroup = $this->displayGroupFactory->createEmpty();
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $displayGroup->displayGroup = $sanitizedParams->getString('displayGroup');
+ $displayGroup->description = $sanitizedParams->getString('description');
+ $displayGroup->isDynamic = $sanitizedParams->getCheckbox('isDynamic');
+ $displayGroup->dynamicCriteria = $sanitizedParams->getString('dynamicCriteria');
+ $displayGroup->dynamicCriteriaLogicalOperator = $sanitizedParams->getString('logicalOperatorName');
+ $displayGroup->folderId = $sanitizedParams->getInt('folderId');
+ $displayGroup->ref1 = $sanitizedParams->getString('ref1');
+ $displayGroup->ref2 = $sanitizedParams->getString('ref2');
+ $displayGroup->ref3 = $sanitizedParams->getString('ref3');
+ $displayGroup->ref4 = $sanitizedParams->getString('ref4');
+ $displayGroup->ref5 = $sanitizedParams->getString('ref5');
+
+ if ($displayGroup->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($displayGroup->folderId)) {
+ $displayGroup->folderId = $this->getUser()->homeFolderId;
+ }
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ $folder = $this->folderFactory->getById($displayGroup->folderId);
+ $displayGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ } else {
+ $displayGroup->permissionsFolderId = 1;
+ }
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+
+ $displayGroup->updateTagLinks($tags);
+ $displayGroup->dynamicCriteriaTags = $sanitizedParams->getString('dynamicCriteriaTags');
+ $displayGroup->dynamicCriteriaExactTags = $sanitizedParams->getCheckbox('exactTags');
+ $displayGroup->dynamicCriteriaTagsLogicalOperator = $sanitizedParams->getString('logicalOperator');
+ }
+
+ if ($displayGroup->isDynamic === 1) {
+ $displayGroup->setDisplayFactory($this->displayFactory);
+ }
+
+ $displayGroup->userId = $this->getUser()->userId;
+ $displayGroup->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpState' => 201,
+ 'message' => sprintf(__('Added %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId,
+ 'data' => $displayGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edits a Display Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/displaygroup/{displayGroupId}",
+ * operationId="displayGroupEdit",
+ * tags={"displayGroup"},
+ * summary="Edit a Display Group",
+ * description="Edit an existing Display Group identified by its Id",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The displayGroupId to edit.",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroup",
+ * in="formData",
+ * description="The Display Group Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The Display Group Description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="A comma separated list of tags for this item",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDynamic",
+ * in="formData",
+ * description="Flag indicating whether this DisplayGroup is Dynamic",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dynamicCriteria",
+ * in="formData",
+ * description="The filter criteria for this dynamic group.
+ * A command separated set of regular expressions to apply",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperatorName",
+ * in="formData",
+ * description="When filtering by multiple dynamic criteria, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dynamicCriteriaTags",
+ * in="formData",
+ * description="The filter criteria for this dynamic group.
+ * A comma separated set of regular expressions to apply",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="formData",
+ * description="When filtering by Tags, should we use exact match?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="formData",
+ * description="When filtering by Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref1",
+ * in="formData",
+ * description="Reference 1",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref2",
+ * in="formData",
+ * description="Reference 2",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref3",
+ * in="formData",
+ * description="Reference 3",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref4",
+ * in="formData",
+ * description="Reference 4",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref5",
+ * in="formData",
+ * description="Reference 5",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayGroup")
+ * )
+ * )
+ */
+ public function edit(Request $request,Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $parsedRequestParams = $this->getSanitizer($request->getParams());
+ $preEditIsDynamic = $displayGroup->getOriginalValue('isDynamic');
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->displayGroup = $parsedRequestParams->getString('displayGroup');
+ $displayGroup->description = $parsedRequestParams->getString('description');
+ $displayGroup->isDynamic = $parsedRequestParams->getCheckbox('isDynamic');
+ $displayGroup->dynamicCriteria = ($displayGroup->isDynamic == 1)
+ ? $parsedRequestParams->getString('dynamicCriteria')
+ : null;
+ $displayGroup->dynamicCriteriaLogicalOperator = ($displayGroup->isDynamic == 1)
+ ? $parsedRequestParams->getString('logicalOperatorName')
+ : 'OR';
+ $displayGroup->folderId = $parsedRequestParams->getInt('folderId', ['default' => $displayGroup->folderId]);
+
+ $displayGroup->ref1 = $parsedRequestParams->getString('ref1');
+ $displayGroup->ref2 = $parsedRequestParams->getString('ref2');
+ $displayGroup->ref3 = $parsedRequestParams->getString('ref3');
+ $displayGroup->ref4 = $parsedRequestParams->getString('ref4');
+ $displayGroup->ref5 = $parsedRequestParams->getString('ref5');
+
+ if ($displayGroup->hasPropertyChanged('folderId')) {
+ if ($displayGroup->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folder = $this->folderFactory->getById($displayGroup->folderId);
+ $displayGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ }
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($parsedRequestParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($parsedRequestParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($parsedRequestParams->getString('tags'));
+ }
+
+ $displayGroup->updateTagLinks($tags);
+ $displayGroup->dynamicCriteriaTags = ($displayGroup->isDynamic == 1)
+ ? $parsedRequestParams->getString('dynamicCriteriaTags')
+ : null;
+ $displayGroup->dynamicCriteriaExactTags = ($displayGroup->isDynamic == 1)
+ ? $parsedRequestParams->getCheckbox('exactTags')
+ : 0;
+ $displayGroup->dynamicCriteriaTagsLogicalOperator = ($displayGroup->isDynamic == 1)
+ ? $parsedRequestParams->getString('logicalOperator')
+ : 'OR';
+ }
+
+ // if we have changed the type from dynamic to non-dynamic or other way around, clear display/dg members
+ if ($preEditIsDynamic != $displayGroup->isDynamic) {
+ $this->getLog()->debug(
+ 'Display Group Id ' . $displayGroup->displayGroupId
+ . ' switched is dynamic from ' . $preEditIsDynamic
+ . ' To ' . $displayGroup->isDynamic . ' Clearing members for this Display Group.'
+ );
+ // get an array of assigned displays
+ $membersDisplays = $this->displayFactory->getByDisplayGroupId($id);
+
+ // get an array of assigned display groups
+ $membersDisplayGroups = $this->displayGroupFactory->getByParentId($id);
+
+ // unassign Displays
+ foreach ($membersDisplays as $display) {
+ $displayGroup->unassignDisplay($display);
+ }
+
+ // unassign Display Groups
+ foreach ($membersDisplayGroups as $dg) {
+ $displayGroup->unassignDisplayGroup($dg);
+ }
+ }
+
+ $displayGroup->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId,
+ 'data' => $displayGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Deletes a Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/displaygroup/{displayGroupId}",
+ * operationId="displayGroupDelete",
+ * tags={"displayGroup"},
+ * summary="Delete a Display Group",
+ * description="Delete an existing Display Group identified by its Id",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The displayGroupId to delete",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function delete(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $displayGroup->load();
+
+ if (!$this->getUser()->checkDeleteable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ throw new AccessDeniedException(__('Displays should be deleted using the Display delete operation'));
+ }
+
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $displayGroup->displayGroup)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Sets the Members of a group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/display/assign",
+ * operationId="displayGroupDisplayAssign",
+ * tags={"displayGroup"},
+ * summary="Assign one or more Displays to a Display Group",
+ * description="Adds the provided Displays to the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to assign to",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * type="array",
+ * in="formData",
+ * description="The Display Ids to assign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="unassignDisplayId",
+ * in="formData",
+ * description="An optional array of Display IDs to unassign",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function assignDisplay(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ throw new InvalidArgumentException(
+ __('This is a Display specific Display Group and its assignments cannot be modified.'),
+ 'displayGroupId'
+ );
+ }
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($displayGroup->isDynamic == 1) {
+ throw new InvalidArgumentException(
+ __('Displays cannot be manually assigned to a Dynamic Group'),
+ 'isDynamic'
+ );
+ }
+
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+
+ $this->getLog()->debug('assignDisplay: displayGroupId loaded: ' . $displayGroup->displayGroupId);
+
+ // Keep track of displays we've changed so that we can notify.
+ $modifiedDisplays = [];
+
+ // Support both an array and a single int.
+ $displays = $sanitizedParams->getParam('displayId');
+ if (is_numeric($displays)) {
+ $displays = [$sanitizedParams->getInt('displayId')];
+ } else {
+ $displays = $sanitizedParams->getIntArray('displayId', ['default' => []]);
+ }
+
+ foreach ($displays as $displayId) {
+ $display = $this->displayFactory->getById($displayId);
+
+ if (!$this->getUser()->checkViewable($this->displayGroupFactory->getById($display->displayGroupId))) {
+ throw new AccessDeniedException(__('Access Denied to Display'));
+ }
+
+ $displayGroup->assignDisplay($display);
+
+ // Store so that we can flag as incomplete
+ if (!in_array($display, $modifiedDisplays)) {
+ $modifiedDisplays[] = $display;
+ }
+ }
+
+ // Have we been provided with unassign id's as well?
+ $displays = $sanitizedParams->getParam('unassignDisplayId');
+ if (is_numeric($displays)) {
+ $displays = [$sanitizedParams->getInt('unassignDisplayId')];
+ } else {
+ $displays = $sanitizedParams->getIntArray('unassignDisplayId', ['default' => []]);
+ }
+
+ foreach ($displays as $displayId) {
+ $display = $this->displayFactory->getById($displayId);
+
+ if (!$this->getUser()->checkViewable($this->displayGroupFactory->getById($display->displayGroupId))) {
+ throw new AccessDeniedException(__('Access Denied to Display'));
+ }
+
+ $displayGroup->unassignDisplay($display);
+
+ // Store so that we can flag as incomplete
+ if (!in_array($display, $modifiedDisplays)) {
+ $modifiedDisplays[] = $display;
+ }
+ }
+
+ // Save the result
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Save the displays themselves
+ foreach ($modifiedDisplays as $display) {
+ /** @var Display $display */
+ $display->notify();
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Displays assigned to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Unassign displays from a Display Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/display/unassign",
+ * operationId="displayGroupDisplayUnassign",
+ * tags={"displayGroup"},
+ * summary="Unassigns one or more Displays to a Display Group",
+ * description="Removes the provided Displays from the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to unassign from",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * type="array",
+ * in="formData",
+ * description="The Display Ids to unassign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function unassignDisplay(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ throw new InvalidArgumentException(
+ __('This is a Display specific Display Group and its assignments cannot be modified.'),
+ 'displayGroupId'
+ );
+ }
+
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($displayGroup->isDynamic == 1) {
+ throw new InvalidArgumentException(
+ __('Displays cannot be manually unassigned to a Dynamic Group'),
+ 'isDynamic'
+ );
+ }
+
+ $displays = $sanitizedParams->getIntArray('displayId', ['default' => []]);
+
+ foreach ($displays as $displayId) {
+ $display = $this->displayFactory->getById($displayId);
+
+ if (!$this->getUser()->checkViewable($this->displayGroupFactory->getById($display->displayGroupId))) {
+ throw new AccessDeniedException(__('Access Denied to Display'));
+ }
+
+ $this->getLog()->debug('Unassigning ' . $display->display);
+
+ $displayGroup->unassignDisplay($display);
+ }
+
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Displays unassigned from %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Sets the Members of a group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/displayGroup/assign",
+ * operationId="displayGroupDisplayGroupAssign",
+ * tags={"displayGroup"},
+ * summary="Assign one or more DisplayGroups to a Display Group",
+ * description="Adds the provided DisplayGroups to the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to assign to",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="array",
+ * in="formData",
+ * description="The displayGroup Ids to assign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="unassignDisplayGroupId",
+ * in="formData",
+ * description="An optional array of displayGroup IDs to unassign",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function assignDisplayGroup(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ throw new InvalidArgumentException(
+ __('This is a Display specific Display Group and its assignments cannot be modified.'),
+ 'displayGroupId'
+ );
+ }
+
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($displayGroup->isDynamic == 1) {
+ throw new InvalidArgumentException(
+ __('DisplayGroups cannot be manually assigned to a Dynamic Group'),
+ 'isDynamic'
+ );
+ }
+
+ $displayGroups = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+
+ foreach ($displayGroups as $assignDisplayGroupId) {
+ $displayGroupAssign = $this->displayGroupFactory->getById($assignDisplayGroupId);
+
+ if (!$this->getUser()->checkViewable($displayGroupAssign)) {
+ throw new AccessDeniedException(__('Access Denied to DisplayGroup'));
+ }
+
+ $displayGroup->assignDisplayGroup($displayGroupAssign);
+ }
+
+ // Have we been provided with unassign id's as well?
+ $displayGroups = $sanitizedParams->getIntArray('unassignDisplayGroupId', ['default' => []]);
+
+ foreach ($displayGroups as $assignDisplayGroupId) {
+ $displayGroupUnassign = $this->displayGroupFactory->getById($assignDisplayGroupId);
+
+ if (!$this->getUser()->checkViewable($displayGroupUnassign)) {
+ throw new AccessDeniedException(__('Access Denied to DisplayGroup'));
+ }
+
+ $displayGroup->unassignDisplayGroup($displayGroupUnassign);
+ }
+
+ // Save the result
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('DisplayGroups assigned to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Unassign DisplayGroups from a Display Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/displayGroup/unassign",
+ * operationId="displayGroupDisplayGroupUnassign",
+ * tags={"displayGroup"},
+ * summary="Unassigns one or more DisplayGroups to a Display Group",
+ * description="Removes the provided DisplayGroups from the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to unassign from",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="array",
+ * in="formData",
+ * description="The DisplayGroup Ids to unassign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function unassignDisplayGroup(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ throw new InvalidArgumentException(
+ __('This is a Display specific Display Group and its assignments cannot be modified.'),
+ 'displayGroupId'
+ );
+ }
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $displayGroup->load();
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+
+ if ($displayGroup->isDynamic == 1) {
+ throw new InvalidArgumentException(
+ __('DisplayGroups cannot be manually unassigned to a Dynamic Group'),
+ 'isDynamic'
+ );
+ }
+
+ $displayGroups = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+
+ foreach ($displayGroups as $assignDisplayGroupId) {
+ $displayGroup->unassignDisplayGroup($this->displayGroupFactory->getById($assignDisplayGroupId));
+ }
+
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('DisplayGroups unassigned from %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Media Form (media linked to displays)
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function mediaForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the groups details
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+
+ $this->getState()->template = 'displaygroup-form-media';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup,
+ 'modules' => $this->moduleFactory->getLibraryModules(),
+ 'media' => $displayGroup->media,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Assign Media
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/media/assign",
+ * operationId="displayGroupMediaAssign",
+ * tags={"displayGroup"},
+ * summary="Assign one or more Media items to a Display Group",
+ * description="Adds the provided Media to the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to assign to",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * type="array",
+ * in="formData",
+ * description="The Media Ids to assign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="unassignMediaId",
+ * type="array",
+ * in="formData",
+ * description="Optional array of Media Id to unassign",
+ * required=false,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function assignMedia(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the groups details
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+
+ $mediaIds = $sanitizedParams->getIntArray('mediaId', ['default' => []]);
+
+ // Loop through all the media
+ foreach ($mediaIds as $mediaId) {
+
+ $media = $this->mediaFactory->getById($mediaId);
+
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException(__('You have selected media that you no longer have permission to use. Please reload the form.'));
+ }
+
+ $displayGroup->assignMedia($media);
+ }
+
+ $unassignMediaIds = $sanitizedParams->getIntArray('unassignMediaId', ['default' => []]);
+
+ // Check for unassign
+ foreach ($unassignMediaIds as $mediaId) {
+ // Get the media record
+ $media = $this->mediaFactory->getById($mediaId);
+
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException(__('You have selected media that you no longer have permission to use. Please reload the form.'));
+ }
+
+ $displayGroup->unassignMedia($media);
+ }
+
+ $displayGroup->setCollectRequired(false);
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Files assigned to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Unassign Media
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/media/unassign",
+ * operationId="displayGroupMediaUnassign",
+ * tags={"displayGroup"},
+ * summary="Unassign one or more Media items from a Display Group",
+ * description="Removes the provided from the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to unassign from",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * type="array",
+ * in="formData",
+ * description="The Media Ids to unassign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function unassignMedia(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the groups details
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+
+ $mediaIds = $sanitizedParams->getIntArray('mediaId', ['default' => []]);
+
+ // Loop through all the media
+ foreach ($mediaIds as $mediaId) {
+ $displayGroup->unassignMedia($this->mediaFactory->getById($mediaId));
+ }
+
+ $displayGroup->setCollectRequired(false);
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Files unassigned from %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Layouts Form (layouts linked to displays)
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function LayoutsForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the groups details
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+
+ $this->getState()->template = 'displaygroup-form-layouts';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup,
+ 'layouts' => $this->layoutFactory->getByDisplayGroupId($displayGroup->displayGroupId),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Assign Layouts
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/layout/assign",
+ * operationId="displayGroupLayoutsAssign",
+ * tags={"displayGroup"},
+ * summary="Assign one or more Layouts items to a Display Group",
+ * description="Adds the provided Layouts to the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to assign to",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * type="array",
+ * in="formData",
+ * description="The Layouts Ids to assign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="unassignLayoutId",
+ * type="array",
+ * in="formData",
+ * description="Optional array of Layouts Id to unassign",
+ * required=false,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function assignLayouts(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the groups details
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+
+ $layoutIds = $sanitizedParams->getIntArray('layoutId', ['default' => []]);
+
+ // Loop through all the Layouts
+ foreach ($layoutIds as $layoutId) {
+
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException(__('You have selected a layout that you no longer have permission to use. Please reload the form.'));
+ }
+
+ $displayGroup->assignLayout($layout);
+ }
+
+ // Check for unassign
+ foreach ($sanitizedParams->getIntArray('unassignLayoutId', ['default' => []]) as $layoutId) {
+ // Get the layout record
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException(__('You have selected a layout that you no longer have permission to use. Please reload the form.'));
+ }
+
+ $displayGroup->unassignLayout($layout);
+ }
+
+ $displayGroup->setCollectRequired(false);
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Layouts assigned to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Unassign Layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/layout/unassign",
+ * operationId="displayGroupLayoutUnassign",
+ * tags={"displayGroup"},
+ * summary="Unassign one or more Layout items from a Display Group",
+ * description="Removes the provided from the Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Display Group to unassign from",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * type="array",
+ * in="formData",
+ * description="The Layout Ids to unassign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function unassignLayouts(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the groups details
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+
+ $layoutIds = $sanitizedParams->getIntArray('layoutId', ['default' => []]);
+
+ // Loop through all the media
+ foreach ($layoutIds as $layoutId) {
+ $this->getLog()->debug('Unassign layoutId ' . $layoutId . ' from ' . $id);
+ $displayGroup->unassignLayout($this->layoutFactory->getById($layoutId));
+ }
+
+ $displayGroup->setCollectRequired(false);
+ $displayGroup->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Layouts unassigned from %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function collectNowForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ // Non-destructive edit-only feature; allow limited view access
+ if (
+ !$this->getUser()->checkEditable($displayGroup)
+ && !$this->getUser()->featureEnabled('displays.limitedView')
+ && !$this->getUser()->featureEnabled('displaygroup.limitedView')
+ ) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'displaygroup-form-collect-now';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('displayGroupCollectNow');
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Cause the player to collect now
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/collectNow",
+ * operationId="displayGroupActionCollectNow",
+ * tags={"displayGroup"},
+ * summary="Action: Collect Now",
+ * description="Send the collect now action to this DisplayGroup",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The display group id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function collectNow(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ // Non-destructive edit-only feature; allow limited view access
+ if (
+ !$this->getUser()->checkEditable($displayGroup)
+ && !$this->getUser()->featureEnabled('displays.limitedView')
+ && !$this->getUser()->featureEnabled('displaygroup.limitedView')
+ ) {
+ throw new AccessDeniedException();
+ }
+
+ $this->playerAction->sendAction($this->displayFactory->getByDisplayGroupId($id), new CollectNowAction());
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Cause the player to collect now
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/clearStatsAndLogs",
+ * operationId="displayGroupActionClearStatsAndLogs",
+ * tags={"displayGroup"},
+ * summary="Action: Clear Stats and Logs",
+ * description="Clear all stats and logs on this Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The display group id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function clearStatsAndLogs(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->playerAction->sendAction($this->displayFactory->getByDisplayGroupId($id), new CollectNowAction());
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Change to a new Layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/changeLayout",
+ * operationId="displayGroupActionChangeLayout",
+ * tags={"displayGroup"},
+ * summary="Action: Change Layout",
+ * description="Send a change layout action to the provided Display Group. This will be sent to Displays in that Group via XMR.",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="This can be either a Display Group or the Display specific Display Group",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="The ID of the Layout to change to. Either this or a campaignId must be provided.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="formData",
+ * description="The Layout specific campaignId of the Layout to change to. Either this or a layoutId must be provided.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="duration",
+ * in="formData",
+ * description="The duration in seconds for this Layout change to remain in effect, after which normal scheduling is resumed.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="downloadRequired",
+ * in="formData",
+ * description="Flag indicating whether the player should perform a collect before playing the Layout.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="changeMode",
+ * in="formData",
+ * description="Whether to queue or replace with this action. Queuing will keep the current change layout action and switch after it is finished. If no active change layout action is present, both options are actioned immediately",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function changeLayout(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get the layoutId or campaignId
+ $layoutId = $sanitizedParams->getInt('layoutId');
+ $campaignId = $sanitizedParams->getInt('campaignId');
+ $downloadRequired = ($sanitizedParams->getCheckbox('downloadRequired') == 1);
+
+ if ($layoutId == 0 && $campaignId == 0) {
+ throw new InvalidArgumentException(__('Please provide a Layout ID or Campaign ID'), 'layoutId');
+ }
+
+ // Check that this user has permissions to see this layout
+ if ($layoutId != 0 && $campaignId == 0) {
+ $layout = $this->layoutFactory->getById($layoutId);
+ } elseif ($layoutId == 0 && $campaignId != 0) {
+ $campaign = $this->campaignFactory->getById($campaignId);
+
+ if ($campaign->isLayoutSpecific == 0) {
+ throw new NotFoundException(__('Please provide Layout specific campaign ID'));
+ }
+
+ $layouts = $this->layoutFactory->getByCampaignId($campaignId);
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Cannot find layout by campaignId'));
+ }
+
+ $layout = $layouts[0];
+ } else {
+ throw new InvalidArgumentException(__('Please provide Layout id or Campaign id'), 'layoutId');
+ }
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check to see if this layout is assigned to this display group.
+ if (count($this->layoutFactory->query(null, ['disableUserCheck' => 1, 'layoutId' => $layout->layoutId, 'displayGroupId' => $id])) <= 0) {
+ // Assign
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+ $displayGroup->assignLayout($layout);
+
+ // Don't collect now, this player action will cause a download.
+ // notify will still occur if the layout isn't already assigned (which is shouldn't be)
+ $displayGroup->setCollectRequired(false);
+
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Convert into a download required
+ $downloadRequired = true;
+ } else {
+ // The layout may not be built at this point
+ if ($downloadRequired) {
+ // in this case we should build it and notify before we send the action
+ // notify should NOT collect now, as we will do that during our own action.
+ $layout = $this->layoutFactory->concurrentRequestLock($layout);
+ try {
+ $layout->xlfToDisk(['notify' => true, 'collectNow' => false]);
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+ }
+ }
+
+ // Create and send the player action
+ $this->playerAction->sendAction($this->displayFactory->getByDisplayGroupId($id), (new ChangeLayoutAction())->setLayoutDetails(
+ $layout->layoutId,
+ $sanitizedParams->getInt('duration'),
+ $downloadRequired,
+ $sanitizedParams->getString('changeMode', ['default' => 'queue'])
+ ));
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Cause the player to revert to its scheduled content
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ *
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/revertToSchedule",
+ * operationId="displayGroupActionRevertToSchedule",
+ * tags={"displayGroup"},
+ * summary="Action: Revert to Schedule",
+ * description="Send the revert to schedule action to this DisplayGroup",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="This can be either a Display Group or the Display specific Display Group",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function revertToSchedule(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->playerAction->sendAction($this->displayFactory->getByDisplayGroupId($id), new RevertToSchedule());
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add an Overlay Layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/overlayLayout",
+ * operationId="displayGroupActionOverlayLayout",
+ * tags={"displayGroup"},
+ * summary="Action: Overlay Layout",
+ * description="Send the overlay layout action to this DisplayGroup, you can pass layoutId or layout specific campaignId",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="This can be either a Display Group or the Display specific Display Group",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="The ID of the Layout to change to. Either this or a campaignId must be provided.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="formData",
+ * description="The Layout specific campaignId of the Layout to change to. Either this or a layoutId must be provided.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="duration",
+ * in="formData",
+ * description="The duration in seconds for this Overlay to remain in effect",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="downloadRequired",
+ * in="formData",
+ * description="Whether to queue or replace with this action. Queuing will keep the current change layout action and switch after it is finished. If no active change layout action is present, both options are actioned immediately",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function overlayLayout(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get the layoutId
+ $layoutId = $sanitizedParams->getInt('layoutId');
+ $campaignId = $sanitizedParams->getInt('campaignId');
+ $downloadRequired = ($sanitizedParams->getCheckbox('downloadRequired') == 1);
+
+ if ($layoutId == 0 && $campaignId == 0) {
+ throw new InvalidArgumentException(__('Please provide a Layout ID or Campaign ID'), 'isDynamic');
+ }
+
+ // Check that this user has permissions to see this layout
+ if ($layoutId != 0 && $campaignId == 0) {
+ $layout = $this->layoutFactory->getById($layoutId);
+ } elseif ($layoutId == 0 && $campaignId != 0) {
+ $campaign = $this->campaignFactory->getById($campaignId);
+
+ if ($campaign->isLayoutSpecific == 0) {
+ throw new NotFoundException(__('Please provide Layout specific campaign ID'));
+ }
+
+ $layouts = $this->layoutFactory->getByCampaignId($campaignId);
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Cannot find layout by campaignId'));
+ }
+
+ $layout = $layouts[0];
+ } else {
+ throw new InvalidArgumentException(__('Please provide Layout id or Campaign id'), 'layoutId');
+ }
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check to see if this layout is assigned to this display group.
+ if (count($this->layoutFactory->query(null, ['disableUserCheck' => 1, 'layoutId' => $layout->layoutId, 'displayGroupId' => $id])) <= 0) {
+ // Assign
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+ $displayGroup->assignLayout($layout);
+ // Don't notify, this player action will cause a download.
+ $displayGroup->setCollectRequired(false);
+ $displayGroup->save(['validate' => false, 'saveTags' => false]);
+
+ // Convert into a download required
+ $downloadRequired = true;
+ } else {
+ // The layout may not be built at this point
+ if ($downloadRequired) {
+ // in this case we should build it and notify before we send the action
+ // notify should NOT collect now, as we will do that during our own action.
+ $layout = $this->layoutFactory->concurrentRequestLock($layout);
+ try {
+ $layout->xlfToDisk(['notify' => true, 'collectNow' => false]);
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+ }
+ }
+
+ $this->playerAction->sendAction($this->displayFactory->getByDisplayGroupId($id), (new OverlayLayoutAction())->setLayoutDetails(
+ $layout->layoutId,
+ $sanitizedParams->getInt('duration'),
+ $downloadRequired
+ ));
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Command Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function commandForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ // Non-destructive edit-only feature; allow limited view access
+ if (
+ !$this->getUser()->checkEditable($displayGroup)
+ && !$this->getUser()->featureEnabled('displaygroup.limitedView')
+ && !$this->getUser()->featureEnabled('displays.limitedView')
+ ) {
+ throw new AccessDeniedException();
+ }
+
+ // Are we a Display Specific Group? If so, then we should restrict the List of commands to those available.
+ if ($displayGroup->isDisplaySpecific == 1) {
+ $display = $this->displayFactory->getByDisplayGroupId($displayGroup->displayGroupId);
+ $commands = $this->commandFactory->query(null, ['type' => $display[0]->clientType]);
+ } else {
+ $commands = $this->commandFactory->query();
+ }
+
+ $this->getState()->template = 'displaygroup-form-command';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup,
+ 'commands' => $commands
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/command",
+ * operationId="displayGroupActionCommand",
+ * tags={"displayGroup"},
+ * summary="Send Command",
+ * description="Send a predefined command to this Group of Displays",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The display group id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="commandId",
+ * in="formData",
+ * description="The Command Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function command(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Non-destructive edit-only feature; allow limited view access
+ if (
+ !$this->getUser()->checkEditable($displayGroup)
+ && !$this->getUser()->featureEnabled('displaygroup.limitedView')
+ && !$this->getUser()->featureEnabled('displays.limitedView')
+ ) {
+ throw new AccessDeniedException();
+ }
+
+ $command = $this->commandFactory->getById($sanitizedParams->getInt('commandId'));
+ $displays = $this->displayFactory->getByDisplayGroupId($id);
+
+ $this->playerAction->sendAction($displays, (new CommandAction())->setCommandCode($command->code));
+
+ // Update the flag
+ foreach ($displays as $display) {
+ /* @var \Xibo\Entity\Display $display */
+ $display->lastCommandSuccess = 0;
+ $display->save(['validate' => false, 'audit' => false]);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayGroup->userId) {
+ throw new AccessDeniedException(__('You do not have permission to delete this profile'));
+ }
+
+ $this->getState()->template = 'displaygroup-form-copy';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy Display Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/copy",
+ * operationId="displayGroupCopy",
+ * tags={"displayGroup"},
+ * summary="Copy Display Group",
+ * description="Copy an existing Display Group",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The Display Group ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroup",
+ * in="formData",
+ * description="The name for the copy",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The description for the copy",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="copyMembers",
+ * in="formData",
+ * description="Flag indicating whether to copy all display and display group members",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="copyAssignments",
+ * in="formData",
+ * description="Flag indicating whether to copy all layout and media assignments",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="copyTags",
+ * in="formData",
+ * description="Flag indicating whether to copy all tags",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayGroup"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ // get display group object
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $displayGroup->setDisplayFactory($this->displayFactory);
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // What should we copy?
+ $copyMembers = $sanitizedParams->getCheckbox('copyMembers');
+ $copyTags = $sanitizedParams->getCheckbox('copyTags');
+ $copyAssignments = $sanitizedParams->getCheckbox('copyAssignments');
+
+ // Save loading if we don't need to.
+ if ($copyTags || $copyMembers || $copyAssignments) {
+ // Load tags
+ $displayGroup->load();
+
+ if ($copyMembers || $copyAssignments) {
+ // Load the entire display group
+ $this->getDispatcher()->dispatch(
+ new DisplayGroupLoadEvent($displayGroup),
+ DisplayGroupLoadEvent::$NAME
+ );
+ }
+ }
+
+ // Copy the group
+ $new = clone $displayGroup;
+ $new->displayGroup = $sanitizedParams->getString('displayGroup');
+ $new->description = $sanitizedParams->getString('description');
+ $new->setOwner($this->getUser()->userId);
+ $new->clearTags();
+
+ // handle display group members
+ if (!$copyMembers) {
+ $new->clearDisplays();
+ $new->clearDisplayGroups();
+ }
+
+ // handle layout and file assignment
+ if (!$copyAssignments) {
+ $new->clearLayouts()->clearMedia();
+ }
+
+ // handle tags
+ if ($copyTags) {
+ $new->updateTagLinks($displayGroup->tags);
+ }
+
+ $new->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $new->displayGroup),
+ 'id' => $new->displayGroupId,
+ 'data' => $new
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Select Folder Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolderForm(Request $request, Response $response, $id)
+ {
+ // Get the Display Group
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'displayGroup' => $displayGroup
+ ];
+
+ $this->getState()->template = 'displaygroup-form-selectfolder';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ *
+ * @SWG\Put(
+ * path="/displaygroup/{id}/selectfolder",
+ * operationId="displayGroupSelectFolder",
+ * tags={"displayGroup"},
+ * summary="Display Group Select folder",
+ * description="Select Folder for Display Group, can also be used with Display specific Display Group ID",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The Display Group ID or Display specific Display Group ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayGroup")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolder(Request $request, Response $response, $id)
+ {
+ // Get the Display Group
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Folders
+ $folderId = $this->getSanitizer($request->getParams())->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+ $displayGroup->folderId = $folder->id;
+ $displayGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ // Save
+ $displayGroup->save([
+ 'manageLinks' => false,
+ 'manageDisplayLinks' => false,
+ 'manageDynamicDisplayLinks' => false,
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Display %s moved to Folder %s'), $displayGroup->displayGroup, $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function triggerWebhookForm(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'displaygroup-form-trigger-webhook';
+ $this->getState()->setData([
+ 'displayGroup' => $displayGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Send a code to a Player to trigger a web hook associated with provided trigger code.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/displaygroup/{displayGroupId}/action/triggerWebhook",
+ * operationId="displayGroupActionTriggerWebhook",
+ * tags={"displayGroup"},
+ * summary="Action: Trigger Web hook",
+ * description="Send the trigger webhook action to this DisplayGroup",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The display group id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="triggerCode",
+ * in="formData",
+ * description="The trigger code that should be sent to the Player",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function triggerWebhook(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $triggerCode = $sanitizedParams->getString('triggerCode');
+
+ if (!$this->getUser()->checkEditable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($triggerCode == '') {
+ throw new InvalidArgumentException(__('Please provide a Trigger Code'), 'triggerCode');
+ }
+
+ $this->playerAction->sendAction(
+ $this->displayFactory->getByDisplayGroupId($id),
+ new TriggerWebhookAction($triggerCode)
+ );
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Command Sent to %s'), $displayGroup->displayGroup),
+ 'id' => $displayGroup->displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/displaygroup/criteria[/{displayGroupId}]",
+ * operationId="ScheduleCriteriaUpdate",
+ * tags={"displayGroup"},
+ * summary="Action: Push Criteria Update",
+ * description="Send criteria updates to the specified DisplayGroup or to all displays if displayGroupId is not
+ * provided.",
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * in="path",
+ * description="The display group id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="criteriaUpdates",
+ * in="body",
+ * description="The criteria updates to send to the Player",
+ * required=true,
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(
+ * type="object",
+ * @SWG\Property(property="metric", type="string"),
+ * @SWG\Property(property="value", type="string"),
+ * @SWG\Property(property="ttl", type="integer")
+ * )
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="Successful operation"
+ * ),
+ * @SWG\Response(
+ * response=400,
+ * description="Invalid criteria format"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $displayGroupId
+ * @return ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws PlayerActionException
+ */
+ public function pushCriteriaUpdate(Request $request, Response $response, int $displayGroupId): Response|ResponseInterface
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get criteria updates
+ $criteriaUpdates = $sanitizedParams->getArray('criteriaUpdates');
+
+ // ensure criteria updates exists
+ if (empty($criteriaUpdates)) {
+ throw new InvalidArgumentException(__('No criteria found.'), 'criteriaUpdates');
+ }
+
+ // Initialize array to hold sanitized criteria updates
+ $sanitizedCriteriaUpdates = [];
+
+ // Loop through each criterion and sanitize the input
+ foreach ($criteriaUpdates as $criteria) {
+ $criteriaSanitizer = $this->getSanitizer($criteria);
+
+ // Sanitize and retrieve the metric, value, and ttl
+ $metric = $criteriaSanitizer->getString('metric');
+ $value = $criteriaSanitizer->getString('value');
+ $ttl = $criteriaSanitizer->getInt('ttl');
+
+ // Ensure each criterion has metric, value, and ttl
+ if (empty($metric) || empty($value) || !isset($ttl)) {
+ // Throw an exception if any of the required fields are missing or empty
+ throw new PlayerActionException(
+ __('Invalid criteria format. Metric, value, and ttl must all be present and not empty.')
+ );
+ }
+
+ // Add sanitized criteria
+ $sanitizedCriteriaUpdates[] = [
+ 'metric' => $metric,
+ 'value' => $value,
+ 'ttl' => abs($ttl)
+ ];
+ }
+
+ // Create and send the player action to displays under the display group
+ $this->playerAction->sendAction(
+ $this->displayFactory->getByDisplayGroupId($displayGroupId),
+ (new ScheduleCriteriaUpdateAction())->setCriteriaUpdates($sanitizedCriteriaUpdates)
+ );
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Schedule criteria updates sent to players.'),
+ 'id' => $displayGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/DisplayProfile.php b/lib/Controller/DisplayProfile.php
new file mode 100644
index 0000000..941bc21
--- /dev/null
+++ b/lib/Controller/DisplayProfile.php
@@ -0,0 +1,698 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\CommandFactory;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayProfileFactory;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DisplayProfile
+ * @package Xibo\Controller
+ */
+class DisplayProfile extends Base
+{
+ use DisplayProfileConfigFields;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /**
+ * @var DayPartFactory
+ */
+ private $dayPartFactory;
+
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ /** @var PlayerVersionFactory */
+ private $playerVersionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param PoolInterface $pool
+ * @param DisplayProfileFactory $displayProfileFactory
+ * @param CommandFactory $commandFactory
+ * @param PlayerVersionFactory $playerVersionFactory
+ * @param DayPartFactory $dayPartFactory
+ */
+ public function __construct($pool, $displayProfileFactory, $commandFactory, $playerVersionFactory, $dayPartFactory)
+ {
+ $this->pool = $pool;
+ $this->displayProfileFactory = $displayProfileFactory;
+ $this->commandFactory = $commandFactory;
+ $this->playerVersionFactory = $playerVersionFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ }
+
+ /**
+ * Include display page template page based on sub page selected
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'displayprofile-page';
+ $this->getState()->setData([
+ 'types' => $this->displayProfileFactory->getAvailableTypes()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/displayprofile",
+ * operationId="displayProfileSearch",
+ * tags={"displayprofile"},
+ * summary="Display Profile Search",
+ * description="Search this users Display Profiles",
+ * @SWG\Parameter(
+ * name="displayProfileId",
+ * in="query",
+ * description="Filter by DisplayProfile Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayProfile",
+ * in="query",
+ * description="Filter by DisplayProfile Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="query",
+ * description="Filter by DisplayProfile Type (windows|android|lg)",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as config,commands,configWithDefault",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DisplayProfile")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function grid(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'displayProfileId' => $parsedQueryParams->getInt('displayProfileId'),
+ 'displayProfile' => $parsedQueryParams->getString('displayProfile'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'type' => $parsedQueryParams->getString('type'),
+ 'logicalOperatorName' => $parsedQueryParams->getString('logicalOperatorName'),
+ ];
+
+ $embed = ($parsedQueryParams->getString('embed') != null)
+ ? explode(',', $parsedQueryParams->getString('embed'))
+ : [];
+
+ $profiles = $this->displayProfileFactory->query(
+ $this->gridRenderSort($parsedQueryParams),
+ $this->gridRenderFilter($filter, $parsedQueryParams)
+ );
+
+ foreach ($profiles as $profile) {
+ // Load the config
+ $profile->load([
+ 'loadConfig' => in_array('config', $embed),
+ 'loadCommands' => in_array('commands', $embed)
+ ]);
+
+ if (in_array('configWithDefault', $embed)) {
+ $profile->includeProperty('configDefault');
+ }
+
+ if (!in_array('config', $embed)) {
+ $profile->excludeProperty('config');
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $profile->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('displayprofile.modify')) {
+ // Default Layout
+ $profile->buttons[] = array(
+ 'id' => 'displayprofile_button_edit',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayProfile.edit.form',
+ ['id' => $profile->displayProfileId]
+ ),
+ 'text' => __('Edit')
+ );
+
+ $profile->buttons[] = array(
+ 'id' => 'displayprofile_button_copy',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayProfile.copy.form',
+ ['id' => $profile->displayProfileId]
+ ),
+ 'text' => __('Copy')
+ );
+
+ if ($this->getUser()->checkDeleteable($profile)) {
+ $profile->buttons[] = array(
+ 'id' => 'displayprofile_button_delete',
+ 'url' => $this->urlFor(
+ $request,
+ 'displayProfile.delete.form',
+ ['id' => $profile->displayProfileId]
+ ),
+ 'text' => __('Delete')
+ );
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->displayProfileFactory->countLast();
+ $this->getState()->setData($profiles);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display Profile Add Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'displayprofile-form-add';
+ $this->getState()->setData([
+ 'types' => $this->displayProfileFactory->getAvailableTypes()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display Profile Add
+ *
+ * @SWG\Post(
+ * path="/displayprofile",
+ * operationId="displayProfileAdd",
+ * tags={"displayprofile"},
+ * summary="Add Display Profile",
+ * description="Add a Display Profile",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Name of the Display Profile",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="formData",
+ * description="The Client Type this Profile will apply to",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isDefault",
+ * in="formData",
+ * description="Flag indicating if this is the default profile for the client type",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayProfile"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $displayProfile = $this->displayProfileFactory->createEmpty();
+ $displayProfile->name = $sanitizedParams->getString('name');
+ $displayProfile->type = $sanitizedParams->getString('type');
+ $displayProfile->isDefault = $sanitizedParams->getCheckbox('isDefault');
+ $displayProfile->userId = $this->getUser()->userId;
+ $displayProfile->isCustom = $this->displayProfileFactory->isCustomType($displayProfile->type);
+
+ // We do not set any config at this point, so that unless the user chooses to edit the display profile
+ // our defaults in the Display Profile Factory take effect
+ $displayProfile->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $displayProfile->name),
+ 'id' => $displayProfile->displayProfileId,
+ 'data' => $displayProfile
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Profile Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayProfile = $this->displayProfileFactory->getById($id);
+
+ // Check permissions
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayProfile->userId) {
+ throw new AccessDeniedException(__('You do not have permission to edit this profile'));
+ }
+
+ // Player Version Setting
+ $versionId = $displayProfile->type === 'chromeOS'
+ ? $displayProfile->getSetting('playerVersionId')
+ : $displayProfile->getSetting('versionMediaId');
+
+ $playerVersions = [];
+
+ // Daypart - Operating Hours
+ $dayPartId = $displayProfile->getSetting('dayPartId');
+ $dayparts = [];
+
+ // Get the Player Version for this display profile type
+ if ($versionId !== null) {
+ try {
+ $playerVersions[] = $this->playerVersionFactory->getById($versionId);
+ } catch (NotFoundException) {
+ $this->getLog()->debug('Unknown versionId set on Display Profile. '
+ . $displayProfile->displayProfileId);
+ }
+ }
+
+ if ($dayPartId !== null) {
+ try {
+ $dayparts[] = $this->dayPartFactory->getById($dayPartId);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('Unknown dayPartId set on Display Profile. ' . $displayProfile->displayProfileId);
+ }
+ }
+
+ // elevated logs
+ $elevateLogsUntil = $displayProfile->getSetting('elevateLogsUntil');
+ $elevateLogsUntilIso = !empty($elevateLogsUntil)
+ ? Carbon::createFromTimestamp($elevateLogsUntil)->format(DateFormatHelper::getSystemFormat())
+ : null;
+ $displayProfile->setUnmatchedProperty('elevateLogsUntilIso', $elevateLogsUntilIso);
+
+ $this->getState()->template = 'displayprofile-form-edit';
+ $this->getState()->setData([
+ 'displayProfile' => $displayProfile,
+ 'commands' => $displayProfile->commands,
+ 'versions' => $playerVersions,
+ 'lockOptions' => json_decode($displayProfile->getSetting('lockOptions', '[]'), true),
+ 'dayParts' => $dayparts
+ ]);
+
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @SWG\Put(
+ * path="/displayprofile/{displayProfileId}",
+ * operationId="displayProfileEdit",
+ * tags={"displayprofile"},
+ * summary="Edit Display Profile",
+ * description="Edit a Display Profile",
+ * @SWG\Parameter(
+ * name="displayProfileId",
+ * in="path",
+ * description="The Display Profile ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Name of the Display Profile",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="formData",
+ * description="The Client Type this Profile will apply to",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isDefault",
+ * in="formData",
+ * description="Flag indicating if this is the default profile for the client type",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayProfile = $this->displayProfileFactory->getById($id);
+
+ $parsedParams = $this->getSanitizer($request->getParams());
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayProfile->userId) {
+ throw new AccessDeniedException(__('You do not have permission to edit this profile'));
+ }
+
+ $displayProfile->name = $parsedParams->getString('name');
+ $displayProfile->isDefault = $parsedParams->getCheckbox('isDefault');
+
+ // Track changes to versionMediaId
+ $originalPlayerVersionId = $displayProfile->getSetting('playerVersionId');
+
+ // Different fields for each client type
+ $this->editConfigFields($displayProfile, $parsedParams);
+
+ // Capture and update commands
+ foreach ($this->commandFactory->query() as $command) {
+ if ($parsedParams->getString('commandString_' . $command->commandId) != null) {
+ // Set and assign the command
+ $command->commandString = $parsedParams->getString('commandString_' . $command->commandId);
+ $command->validationString = $parsedParams->getString('validationString_' . $command->commandId);
+ $command->createAlertOn = $parsedParams->getString('createAlertOn_' . $command->commandId);
+
+ $displayProfile->assignCommand($command);
+ } else {
+ $displayProfile->unassignCommand($command);
+ }
+ }
+
+ // If we are chromeOS and the default profile, has the player version changed?
+ if ($displayProfile->type === 'chromeOS'
+ && ($displayProfile->isDefault || $displayProfile->hasPropertyChanged('isDefault'))
+ && ($originalPlayerVersionId !== $displayProfile->getSetting('playerVersionId'))
+ ) {
+ $this->getLog()->debug('edit: updating symlink to the latest chromeOS version');
+
+ // Update a symlink to the new player version.
+ try {
+ $version = $this->playerVersionFactory->getById($displayProfile->getSetting('playerVersionId'));
+ $version->setActive();
+ } catch (NotFoundException) {
+ $this->getLog()->error('edit: Player version does not exist');
+ }
+ }
+
+ // Save the changes
+ $displayProfile->save();
+
+ // Clear the display cached
+ $this->pool->deleteItem('display/');
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $displayProfile->name),
+ 'id' => $displayProfile->displayProfileId,
+ 'data' => $displayProfile
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayProfile = $this->displayProfileFactory->getById($id);
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayProfile->userId)
+ throw new AccessDeniedException(__('You do not have permission to edit this profile'));
+
+ $this->getState()->template = 'displayprofile-form-delete';
+ $this->getState()->setData([
+ 'displayProfile' => $displayProfile,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Display Profile
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @SWG\Delete(
+ * path="/displayprofile/{displayProfileId}",
+ * operationId="displayProfileDelete",
+ * tags={"displayprofile"},
+ * summary="Delete Display Profile",
+ * description="Delete an existing Display Profile",
+ * @SWG\Parameter(
+ * name="displayProfileId",
+ * in="path",
+ * description="The Display Profile ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function delete(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayProfile = $this->displayProfileFactory->getById($id);
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayProfile->userId) {
+ throw new AccessDeniedException(__('You do not have permission to delete this profile'));
+ }
+
+ $displayProfile->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $displayProfile->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayProfile = $this->displayProfileFactory->getById($id);
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayProfile->userId)
+ throw new AccessDeniedException(__('You do not have permission to delete this profile'));
+
+ $this->getState()->template = 'displayprofile-form-copy';
+ $this->getState()->setData([
+ 'displayProfile' => $displayProfile
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy Display Profile
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @SWG\Post(
+ * path="/displayprofile/{displayProfileId}/copy",
+ * operationId="displayProfileCopy",
+ * tags={"displayprofile"},
+ * summary="Copy Display Profile",
+ * description="Copy an existing Display Profile",
+ * @SWG\Parameter(
+ * name="displayProfileId",
+ * in="path",
+ * description="The Display Profile ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="path",
+ * description="The name for the copy",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayProfile"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ // Create a form out of the config object.
+ $displayProfile = $this->displayProfileFactory->getById($id);
+
+ if ($this->getUser()->userTypeId != 1 && $this->getUser()->userId != $displayProfile->userId) {
+ throw new AccessDeniedException(__('You do not have permission to delete this profile'));
+ }
+
+ // clear DisplayProfileId, commands and set isDefault to 0
+ $new = clone $displayProfile;
+ $new->name = $this->getSanitizer($request->getParams())->getString('name');
+
+ foreach ($displayProfile->commands as $command) {
+ /* @var \Xibo\Entity\Command $command */
+ if (!empty($command->commandStringDisplayProfile)) {
+ // if the original Display Profile has a commandString
+ // assign this command with the same commandString to new Display Profile
+ // commands with only default commandString are not directly assigned to Display profile
+ $command->commandString = $command->commandStringDisplayProfile;
+ $command->validationString = $command->validationStringDisplayProfile;
+ $new->assignCommand($command);
+ }
+ }
+
+ $new->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $new->name),
+ 'id' => $new->displayProfileId,
+ 'data' => $new
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/DisplayProfileConfigFields.php b/lib/Controller/DisplayProfileConfigFields.php
new file mode 100644
index 0000000..57b7bc7
--- /dev/null
+++ b/lib/Controller/DisplayProfileConfigFields.php
@@ -0,0 +1,1106 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Trait DisplayProfileConfigFields
+ * @package Xibo\Controller
+ */
+trait DisplayProfileConfigFields
+{
+ /**
+ * Edit config fields
+ * @param \Xibo\Entity\DisplayProfile $displayProfile
+ * @param SanitizerInterface $sanitizedParams
+ * @param null|array $config if empty will edit the config of provided display profile
+ * @param \Xibo\Entity\Display $display
+ * @return null|array
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editConfigFields($displayProfile, $sanitizedParams, $config = null, $display = null)
+ {
+ // Setting on our own config or not?
+ $ownConfig = ($config === null);
+
+ $changedSettings = [];
+
+ switch ($displayProfile->getClientType()) {
+
+ case 'android':
+ if ($sanitizedParams->hasParam('emailAddress')) {
+ $this->handleChangedSettings('emailAddress', ($ownConfig) ? $displayProfile->getSetting('emailAddress') : $display->getSetting('emailAddress'), $sanitizedParams->getString('emailAddress'), $changedSettings);
+ $displayProfile->setSetting('emailAddress', $sanitizedParams->getString('emailAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('settingsPassword')) {
+ $this->handleChangedSettings('settingsPassword', ($ownConfig) ? $displayProfile->getSetting('settingsPassword') : $display->getSetting('settingsPassword'), $sanitizedParams->getString('settingsPassword'), $changedSettings);
+ $displayProfile->setSetting('settingsPassword', $sanitizedParams->getString('settingsPassword'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('collectInterval')) {
+ $this->handleChangedSettings('collectInterval', ($ownConfig) ? $displayProfile->getSetting('collectInterval') : $display->getSetting('collectInterval'), $sanitizedParams->getInt('collectInterval'), $changedSettings);
+ $displayProfile->setSetting('collectInterval', $sanitizedParams->getInt('collectInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadStartWindow')) {
+ $this->handleChangedSettings('downloadStartWindow', ($ownConfig) ? $displayProfile->getSetting('downloadStartWindow') : $display->getSetting('downloadStartWindow'), $sanitizedParams->getString('downloadStartWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadStartWindow', $sanitizedParams->getString('downloadStartWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadEndWindow')) {
+ $this->handleChangedSettings('downloadEndWindow', ($ownConfig) ? $displayProfile->getSetting('downloadEndWindow') : $display->getSetting('downloadEndWindow'), $sanitizedParams->getString('downloadEndWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadEndWindow', $sanitizedParams->getString('downloadEndWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrNetworkAddress')) {
+ $this->handleChangedSettings('xmrNetworkAddress', ($ownConfig) ? $displayProfile->getSetting('xmrNetworkAddress') : $display->getSetting('xmrNetworkAddress'), $sanitizedParams->getString('xmrNetworkAddress'), $changedSettings);
+ $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrWebSocketAddress')) {
+ $this->handleChangedSettings(
+ 'xmrWebSocketAddress',
+ ($ownConfig)
+ ? $displayProfile->getSetting('xmrWebSocketAddress')
+ : $display->getSetting('xmrWebSocketAddress'),
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'xmrWebSocketAddress',
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('statsEnabled')) {
+ $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings);
+ $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('aggregationLevel')) {
+ $this->handleChangedSettings('aggregationLevel', ($ownConfig) ? $displayProfile->getSetting('aggregationLevel') : $display->getSetting('aggregationLevel'), $sanitizedParams->getString('aggregationLevel'), $changedSettings);
+ $displayProfile->setSetting('aggregationLevel', $sanitizedParams->getString('aggregationLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('orientation')) {
+ $this->handleChangedSettings('orientation', ($ownConfig) ? $displayProfile->getSetting('orientation') : $display->getSetting('orientation'), $sanitizedParams->getInt('orientation'), $changedSettings);
+ $displayProfile->setSetting('orientation', $sanitizedParams->getInt('orientation'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenDimensions')) {
+ $this->handleChangedSettings('screenDimensions', ($ownConfig) ? $displayProfile->getSetting('screenDimensions') : $display->getSetting('screenDimensions'), $sanitizedParams->getString('screenDimensions'), $changedSettings);
+ $displayProfile->setSetting('screenDimensions', $sanitizedParams->getString('screenDimensions'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('blacklistVideo')) {
+ $this->handleChangedSettings('blacklistVideo', ($ownConfig) ? $displayProfile->getSetting('blacklistVideo') : $display->getSetting('blacklistVideo'), $sanitizedParams->getCheckbox('blacklistVideo'), $changedSettings);
+ $displayProfile->setSetting('blacklistVideo', $sanitizedParams->getCheckbox('blacklistVideo'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('storeHtmlOnInternal')) {
+ $this->handleChangedSettings('storeHtmlOnInternal', ($ownConfig) ? $displayProfile->getSetting('storeHtmlOnInternal') : $display->getSetting('storeHtmlOnInternal'), $sanitizedParams->getCheckbox('storeHtmlOnInternal'), $changedSettings);
+ $displayProfile->setSetting('storeHtmlOnInternal', $sanitizedParams->getCheckbox('storeHtmlOnInternal'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('useSurfaceVideoView')) {
+ $this->handleChangedSettings('useSurfaceVideoView', ($ownConfig) ? $displayProfile->getSetting('useSurfaceVideoView') : $display->getSetting('useSurfaceVideoView'), $sanitizedParams->getCheckbox('useSurfaceVideoView'), $changedSettings);
+ $displayProfile->setSetting('useSurfaceVideoView', $sanitizedParams->getCheckbox('useSurfaceVideoView'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('logLevel')) {
+ $this->handleChangedSettings('logLevel', ($ownConfig) ? $displayProfile->getSetting('logLevel') : $display->getSetting('logLevel'), $sanitizedParams->getString('logLevel'), $changedSettings);
+ $displayProfile->setSetting('logLevel', $sanitizedParams->getString('logLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('elevateLogsUntil')) {
+ $this->handleChangedSettings(
+ 'elevateLogsUntil',
+ ($ownConfig)
+ ? $displayProfile->getSetting('elevateLogsUntil')
+ : $display->getSetting('elevateLogsUntil'),
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'elevateLogsUntil',
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('versionMediaId')) {
+ $this->handleChangedSettings('versionMediaId', ($ownConfig) ? $displayProfile->getSetting('versionMediaId') : $display->getSetting('versionMediaId'), $sanitizedParams->getInt('versionMediaId'), $changedSettings);
+ $displayProfile->setSetting('versionMediaId', $sanitizedParams->getInt('versionMediaId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('startOnBoot')) {
+ $this->handleChangedSettings('startOnBoot', ($ownConfig) ? $displayProfile->getSetting('startOnBoot') : $display->getSetting('startOnBoot'), $sanitizedParams->getCheckbox('startOnBoot'), $changedSettings);
+ $displayProfile->setSetting('startOnBoot', $sanitizedParams->getCheckbox('startOnBoot'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('actionBarMode')) {
+ $this->handleChangedSettings('actionBarMode', ($ownConfig) ? $displayProfile->getSetting('actionBarMode') : $display->getSetting('actionBarMode'), $sanitizedParams->getInt('actionBarMode'), $changedSettings);
+ $displayProfile->setSetting('actionBarMode', $sanitizedParams->getInt('actionBarMode'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('actionBarDisplayDuration')) {
+ $this->handleChangedSettings('actionBarDisplayDuration', ($ownConfig) ? $displayProfile->getSetting('actionBarDisplayDuration') : $display->getSetting('actionBarDisplayDuration'), $sanitizedParams->getInt('actionBarDisplayDuration'), $changedSettings);
+ $displayProfile->setSetting('actionBarDisplayDuration', $sanitizedParams->getInt('actionBarDisplayDuration'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('actionBarIntent')) {
+ $this->handleChangedSettings('actionBarIntent', ($ownConfig) ? $displayProfile->getSetting('actionBarIntent') : $display->getSetting('actionBarIntent'), $sanitizedParams->getString('actionBarIntent'), $changedSettings);
+ $displayProfile->setSetting('actionBarIntent', $sanitizedParams->getString('actionBarIntent'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('autoRestart')) {
+ $this->handleChangedSettings('autoRestart', ($ownConfig) ? $displayProfile->getSetting('autoRestart') : $display->getSetting('autoRestart'), $sanitizedParams->getCheckbox('autoRestart'), $changedSettings);
+ $displayProfile->setSetting('autoRestart', $sanitizedParams->getCheckbox('autoRestart'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('startOnBootDelay')) {
+ $this->handleChangedSettings('startOnBootDelay', ($ownConfig) ? $displayProfile->getSetting('startOnBootDelay') : $display->getSetting('startOnBootDelay'), $sanitizedParams->getInt('startOnBootDelay'), $changedSettings);
+ $displayProfile->setSetting('startOnBootDelay', $sanitizedParams->getInt('startOnBootDelay'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sendCurrentLayoutAsStatusUpdate')) {
+ $this->handleChangedSettings('sendCurrentLayoutAsStatusUpdate', ($ownConfig) ? $displayProfile->getSetting('sendCurrentLayoutAsStatusUpdate') : $display->getSetting('sendCurrentLayoutAsStatusUpdate'), $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $changedSettings);
+ $displayProfile->setSetting('sendCurrentLayoutAsStatusUpdate', $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotRequestInterval')) {
+ $this->handleChangedSettings('screenShotRequestInterval', ($ownConfig) ? $displayProfile->getSetting('screenShotRequestInterval') : $display->getSetting('screenShotRequestInterval'), $sanitizedParams->getInt('screenShotRequestInterval'), $changedSettings);
+ $displayProfile->setSetting('screenShotRequestInterval', $sanitizedParams->getInt('screenShotRequestInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('expireModifiedLayouts')) {
+ $this->handleChangedSettings('expireModifiedLayouts', ($ownConfig) ? $displayProfile->getSetting('expireModifiedLayouts') : $display->getSetting('expireModifiedLayouts'), $sanitizedParams->getCheckbox('expireModifiedLayouts'), $changedSettings);
+ $displayProfile->setSetting('expireModifiedLayouts', $sanitizedParams->getCheckbox('expireModifiedLayouts'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotIntent')) {
+ $this->handleChangedSettings('screenShotIntent', ($ownConfig) ? $displayProfile->getSetting('screenShotIntent') : $display->getSetting('screenShotIntent'), $sanitizedParams->getString('screenShotIntent'), $changedSettings);
+ $displayProfile->setSetting('screenShotIntent', $sanitizedParams->getString('screenShotIntent'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotSize')) {
+ $this->handleChangedSettings('screenShotSize', ($ownConfig) ? $displayProfile->getSetting('screenShotSize') : $display->getSetting('screenShotSize'), $sanitizedParams->getInt('screenShotSize'), $changedSettings);
+ $displayProfile->setSetting('screenShotSize', $sanitizedParams->getInt('screenShotSize'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('updateStartWindow')) {
+ $this->handleChangedSettings('updateStartWindow', ($ownConfig) ? $displayProfile->getSetting('updateStartWindow') : $display->getSetting('updateStartWindow'), $sanitizedParams->getString('updateStartWindow'), $changedSettings);
+ $displayProfile->setSetting('updateStartWindow', $sanitizedParams->getString('updateStartWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('updateEndWindow')) {
+ $this->handleChangedSettings('updateEndWindow', ($ownConfig) ? $displayProfile->getSetting('updateEndWindow') : $display->getSetting('updateEndWindow'), $sanitizedParams->getString('updateEndWindow'), $changedSettings);
+ $displayProfile->setSetting('updateEndWindow', $sanitizedParams->getString('updateEndWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('dayPartId')) {
+ $this->handleChangedSettings('dayPartId', ($ownConfig) ? $displayProfile->getSetting('dayPartId') : $display->getSetting('dayPartId'), $sanitizedParams->getInt('dayPartId'), $changedSettings);
+ $displayProfile->setSetting('dayPartId', $sanitizedParams->getInt('dayPartId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('restartWifiOnConnectionFailure')) {
+ $this->handleChangedSettings(
+ 'restartWifiOnConnectionFailure',
+ ($ownConfig)
+ ? $displayProfile->getSetting('restartWifiOnConnectionFailure')
+ : $display->getSetting('restartWifiOnConnectionFailure'),
+ $sanitizedParams->getCheckbox('restartWifiOnConnectionFailure'),
+ $changedSettings
+ );
+
+ $displayProfile->setSetting(
+ 'restartWifiOnConnectionFailure',
+ $sanitizedParams->getCheckbox('restartWifiOnConnectionFailure'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('webViewPluginState')) {
+ $this->handleChangedSettings('webViewPluginState', ($ownConfig) ? $displayProfile->getSetting('webViewPluginState') : $display->getSetting('webViewPluginState'), $sanitizedParams->getString('webViewPluginState'), $changedSettings);
+ $displayProfile->setSetting('webViewPluginState', $sanitizedParams->getString('webViewPluginState'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('hardwareAccelerateWebViewMode')) {
+ $this->handleChangedSettings('hardwareAccelerateWebViewMode', ($ownConfig) ? $displayProfile->getSetting('hardwareAccelerateWebViewMode') : $display->getSetting('hardwareAccelerateWebViewMode'), $sanitizedParams->getString('hardwareAccelerateWebViewMode'), $changedSettings);
+ $displayProfile->setSetting('hardwareAccelerateWebViewMode', $sanitizedParams->getString('hardwareAccelerateWebViewMode'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('timeSyncFromCms')) {
+ $this->handleChangedSettings('timeSyncFromCms', ($ownConfig) ? $displayProfile->getSetting('timeSyncFromCms') : $display->getSetting('timeSyncFromCms'), $sanitizedParams->getCheckbox('timeSyncFromCms'), $changedSettings);
+ $displayProfile->setSetting('timeSyncFromCms', $sanitizedParams->getCheckbox('timeSyncFromCms'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('webCacheEnabled')) {
+ $this->handleChangedSettings('webCacheEnabled', ($ownConfig) ? $displayProfile->getSetting('webCacheEnabled') : $display->getSetting('webCacheEnabled'), $sanitizedParams->getCheckbox('webCacheEnabled'), $changedSettings);
+ $displayProfile->setSetting('webCacheEnabled', $sanitizedParams->getCheckbox('webCacheEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('serverPort')) {
+ $this->handleChangedSettings('serverPort', ($ownConfig) ? $displayProfile->getSetting('serverPort') : $display->getSetting('serverPort'), $sanitizedParams->getInt('serverPort'), $changedSettings);
+ $displayProfile->setSetting('serverPort', $sanitizedParams->getInt('serverPort'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('installWithLoadedLinkLibraries')) {
+ $this->handleChangedSettings('installWithLoadedLinkLibraries', ($ownConfig) ? $displayProfile->getSetting('installWithLoadedLinkLibraries') : $display->getSetting('installWithLoadedLinkLibraries'), $sanitizedParams->getCheckbox('installWithLoadedLinkLibraries'), $changedSettings);
+ $displayProfile->setSetting('installWithLoadedLinkLibraries', $sanitizedParams->getCheckbox('installWithLoadedLinkLibraries'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('forceHttps')) {
+ $this->handleChangedSettings('forceHttps', ($ownConfig) ? $displayProfile->getSetting('forceHttps') : $display->getSetting('forceHttps'), $sanitizedParams->getCheckbox('forceHttps'), $changedSettings);
+ $displayProfile->setSetting('forceHttps', $sanitizedParams->getCheckbox('forceHttps'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('isUseMultipleVideoDecoders')) {
+ $this->handleChangedSettings('isUseMultipleVideoDecoders', ($ownConfig) ? $displayProfile->getSetting('isUseMultipleVideoDecoders') : $display->getSetting('isUseMultipleVideoDecoders'), $sanitizedParams->getString('isUseMultipleVideoDecoders'), $changedSettings);
+ $displayProfile->setSetting('isUseMultipleVideoDecoders', $sanitizedParams->getString('isUseMultipleVideoDecoders'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('maxRegionCount')) {
+ $this->handleChangedSettings('maxRegionCount', ($ownConfig) ? $displayProfile->getSetting('maxRegionCount') : $display->getSetting('maxRegionCount'), $sanitizedParams->getInt('maxRegionCount'), $changedSettings);
+ $displayProfile->setSetting('maxRegionCount', $sanitizedParams->getInt('maxRegionCount'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('embeddedServerAllowWan')) {
+ $this->handleChangedSettings('embeddedServerAllowWan', ($ownConfig) ? $displayProfile->getSetting('embeddedServerAllowWan') : $display->getSetting('embeddedServerAllowWan'), $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $changedSettings);
+ $displayProfile->setSetting('embeddedServerAllowWan', $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('isRecordGeoLocationOnProofOfPlay')) {
+ $this->handleChangedSettings('isRecordGeoLocationOnProofOfPlay', ($ownConfig) ? $displayProfile->getSetting('isRecordGeoLocationOnProofOfPlay') : $display->getSetting('isRecordGeoLocationOnProofOfPlay'), $sanitizedParams->getCheckbox('isRecordGeoLocationOnProofOfPlay'), $changedSettings);
+ $displayProfile->setSetting('isRecordGeoLocationOnProofOfPlay', $sanitizedParams->getCheckbox('isRecordGeoLocationOnProofOfPlay'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('videoEngine')) {
+ $this->handleChangedSettings('videoEngine', ($ownConfig) ? $displayProfile->getSetting('videoEngine') : $display->getSetting('videoEngine'), $sanitizedParams->getString('videoEngine'), $changedSettings);
+ $displayProfile->setSetting('videoEngine', $sanitizedParams->getString('videoEngine'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('isTouchEnabled')) {
+ $this->handleChangedSettings('isTouchEnabled', ($ownConfig) ? $displayProfile->getSetting('isTouchEnabled') : $display->getSetting('isTouchEnabled'), $sanitizedParams->getCheckbox('isTouchEnabled'), $changedSettings);
+ $displayProfile->setSetting('isTouchEnabled', $sanitizedParams->getCheckbox('isTouchEnabled'), $ownConfig, $config);
+ }
+
+ break;
+
+ case 'windows':
+ if ($sanitizedParams->hasParam('collectInterval')) {
+ $this->handleChangedSettings('collectInterval', ($ownConfig) ? $displayProfile->getSetting('collectInterval') : $display->getSetting('collectInterval'), $sanitizedParams->getInt('collectInterval'), $changedSettings);
+ $displayProfile->setSetting('collectInterval', $sanitizedParams->getInt('collectInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadStartWindow')) {
+ $this->handleChangedSettings('downloadStartWindow', ($ownConfig) ? $displayProfile->getSetting('downloadStartWindow') : $display->getSetting('downloadStartWindow'), $sanitizedParams->getString('downloadStartWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadStartWindow', $sanitizedParams->getString('downloadStartWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadEndWindow')) {
+ $this->handleChangedSettings('downloadEndWindow', ($ownConfig) ? $displayProfile->getSetting('downloadEndWindow') : $display->getSetting('downloadEndWindow'), $sanitizedParams->getString('downloadEndWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadEndWindow', $sanitizedParams->getString('downloadEndWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrNetworkAddress')) {
+ $this->handleChangedSettings('xmrNetworkAddress', ($ownConfig) ? $displayProfile->getSetting('xmrNetworkAddress') : $display->getSetting('xmrNetworkAddress'), $sanitizedParams->getString('xmrNetworkAddress'), $changedSettings);
+ $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrWebSocketAddress')) {
+ $this->handleChangedSettings(
+ 'xmrWebSocketAddress',
+ ($ownConfig)
+ ? $displayProfile->getSetting('xmrWebSocketAddress')
+ : $display->getSetting('xmrWebSocketAddress'),
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'xmrWebSocketAddress',
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('dayPartId')) {
+ $this->handleChangedSettings('dayPartId', ($ownConfig) ? $displayProfile->getSetting('dayPartId') : $display->getSetting('dayPartId'), $sanitizedParams->getInt('dayPartId'), $changedSettings);
+ $displayProfile->setSetting('dayPartId', $sanitizedParams->getInt('dayPartId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('statsEnabled')) {
+ $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings);
+ $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('aggregationLevel')) {
+ $this->handleChangedSettings('aggregationLevel', ($ownConfig) ? $displayProfile->getSetting('aggregationLevel') : $display->getSetting('aggregationLevel'), $sanitizedParams->getString('aggregationLevel'), $changedSettings);
+ $displayProfile->setSetting('aggregationLevel', $sanitizedParams->getString('aggregationLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('powerpointEnabled')) {
+ $this->handleChangedSettings('powerpointEnabled', ($ownConfig) ? $displayProfile->getSetting('powerpointEnabled') : $display->getSetting('powerpointEnabled'), $sanitizedParams->getCheckbox('powerpointEnabled'), $changedSettings);
+ $displayProfile->setSetting('powerpointEnabled', $sanitizedParams->getCheckbox('powerpointEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sizeX')) {
+ $this->handleChangedSettings('sizeX', ($ownConfig) ? $displayProfile->getSetting('sizeX') : $display->getSetting('sizeX'), $sanitizedParams->getDouble('sizeX'), $changedSettings);
+ $displayProfile->setSetting('sizeX', $sanitizedParams->getDouble('sizeX'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sizeY')) {
+ $this->handleChangedSettings('sizeY', ($ownConfig) ? $displayProfile->getSetting('sizeY') : $display->getSetting('sizeY'), $sanitizedParams->getDouble('sizeY'), $changedSettings);
+ $displayProfile->setSetting('sizeY', $sanitizedParams->getDouble('sizeY'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('offsetX')) {
+ $this->handleChangedSettings('offsetX', ($ownConfig) ? $displayProfile->getSetting('offsetX') : $display->getSetting('offsetX'), $sanitizedParams->getDouble('offsetX'), $changedSettings);
+ $displayProfile->setSetting('offsetX', $sanitizedParams->getDouble('offsetX'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('offsetY')) {
+ $this->handleChangedSettings('offsetY', ($ownConfig) ? $displayProfile->getSetting('offsetY') : $display->getSetting('offsetY'), $sanitizedParams->getDouble('offsetY'), $changedSettings);
+ $displayProfile->setSetting('offsetY', $sanitizedParams->getDouble('offsetY'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('clientInfomationCtrlKey')) {
+ $this->handleChangedSettings('clientInfomationCtrlKey', ($ownConfig) ? $displayProfile->getSetting('clientInfomationCtrlKey') : $display->getSetting('clientInfomationCtrlKey'), $sanitizedParams->getCheckbox('clientInfomationCtrlKey'), $changedSettings);
+ $displayProfile->setSetting('clientInfomationCtrlKey', $sanitizedParams->getCheckbox('clientInfomationCtrlKey'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('clientInformationKeyCode')) {
+ $this->handleChangedSettings('clientInformationKeyCode', ($ownConfig) ? $displayProfile->getSetting('clientInformationKeyCode') : $display->getSetting('clientInformationKeyCode'), $sanitizedParams->getString('clientInformationKeyCode'), $changedSettings);
+ $displayProfile->setSetting('clientInformationKeyCode', $sanitizedParams->getString('clientInformationKeyCode'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('logLevel')) {
+ $this->handleChangedSettings('logLevel', ($ownConfig) ? $displayProfile->getSetting('logLevel') : $display->getSetting('logLevel'), $sanitizedParams->getString('logLevel'), $changedSettings);
+ $displayProfile->setSetting('logLevel', $sanitizedParams->getString('logLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('elevateLogsUntil')) {
+ $this->handleChangedSettings(
+ 'elevateLogsUntil',
+ ($ownConfig)
+ ? $displayProfile->getSetting('elevateLogsUntil')
+ : $display->getSetting('elevateLogsUntil'),
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'elevateLogsUntil',
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('logToDiskLocation')){
+ $this->handleChangedSettings('logToDiskLocation', ($ownConfig) ? $displayProfile->getSetting('logToDiskLocation') : $display->getSetting('logToDiskLocation'), $sanitizedParams->getString('logToDiskLocation'), $changedSettings);
+ $displayProfile->setSetting('logToDiskLocation', $sanitizedParams->getString('logToDiskLocation'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('showInTaskbar')) {
+ $this->handleChangedSettings('showInTaskbar', ($ownConfig) ? $displayProfile->getSetting('showInTaskbar') : $display->getSetting('showInTaskbar'), $sanitizedParams->getCheckbox('showInTaskbar'), $changedSettings);
+ $displayProfile->setSetting('showInTaskbar', $sanitizedParams->getCheckbox('showInTaskbar'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('cursorStartPosition')) {
+ $this->handleChangedSettings('cursorStartPosition', ($ownConfig) ? $displayProfile->getSetting('cursorStartPosition') : $display->getSetting('cursorStartPosition'), $sanitizedParams->getString('cursorStartPosition'), $changedSettings);
+ $displayProfile->setSetting('cursorStartPosition', $sanitizedParams->getString('cursorStartPosition'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('doubleBuffering')) {
+ $this->handleChangedSettings('doubleBuffering', ($ownConfig) ? $displayProfile->getSetting('doubleBuffering') : $display->getSetting('doubleBuffering'), $sanitizedParams->getCheckbox('doubleBuffering'), $changedSettings);
+ $displayProfile->setSetting('doubleBuffering', $sanitizedParams->getCheckbox('doubleBuffering'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('emptyLayoutDuration')) {
+ $this->handleChangedSettings('emptyLayoutDuration', ($ownConfig) ? $displayProfile->getSetting('emptyLayoutDuration') : $display->getSetting('emptyLayoutDuration'), $sanitizedParams->getInt('emptyLayoutDuration'), $changedSettings);
+ $displayProfile->setSetting('emptyLayoutDuration', $sanitizedParams->getInt('emptyLayoutDuration'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('enableMouse')) {
+ $this->handleChangedSettings('enableMouse', ($ownConfig) ? $displayProfile->getSetting('enableMouse') : $display->getSetting('enableMouse'), $sanitizedParams->getCheckbox('enableMouse'), $changedSettings);
+ $displayProfile->setSetting('enableMouse', $sanitizedParams->getCheckbox('enableMouse'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('enableShellCommands')) {
+ $this->handleChangedSettings('enableShellCommands', ($ownConfig) ? $displayProfile->getSetting('enableShellCommands') : $display->getSetting('enableShellCommands'), $sanitizedParams->getCheckbox('enableShellCommands'), $changedSettings);
+ $displayProfile->setSetting('enableShellCommands', $sanitizedParams->getCheckbox('enableShellCommands'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('expireModifiedLayouts')) {
+ $this->handleChangedSettings('expireModifiedLayouts', ($ownConfig) ? $displayProfile->getSetting('expireModifiedLayouts') : $display->getSetting('expireModifiedLayouts'), $sanitizedParams->getCheckbox('expireModifiedLayouts'), $changedSettings);
+ $displayProfile->setSetting('expireModifiedLayouts', $sanitizedParams->getCheckbox('expireModifiedLayouts'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('maxConcurrentDownloads')) {
+ $this->handleChangedSettings('maxConcurrentDownloads', ($ownConfig) ? $displayProfile->getSetting('maxConcurrentDownloads') : $display->getSetting('maxConcurrentDownloads'), $sanitizedParams->getInt('maxConcurrentDownloads'), $changedSettings);
+ $displayProfile->setSetting('maxConcurrentDownloads', $sanitizedParams->getInt('maxConcurrentDownloads'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('shellCommandAllowList')) {
+ $this->handleChangedSettings('shellCommandAllowList', ($ownConfig) ? $displayProfile->getSetting('shellCommandAllowList') : $display->getSetting('shellCommandAllowList'), $sanitizedParams->getString('shellCommandAllowList'), $changedSettings);
+ $displayProfile->setSetting('shellCommandAllowList', $sanitizedParams->getString('shellCommandAllowList'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sendCurrentLayoutAsStatusUpdate')) {
+ $this->handleChangedSettings('sendCurrentLayoutAsStatusUpdate', ($ownConfig) ? $displayProfile->getSetting('sendCurrentLayoutAsStatusUpdate') : $display->getSetting('sendCurrentLayoutAsStatusUpdate'), $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $changedSettings);
+ $displayProfile->setSetting('sendCurrentLayoutAsStatusUpdate', $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotRequestInterval')) {
+ $this->handleChangedSettings('screenShotRequestInterval', ($ownConfig) ? $displayProfile->getSetting('screenShotRequestInterval') : $display->getSetting('screenShotRequestInterval'), $sanitizedParams->getInt('screenShotRequestInterval'), $changedSettings);
+ $displayProfile->setSetting('screenShotRequestInterval', $sanitizedParams->getInt('screenShotRequestInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotSize')) {
+ $this->handleChangedSettings('screenShotSize', ($ownConfig) ? $displayProfile->getSetting('screenShotSize') : $display->getSetting('screenShotSize'), $sanitizedParams->getInt('screenShotSize'), $changedSettings);
+ $displayProfile->setSetting('screenShotSize', $sanitizedParams->getInt('screenShotSize'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('maxLogFileUploads')) {
+ $this->handleChangedSettings('maxLogFileUploads', ($ownConfig) ? $displayProfile->getSetting('maxLogFileUploads') : $display->getSetting('maxLogFileUploads'), $sanitizedParams->getInt('maxLogFileUploads'), $changedSettings);
+ $displayProfile->setSetting('maxLogFileUploads', $sanitizedParams->getInt('maxLogFileUploads'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('embeddedServerPort')) {
+ $this->handleChangedSettings('embeddedServerPort', ($ownConfig) ? $displayProfile->getSetting('embeddedServerPort') : $display->getSetting('embeddedServerPort'), $sanitizedParams->getInt('embeddedServerPort'), $changedSettings);
+ $displayProfile->setSetting('embeddedServerPort', $sanitizedParams->getInt('embeddedServerPort'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('preventSleep')) {
+ $this->handleChangedSettings('preventSleep', ($ownConfig) ? $displayProfile->getSetting('preventSleep') : $display->getSetting('preventSleep'), $sanitizedParams->getCheckbox('preventSleep'), $changedSettings);
+ $displayProfile->setSetting('preventSleep', $sanitizedParams->getCheckbox('preventSleep'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('forceHttps')) {
+ $this->handleChangedSettings('forceHttps', ($ownConfig) ? $displayProfile->getSetting('forceHttps') : $display->getSetting('forceHttps'), $sanitizedParams->getCheckbox('forceHttps'), $changedSettings);
+ $displayProfile->setSetting('forceHttps', $sanitizedParams->getCheckbox('forceHttps'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('authServerWhitelist')) {
+ $this->handleChangedSettings('authServerWhitelist', ($ownConfig) ? $displayProfile->getSetting('authServerWhitelist') : $display->getSetting('authServerWhitelist'), $sanitizedParams->getString('authServerWhitelist'), $changedSettings);
+ $displayProfile->setSetting('authServerWhitelist', $sanitizedParams->getString('authServerWhitelist'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('edgeBrowserWhitelist')) {
+ $this->handleChangedSettings('edgeBrowserWhitelist', ($ownConfig) ? $displayProfile->getSetting('edgeBrowserWhitelist') : $display->getSetting('edgeBrowserWhitelist'), $sanitizedParams->getString('edgeBrowserWhitelist'), $changedSettings);
+ $displayProfile->setSetting('edgeBrowserWhitelist', $sanitizedParams->getString('edgeBrowserWhitelist'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('embeddedServerAllowWan')) {
+ $this->handleChangedSettings('embeddedServerAllowWan', ($ownConfig) ? $displayProfile->getSetting('embeddedServerAllowWan') : $display->getSetting('embeddedServerAllowWan'), $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $changedSettings);
+ $displayProfile->setSetting('embeddedServerAllowWan', $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('isRecordGeoLocationOnProofOfPlay')) {
+ $this->handleChangedSettings('isRecordGeoLocationOnProofOfPlay', ($ownConfig) ? $displayProfile->getSetting('isRecordGeoLocationOnProofOfPlay') : $display->getSetting('isRecordGeoLocationOnProofOfPlay'), $sanitizedParams->getCheckbox('isRecordGeoLocationOnProofOfPlay'), $changedSettings);
+ $displayProfile->setSetting('isRecordGeoLocationOnProofOfPlay', $sanitizedParams->getCheckbox('isRecordGeoLocationOnProofOfPlay'), $ownConfig, $config);
+ }
+
+ break;
+
+ case 'linux':
+ if ($sanitizedParams->hasParam('collectInterval')) {
+ $this->handleChangedSettings('collectInterval',($ownConfig) ? $displayProfile->getSetting('collectInterval') : $display->getSetting('collectInterval'), $sanitizedParams->getInt('collectInterval'), $changedSettings);
+ $displayProfile->setSetting('collectInterval', $sanitizedParams->getInt('collectInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadStartWindow')) {
+ $this->handleChangedSettings('downloadStartWindow', ($ownConfig) ? $displayProfile->getSetting('downloadStartWindow') : $display->getSetting('downloadStartWindow'), $sanitizedParams->getString('downloadStartWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadStartWindow', $sanitizedParams->getString('downloadStartWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadEndWindow')) {
+ $this->handleChangedSettings('downloadEndWindow', ($ownConfig) ? $displayProfile->getSetting('downloadEndWindow') : $display->getSetting('downloadEndWindow'), $sanitizedParams->getString('downloadEndWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadEndWindow', $sanitizedParams->getString('downloadEndWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('dayPartId')) {
+ $this->handleChangedSettings('dayPartId', ($ownConfig) ? $displayProfile->getSetting('dayPartId') : $display->getSetting('dayPartId'), $sanitizedParams->getInt('dayPartId'), $changedSettings);
+ $displayProfile->setSetting('dayPartId', $sanitizedParams->getInt('dayPartId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrNetworkAddress')) {
+ $this->handleChangedSettings('xmrNetworkAddress',($ownConfig) ? $displayProfile->getSetting('xmrNetworkAddress') : $display->getSetting('xmrNetworkAddress'), $sanitizedParams->getString('xmrNetworkAddress'), $changedSettings);
+ $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrWebSocketAddress')) {
+ $this->handleChangedSettings(
+ 'xmrWebSocketAddress',
+ ($ownConfig)
+ ? $displayProfile->getSetting('xmrWebSocketAddress')
+ : $display->getSetting('xmrWebSocketAddress'),
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'xmrWebSocketAddress',
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('statsEnabled')) {
+ $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings);
+ $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('aggregationLevel')) {
+ $this->handleChangedSettings('aggregationLevel', ($ownConfig) ? $displayProfile->getSetting('aggregationLevel') : $display->getSetting('aggregationLevel'), $sanitizedParams->getString('aggregationLevel'), $changedSettings);
+ $displayProfile->setSetting('aggregationLevel', $sanitizedParams->getString('aggregationLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sizeX')) {
+ $this->handleChangedSettings('sizeX', ($ownConfig) ? $displayProfile->getSetting('sizeX') : $display->getSetting('sizeX'), $sanitizedParams->getDouble('sizeX'), $changedSettings);
+ $displayProfile->setSetting('sizeX', $sanitizedParams->getDouble('sizeX'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sizeY')) {
+ $this->handleChangedSettings('sizeY', ($ownConfig) ? $displayProfile->getSetting('sizeY') : $display->getSetting('sizeY'), $sanitizedParams->getDouble('sizeY'), $changedSettings);
+ $displayProfile->setSetting('sizeY', $sanitizedParams->getDouble('sizeY'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('offsetX')) {
+ $this->handleChangedSettings('offsetX', ($ownConfig) ? $displayProfile->getSetting('offsetX') : $display->getSetting('offsetX'), $sanitizedParams->getDouble('offsetX'), $changedSettings);
+ $displayProfile->setSetting('offsetX', $sanitizedParams->getDouble('offsetX'), $ownConfig, $config);
+ }
+
+ if($sanitizedParams->hasParam('offsetY')) {
+ $this->handleChangedSettings('offsetY', ($ownConfig) ? $displayProfile->getSetting('offsetY') : $display->getSetting('offsetY'), $sanitizedParams->getDouble('offsetY'), $changedSettings);
+ $displayProfile->setSetting('offsetY', $sanitizedParams->getDouble('offsetY'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('logLevel')) {
+ $this->handleChangedSettings('logLevel', ($ownConfig) ? $displayProfile->getSetting('logLevel') : $display->getSetting('logLevel'), $sanitizedParams->getString('logLevel'), $changedSettings);
+ $displayProfile->setSetting('logLevel', $sanitizedParams->getString('logLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('elevateLogsUntil')) {
+ $this->handleChangedSettings(
+ 'elevateLogsUntil',
+ ($ownConfig)
+ ? $displayProfile->getSetting('elevateLogsUntil')
+ : $display->getSetting('elevateLogsUntil'),
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'elevateLogsUntil',
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('enableShellCommands')) {
+ $this->handleChangedSettings('enableShellCommands',($ownConfig) ? $displayProfile->getSetting('enableShellCommands') : $display->getSetting('enableShellCommands'), $sanitizedParams->getCheckbox('enableShellCommands'), $changedSettings);
+ $displayProfile->setSetting('enableShellCommands', $sanitizedParams->getCheckbox('enableShellCommands'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('expireModifiedLayouts')) {
+ $this->handleChangedSettings('expireModifiedLayouts',($ownConfig) ? $displayProfile->getSetting('expireModifiedLayouts') : $display->getSetting('expireModifiedLayouts'), $sanitizedParams->getCheckbox('expireModifiedLayouts'), $changedSettings);
+ $displayProfile->setSetting('expireModifiedLayouts', $sanitizedParams->getCheckbox('expireModifiedLayouts'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('maxConcurrentDownloads')) {
+ $this->handleChangedSettings('maxConcurrentDownloads', ($ownConfig) ? $displayProfile->getSetting('maxConcurrentDownloads') : $display->getSetting('maxConcurrentDownloads'), $sanitizedParams->getInt('maxConcurrentDownloads'), $changedSettings);
+ $displayProfile->setSetting('maxConcurrentDownloads', $sanitizedParams->getInt('maxConcurrentDownloads'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('shellCommandAllowList')) {
+ $this->handleChangedSettings('shellCommandAllowList', ($ownConfig) ? $displayProfile->getSetting('shellCommandAllowList') : $display->getSetting('shellCommandAllowList'), $sanitizedParams->getString('shellCommandAllowList'), $changedSettings);
+ $displayProfile->setSetting('shellCommandAllowList', $sanitizedParams->getString('shellCommandAllowList'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sendCurrentLayoutAsStatusUpdate')) {
+ $this->handleChangedSettings('sendCurrentLayoutAsStatusUpdate', ($ownConfig) ? $displayProfile->getSetting('sendCurrentLayoutAsStatusUpdate') : $display->getSetting('sendCurrentLayoutAsStatusUpdate'), $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $changedSettings);
+ $displayProfile->setSetting('sendCurrentLayoutAsStatusUpdate', $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotRequestInterval')) {
+ $this->handleChangedSettings('screenShotRequestInterval', ($ownConfig) ? $displayProfile->getSetting('screenShotRequestInterval') : $display->getSetting('screenShotRequestInterval'), $sanitizedParams->getInt('screenShotRequestInterval'), $changedSettings);
+ $displayProfile->setSetting('screenShotRequestInterval', $sanitizedParams->getInt('screenShotRequestInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotSize')) {
+ $this->handleChangedSettings('screenShotSize', ($ownConfig) ? $displayProfile->getSetting('screenShotSize') : $display->getSetting('screenShotSize'), $sanitizedParams->getInt('screenShotSize'), $changedSettings);
+ $displayProfile->setSetting('screenShotSize', $sanitizedParams->getInt('screenShotSize'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('maxLogFileUploads')) {
+ $this->handleChangedSettings('maxLogFileUploads', ($ownConfig) ? $displayProfile->getSetting('maxLogFileUploads') : $display->getSetting('maxLogFileUploads'), $sanitizedParams->getInt('maxLogFileUploads'), $changedSettings);
+ $displayProfile->setSetting('maxLogFileUploads', $sanitizedParams->getInt('maxLogFileUploads'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('embeddedServerPort')) {
+ $this->handleChangedSettings('embeddedServerPort',($ownConfig) ? $displayProfile->getSetting('embeddedServerPort') : $display->getSetting('embeddedServerPort'), $sanitizedParams->getInt('embeddedServerPort'), $changedSettings);
+ $displayProfile->setSetting('embeddedServerPort', $sanitizedParams->getInt('embeddedServerPort'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('preventSleep')) {
+ $this->handleChangedSettings('preventSleep',($ownConfig) ? $displayProfile->getSetting('preventSleep') : $display->getSetting('preventSleep'), $sanitizedParams->getCheckbox('preventSleep'), $changedSettings);
+ $displayProfile->setSetting('preventSleep', $sanitizedParams->getCheckbox('preventSleep'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('forceHttps')) {
+ $this->handleChangedSettings('forceHttps', ($ownConfig) ? $displayProfile->getSetting('forceHttps') : $display->getSetting('forceHttps'), $sanitizedParams->getCheckbox('forceHttps'), $changedSettings);
+ $displayProfile->setSetting('forceHttps', $sanitizedParams->getCheckbox('forceHttps'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('embeddedServerAllowWan')) {
+ $this->handleChangedSettings('embeddedServerAllowWan', ($ownConfig) ? $displayProfile->getSetting('embeddedServerAllowWan') : $display->getSetting('embeddedServerAllowWan'), $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $changedSettings);
+ $displayProfile->setSetting('embeddedServerAllowWan', $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $ownConfig, $config);
+ }
+
+ break;
+
+ case 'lg':
+ case 'sssp':
+
+ if ($sanitizedParams->hasParam('emailAddress')) {
+ $this->handleChangedSettings('emailAddress', ($ownConfig) ? $displayProfile->getSetting('emailAddress') : $display->getSetting('emailAddress'), $sanitizedParams->getString('emailAddress'), $changedSettings);
+ $displayProfile->setSetting('emailAddress', $sanitizedParams->getString('emailAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('collectInterval')) {
+ $this->handleChangedSettings('collectInterval', ($ownConfig) ? $displayProfile->getSetting('collectInterval') : $display->getSetting('collectInterval'), $sanitizedParams->getInt('collectInterval'), $changedSettings);
+ $displayProfile->setSetting('collectInterval', $sanitizedParams->getInt('collectInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadStartWindow')) {
+ $this->handleChangedSettings('downloadStartWindow', ($ownConfig) ? $displayProfile->getSetting('downloadStartWindow') : $display->getSetting('downloadStartWindow'), $sanitizedParams->getString('downloadStartWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadStartWindow', $sanitizedParams->getString('downloadStartWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('downloadEndWindow')) {
+ $this->handleChangedSettings('downloadEndWindow', ($ownConfig) ? $displayProfile->getSetting('downloadEndWindow') : $display->getSetting('downloadEndWindow'), $sanitizedParams->getString('downloadEndWindow'), $changedSettings);
+ $displayProfile->setSetting('downloadEndWindow', $sanitizedParams->getString('downloadEndWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('updateStartWindow')) {
+ $this->handleChangedSettings('updateStartWindow', ($ownConfig) ? $displayProfile->getSetting('updateStartWindow') : $display->getSetting('updateStartWindow'), $sanitizedParams->getString('updateStartWindow'), $changedSettings);
+ $displayProfile->setSetting('updateStartWindow', $sanitizedParams->getString('updateStartWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('updateEndWindow')) {
+ $this->handleChangedSettings('updateEndWindow', ($ownConfig) ? $displayProfile->getSetting('updateEndWindow') : $display->getSetting('updateEndWindow'), $sanitizedParams->getString('updateEndWindow'), $changedSettings);
+ $displayProfile->setSetting('updateEndWindow', $sanitizedParams->getString('updateEndWindow'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('dayPartId')) {
+ $this->handleChangedSettings('dayPartId', ($ownConfig) ? $displayProfile->getSetting('dayPartId') : $display->getSetting('dayPartId'), $sanitizedParams->getInt('dayPartId'), $changedSettings);
+ $displayProfile->setSetting('dayPartId', $sanitizedParams->getInt('dayPartId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrNetworkAddress')) {
+ $this->handleChangedSettings('xmrNetworkAddress',($ownConfig) ? $displayProfile->getSetting('xmrNetworkAddress') : $display->getSetting('xmrNetworkAddress'), $sanitizedParams->getString('xmrNetworkAddress'), $changedSettings);
+ $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrWebSocketAddress')) {
+ $this->handleChangedSettings(
+ 'xmrWebSocketAddress',
+ ($ownConfig)
+ ? $displayProfile->getSetting('xmrWebSocketAddress')
+ : $display->getSetting('xmrWebSocketAddress'),
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'xmrWebSocketAddress',
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('statsEnabled')) {
+ $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings);
+ $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('aggregationLevel')) {
+ $this->handleChangedSettings('aggregationLevel', ($ownConfig) ? $displayProfile->getSetting('aggregationLevel') : $display->getSetting('aggregationLevel'), $sanitizedParams->getString('aggregationLevel'), $changedSettings);
+ $displayProfile->setSetting('aggregationLevel', $sanitizedParams->getString('aggregationLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('orientation')) {
+ $this->handleChangedSettings('orientation',($ownConfig) ? $displayProfile->getSetting('orientation') : $display->getSetting('orientation'), $sanitizedParams->getInt('orientation'), $changedSettings);
+ $displayProfile->setSetting('orientation', $sanitizedParams->getInt('orientation'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('logLevel')) {
+ $this->handleChangedSettings('logLevel', ($ownConfig) ? $displayProfile->getSetting('logLevel') : $display->getSetting('logLevel'), $sanitizedParams->getString('logLevel'), $changedSettings);
+ $displayProfile->setSetting('logLevel', $sanitizedParams->getString('logLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('elevateLogsUntil')) {
+ $this->handleChangedSettings(
+ 'elevateLogsUntil',
+ ($ownConfig)
+ ? $displayProfile->getSetting('elevateLogsUntil')
+ : $display->getSetting('elevateLogsUntil'),
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'elevateLogsUntil',
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('versionMediaId')) {
+ $this->handleChangedSettings('versionMediaId', ($ownConfig) ? $displayProfile->getSetting('versionMediaId') : $display->getSetting('versionMediaId'), $sanitizedParams->getInt('versionMediaId'), $changedSettings);
+ $displayProfile->setSetting('versionMediaId', $sanitizedParams->getInt('versionMediaId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('actionBarMode')) {
+ $this->handleChangedSettings('actionBarMode', ($ownConfig) ? $displayProfile->getSetting('actionBarMode') : $display->getSetting('actionBarMode'), $sanitizedParams->getInt('actionBarMode'), $changedSettings);
+ $displayProfile->setSetting('actionBarMode', $sanitizedParams->getInt('actionBarMode'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('actionBarDisplayDuration')) {
+ $this->handleChangedSettings('actionBarDisplayDuration', ($ownConfig) ? $displayProfile->getSetting('actionBarDisplayDuration') : $display->getSetting('actionBarDisplayDuration'), $sanitizedParams->getInt('actionBarDisplayDuration'), $changedSettings);
+ $displayProfile->setSetting('actionBarDisplayDuration', $sanitizedParams->getInt('actionBarDisplayDuration'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('sendCurrentLayoutAsStatusUpdate')) {
+ $this->handleChangedSettings('sendCurrentLayoutAsStatusUpdate', ($ownConfig) ? $displayProfile->getSetting('sendCurrentLayoutAsStatusUpdate') : $display->getSetting('sendCurrentLayoutAsStatusUpdate'), $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $changedSettings);
+ $displayProfile->setSetting('sendCurrentLayoutAsStatusUpdate', $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotSize')) {
+ $this->handleChangedSettings('screenShotSize', ($ownConfig) ? $displayProfile->getSetting('screenShotSize') : $display->getSetting('screenShotSize'), $sanitizedParams->getInt('screenShotSize'), $changedSettings);
+ $displayProfile->setSetting('screenShotSize', $sanitizedParams->getInt('screenShotSize'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('mediaInventoryTimer')) {
+ $this->handleChangedSettings('mediaInventoryTimer',($ownConfig) ? $displayProfile->getSetting('mediaInventoryTimer') : $display->getSetting('mediaInventoryTimer'), $sanitizedParams->getInt('mediaInventoryTimer'), $changedSettings);
+ $displayProfile->setSetting('mediaInventoryTimer', $sanitizedParams->getInt('mediaInventoryTimer'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('forceHttps')) {
+ $this->handleChangedSettings('forceHttps', ($ownConfig) ? $displayProfile->getSetting('forceHttps') : $display->getSetting('forceHttps'), $sanitizedParams->getCheckbox('forceHttps'), $changedSettings);
+ $displayProfile->setSetting('forceHttps', $sanitizedParams->getCheckbox('forceHttps'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('serverPort')) {
+ $this->handleChangedSettings('serverPort', ($ownConfig) ? $displayProfile->getSetting('serverPort') : $display->getSetting('serverPort'), $sanitizedParams->getInt('serverPort'), $changedSettings);
+ $displayProfile->setSetting('serverPort', $sanitizedParams->getInt('serverPort'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('embeddedServerAllowWan')) {
+ $this->handleChangedSettings('embeddedServerAllowWan', ($ownConfig) ? $displayProfile->getSetting('embeddedServerAllowWan') : $display->getSetting('embeddedServerAllowWan'), $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $changedSettings);
+ $displayProfile->setSetting('embeddedServerAllowWan', $sanitizedParams->getCheckbox('embeddedServerAllowWan'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotRequestInterval')) {
+ $this->handleChangedSettings('screenShotRequestInterval', ($ownConfig) ? $displayProfile->getSetting('screenShotRequestInterval') : $display->getSetting('screenShotRequestInterval'), $sanitizedParams->getInt('screenShotRequestInterval'), $changedSettings);
+ $displayProfile->setSetting('screenShotRequestInterval', $sanitizedParams->getInt('screenShotRequestInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('timers')) {
+ // Options object to be converted to a JSON string
+ $timerOptions = (object)[];
+
+ $timers = $sanitizedParams->getArray('timers');
+
+ foreach ($timers as $timer) {
+ $timerDay = $timer['day'];
+
+ if (sizeof($timers) == 1 && $timerDay == '') {
+ break;
+ } else {
+ if ($timerDay == '' || property_exists($timerOptions, $timerDay)) {
+ // Repeated or Empty day input, throw exception
+ throw new InvalidArgumentException(__('On/Off Timers: Please check the days selected and remove the duplicates or empty'),
+ 'timers');
+ } else {
+ // Get time values
+ $timerOn = $timer['on'];
+ $timerOff = $timer['off'];
+
+ // Check the on/off times are in the correct format (H:i)
+ if (strlen($timerOn) != 5 || strlen($timerOff) != 5) {
+ throw new InvalidArgumentException(__('On/Off Timers: Please enter a on and off date for any row with a day selected, or remove that row'),
+ 'timers');
+ } else {
+ //Build object and add it to the main options object
+ $temp = [];
+ $temp['on'] = $timerOn;
+ $temp['off'] = $timerOff;
+ $timerOptions->$timerDay = $temp;
+ }
+ }
+ }
+ }
+
+ $this->handleChangedSettings('timers', ($ownConfig) ? $displayProfile->getSetting('timers') : $display->getSetting('timers'), json_encode($timerOptions), $changedSettings);
+ // Encode option and save it as a string to the lock setting
+ $displayProfile->setSetting('timers', json_encode($timerOptions), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('pictureControls')) {
+ // Options object to be converted to a JSON string
+ $pictureControlsOptions = (object)[];
+
+ // Special string properties map
+ $specialProperties = (object)[];
+ $specialProperties->dynamicContrast = ["off", "low", "medium", "high"];
+ $specialProperties->superResolution = ["off", "low", "medium", "high"];
+ $specialProperties->colorGamut = ["normal", "extended"];
+ $specialProperties->dynamicColor = ["off", "low", "medium", "high"];
+ $specialProperties->noiseReduction = ["auto", "off", "low", "medium", "high"];
+ $specialProperties->mpegNoiseReduction = ["auto", "off", "low", "medium", "high"];
+ $specialProperties->blackLevel = ["low", "high"];
+ $specialProperties->gamma = ["low", "medium", "high", "high2"];
+
+ // Get array from request
+ $pictureControls = $sanitizedParams->getArray('pictureControls');
+
+ foreach ($pictureControls as $pictureControl) {
+ $propertyName = $pictureControl['property'];
+
+ if (sizeof($pictureControls) == 1 && $propertyName == '') {
+ break;
+ } else {
+ if ($propertyName == '' || property_exists($pictureControlsOptions, $propertyName)) {
+ // Repeated or Empty property input, throw exception
+ throw new InvalidArgumentException(__('Picture: Please check the settings selected and remove the duplicates or empty'),
+ 'pictureOptions');
+ } else {
+ // Get time values
+ $propertyValue = $pictureControl['value'];
+
+ // Check the on/off times are in the correct format (H:i)
+ if (property_exists($specialProperties, $propertyName)) {
+ $pictureControlsOptions->$propertyName = $specialProperties->$propertyName[$propertyValue];
+ } else {
+ //Build object and add it to the main options object
+ $pictureControlsOptions->$propertyName = (int)$propertyValue;
+ }
+ }
+ }
+ }
+
+ $this->handleChangedSettings('pictureOptions', ($ownConfig) ? $displayProfile->getSetting('pictureOptions') : $display->getSetting('pictureOptions'), json_encode($pictureControlsOptions), $changedSettings);
+ // Encode option and save it as a string to the lock setting
+ $displayProfile->setSetting('pictureOptions', json_encode($pictureControlsOptions), $ownConfig, $config);
+ }
+
+ // Get values from lockOptions params
+ $usblock = $sanitizedParams->getString('usblock', ['default' => 'empty']);
+ $osdlock = $sanitizedParams->getString('osdlock', ['default' => 'empty']);
+ $keylockLocal = $sanitizedParams->getString('keylockLocal', ['default' => '']);
+ $keylockRemote = $sanitizedParams->getString('keylockRemote', ['default' => '']);
+
+ // Options object to be converted to a JSON string
+ $lockOptions = (object)[];
+
+ if ($usblock != 'empty' && $displayProfile->type == 'lg') {
+ $lockOptions->usblock = $usblock === 'true' ? true : false;
+ }
+
+ if ($osdlock != 'empty') {
+ $lockOptions->osdlock = $osdlock === 'true' ? true : false;
+ }
+
+ if ($keylockLocal != '' || $keylockRemote != '') {
+ // Keylock sub object
+ $lockOptions->keylock = (object)[];
+
+ if ($keylockLocal != '') {
+ $lockOptions->keylock->local = $keylockLocal;
+ }
+
+ if ($keylockRemote != '') {
+ $lockOptions->keylock->remote = $keylockRemote;
+ }
+ }
+
+ $this->handleChangedSettings('lockOptions', ($ownConfig) ? $displayProfile->getSetting('lockOptions') : $display->getSetting('lockOptions'), json_encode($lockOptions), $changedSettings);
+ // Encode option and save it as a string to the lock setting
+ $displayProfile->setSetting('lockOptions', json_encode($lockOptions), $ownConfig, $config);
+
+ // Multiple video decoders
+ if ($sanitizedParams->hasParam('isUseMultipleVideoDecoders')) {
+ $this->handleChangedSettings(
+ 'isUseMultipleVideoDecoders',
+ ($ownConfig)
+ ? $displayProfile->getSetting('isUseMultipleVideoDecoders')
+ : $display->getSetting('isUseMultipleVideoDecoders'),
+ $sanitizedParams->getString('isUseMultipleVideoDecoders'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'isUseMultipleVideoDecoders',
+ $sanitizedParams->getString('isUseMultipleVideoDecoders'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ break;
+
+ case 'chromeOS':
+ if ($sanitizedParams->hasParam('licenceCode')) {
+ $this->handleChangedSettings('licenceCode', ($ownConfig) ? $displayProfile->getSetting('licenceCode') : $display->getSetting('licenceCode'), $sanitizedParams->getString('licenceCode'), $changedSettings);
+ $displayProfile->setSetting('licenceCode', $sanitizedParams->getString('licenceCode'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('collectInterval')) {
+ $this->handleChangedSettings('collectInterval', ($ownConfig) ? $displayProfile->getSetting('collectInterval') : $display->getSetting('collectInterval'), $sanitizedParams->getInt('collectInterval'), $changedSettings);
+ $displayProfile->setSetting('collectInterval', $sanitizedParams->getInt('collectInterval'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('dayPartId')) {
+ $this->handleChangedSettings('dayPartId', ($ownConfig) ? $displayProfile->getSetting('dayPartId') : $display->getSetting('dayPartId'), $sanitizedParams->getInt('dayPartId'), $changedSettings);
+ $displayProfile->setSetting('dayPartId', $sanitizedParams->getInt('dayPartId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrNetworkAddress')) {
+ $this->handleChangedSettings('xmrNetworkAddress',($ownConfig) ? $displayProfile->getSetting('xmrNetworkAddress') : $display->getSetting('xmrNetworkAddress'), $sanitizedParams->getString('xmrNetworkAddress'), $changedSettings);
+ $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('xmrWebSocketAddress')) {
+ $this->handleChangedSettings(
+ 'xmrWebSocketAddress',
+ ($ownConfig)
+ ? $displayProfile->getSetting('xmrWebSocketAddress')
+ : $display->getSetting('xmrWebSocketAddress'),
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'xmrWebSocketAddress',
+ $sanitizedParams->getString('xmrWebSocketAddress'),
+
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('statsEnabled')) {
+ $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings);
+ $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('aggregationLevel')) {
+ $this->handleChangedSettings('aggregationLevel', ($ownConfig) ? $displayProfile->getSetting('aggregationLevel') : $display->getSetting('aggregationLevel'), $sanitizedParams->getString('aggregationLevel'), $changedSettings);
+ $displayProfile->setSetting('aggregationLevel', $sanitizedParams->getString('aggregationLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('logLevel')) {
+ $this->handleChangedSettings('logLevel', ($ownConfig) ? $displayProfile->getSetting('logLevel') : $display->getSetting('logLevel'), $sanitizedParams->getString('logLevel'), $changedSettings);
+ $displayProfile->setSetting('logLevel', $sanitizedParams->getString('logLevel'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('elevateLogsUntil')) {
+ $this->handleChangedSettings(
+ 'elevateLogsUntil',
+ ($ownConfig)
+ ? $displayProfile->getSetting('elevateLogsUntil')
+ : $display->getSetting('elevateLogsUntil'),
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'elevateLogsUntil',
+ $sanitizedParams->getDate('elevateLogsUntil')?->format('U'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('sendCurrentLayoutAsStatusUpdate')) {
+ $this->handleChangedSettings(
+ 'sendCurrentLayoutAsStatusUpdate',
+ ($ownConfig)
+ ? $displayProfile->getSetting('sendCurrentLayoutAsStatusUpdate')
+ : $display->getSetting('sendCurrentLayoutAsStatusUpdate'),
+ $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'),
+ $changedSettings
+ );
+ $displayProfile->setSetting(
+ 'sendCurrentLayoutAsStatusUpdate',
+ $sanitizedParams->getCheckbox('sendCurrentLayoutAsStatusUpdate'),
+ $ownConfig,
+ $config
+ );
+ }
+
+ if ($sanitizedParams->hasParam('playerVersionId')) {
+ $this->handleChangedSettings('playerVersionId', ($ownConfig) ? $displayProfile->getSetting('playerVersionId') : $display->getSetting('playerVersionId'), $sanitizedParams->getInt('playerVersionId'), $changedSettings);
+ $displayProfile->setSetting('playerVersionId', $sanitizedParams->getInt('playerVersionId'), $ownConfig, $config);
+ }
+
+ if ($sanitizedParams->hasParam('screenShotSize')) {
+ $this->handleChangedSettings('screenShotSize', ($ownConfig) ? $displayProfile->getSetting('screenShotSize') : $display->getSetting('screenShotSize'), $sanitizedParams->getInt('screenShotSize'), $changedSettings);
+ $displayProfile->setSetting('screenShotSize', $sanitizedParams->getInt('screenShotSize'), $ownConfig, $config);
+ }
+
+ break;
+
+ default:
+ if ($displayProfile->isCustom()) {
+ $this->getLog()->info('Edit for custom Display profile type ' . $displayProfile->getClientType());
+ $config = $displayProfile->handleCustomFields($sanitizedParams, $config, $display);
+ } else {
+ $this->getLog()->info('Edit for unknown type ' . $displayProfile->getClientType());
+ }
+ }
+
+ if ($changedSettings != []) {
+ $this->getLog()->audit( ($ownConfig) ? 'DisplayProfile' : 'Display', ($ownConfig) ? $displayProfile->displayProfileId : $display->displayId, ($ownConfig) ? 'Updated' : 'Display Saved', $changedSettings);
+ }
+
+ return $config;
+ }
+
+ private function handleChangedSettings($setting, $oldValue, $newValue, &$changedSettings)
+ {
+ if ($oldValue != $newValue) {
+ $changedSettings[$setting] = $oldValue . ' > ' . $newValue;
+ }
+ }
+}
diff --git a/lib/Controller/Fault.php b/lib/Controller/Fault.php
new file mode 100644
index 0000000..6166764
--- /dev/null
+++ b/lib/Controller/Fault.php
@@ -0,0 +1,270 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LogFactory;
+use Xibo\Helper\Environment;
+use Xibo\Helper\Random;
+use Xibo\Helper\SendFile;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Fault
+ * @package Xibo\Controller
+ */
+class Fault extends Base
+{
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /**
+ * @var LogFactory
+ */
+ private $logFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param LogFactory $logFactory
+ * @param DisplayFactory $displayFactory
+ */
+ public function __construct($store, $logFactory, $displayFactory)
+ {
+ $this->store = $store;
+ $this->logFactory = $logFactory;
+ $this->displayFactory = $displayFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $url = $request->getUri() . $request->getUri()->getPath();
+
+ $config = $this->getConfig();
+ $data = [
+ 'environmentCheck' => $config->checkEnvironment(),
+ 'environmentFault' => $config->envFault,
+ 'environmentWarning' => $config->envWarning,
+ 'binLogError' => ($config->checkBinLogEnabled() && !$config->checkBinLogFormat()),
+ 'urlError' => !Environment::checkUrl($url)
+ ];
+
+ $this->getState()->template = 'fault-page';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function collect(Request $request, Response $response)
+ {
+ $this->setNoOutput(true);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Create a ZIP file
+ $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . Random::generateString();
+ $zip = new \ZipArchive();
+
+ $result = $zip->open($tempFileName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+ if ($result !== true) {
+ throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: ' . $result));
+ }
+
+ // Decide what we output based on the options selected.
+ $outputVersion = $sanitizedParams->getCheckbox('outputVersion') == 1;
+ $outputLog = $sanitizedParams->getCheckbox('outputLog') == 1;
+ $outputEnvCheck = $sanitizedParams->getCheckbox('outputEnvCheck') == 1;
+ $outputSettings = $sanitizedParams->getCheckbox('outputSettings') == 1;
+ $outputDisplays = $sanitizedParams->getCheckbox('outputDisplays') == 1;
+ $outputDisplayProfile = $sanitizedParams->getCheckbox('outputDisplayProfile') == 1;
+
+ if (!$outputVersion &&
+ !$outputLog &&
+ !$outputEnvCheck &&
+ !$outputSettings &&
+ !$outputDisplays &&
+ !$outputDisplayProfile
+ ) {
+ throw new InvalidArgumentException(__('Please select at least one option'));
+ }
+
+ $environmentVariables = [
+ 'app_ver' => Environment::$WEBSITE_VERSION_NAME,
+ 'XmdsVersion' => Environment::$XMDS_VERSION,
+ 'XlfVersion' => Environment::$XLF_VERSION
+ ];
+
+ // Should we output the version?
+ if ($outputVersion) {
+ $zip->addFromString('version.json', json_encode($environmentVariables, JSON_PRETTY_PRINT));
+ }
+
+ // Should we output a log?
+ if ($outputLog) {
+ $tempLogFile = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/log_' . Random::generateString();
+ $out = fopen($tempLogFile, 'w');
+ fputcsv(
+ $out,
+ [
+ 'logId',
+ 'runNo',
+ 'logDate',
+ 'channel',
+ 'page',
+ 'function',
+ 'message',
+ 'display.display',
+ 'type',
+ 'sessionHistoryId'
+ ]
+ );
+
+ $fromDt = Carbon::now()->subSeconds(60 * 10)->format('U');
+ // Do some post processing
+ foreach ($this->logFactory->query(['logId'], ['fromDt' => $fromDt]) as $row) {
+ /* @var \Xibo\Entity\LogEntry $row */
+ fputcsv(
+ $out,
+ [
+ $row->logId,
+ $row->runNo,
+ $row->logDate,
+ $row->channel,
+ $row->page,
+ $row->function,
+ $row->message,
+ $row->display,
+ $row->type,
+ $row->sessionHistoryId
+ ]
+ );
+ }
+
+ fclose($out);
+
+ $zip->addFile($tempLogFile, 'log.csv');
+ }
+
+ // Output ENV Check
+ if ($outputEnvCheck) {
+ $zip->addFromString('environment.json', json_encode(array_map(function ($element) {
+ unset($element['advice']);
+ return $element;
+ }, $this->getConfig()->checkEnvironment()), JSON_PRETTY_PRINT));
+ }
+
+ // Output Settings
+ if ($outputSettings) {
+ $zip->addFromString('settings.json', json_encode(array_map(function ($element) {
+ return [$element['setting'] => $element['value']];
+ }, $this->store->select('SELECT setting, `value` FROM `setting`', [])), JSON_PRETTY_PRINT));
+ }
+
+ // Output Displays
+ if ($outputDisplays) {
+ $displays = $this->displayFactory->query(['display']);
+
+ // Output Profiles
+ if ($outputDisplayProfile) {
+ foreach ($displays as $display) {
+ /** @var \Xibo\Entity\Display $display */
+ $display->setUnmatchedProperty('settingProfile', array_map(function ($element) {
+ unset($element['helpText']);
+ return $element;
+ }, $display->getSettings()));
+ }
+ }
+
+ $zip->addFromString('displays.json', json_encode($displays, JSON_PRETTY_PRINT));
+ }
+
+ // Close the ZIP file
+ $zip->close();
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $tempFileName,
+ 'troubleshoot.zip'
+ ));
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function debugOn(Request $request, Response $response)
+ {
+ $this->getConfig()->changeSetting('audit', 'debug');
+ $this->getConfig()->changeSetting('ELEVATE_LOG_UNTIL', Carbon::now()->addMinutes(30)->format('U'));
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Switched to Debug Mode')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function debugOff(Request $request, Response $response)
+ {
+ $this->getConfig()->changeSetting('audit', $this->getConfig()->getSetting('RESTING_LOG_LEVEL'));
+ $this->getConfig()->changeSetting('ELEVATE_LOG_UNTIL', '');
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Switched to Normal Mode')
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Folder.php b/lib/Controller/Folder.php
new file mode 100644
index 0000000..bd8b011
--- /dev/null
+++ b/lib/Controller/Folder.php
@@ -0,0 +1,564 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\FolderFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+class Folder extends Base
+{
+ /**
+ * @var FolderFactory
+ */
+ private $folderFactory;
+
+ /**
+ * Set common dependencies.
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct(FolderFactory $folderFactory)
+ {
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'folders-page';
+ $this->getState()->setData([]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns JSON representation of the Folder tree
+ *
+ * @SWG\Get(
+ * path="/folders",
+ * operationId="folderSearch",
+ * tags={"folder"},
+ * summary="Search Folders",
+ * description="Returns JSON representation of the Folder tree",
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="path",
+ * description="Show usage details for the specified Folder Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="gridView",
+ * in="query",
+ * description="Flag (0, 1), Show Folders in a standard grid response",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Use with gridView, Filter by Folder Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderName",
+ * in="query",
+ * description="Use with gridView, Filter by Folder name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactFolderName",
+ * in="query",
+ * description="Use with gridView, Filter by exact Folder name match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Folder")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response, $folderId = null)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ if ($params->getInt('gridView') === 1) {
+ $folders = $this->folderFactory->query($this->gridRenderSort($params), $this->gridRenderFilter([
+ 'folderName' => $params->getString('folderName'),
+ 'folderId' => $params->getInt('folderId'),
+ 'exactFolderName' => $params->getInt('exactFolderName'),
+ ], $params));
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->folderFactory->countLast();
+ $this->getState()->setData($folders);
+
+ return $this->render($request, $response);
+ } else if ($params->getString('folderName') !== null) {
+ // Search all folders by name
+ $folders = $this->folderFactory->query($this->gridRenderSort($params), $this->gridRenderFilter([
+ 'folderName' => $params->getString('folderName'),
+ 'exactFolderName' => $params->getInt('exactFolderName'),
+ ], $params));
+
+ return $response->withJson($folders);
+ } else if ($folderId !== null) {
+ // Should we return information for a specific folder?
+ $folder = $this->folderFactory->getById($folderId);
+
+ $this->decorateWithButtons($folder);
+ $this->folderFactory->decorateWithHomeFolderCount($folder);
+ $this->folderFactory->decorateWithSharing($folder);
+ $this->folderFactory->decorateWithUsage($folder);
+
+ return $response->withJson($folder);
+ } else {
+ // Show a tree view of all folders.
+ $rootFolder = $this->folderFactory->getById(1);
+
+ // homeFolderId,
+ // do we show tree for current user
+ // or a specified user?
+ $homeFolderId = ($params->getInt('homeFolderId') !== null)
+ ? $params->getInt('homeFolderId')
+ : $this->getUser()->homeFolderId;
+
+ $this->buildTreeView($rootFolder, $homeFolderId);
+ return $response->withJson([$rootFolder]);
+ }
+ }
+
+ /**
+ * @param \Xibo\Entity\Folder $folder
+ * @param int $homeFolderId
+ * @throws InvalidArgumentException
+ */
+ private function buildTreeView(\Xibo\Entity\Folder $folder, int $homeFolderId)
+ {
+ // Set the folder type
+ $folder->type = '';
+ if ($folder->isRoot === 1) {
+ $folder->type = 'root';
+ }
+
+ if ($homeFolderId === $folder->id) {
+ $folder->type = 'home';
+ }
+
+ if (!empty($folder->children)) {
+ $children = array_filter(explode(',', $folder->children));
+ } else {
+ $children = [];
+ }
+ $childrenDetails = [];
+
+ foreach ($children as $childId) {
+ try {
+ $child = $this->folderFactory->getById($childId);
+
+ if ($child->children != null) {
+ $this->buildTreeView($child, $homeFolderId);
+ }
+
+ if (!$this->getUser()->checkViewable($child)) {
+ $child->text = __('Private Folder');
+ $child->type = 'disabled';
+ }
+
+ if ($homeFolderId === $child->id) {
+ $child->type = 'home';
+ }
+
+ $childrenDetails[] = $child;
+ } catch (NotFoundException $exception) {
+ // this should be fine, just log debug message about it.
+ $this->getLog()->debug('User does not have permissions to Folder ID ' . $childId);
+ }
+ }
+
+ $folder->children = $childrenDetails;
+ }
+
+ /**
+ * Add a new Folder
+ *
+ * @SWG\Post(
+ * path="/folders",
+ * operationId="folderAdd",
+ * tags={"folder"},
+ * summary="Add Folder",
+ * description="Add a new Folder to the specified parent Folder",
+ * @SWG\Parameter(
+ * name="text",
+ * in="formData",
+ * description="Folder Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="parentId",
+ * in="formData",
+ * description="The ID of the parent Folder, if not provided, Folder will be added under Root Folder",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * @SWG\Items(ref="#/definitions/Folder")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $folder = $this->folderFactory->createEmpty();
+ $folder->text = $sanitizedParams->getString('text');
+ $folder->parentId = $sanitizedParams->getString('parentId', ['default' => 1]);
+
+ $folder->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Added %s'), $folder->text),
+ 'id' => $folder->id,
+ 'data' => $folder
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit existing Folder
+ *
+ * @SWG\Put(
+ * path="/folders/{folderId}",
+ * operationId="folderEdit",
+ * tags={"folder"},
+ * summary="Edit Folder",
+ * description="Edit existing Folder",
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="path",
+ * description="Folder ID to edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="text",
+ * in="formData",
+ * description="Folder Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * @SWG\Items(ref="#/definitions/Folder")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $folderId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function edit(Request $request, Response $response, $folderId)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $folder = $this->folderFactory->getById($folderId);
+
+ if ($folder->isRoot === 1) {
+ throw new InvalidArgumentException(__('Cannot edit root Folder'), 'isRoot');
+ }
+
+ if (!$this->getUser()->checkEditable($folder)) {
+ throw new AccessDeniedException();
+ }
+
+ $folder->text = $sanitizedParams->getString('text');
+
+ $folder->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $folder->text),
+ 'id' => $folder->id,
+ 'data' => $folder
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete existing Folder
+ *
+ * @SWG\Delete(
+ * path="/folders/{folderId}",
+ * operationId="folderDelete",
+ * tags={"folder"},
+ * summary="Delete Folder",
+ * description="Delete existing Folder",
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="path",
+ * description="Folder ID to edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation",
+ * @SWG\Schema(
+ * @SWG\Items(ref="#/definitions/Folder")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $folderId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function delete(Request $request, Response $response, $folderId)
+ {
+ $folder = $this->folderFactory->getById($folderId);
+ $folder->load();
+
+ if ($folder->isRoot === 1) {
+ throw new InvalidArgumentException(__('Cannot remove root Folder'), 'isRoot');
+ }
+
+ if (!$this->getUser()->checkDeleteable($folder)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($folder->isHome()) {
+ throw new InvalidArgumentException(
+ __('Cannot remove Folder set as home Folder for a user'),
+ 'folderId',
+ __('Change home Folder for Users using this Folder before deleting')
+ );
+ }
+
+ if ($folder->id == $this->getConfig()->getSetting('DISPLAY_DEFAULT_FOLDER')) {
+ throw new InvalidArgumentException(
+ __('Cannot remove Folder set as default Folder for new Displays'),
+ 'folderId',
+ __('Change Default Folder for new Displays before deleting')
+ );
+ }
+
+ // Check if the folder is in use
+ $this->folderFactory->decorateWithUsage($folder);
+ $usage = $folder->getUnmatchedProperty('usage');
+
+ // Prevent deletion if the folder has any usage
+ if (!empty($usage)) {
+ $usageDetails = [];
+
+ // Loop through usage data and construct the formatted message
+ foreach ($usage as $item) {
+ $usageDetails[] = $item['type'] . ' (' . $item['count'] . ')';
+ }
+
+ throw new InvalidArgumentException(
+ __('Cannot remove Folder with content: ' . implode(', ', $usageDetails)),
+ 'folderId',
+ __('Reassign objects from this Folder before deleting.')
+ );
+ }
+
+ try {
+ $folder->delete();
+ } catch (\Exception $exception) {
+ $this->getLog()->debug('Folder delete failed with message: ' . $exception->getMessage());
+ throw new InvalidArgumentException(
+ __('Cannot remove Folder with content'),
+ 'folderId',
+ __('Reassign objects from this Folder before deleting.')
+ );
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Deleted %s'), $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $folderId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function getContextMenuButtons(Request $request, Response $response, $folderId)
+ {
+ $folder = $this->folderFactory->getById($folderId);
+ $this->decorateWithButtons($folder);
+
+ return $response->withJson($folder->buttons);
+ }
+
+ private function decorateWithButtons(\Xibo\Entity\Folder $folder)
+ {
+ $user = $this->getUser();
+
+ if ($user->featureEnabled('folder.add')
+ && $user->checkViewable($folder)
+ && (!$folder->isRoot() || $user->isSuperAdmin())
+ ) {
+ $folder->buttons['create'] = true;
+ }
+
+ $featureModify = $user->featureEnabled('folder.modify');
+ if ($featureModify
+ && $user->checkEditable($folder)
+ && !$folder->isRoot()
+ && ($this->getUser()->isSuperAdmin() || $folder->getId() !== $this->getUser()->homeFolderId)
+ ) {
+ $folder->buttons['modify'] = true;
+ }
+
+ if ($featureModify
+ && $user->checkDeleteable($folder)
+ && !$folder->isRoot()
+ && ($this->getUser()->isSuperAdmin() || $folder->getId() !== $this->getUser()->homeFolderId)
+ ) {
+ $folder->buttons['delete'] = true;
+ }
+
+ if ($user->isSuperAdmin() && !$folder->isRoot()) {
+ $folder->buttons['share'] = true;
+ }
+
+ if (!$folder->isRoot() && $user->checkViewable($folder) && $user->featureEnabled('folder.modify')) {
+ $folder->buttons['move'] = true;
+ }
+ }
+
+ public function moveForm(Request $request, Response $response, $folderId)
+ {
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ if (!$this->getUser()->checkEditable($folder)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'folder-form-move';
+ $this->getState()->setData([
+ 'folder' => $folder,
+ 'deletable' => $this->getUser()->checkDeleteable($folder)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ public function move(Request $request, Response $response, $folderId)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $folder = $this->folderFactory->getById($folderId);
+ $newParentFolder = $this->folderFactory->getById($params->getInt('folderId'), 0);
+
+ if (!$this->getUser()->checkEditable($folder)
+ || $folder->isRoot()
+ || !$this->getUser()->checkViewable($newParentFolder)
+ ) {
+ throw new AccessDeniedException();
+ }
+
+ if ($folder->id === $params->getInt('folderId')) {
+ throw new InvalidArgumentException(
+ __('Please select different folder, cannot move Folder to the same Folder')
+ );
+ }
+
+ if ($folder->isTheSameBranch($newParentFolder->getId())) {
+ throw new InvalidArgumentException(
+ __('Please select different folder, cannot move Folder inside of one of its sub-folders')
+ );
+ }
+
+ if ($folder->parentId === $newParentFolder->getId() && $params->getCheckbox('merge') !== 1) {
+ throw new InvalidArgumentException(__('This Folder is already a sub-folder of the selected Folder, if you wish to move its content to the parent Folder, please check the merge checkbox.'));//phpcs:ignore
+ }
+
+ // if we need to merge contents of the folder, dispatch an event that will move every object inside the folder
+ // to the new folder, any sub-folders will be moved to the new parent folder keeping the tree structure.
+ if ($params->getCheckbox('merge') === 1) {
+ $event = new \Xibo\Event\FolderMovingEvent($folder, $newParentFolder, true);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+
+ // after moving event is done, we should be able to safely delete the original folder
+ $folder = $this->folderFactory->getById($folderId, 0);
+ $folder->load();
+ $folder->delete();
+ } else {
+ // if we just want to move the Folder to new parent, we move folder and its sub-folders to the new parent
+ // changing the permissionsFolderId as well if needed.
+ $folder->updateFoldersAfterMove($folder->parentId, $newParentFolder->getId());
+ }
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Font.php b/lib/Controller/Font.php
new file mode 100644
index 0000000..35ab4e9
--- /dev/null
+++ b/lib/Controller/Font.php
@@ -0,0 +1,593 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use GuzzleHttp\Psr7\Stream;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Invalidation;
+use Xibo\Factory\FontFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\HttpCacheProvider;
+use Xibo\Service\DownloadService;
+use Xibo\Service\MediaService;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Service\UploadService;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+class Font extends Base
+{
+ /**
+ * @var FontFactory
+ */
+ private $fontFactory;
+ /**
+ * @var MediaServiceInterface
+ */
+ private $mediaService;
+
+ public function __construct(FontFactory $fontFactory)
+ {
+ $this->fontFactory = $fontFactory;
+ }
+
+ public function useMediaService(MediaServiceInterface $mediaService)
+ {
+ $this->mediaService = $mediaService;
+ }
+
+ public function getMediaService(): MediaServiceInterface
+ {
+ return $this->mediaService->setUser($this->getUser());
+ }
+
+ public function getFontFactory() : FontFactory
+ {
+ return $this->fontFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ if (!$this->getUser()->featureEnabled('font.view')) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'fonts-page';
+ $this->getState()->setData([
+ 'validExt' => implode('|', $this->getValidExtensions())
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Prints out a Table of all Font items
+ *
+ * @SWG\Get(
+ * path="/fonts",
+ * operationId="fontSearch",
+ * tags={"font"},
+ * summary="Font Search",
+ * description="Search the available Fonts",
+ * @SWG\Parameter(
+ * name="id",
+ * in="query",
+ * description="Filter by Font Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by Font Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Font")
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ // Construct the SQL
+ $fonts = $this->fontFactory->query($this->gridRenderSort($parsedQueryParams), $this->gridRenderFilter([
+ 'id' => $parsedQueryParams->getInt('id'),
+ 'name' => $parsedQueryParams->getString('name'),
+ ], $parsedQueryParams));
+
+ foreach ($fonts as $font) {
+ $font->setUnmatchedProperty('fileSizeFormatted', ByteFormatter::format($font->size));
+ $font->buttons = [];
+ if ($this->isApi($request)) {
+ break;
+ }
+
+ // download the font file
+ $font->buttons[] = [
+ 'id' => 'content_button_download',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'font.download', ['id' => $font->id]),
+ 'text' => __('Download')
+ ];
+
+ // font details from fontLib and preview text
+ $font->buttons[] = [
+ 'id' => 'font_button_details',
+ 'url' => $this->urlFor($request, 'font.details', ['id' => $font->id]),
+ 'text' => __('Details')
+ ];
+
+ $font->buttons[] = ['divider' => true];
+
+ if ($this->getUser()->featureEnabled('font.delete')) {
+ // Delete Button
+ $font->buttons[] = [
+ 'id' => 'content_button_delete',
+ 'url' => $this->urlFor($request, 'font.form.delete', ['id' => $font->id]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'font.delete', ['id' => $font->id])
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'content_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $font->name]
+ ]
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->fontFactory->countLast();
+ $this->getState()->setData($fonts);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Font details provided by FontLib
+ *
+ * @SWG\Get(
+ * path="/fonts/details/{id}",
+ * operationId="fontDetails",
+ * tags={"font"},
+ * summary="Font Details",
+ * description="Get the Font details",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Font ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="object",
+ * additionalProperties={
+ * "title"="details",
+ * "type"="array"
+ * }
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \FontLib\Exception\FontNotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getFontLibDetails(Request $request, Response $response, $id)
+ {
+ $font = $this->fontFactory->getById($id);
+ $fontLib = \FontLib\Font::load($font->getFilePath());
+ $fontLib->parse();
+
+ $fontDetails = [
+ 'Name' => $fontLib->getFontName(),
+ 'SubFamily Name' => $fontLib->getFontSubfamily(),
+ 'Subfamily ID' => $fontLib->getFontSubfamilyID(),
+ 'Full Name' => $fontLib->getFontFullName(),
+ 'Version' => $fontLib->getFontVersion(),
+ 'Font Weight' => $fontLib->getFontWeight(),
+ 'Font Postscript Name' => $fontLib->getFontPostscriptName(),
+ 'Font Copyright' => $fontLib->getFontCopyright(),
+ ];
+
+ $this->getState()->template = 'fonts-fontlib-details';
+ $this->getState()->setData([
+ 'details' => $fontDetails,
+ 'fontId' => $font->id
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/fonts/download/{id}",
+ * operationId="fontDownload",
+ * tags={"font"},
+ * summary="Download Font",
+ * description="Download a Font file from the Library",
+ * produces={"application/octet-stream"},
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Font ID to Download",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(type="file"),
+ * @SWG\Header(
+ * header="X-Sendfile",
+ * description="Apache Send file header - if enabled.",
+ * type="string"
+ * ),
+ * @SWG\Header(
+ * header="X-Accel-Redirect",
+ * description="nginx send file header - if enabled.",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function download(Request $request, Response $response, $id)
+ {
+ if (is_numeric($id)) {
+ $font = $this->fontFactory->getById($id);
+ } else {
+ $font = $this->fontFactory->getByName($id)[0];
+ }
+
+ $this->getLog()->debug('Download request for fontId ' . $id);
+
+ $library = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ $sendFileMode = $this->getConfig()->getSetting('SENDFILE_MODE');
+ $attachmentName = urlencode($font->fileName);
+ $libraryPath = $library . 'fonts' . DIRECTORY_SEPARATOR . $font->fileName;
+
+ $downLoadService = new DownloadService($libraryPath, $sendFileMode);
+ $downLoadService->useLogger($this->getLog()->getLoggerInterface());
+
+ return $downLoadService->returnFile($response, $attachmentName, '/download/fonts/' . $font->fileName);
+ }
+
+ /**
+ * @return string[]
+ */
+ private function getValidExtensions()
+ {
+ return ['otf', 'ttf', 'eot', 'svg', 'woff'];
+ }
+
+ /**
+ * Font Upload
+ *
+ * @SWG\Post(
+ * path="/fonts",
+ * operationId="fontUpload",
+ * tags={"font"},
+ * summary="Font Upload",
+ * description="Upload a new Font file",
+ * @SWG\Parameter(
+ * name="files",
+ * in="formData",
+ * description="The Uploaded File",
+ * type="file",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Optional Font Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function add(Request $request, Response $response)
+ {
+ if (!$this->getUser()->featureEnabled('font.add')) {
+ throw new AccessDeniedException();
+ }
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($libraryFolder);
+ $validExt = $this->getValidExtensions();
+
+ // Make sure there is room in the library
+ $libraryLimit = $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+
+ $options = [
+ 'accept_file_types' => '/\.' . implode('|', $validExt) . '$/i',
+ 'libraryLimit' => $libraryLimit,
+ 'libraryQuotaFull' => ($libraryLimit > 0 && $this->getMediaService()->libraryUsage() > $libraryLimit),
+ ];
+
+ // Output handled by UploadHandler
+ $this->setNoOutput(true);
+
+ $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options));
+
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ $uploadService = new UploadService($libraryFolder . 'temp/', $options, $this->getLog(), $this->getState());
+ $uploadHandler = $uploadService->createUploadHandler();
+
+ $uploadHandler->setPostProcessor(function ($file, $uploadHandler) use ($libraryFolder) {
+ // Return right away if the file already has an error.
+ if (!empty($file->error)) {
+ return $file;
+ }
+
+ $this->getUser()->isQuotaFullByUser(true);
+
+ // Get the uploaded file and move it to the right place
+ $filePath = $libraryFolder . 'temp/' . $file->fileName;
+
+ // Add the Font
+ $font = $this->getFontFactory()
+ ->createFontFromUpload($filePath, $file->name, $file->fileName, $this->getUser()->userName);
+ $font->save();
+
+ // Test to ensure the final file size is the same as the file size we're expecting
+ if ($file->size != $font->size) {
+ throw new InvalidArgumentException(
+ __('Sorry this is a corrupted upload, the file size doesn\'t match what we\'re expecting.'),
+ 'size'
+ );
+ }
+
+ // everything is fine, move the file from temp folder.
+ rename($filePath, $libraryFolder . 'fonts/' . $font->fileName);
+
+ // return
+ $file->id = $font->id;
+ $file->md5 = $font->md5;
+ $file->name = $font->name;
+
+ return $file;
+ });
+
+ // Handle the post request
+ $uploadHandler->post();
+
+ // all done, refresh fonts.css
+ $this->getMediaService()->updateFontsCss();
+
+ // Explicitly set the Content-Type header to application/json
+ $response = $response->withHeader('Content-Type', 'application/json');
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Font Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ if (!$this->getUser()->featureEnabled('font.delete')) {
+ throw new AccessDeniedException();
+ }
+
+ if (is_numeric($id)) {
+ $font = $this->fontFactory->getById($id);
+ } else {
+ $font = $this->fontFactory->getByName($id)[0];
+ }
+
+ $this->getState()->template = 'font-form-delete';
+ $this->getState()->setData([
+ 'font' => $font
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Font Delete
+ *
+ * @SWG\Delete(
+ * path="/fonts/{id}/delete",
+ * operationId="fontDelete",
+ * tags={"font"},
+ * summary="Font Delete",
+ * description="Delete existing Font file",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Font ID to delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ if (!$this->getUser()->featureEnabled('font.delete')) {
+ throw new AccessDeniedException();
+ }
+
+ if (is_numeric($id)) {
+ $font = $this->fontFactory->getById($id);
+ } else {
+ $font = $this->fontFactory->getByName($id)[0];
+ }
+
+ // delete record and file
+ $font->delete();
+
+ // refresh fonts.css
+ $this->getMediaService()->updateFontsCss();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Return the CMS flavored font css
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function fontCss(Request $request, Response $response)
+ {
+ $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'fonts/local_fontcss';
+
+ $cacheItem = $this->getMediaService()->getPool()->getItem('localFontCss');
+ $cacheItem->setInvalidationMethod(Invalidation::SLEEP, 5000, 15);
+
+ if ($cacheItem->isMiss()) {
+ $this->getLog()->debug('local font css cache has expired, regenerating');
+
+ $cacheItem->lock(60);
+ $localCss = '';
+ // Regenerate the CSS for fonts
+ foreach ($this->fontFactory->query() as $font) {
+ // Go through all installed fonts each time and regenerate.
+ $fontTemplate = '@font-face {
+ font-family: \'[family]\';
+ src: url(\'[url]\');
+}';
+ // Css for the local CMS contains the full download path to the font
+ $url = $this->urlFor($request, 'font.download', ['id' => $font->id]);
+ $localCss .= str_replace('[url]', $url, str_replace('[family]', $font->familyName, $fontTemplate));
+ }
+
+ // cache
+ $cacheItem->set($localCss);
+ $cacheItem->expiresAfter(new \DateInterval('P30D'));
+ $this->getMediaService()->getPool()->saveDeferred($cacheItem);
+ } else {
+ $this->getLog()->debug('local font css file served from cache ');
+ $localCss = $cacheItem->get();
+ }
+
+ // Return the CSS to the browser as a file
+ $out = fopen($tempFileName, 'w');
+ if (!$out) {
+ throw new ConfigurationException(__('Unable to write to the library'));
+ }
+ fputs($out, $localCss);
+ fclose($out);
+
+ // Work out the etag
+ $response = HttpCacheProvider::withEtag($response, md5($localCss));
+
+ $this->setNoOutput(true);
+
+ $response = $response->withHeader('Content-Type', 'text/css')
+ ->withBody(new Stream(fopen($tempFileName, 'r')));
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/IconDashboard.php b/lib/Controller/IconDashboard.php
new file mode 100644
index 0000000..d88f336
--- /dev/null
+++ b/lib/Controller/IconDashboard.php
@@ -0,0 +1,52 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Slim\Views\Twig;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+
+/**
+ * Class IconDashboard
+ * @package Xibo\Controller
+ */
+class IconDashboard extends Base
+{
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'dashboard-icon-page';
+
+ return $this->render($request, $response);
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/Layout.php b/lib/Controller/Layout.php
new file mode 100644
index 0000000..732243d
--- /dev/null
+++ b/lib/Controller/Layout.php
@@ -0,0 +1,3540 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use GuzzleHttp\Psr7\Stream;
+use Intervention\Image\ImageManagerStatic as Img;
+use Mimey\MimeTypes;
+use Parsedown;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Stash\Item;
+use Xibo\Entity\Region;
+use Xibo\Entity\Session;
+use Xibo\Event\TemplateProviderImportEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\ResolutionFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\WidgetDataFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Helper\LayoutUploadHandler;
+use Xibo\Helper\Profiler;
+use Xibo\Helper\SendFile;
+use Xibo\Helper\Status;
+use Xibo\Service\MediaService;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Render\WidgetDownloader;
+use Xibo\Widget\SubPlaylistItem;
+
+/**
+ * Class Layout
+ * @package Xibo\Controller
+ *
+ */
+class Layout extends Base
+{
+ /**
+ * @var Session
+ */
+ private $session;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var ResolutionFactory
+ */
+ private $resolutionFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var ModuleFactory
+ */
+ private $moduleFactory;
+
+ /**
+ * @var UserGroupFactory
+ */
+ private $userGroupFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var MediaServiceInterface */
+ private $mediaService;
+ private WidgetFactory $widgetFactory;
+ private PlaylistFactory $playlistFactory;
+
+ /**
+ * Set common dependencies.
+ * @param Session $session
+ * @param UserFactory $userFactory
+ * @param ResolutionFactory $resolutionFactory
+ * @param LayoutFactory $layoutFactory
+ * @param ModuleFactory $moduleFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param TagFactory $tagFactory
+ * @param MediaFactory $mediaFactory
+ * @param DataSetFactory $dataSetFactory
+ * @param CampaignFactory $campaignFactory
+ * @param $displayGroupFactory
+ */
+ public function __construct(
+ $session,
+ $userFactory,
+ $resolutionFactory,
+ $layoutFactory,
+ $moduleFactory,
+ $userGroupFactory,
+ $tagFactory,
+ $mediaFactory,
+ $dataSetFactory,
+ $campaignFactory,
+ $displayGroupFactory,
+ $pool,
+ MediaServiceInterface $mediaService,
+ WidgetFactory $widgetFactory,
+ private readonly WidgetDataFactory $widgetDataFactory,
+ PlaylistFactory $playlistFactory,
+ ) {
+ $this->session = $session;
+ $this->userFactory = $userFactory;
+ $this->resolutionFactory = $resolutionFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->tagFactory = $tagFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->dataSetFactory = $dataSetFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->pool = $pool;
+ $this->mediaService = $mediaService;
+ $this->widgetFactory = $widgetFactory;
+ $this->playlistFactory = $playlistFactory;
+ }
+
+ /**
+ * @return LayoutFactory
+ */
+ public function getLayoutFactory()
+ {
+ return $this->layoutFactory;
+ }
+
+ /**
+ * @return DataSetFactory
+ */
+ public function getDataSetFactory()
+ {
+ return $this->dataSetFactory;
+ }
+
+ /**
+ * Displays the Layout Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ // Call to render the template
+ $this->getState()->template = 'layout-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Display the Layout Designer
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayDesigner(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->loadById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($layout))
+ throw new AccessDeniedException();
+
+ // Get the parent layout if it's editable
+ if ($layout->isEditable()) {
+ // Get the Layout using the Draft ID
+ $layout = $this->layoutFactory->getByParentId($id);
+ }
+
+ // Work out our resolution, if it does not exist, create it.
+ try {
+ if ($layout->schemaVersion < 2) {
+ $resolution = $this->resolutionFactory->getByDesignerDimensions($layout->width, $layout->height);
+ } else {
+ $resolution = $this->resolutionFactory->getByDimensions($layout->width, $layout->height);
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->info('Layout Editor with an unknown resolution, we will create it with name: ' . $layout->width . ' x ' . $layout->height);
+
+ $resolution = $this->resolutionFactory->create($layout->width . ' x ' . $layout->height, (int)$layout->width, (int)$layout->height);
+ $resolution->userId = $this->userFactory->getSystemUser()->userId;
+ $resolution->save();
+ }
+
+ $moduleFactory = $this->moduleFactory;
+ $isTemplate = $layout->hasTag('template');
+
+ // Get a list of timezones
+ $timeZones = [];
+ foreach (DateFormatHelper::timezoneList() as $key => $value) {
+ $timeZones[] = ['id' => $key, 'value' => $value];
+ }
+
+ // Set up any JavaScript translations
+ $data = [
+ 'publishedLayoutId' => $id,
+ 'layout' => $layout,
+ 'resolution' => $resolution,
+ 'isTemplate' => $isTemplate,
+ 'zoom' => $sanitizedParams->getDouble('zoom', [
+ 'default' => $this->getUser()->getOptionValue('defaultDesignerZoom', 1)
+ ]),
+ 'modules' => $moduleFactory->getAssignableModules(),
+ 'timeZones' => $timeZones,
+ ];
+
+ // Call the render the template
+ $this->getState()->template = 'layout-designer-page';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a Layout
+ * @SWG\Post(
+ * path="/layout",
+ * operationId="layoutAdd",
+ * tags={"layout"},
+ * summary="Add a Layout",
+ * description="Add a new Layout to the CMS",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The layout name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The layout description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="formData",
+ * description="If the Layout should be created with a Template, provide the ID, otherwise don't provide",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="formData",
+ * description="If a Template is not provided, provide the resolutionId for this Layout.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="returnDraft",
+ * in="formData",
+ * description="Should we return the Draft Layout or the Published Layout on Success?",
+ * type="boolean",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Code identifier for this Layout",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $name = $sanitizedParams->getString('name');
+ $description = $sanitizedParams->getString('description');
+ $enableStat = $sanitizedParams->getCheckbox('enableStat');
+ $autoApplyTransitions = (int)$this->getConfig()->getSetting('DEFAULT_TRANSITION_AUTO_APPLY');
+ $code = $sanitizedParams->getString('code', ['defaultOnEmptyString' => true]);
+
+ // Folders
+ $folderId = $sanitizedParams->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ // Name
+ if (empty($name)) {
+ // Create our own name for this layout.
+ $name = sprintf(__('Untitled %s'), Carbon::now()->format(DateFormatHelper::getSystemFormat()));
+ }
+
+ // Tags
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ } else {
+ $tags = [];
+ }
+
+ $templateId = $sanitizedParams->getString('layoutId');
+ $resolutionId = $sanitizedParams->getInt('resolutionId');
+ $template = null;
+
+ // If we have a templateId provided then we create from there.
+ if (!empty($templateId)) {
+ $this->getLog()->debug('add: loading template for clone operation. templateId: ' . $templateId);
+
+ // Load the template
+ $template = $this->layoutFactory->loadById($templateId);
+
+ // Empty all the ID's
+ $layout = clone $template;
+
+ // Overwrite our new properties
+ $layout->layout = $name;
+ $layout->description = $description;
+ $layout->code = $code;
+ $layout->updateTagLinks($tags);
+
+ $this->getLog()->debug('add: loaded and cloned, about to setOwner. templateId: ' . $templateId);
+
+ // Set the owner
+ $layout->setOwner($this->getUser()->userId, true);
+ } else {
+ $this->getLog()->debug('add: no template, using resolution: ' . $resolutionId);
+
+ // Empty template so we create a blank layout with the provided resolution
+ if (empty($resolutionId)) {
+ // Get the nearest landscape resolution we can
+ $resolution = $this->resolutionFactory->getClosestMatchingResolution(1920, 1080);
+
+ // Get the ID
+ $resolutionId = $resolution->resolutionId;
+ $this->getLog()->debug('add: resolution resolved: ' . $resolutionId);
+ }
+
+ $layout = $this->layoutFactory->createFromResolution(
+ $resolutionId,
+ $this->getUser()->userId,
+ $name,
+ $description,
+ $tags,
+ $code,
+ false
+ );
+ }
+
+ // Do we have an 'Enable Layout Stats Collection?' checkbox?
+ // If not, we fall back to the default Stats Collection setting.
+ if (!$sanitizedParams->hasParam('enableStat')) {
+ $enableStat = (int)$this->getConfig()->getSetting('LAYOUT_STATS_ENABLED_DEFAULT');
+ }
+
+ // Set layout enableStat flag
+ $layout->enableStat = $enableStat;
+
+ // Set auto apply transitions flag
+ $layout->autoApplyTransitions = $autoApplyTransitions;
+
+ // set folderId
+ $layout->folderId = $folderId;
+
+ // Save
+ $layout->save(['appendCountOnDuplicate' => true]);
+
+ if ($templateId != null && $template !== null) {
+ $layout->copyActions($layout, $template);
+ // set Layout original values to current values
+ $layout->setOriginals();
+ }
+
+ $allRegions = array_merge($layout->regions, $layout->drawers);
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+ if ($templateId != null && $template !== null) {
+ // Match our original region id to the id in the parent layout
+ $original = $template->getRegionOrDrawer($region->getOriginalValue('regionId'));
+
+ // Make sure Playlist closure table from the published one are copied over
+ $original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId);
+
+ // set Region original values to current values
+ $region->setOriginals();
+ foreach ($region->regionPlaylist->widgets as $widget) {
+ // set Widget original values to current values
+ $widget->setOriginals();
+ }
+ }
+ $campaign = $this->campaignFactory->getById($layout->campaignId);
+
+ $playlist = $region->getPlaylist();
+ $playlist->folderId = $campaign->folderId;
+ $playlist->permissionsFolderId = $campaign->permissionsFolderId;
+ $playlist->save();
+ }
+
+ $this->getLog()->debug('Layout Added');
+
+ // Automatically checkout the new layout for edit
+ $layout = $this->layoutFactory->checkoutLayout($layout, $sanitizedParams->getCheckbox('returnDraft'));
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/layout/{layoutId}",
+ * operationId="layoutEdit",
+ * summary="Edit Layout",
+ * description="Edit a Layout",
+ * tags={"layout"},
+ * @SWG\Parameter(
+ * name="layoutId",
+ * type="integer",
+ * in="path",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Layout Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The Layout Description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="A comma separated list of Tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="formData",
+ * description="A flag indicating whether this Layout is retired.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="enableStat",
+ * in="formData",
+ * description="Flag indicating whether the Layout stat is enabled",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Code identifier for this Layout",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $folderChanged = false;
+ $nameChanged = false;
+
+ // check if we're dealing with the template
+ $isTemplate = $layout->hasTag('template');
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout))
+ throw new AccessDeniedException();
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot edit Layout properties on a Draft'), 'layoutId');
+ }
+
+ $layout->layout = $sanitizedParams->getString('name');
+ $layout->description = $sanitizedParams->getString('description');
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ $layout->updateTagLinks($this->tagFactory->tagsFromString($sanitizedParams->getString('tags')));
+ }
+
+ // if it was not a template, and user added template tag, throw an error.
+ if (!$isTemplate && $layout->hasTag('template')) {
+ throw new InvalidArgumentException(__('Cannot assign a Template tag to a Layout, to create a template use the Save Template button instead.'), 'tags');
+ }
+
+ $layout->retired = $sanitizedParams->getCheckbox('retired');
+ $layout->enableStat = $sanitizedParams->getCheckbox('enableStat');
+ $layout->code = $sanitizedParams->getString('code');
+ $layout->folderId = $sanitizedParams->getInt('folderId', ['default' => $layout->folderId]);
+
+ if ($layout->hasPropertyChanged('folderId')) {
+ if ($layout->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folderChanged = true;
+ }
+
+ if ($layout->hasPropertyChanged('layout')) {
+ $nameChanged = true;
+ }
+
+ // Save
+ $layout->save([
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => true,
+ 'setBuildRequired' => false,
+ 'notify' => false
+ ]);
+
+ if ($folderChanged || $nameChanged) {
+ // permissionsFolderId depends on the Campaign, hence why we need to get the edited Layout back here
+ $editedLayout = $this->layoutFactory->getById($layout->layoutId);
+
+ // this will return the original Layout we edited and its draft
+ $layouts = $this->layoutFactory->getByCampaignId($layout->campaignId, true, true);
+
+ foreach ($layouts as $savedLayout) {
+ // if we changed the name of the original Layout, updated its draft name as well
+ if ($savedLayout->isChild() && $nameChanged) {
+ $savedLayout->layout = $editedLayout->layout;
+ $savedLayout->save([
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => false,
+ 'notify' => false
+ ]);
+ }
+
+ // if the folder changed on original Layout, make sure we keep its regionPlaylists and draft regionPlaylists updated
+ if ($folderChanged) {
+ $savedLayout->load();
+ $allRegions = array_merge($savedLayout->regions, $savedLayout->drawers);
+ foreach ($allRegions as $region) {
+ $playlist = $region->getPlaylist();
+ $playlist->folderId = $editedLayout->folderId;
+ $playlist->permissionsFolderId = $editedLayout->permissionsFolderId;
+ $playlist->save();
+ }
+ }
+ }
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Layout Background
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @SWG\Put(
+ * path="/layout/background/{layoutId}",
+ * operationId="layoutEditBackground",
+ * summary="Edit Layout Background",
+ * description="Edit a Layout Background",
+ * tags={"layout"},
+ * @SWG\Parameter(
+ * name="layoutId",
+ * type="integer",
+ * in="path",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="backgroundColor",
+ * in="formData",
+ * description="A HEX color to use as the background color of this Layout.",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="backgroundImageId",
+ * in="formData",
+ * description="A media ID to use as the background image for this Layout.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="backgroundzIndex",
+ * in="formData",
+ * description="The Layer Number to use for the background.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="formData",
+ * description="The Resolution ID to use on this Layout.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ */
+ public function editBackground(Request $request, Response $response, $id): Response
+ {
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Layout is a Draft
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ $layout->backgroundColor = $sanitizedParams->getString('backgroundColor');
+ $layout->backgroundImageId = $sanitizedParams->getInt('backgroundImageId');
+ $layout->backgroundzIndex = $sanitizedParams->getInt('backgroundzIndex');
+ $layout->autoApplyTransitions = $sanitizedParams->getCheckbox('autoApplyTransitions');
+
+ // Check the status of the media file
+ if ($layout->backgroundImageId) {
+ $media = $this->mediaFactory->getById($layout->backgroundImageId);
+
+ if ($media->mediaType === 'image' && $media->released === 2) {
+ throw new InvalidArgumentException(sprintf(
+ __('%s set as the layout background image is too large. Please ensure that none of the images in your layout are larger than your Resize Limit on their longest edge.'),//phpcs:ignore
+ $media->name
+ ));
+ }
+ }
+
+ // Resolution
+ $saveRegions = false;
+ $resolution = $this->resolutionFactory->getById($sanitizedParams->getInt('resolutionId'));
+
+ if ($layout->width != $resolution->width || $layout->height != $resolution->height) {
+ $this->getLog()->debug('editBackground: resolution dimensions have changed, updating layout');
+
+ $layout->load([
+ 'loadPlaylists' => false,
+ 'loadPermissions' => false,
+ 'loadCampaigns' => false,
+ 'loadActions' => false,
+ ]);
+ $layout->width = $resolution->width;
+ $layout->height = $resolution->height;
+ $layout->orientation = ($layout->width >= $layout->height) ? 'landscape' : 'portrait';
+
+ // Update the canvas region with its new width/height.
+ foreach ($layout->regions as $region) {
+ if ($region->type === 'canvas') {
+ $this->getLog()->debug('editBackground: canvas region needs changing too');
+
+ $region->width = $layout->width;
+ $region->height = $layout->height;
+ $saveRegions = true;
+ }
+ }
+ }
+
+ // Save
+ $layout->save([
+ 'saveLayout' => true,
+ 'saveRegions' => $saveRegions,
+ 'saveTags' => true,
+ 'setBuildRequired' => true,
+ 'notify' => false
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Apply a template to a Layout
+ * @SWG\Put(
+ * path="/layout/applyTemplate/{layoutId}",
+ * operationId="layoutApplyTemplate",
+ * tags={"layout"},
+ * summary="Apply Template",
+ * description="Apply a new Template to an existing Layout, replacing it.",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * type="integer",
+ * in="path",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="templateId",
+ * in="formData",
+ * description="If the Layout should be created with a Template, provide the ID, otherwise don't provide",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function applyTemplate(Request $request, Response $response, $id): Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get the existing layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Layout is a Draft
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Discard the current draft and replace it
+ $layout->discardDraft(false);
+
+ // Is the source remote (undocumented as it should only be via WEB)
+ $source = $sanitizedParams->getString('source');
+ if ($source === 'remote') {
+ // Hand off to the connector
+ $event = new TemplateProviderImportEvent(
+ $sanitizedParams->getString('download'),
+ $sanitizedParams->getString('templateId'),
+ $this->getConfig()->getSetting('LIBRARY_LOCATION')
+ );
+
+ $this->getLog()->debug('Dispatching event. ' . $event->getName());
+ try {
+ $this->getDispatcher()->dispatch($event, $event->getName());
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Template search: Exception in dispatched event: ' . $exception->getMessage());
+ $this->getLog()->debug($exception->getTraceAsString());
+ }
+
+ $template = $this->getLayoutFactory()->createFromZip(
+ $event->getFilePath(),
+ $layout->layout,
+ $this->getUser()->userId,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ $this->getDataSetFactory(),
+ '',
+ $this->mediaService,
+ $layout->folderId,
+ false,
+ );
+
+ $template->managePlaylistClosureTable();
+ $template->manageActions();
+
+ // Handle widget data
+ $fallback = $layout->getUnmatchedProperty('fallback');
+ if ($fallback !== null) {
+ foreach ($layout->getAllWidgets() as $widget) {
+ // Did this widget have fallback data included in its export?
+ if (array_key_exists($widget->tempWidgetId, $fallback)) {
+ foreach ($fallback[$widget->tempWidgetId] as $item) {
+ // We create the widget data with the new widgetId
+ $this->widgetDataFactory
+ ->create(
+ $widget->widgetId,
+ $item['data'] ?? [],
+ intval($item['displayOrder'] ?? 1),
+ )
+ ->save();
+ }
+ }
+ }
+ }
+
+ @unlink($event->getFilePath());
+ } else {
+ $templateId = $sanitizedParams->getInt('templateId');
+ $this->getLog()->debug('add: loading template for clone operation. templateId: ' . $templateId);
+
+ // Clone the template
+ $template = clone $this->layoutFactory->loadById($templateId);
+
+ // Overwrite our new properties
+ $template->layout = $layout->layout;
+ $template->setOwner($layout->ownerId);
+ }
+
+ // Persist the parentId
+ $template->parentId = $layout->parentId;
+ $template->campaignId = $layout->campaignId;
+ $template->publishedStatusId = 2;
+ $template->save(['validate' => false]);
+
+ // for remote source, we import the Layout and save the thumbnail to temporary file
+ // after save we can move the image to correct library folder, as we have campaignId
+ if ($source === 'remote' && !empty($layout->getUnmatchedProperty('thumbnail'))) {
+ rename($layout->getUnmatchedProperty('thumbnail'), $template->getThumbnailUri());
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $layout->layout),
+ 'id' => $template->layoutId,
+ 'data' => $template,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($layout))
+ throw new AccessDeniedException(__('You do not have permissions to delete this layout'));
+
+ $data = [
+ 'layout' => $layout,
+ ];
+
+ $this->getState()->template = 'layout-form-delete';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Clear Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function clearForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($layout))
+ throw new AccessDeniedException(__('You do not have permissions to clear this layout'));
+
+ $data = [
+ 'layout' => $layout,
+ ];
+
+ $this->getState()->template = 'layout-form-clear';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Retire Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function retireForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ $data = [
+ 'layout' => $layout,
+ ];
+
+ $this->getState()->template = 'layout-form-retire';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Deletes a layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/layout/{layoutId}",
+ * operationId="layoutDelete",
+ * tags={"layout"},
+ * summary="Delete Layout",
+ * description="Delete a Layout",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function delete(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->loadById($id);
+
+ if (!$this->getUser()->checkDeleteable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete this layout'));
+ }
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot delete Layout from its Draft, delete the parent'), 'layoutId');
+ }
+
+ $layout->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $layout->layout)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Clears a layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ *
+ * @SWG\Post(
+ * path="/layout/{layoutId}",
+ * operationId="layoutClear",
+ * tags={"layout"},
+ * summary="Clear Layout",
+ * description="Clear a draft layouts canvas of all widgets and elements, leaving it blank.",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID to Clear, must be a draft.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function clear(Request $request, Response $response, $id): Response
+ {
+ // Get the existing layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Layout is a Draft
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Discard the current draft and replace it
+ $layout->discardDraft(false);
+
+ // Blank
+ $resolution = $this->resolutionFactory->getClosestMatchingResolution($layout->width, $layout->height);
+ $blank = $this->layoutFactory->createFromResolution(
+ $resolution->resolutionId,
+ $layout->ownerId,
+ $layout->layout,
+ null,
+ null,
+ null,
+ false
+ );
+
+ // Persist the parentId
+ $blank->parentId = $layout->parentId;
+ $blank->campaignId = $layout->campaignId;
+ $blank->publishedStatusId = 2;
+ $blank->save(['validate' => false, 'auditMessage' => 'Canvas Cleared']);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Cleared %s'), $layout->layout),
+ 'id' => $blank->layoutId,
+ 'data' => $blank,
+ ]);
+
+ return $this->render($request, $response);
+ }
+ /**
+ * Retires a layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/layout/retire/{layoutId}",
+ * operationId="layoutRetire",
+ * tags={"layout"},
+ * summary="Retire Layout",
+ * description="Retire a Layout so that it isn't available to Schedule. Existing Layouts will still be played",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function retire(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot modify Layout from its Draft'), 'layoutId');
+ }
+
+ // Make sure we aren't the global default
+ if ($layout->layoutId == $this->getConfig()->getSetting('DEFAULT_LAYOUT')) {
+ throw new InvalidArgumentException(__('This Layout is used as the global default and cannot be retired'),
+ 'layoutId');
+ }
+
+ $layout->retired = 1;
+ $layout->save([
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => false
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Retired %s'), $layout->layout)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Unretire Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function unretireForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ $data = [
+ 'layout' => $layout,
+ ];
+
+ $this->getState()->template = 'layout-form-unretire';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+
+ }
+
+ /**
+ * Unretires a layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/layout/unretire/{layoutId}",
+ * operationId="layoutUnretire",
+ * tags={"layout"},
+ * summary="Unretire Layout",
+ * description="Retire a Layout so that it isn't available to Schedule. Existing Layouts will still be played",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function unretire(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot modify Layout from its Draft'), 'layoutId');
+ }
+
+ $layout->retired = 0;
+
+ $layout->save([
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => false,
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Unretired %s'), $layout->layout)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Enable Stats Collection of a layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/layout/setenablestat/{layoutId}",
+ * operationId="layoutSetEnableStat",
+ * tags={"layout"},
+ * summary="Enable Stats Collection",
+ * description="Set Enable Stats Collection? to use for the collection of Proof of Play statistics for a Layout.",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="enableStat",
+ * in="formData",
+ * description="Flag indicating whether the Layout stat is enabled",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function setEnableStat(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot modify Layout from its Draft'), 'layoutId');
+ }
+
+ $enableStat = $sanitizedParams->getCheckbox('enableStat');
+
+ $layout->enableStat = $enableStat;
+ $layout->save(['saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('For Layout %s Enable Stats Collection is set to %s'), $layout->layout, ($layout->enableStat == 1) ? __('On') : __('Off'))
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Enable Stat Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function setEnableStatForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ $data = [
+ 'layout' => $layout,
+ ];
+
+ $this->getState()->template = 'layout-form-setenablestat';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the Layout Grid
+ *
+ * @SWG\Get(
+ * path="/layout",
+ * operationId="layoutSearch",
+ * tags={"layout"},
+ * summary="Search Layouts",
+ * description="Search for Layouts viewable by this user",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="query",
+ * description="Filter by Layout Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="parentId",
+ * in="query",
+ * description="Filter by parent Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="showDrafts",
+ * in="query",
+ * description="Flag indicating whether to show drafts",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layout",
+ * in="query",
+ * description="Filter by partial Layout name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userId",
+ * in="query",
+ * description="Filter by user Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="query",
+ * description="Filter by retired flag",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="query",
+ * description="Filter by Tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="query",
+ * description="A flag indicating whether to treat the tags filter as an exact match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="query",
+ * description="When filtering by multiple Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ownerUserGroupId",
+ * in="query",
+ * description="Filter by users in this UserGroupId",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="publishedStatusId",
+ * in="query",
+ * description="Filter by published status id, 1 - Published, 2 - Draft",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as regions, playlists, widgets, tags, campaigns, permissions",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="query",
+ * description="Get all Layouts for a given campaignId",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Layout")
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $this->getState()->template = 'grid';
+
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+ // Should we parse the description into markdown
+ $showDescriptionId = $parsedQueryParams->getInt('showDescriptionId');
+
+ // We might need to embed some extra content into the response if the "Show Description"
+ // is set to media listing
+ if ($showDescriptionId === 3) {
+ $embed = ['regions', 'playlists', 'widgets'];
+ } else {
+ // Embed?
+ $embed = ($parsedQueryParams->getString('embed') != null)
+ ? explode(',', $parsedQueryParams->getString('embed'))
+ : [];
+ }
+
+ // Get all layouts
+ $layouts = $this->layoutFactory->query($this->gridRenderSort($parsedQueryParams), $this->gridRenderFilter([
+ 'layout' => $parsedQueryParams->getString('layout'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'userId' => $parsedQueryParams->getInt('userId'),
+ 'retired' => $parsedQueryParams->getInt('retired'),
+ 'tags' => $parsedQueryParams->getString('tags'),
+ 'exactTags' => $parsedQueryParams->getCheckbox('exactTags'),
+ 'filterLayoutStatusId' => $parsedQueryParams->getInt('layoutStatusId'),
+ 'layoutId' => $parsedQueryParams->getInt('layoutId'),
+ 'parentId' => $parsedQueryParams->getInt('parentId'),
+ 'showDrafts' => $parsedQueryParams->getInt('showDrafts'),
+ 'ownerUserGroupId' => $parsedQueryParams->getInt('ownerUserGroupId'),
+ 'mediaLike' => $parsedQueryParams->getString('mediaLike'),
+ 'publishedStatusId' => $parsedQueryParams->getInt('publishedStatusId'),
+ 'activeDisplayGroupId' => $parsedQueryParams->getInt('activeDisplayGroupId'),
+ 'campaignId' => $parsedQueryParams->getInt('campaignId'),
+ 'folderId' => $parsedQueryParams->getInt('folderId'),
+ 'codeLike' => $parsedQueryParams->getString('codeLike'),
+ 'orientation' => $parsedQueryParams->getString('orientation', ['defaultOnEmptyString' => true]),
+ 'onlyMyLayouts' => $parsedQueryParams->getCheckbox('onlyMyLayouts'),
+ 'logicalOperator' => $parsedQueryParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $parsedQueryParams->getString('logicalOperatorName'),
+ 'campaignType' => 'list',
+ 'modifiedSinceDt' => $parsedQueryParams->getDate('modifiedSinceDt'),
+ ], $parsedQueryParams));
+
+ foreach ($layouts as $layout) {
+ /* @var \Xibo\Entity\Layout $layout */
+
+ if (in_array('regions', $embed)) {
+ $layout->load([
+ 'loadPlaylists' => in_array('playlists', $embed),
+ 'loadCampaigns' => in_array('campaigns', $embed),
+ 'loadPermissions' => in_array('permissions', $embed),
+ 'loadTags' => in_array('tags', $embed),
+ 'loadWidgets' => in_array('widgets', $embed),
+ 'loadActions' => in_array('actions', $embed)
+ ]);
+ }
+
+ // Populate the status message
+ $layout->getStatusMessage();
+
+ // Add Locking information
+ $layout = $this->layoutFactory->decorateLockedProperties($layout);
+
+ // Annotate each Widget with its validity, tags and permissions
+ if (in_array('widget_validity', $embed) || in_array('tags', $embed) || in_array('permissions', $embed)) {
+ foreach ($layout->getAllWidgets() as $widget) {
+ try {
+ $module = $this->moduleFactory->getByType($widget->type);
+ } catch (NotFoundException $notFoundException) {
+ // This module isn't available, mark it as invalid.
+ $widget->isValid = false;
+ $widget->setUnmatchedProperty('moduleName', __('Invalid Module'));
+ $widget->setUnmatchedProperty('name', __('Invalid Module'));
+ $widget->setUnmatchedProperty('tags', []);
+ $widget->setUnmatchedProperty('isDeletable', 1);
+ continue;
+ }
+
+ $widget->setUnmatchedProperty('moduleName', $module->name);
+ $widget->setUnmatchedProperty('moduleDataType', $module->dataType);
+
+ if ($module->regionSpecific == 0) {
+ // Use the media assigned to this widget
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', null) ?: $media->name);
+
+ // Augment with tags
+ $widget->setUnmatchedProperty('tags', $media->tags);
+ } else {
+ $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', null) ?: $module->name);
+ $widget->setUnmatchedProperty('tags', []);
+ }
+
+ // Sub-playlists should calculate a fresh duration
+ if ($widget->type === 'subplaylist') {
+ // We know we have a provider class for this module.
+ $widget->calculateDuration($module);
+ }
+
+ if (in_array('widget_validity', $embed)) {
+ $status = 0;
+ $layout->assessWidgetStatus($module, $widget, $status);
+ $widget->isValid = $status === 1;
+ }
+
+ // apply default transitions to a dynamic parameters on widget object.
+ if ($layout->autoApplyTransitions == 1) {
+ $widgetTransIn = $widget->getOptionValue('transIn', $this->getConfig()->getSetting('DEFAULT_TRANSITION_IN'));
+ $widgetTransOut = $widget->getOptionValue('transOut', $this->getConfig()->getSetting('DEFAULT_TRANSITION_OUT'));
+ $widgetTransInDuration = $widget->getOptionValue('transInDuration', $this->getConfig()->getSetting('DEFAULT_TRANSITION_DURATION'));
+ $widgetTransOutDuration = $widget->getOptionValue('transOutDuration', $this->getConfig()->getSetting('DEFAULT_TRANSITION_DURATION'));
+ } else {
+ $widgetTransIn = $widget->getOptionValue('transIn', null);
+ $widgetTransOut = $widget->getOptionValue('transOut', null);
+ $widgetTransInDuration = $widget->getOptionValue('transInDuration', null);
+ $widgetTransOutDuration = $widget->getOptionValue('transOutDuration', null);
+ }
+
+ $widget->transitionIn = $widgetTransIn;
+ $widget->transitionOut = $widgetTransOut;
+ $widget->transitionDurationIn = $widgetTransInDuration;
+ $widget->transitionDurationOut = $widgetTransOutDuration;
+
+ if (in_array('permissions', $embed)) {
+ // Augment with editable flag
+ $widget->setUnmatchedProperty('isEditable', $this->getUser()->checkEditable($widget));
+
+ // Augment with deletable flag
+ $widget->setUnmatchedProperty('isDeletable', $this->getUser()->checkDeleteable($widget));
+
+ // Augment with viewable flag
+ $widget->setUnmatchedProperty('isViewable', $this->getUser()->checkViewable($widget));
+
+ // Augment with permissions flag
+ $widget->setUnmatchedProperty(
+ 'isPermissionsModifiable',
+ $this->getUser()->checkPermissionsModifyable($widget)
+ );
+ }
+ }
+
+ /** @var Region[] $allRegions */
+ $allRegions = array_merge($layout->regions, $layout->drawers);
+
+ // Augment regions with permissions
+ foreach ($allRegions as $region) {
+ if (in_array('permissions', $embed)) {
+ // Augment with editable flag
+ $region->setUnmatchedProperty('isEditable', $this->getUser()->checkEditable($region));
+
+ // Augment with deletable flag
+ $region->setUnmatchedProperty('isDeletable', $this->getUser()->checkDeleteable($region));
+
+ // Augment with viewable flag
+ $region->setUnmatchedProperty('isViewable', $this->getUser()->checkViewable($region));
+
+ // Augment with permissions flag
+ $region->setUnmatchedProperty(
+ 'isPermissionsModifiable',
+ $this->getUser()->checkPermissionsModifyable($region)
+ );
+ }
+ }
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $layout->includeProperty('buttons');
+
+ // Thumbnail
+ $layout->setUnmatchedProperty('thumbnail', '');
+ if (file_exists($layout->getThumbnailUri())) {
+ $layout->setUnmatchedProperty(
+ 'thumbnail',
+ $this->urlFor($request, 'layout.download.thumbnail', ['id' => $layout->layoutId])
+ );
+ }
+
+ // Fix up the description
+ $layout->setUnmatchedProperty('descriptionFormatted', $layout->description);
+
+ if ($layout->description != '') {
+ if ($showDescriptionId == 1) {
+ // Parse down for description
+ $layout->setUnmatchedProperty(
+ 'descriptionFormatted',
+ Parsedown::instance()->setSafeMode(true)->text($layout->description)
+ );
+ } else if ($showDescriptionId == 2) {
+ $layout->setUnmatchedProperty('descriptionFormatted', strtok($layout->description, "\n"));
+ }
+ }
+
+ if ($showDescriptionId === 3) {
+ // Load in the entire object model - creating module objects so that we can get the name of each
+ // widget and its items.
+ foreach ($layout->regions as $region) {
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ $module = $this->moduleFactory->getByType($widget->type);
+ $widget->setUnmatchedProperty('moduleName', $module->name);
+ $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', $module->name));
+ }
+ }
+
+ // provide our layout object to a template to render immediately
+ $layout->setUnmatchedProperty('descriptionFormatted', $this->renderTemplateToString(
+ 'layout-page-grid-widgetlist',
+ (array)$layout
+ ));
+ }
+
+ $layout->setUnmatchedProperty('statusDescription', match ($layout->status) {
+ Status::$STATUS_VALID => __('This Layout is ready to play'),
+ Status::$STATUS_PLAYER => __('There are items on this Layout that can only be assessed by the Display'),
+ Status::$STATUS_NOT_BUILT => __('This Layout has not been built yet'),
+ default => __('This Layout is invalid and should not be scheduled'),
+ });
+
+ $layout->setUnmatchedProperty('enableStatDescription', match ($layout->enableStat) {
+ 1 => __('This Layout has enable stat collection set to ON'),
+ default => __('This Layout has enable stat collection set to OFF'),
+ });
+
+ // Check if user has "delete permissions" - for layout designer to show/hide Delete button
+ $layout->setUnmatchedProperty('deletePermission', $this->getUser()->featureEnabled('layout.modify'));
+
+ // Check if user has view permissions to the schedule now page - for layout designer to show/hide
+ // the Schedule Now button
+ $layout->setUnmatchedProperty('scheduleNowPermission', $this->getUser()->featureEnabled('schedule.add'));
+
+ // Add some buttons for this row
+ if ($this->getUser()->featureEnabled('layout.modify')
+ && $this->getUser()->checkEditable($layout)
+ ) {
+ // Design Button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_design',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'layout.designer', array('id' => $layout->layoutId)),
+ 'text' => __('Design')
+ );
+
+ // Should we show a publish/discard button?
+ if ($layout->isEditable()) {
+ $layout->buttons[] = ['divider' => true];
+
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_publish',
+ 'url' => $this->urlFor($request, 'layout.publish.form', ['id' => $layout->layoutId]),
+ 'text' => __('Publish')
+ );
+
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_discard',
+ 'url' => $this->urlFor($request, 'layout.discard.form', ['id' => $layout->layoutId]),
+ 'text' => __('Discard')
+ );
+
+ $layout->buttons[] = ['divider' => true];
+ } else {
+ $layout->buttons[] = ['divider' => true];
+
+ // Checkout Button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_checkout',
+ 'url' => $this->urlFor($request, 'layout.checkout.form', ['id' => $layout->layoutId]),
+ 'text' => __('Checkout'),
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.checkout', ['id' => $layout->layoutId])],
+ ['name' => 'commit-method', 'value' => 'PUT']
+ ]
+ );
+
+ $layout->buttons[] = ['divider' => true];
+ }
+ }
+
+ // Preview
+ if ($this->getUser()->featureEnabled('layout.view')) {
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_preview',
+ 'external' => true,
+ 'url' => '#',
+ 'onclick' => 'createMiniLayoutPreview',
+ 'onclickParam' => $this->urlFor($request, 'layout.preview', ['id' => $layout->layoutId]),
+ 'text' => __('Preview Layout')
+ );
+
+ // Also offer a way to preview the draft layout.
+ if ($layout->hasDraft()) {
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_preview_draft',
+ 'external' => true,
+ 'url' => '#',
+ 'onclick' => 'createMiniLayoutPreview',
+ 'onclickParam' => $this->urlFor($request, 'layout.preview', ['id' => $layout->layoutId]) . '?isPreviewDraft=true',
+ 'text' => __('Preview Draft Layout')
+ );
+ }
+
+ $layout->buttons[] = ['divider' => true];
+ }
+
+ // Schedule
+ if ($this->getUser()->featureEnabled('schedule.add')) {
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_schedule',
+ 'url' => $this->urlFor($request, 'schedule.add.form', ['id' => $layout->campaignId, 'from' => 'Layout']),
+ 'text' => __('Schedule')
+ );
+ }
+
+ // Assign to Campaign
+ if ($this->getUser()->featureEnabled('campaign.modify')) {
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_assignTo_campaign',
+ 'url' => $this->urlFor($request, 'layout.assignTo.campaign.form', ['id' => $layout->layoutId]),
+ 'text' => __('Assign to Campaign')
+ );
+ }
+
+ $layout->buttons[] = ['divider' => true];
+
+ if ($this->getUser()->featureEnabled('playlist.view')) {
+ $layout->buttons[] = [
+ 'id' => 'layout_button_playlist_jump',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'playlist.view') .'?layoutId=' . $layout->layoutId,
+ 'text' => __('Jump to Playlists included on this Layout')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('campaign.view')) {
+ $layout->buttons[] = [
+ 'id' => 'layout_button_campaign_jump',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'campaign.view') .'?layoutId=' . $layout->layoutId,
+ 'text' => __('Jump to Campaigns containing this Layout')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('library.view')) {
+ $layout->buttons[] = [
+ 'id' => 'layout_button_media_jump',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'library.view') .'?layoutId=' . $layout->layoutId,
+ 'text' => __('Jump to Media included on this Layout')
+ ];
+ }
+
+ $layout->buttons[] = ['divider' => true];
+
+ // Only proceed if we have edit permissions
+ if ($this->getUser()->featureEnabled('layout.modify')
+ && $this->getUser()->checkEditable($layout)
+ ) {
+ // Edit Button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_edit',
+ 'url' => $this->urlFor($request, 'layout.edit.form', ['id' => $layout->layoutId]),
+ 'text' => __('Edit')
+ );
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $layout->buttons[] = [
+ 'id' => 'campaign_button_selectfolder',
+ 'url' => $this->urlFor($request, 'campaign.selectfolder.form', ['id' => $layout->campaignId]),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'campaign.selectfolder', ['id' => $layout->campaignId])],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'campaign_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $layout->layout],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ // Copy Button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_copy',
+ 'url' => $this->urlFor($request, 'layout.copy.form', ['id' => $layout->layoutId]),
+ 'text' => __('Copy')
+ );
+
+ // Retire Button
+ if ($layout->retired == 0) {
+ $layout->buttons[] = [
+ 'id' => 'layout_button_retire',
+ 'url' => $this->urlFor($request, 'layout.retire.form', ['id' => $layout->layoutId]),
+ 'text' => __('Retire'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.retire', ['id' => $layout->layoutId])],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'layout_button_retire'],
+ ['name' => 'text', 'value' => __('Retire')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $layout->layout]
+ ]
+ ];
+ } else {
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_unretire',
+ 'url' => $this->urlFor($request, 'layout.unretire.form', ['id' => $layout->layoutId]),
+ 'text' => __('Unretire'),
+ );
+ }
+
+ // Extra buttons if have delete permissions
+ if ($this->getUser()->checkDeleteable($layout)) {
+ // Delete Button
+ $layout->buttons[] = [
+ 'id' => 'layout_button_delete',
+ 'url' => $this->urlFor($request, 'layout.delete.form', ['id' => $layout->layoutId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.delete', ['id' => $layout->layoutId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'layout_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $layout->layout]
+ ]
+ ];
+ }
+
+ // Set Enable Stat
+ $layout->buttons[] = [
+ 'id' => 'layout_button_setenablestat',
+ 'url' => $this->urlFor($request, 'layout.setenablestat.form', ['id' => $layout->layoutId]),
+ 'text' => __('Enable stats collection?'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.setenablestat', ['id' => $layout->layoutId])],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'layout_button_setenablestat'],
+ ['name' => 'text', 'value' => __('Enable stats collection?')],
+ ['name' => 'rowtitle', 'value' => $layout->layout],
+ ['name' => 'form-callback', 'value' => 'setEnableStatMultiSelectFormOpen']
+ ]
+ ];
+
+ $layout->buttons[] = ['divider' => true];
+
+ if ($this->getUser()->featureEnabled('template.modify') && !$layout->isEditable()) {
+ // Save template button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_save_template',
+ 'url' => $this->urlFor($request, 'template.from.layout.form', ['id' => $layout->layoutId]),
+ 'text' => __('Save Template')
+ );
+ }
+
+ // Export Button
+ if ($this->getUser()->featureEnabled('layout.export')) {
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_export',
+ 'url' => $this->urlFor($request, 'layout.export.form', ['id' => $layout->layoutId]),
+ 'text' => __('Export')
+ );
+ }
+
+ // Extra buttons if we have modify permissions
+ if ($this->getUser()->checkPermissionsModifyable($layout)) {
+ // Permissions button
+ $layout->buttons[] = [
+ 'id' => 'layout_button_permissions',
+ 'url' => $this->urlFor($request, 'user.permissions.form', ['entity' => 'Campaign', 'id' => $layout->campaignId]),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'user.permissions.multi', ['entity' => 'Campaign', 'id' => $layout->campaignId])],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'layout_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $layout->layout],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ ['name' => 'custom-handler-url', 'value' => $this->urlFor($request, 'user.permissions.multi.form', ['entity' => 'Campaign'])],
+ ['name' => 'content-id-name', 'value' => 'campaignId']
+ ]
+ ];
+ }
+ }
+ }
+
+ // Store the table rows
+ $this->getState()->recordsTotal = $this->layoutFactory->countLast();
+ $this->getState()->setData($layouts);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'layout-form-edit';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ 'tagString' => $layout->getTagString(),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function editBackgroundForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Edits always happen on Drafts, get the draft Layout using the Parent Layout ID
+ if ($layout->schemaVersion < 2) {
+ $resolution = $this->resolutionFactory->getByDesignerDimensions($layout->width, $layout->height);
+ } else {
+ $resolution = $this->resolutionFactory->getByDimensions($layout->width, $layout->height);
+ }
+
+ // If we have a background image, output it
+ $backgroundId = $sanitizedParams->getInt('backgroundOverride', ['default' => $layout->backgroundImageId]);
+ $backgrounds = ($backgroundId != null) ? [$this->mediaFactory->getById($backgroundId)] : [];
+
+ $this->getState()->template = 'layout-form-background';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ 'resolution' => $resolution,
+ 'resolutions' => $this->resolutionFactory->query(
+ ['resolution'],
+ [
+ 'withCurrent' => $resolution->resolutionId,
+ 'enabled' => 1
+ ]
+ ),
+ 'backgroundId' => $backgroundId,
+ 'backgrounds' => $backgrounds,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy layout form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($layout))
+ throw new AccessDeniedException();
+
+ $this->getState()->template = 'layout-form-copy';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copies a layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/layout/copy/{layoutId}",
+ * operationId="layoutCopy",
+ * tags={"layout"},
+ * summary="Copy Layout",
+ * description="Copy a Layout, providing a new name if applicable",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID to Copy",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The name for the new Layout",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The Description for the new Layout",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="copyMediaFiles",
+ * in="formData",
+ * description="Flag indicating whether to make new Copies of all Media Files assigned to the Layout being Copied",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $originalLayout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($originalLayout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Make sure we're not a draft
+ if ($originalLayout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot copy a Draft Layout'), 'layoutId');
+ }
+
+ // Load the layout for Copy
+ $originalLayout->load();
+
+ // Clone
+ $layout = clone $originalLayout;
+
+ $this->getLog()->debug('Tag values from original layout: ' . $originalLayout->getTagString());
+
+ $layout->layout = $sanitizedParams->getString('name');
+ $layout->description = $sanitizedParams->getString('description');
+ $layout->updateTagLinks($originalLayout->tags);
+ $layout->setOwner($this->getUser()->userId, true);
+
+ // Copy the media on the layout and change the assignments.
+ // https://github.com/xibosignage/xibo/issues/1283
+ if ($sanitizedParams->getCheckbox('copyMediaFiles') == 1) {
+ // track which Media Id we already copied
+ $copiedMediaIds = [];
+ foreach ($layout->getAllWidgets() as $widget) {
+ // Copy the media
+ if ( $widget->type === 'image' || $widget->type === 'video' || $widget->type === 'pdf' || $widget->type === 'powerpoint' || $widget->type === 'audio' ) {
+ $oldMedia = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+
+ // check if we already cloned this media, if not, do it and add it the array
+ if (!array_key_exists($oldMedia->mediaId, $copiedMediaIds)) {
+ $media = clone $oldMedia;
+ $media->setOwner($this->getUser()->userId);
+ $media->save();
+ $copiedMediaIds[$oldMedia->mediaId] = $media->mediaId;
+ } else {
+ // if we already cloned that media, look it up and assign to Widget.
+ $mediaId = $copiedMediaIds[$oldMedia->mediaId];
+ $media = $this->mediaFactory->getById($mediaId);
+ }
+
+ $widget->unassignMedia($oldMedia->mediaId);
+ $widget->assignMedia($media->mediaId);
+
+ // Update the widget option with the new ID
+ $widget->setOptionValue('uri', 'attrib', $media->storedAs);
+ }
+ }
+
+ // Also handle the background image, if there is one
+ if ($layout->backgroundImageId != 0) {
+ $oldMedia = $this->mediaFactory->getById($layout->backgroundImageId);
+ // check if we already cloned this media, if not, do it and add it the array
+ if (!array_key_exists($oldMedia->mediaId, $copiedMediaIds)) {
+ $media = clone $oldMedia;
+ $media->setOwner($this->getUser()->userId);
+ $media->save();
+ $copiedMediaIds[$oldMedia->mediaId] = $media->mediaId;
+ } else {
+ // if we already cloned that media, look it up and assign to Layout backgroundImage.
+ $mediaId = $copiedMediaIds[$oldMedia->mediaId];
+ $media = $this->mediaFactory->getById($mediaId);
+ }
+
+ $layout->backgroundImageId = $media->mediaId;
+ }
+ }
+
+ // Save the new layout
+ $layout->save();
+
+ $allRegions = array_merge($layout->regions, $layout->drawers);
+
+ // this will adjust source/target Ids in the copied layout
+ $layout->copyActions($layout, $originalLayout);
+
+ // Sub-Playlist
+ /** @var Region $region */
+ foreach ($allRegions as $region) {
+ // Match our original region id to the id in the parent layout
+ $original = $originalLayout->getRegionOrDrawer($region->getOriginalValue('regionId'));
+
+ // Make sure Playlist closure table from the published one are copied over
+ $original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Copied as %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/layout/{layoutId}/tag",
+ * operationId="layoutTag",
+ * tags={"layout"},
+ * summary="Tag Layout",
+ * description="Tag a Layout with one or more tags",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout Id to Tag",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tag",
+ * in="formData",
+ * description="An array of tags",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function tag(Request $request, Response $response, $id)
+ {
+ // Edit permission
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($layout))
+ throw new AccessDeniedException();
+
+ // Make sure we're not a draft
+ if ($layout->isChild())
+ throw new InvalidArgumentException(__('Cannot manage tags on a Draft Layout'), 'layoutId');
+
+ $tags = $sanitizedParams->getArray('tag');
+
+ if (count($tags) <= 0) {
+ throw new InvalidArgumentException(__('No tags to assign'));
+ }
+
+ foreach ($tags as $tag) {
+ $layout->assignTag($this->tagFactory->tagFromString($tag));
+ }
+
+ $layout->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Tagged %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/layout/{layoutId}/untag",
+ * operationId="layoutUntag",
+ * tags={"layout"},
+ * summary="Untag Layout",
+ * description="Untag a Layout with one or more tags",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout Id to Untag",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tag",
+ * in="formData",
+ * description="An array of tags",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function untag(Request $request, Response $response, $id)
+ {
+ // Edit permission
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($layout))
+ throw new AccessDeniedException();
+
+ // Make sure we're not a draft
+ if ($layout->isChild())
+ throw new InvalidArgumentException(__('Cannot manage tags on a Draft Layout'), 'layoutId');
+
+ $tags = $sanitizedParams->getArray('tag');
+
+ if (count($tags) <= 0)
+ throw new InvalidArgumentException(__('No tags to unassign'), 'tag');
+
+ foreach ($tags as $tag) {
+ $layout->unassignTag($this->tagFactory->tagFromString($tag));
+ }
+
+ $layout->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Untagged %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Layout Status
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Get(
+ * path="/layout/status/{layoutId}",
+ * operationId="layoutStatus",
+ * tags={"layout"},
+ * summary="Layout Status",
+ * description="Calculate the Layout status and return a Layout",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout Id to get the status",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ */
+ public function status(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($id));
+ try {
+ $layout = $this->layoutFactory->decorateLockedProperties($layout);
+ $layout->xlfToDisk();
+ } finally {
+ // Release lock
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+
+ switch ($layout->status) {
+ case Status::$STATUS_VALID:
+ $status = __('This Layout is ready to play');
+ break;
+
+ case Status::$STATUS_PLAYER:
+ $status = __('There are items on this Layout that can only be assessed by the Display');
+ break;
+
+ case Status::$STATUS_NOT_BUILT:
+ $status = __('This Layout has not been built yet');
+ break;
+
+ default:
+ $status = __('This Layout is invalid and should not be scheduled');
+ }
+
+ // We want a different return depending on whether we are arriving through the API or WEB routes
+ if ($this->isApi($request)) {
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => $status,
+ 'id' => $layout->status,
+ 'data' => $layout
+ ]);
+ } else {
+ $this->getState()->html = $status;
+ $this->getState()->extra = [
+ 'status' => $layout->status,
+ 'duration' => $layout->duration,
+ 'statusMessage' => $layout->getStatusMessage(),
+ 'isLocked' => $layout->isLocked
+ ];
+
+ $this->getState()->success = true;
+ $this->session->refreshExpiry = false;
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Export Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function exportForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($layout))
+ throw new AccessDeniedException();
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot export Draft Layout'), 'layoutId');
+ }
+
+ // Render the form
+ $this->getState()->template = 'layout-form-export';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ 'saveAs' => 'export_' . preg_replace('/[^a-z0-9]+/', '-', strtolower($layout->layout))
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function export(Request $request, Response $response, $id)
+ {
+ $this->setNoOutput(true);
+
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Make sure we're not a draft
+ if ($layout->isChild()) {
+ throw new InvalidArgumentException(__('Cannot export Draft Layout'), 'layoutId');
+ }
+
+ // Save As?
+ $saveAs = $sanitizedParams->getString('saveAs');
+
+ // Make sure our file name is reasonable
+ if (empty($saveAs)) {
+ $saveAs = 'export_' . preg_replace('/[^a-z0-9]+/', '-', strtolower($layout->layout));
+ } else {
+ $saveAs = preg_replace('/[^a-z0-9]+/', '-', strtolower($saveAs));
+ }
+
+ $fileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $saveAs . '.zip';
+ $layout->toZip(
+ $this->dataSetFactory,
+ $this->widgetDataFactory,
+ $fileName,
+ [
+ 'includeData' => ($sanitizedParams->getCheckbox('includeData') == 1),
+ 'includeFallback' => ($sanitizedParams->getCheckbox('includeFallback') == 1),
+ ]
+ );
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $fileName
+ ));
+ }
+
+ /**
+ * TODO: Not sure how to document this.
+ * SWG\Post(
+ * path="/layout/import",
+ * operationId="layoutImport",
+ * tags={"layout"},
+ * summary="Import Layout",
+ * description="Upload and Import a Layout",
+ * consumes="multipart/form-data",
+ * SWG\Parameter(
+ * name="file",
+ * in="formData",
+ * description="The file",
+ * type="file",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function import(Request $request, Response $response)
+ {
+ $this->getLog()->debug('Import Layout');
+ $parsedBody = $this->getSanitizer($request->getParams());
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($this->getConfig()->getSetting('LIBRARY_LOCATION'));
+
+ // Make sure there is room in the library
+ $libraryLimit = $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+
+ // Folders
+ $folderId = $parsedBody->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $options = [
+ 'userId' => $this->getUser()->userId,
+ 'controller' => $this,
+ 'dataSetFactory' => $this->getDataSetFactory(),
+ 'widgetDataFactory' => $this->widgetDataFactory,
+ 'image_versions' => [],
+ 'accept_file_types' => '/\.zip$/i',
+ 'libraryLimit' => $libraryLimit,
+ 'libraryQuotaFull' => ($libraryLimit > 0 && $this->mediaService->libraryUsage() > $libraryLimit),
+ 'mediaService' => $this->mediaService,
+ 'sanitizerService' => $this->getSanitizerService(),
+ 'folderId' => $folderId,
+ ];
+
+ $this->setNoOutput();
+
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ new LayoutUploadHandler($libraryFolder . 'temp/', $this->getLog()->getLoggerInterface(), $options);
+
+ // Explicitly set the Content-Type header to application/json
+ return $response->withHeader('Content-Type', 'application/json');
+ }
+
+ /**
+ * Gets a file from the library
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function downloadBackground(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('Layout Download background request for layoutId ' . $id);
+
+ $layout = $this->layoutFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($layout->backgroundImageId == null) {
+ throw new NotFoundException();
+ }
+
+ // This media may not be viewable, but we won't check it because the user has permission to view the
+ // layout that it is assigned to.
+ $media = $this->mediaFactory->getById($layout->backgroundImageId);
+
+ // Make a media module
+ if ($media->mediaType !== 'image') {
+ throw new NotFoundException(__('Layout background must be an image'));
+ }
+
+ // Hand over to the widget downloader
+ $downloader = new WidgetDownloader(
+ $this->getConfig()->getSetting('LIBRARY_LOCATION'),
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $this->getConfig()->getSetting('DEFAULT_RESIZE_LIMIT', 6000)
+ );
+ $downloader->useLogger($this->getLog()->getLoggerInterface());
+ $response = $downloader->imagePreview(
+ $this->getSanitizer([
+ 'width' => $layout->width,
+ 'height' => $layout->height,
+ 'proportional' => 0,
+ ]),
+ $media->storedAs,
+ $response,
+ );
+
+ $this->setNoOutput(true);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Assign to Campaign Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function assignToCampaignForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Render the form
+ $this->getState()->template = 'layout-form-assign-to-campaign';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Checkout Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function checkoutForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ $data = ['layout' => $layout];
+
+ $this->getState()->template = 'layout-form-checkout';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('layoutCheckoutForm');
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Checkout Layout
+ *
+ * @SWG\Put(
+ * path="/layout/checkout/{layoutId}",
+ * operationId="layoutCheckout",
+ * tags={"layout"},
+ * summary="Checkout Layout",
+ * description="Checkout a Layout so that it can be edited. The original Layout will still be played",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function checkout(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ // Can't checkout a Layout which can already be edited
+ if ($layout->isEditable()) {
+ throw new InvalidArgumentException(__('Layout is already checked out'), 'statusId');
+ }
+
+ // Checkout this Layout
+ $draft = $this->layoutFactory->checkoutLayout($layout);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Checked out %s'), $layout->layout),
+ 'id' => $draft->layoutId,
+ 'data' => $draft
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Publish Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function publishForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ $data = ['layout' => $layout];
+
+ $this->getState()->template = 'layout-form-publish';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Publish Layout
+ *
+ * @SWG\Put(
+ * path="/layout/publish/{layoutId}",
+ * operationId="layoutPublish",
+ * tags={"layout"},
+ * summary="Publish Layout",
+ * description="Publish a Layout, discarding the original",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="publishNow",
+ * in="formData",
+ * description="Flag, indicating whether to publish layout now",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="publishDate",
+ * in="formData",
+ * description="The date/time at which layout should be published",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function publish(Request $request, Response $response, $id)
+ {
+ Profiler::start('Layout::publish', $this->getLog());
+ $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($id), true);
+ try {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $publishDate = $sanitizedParams->getDate('publishDate');
+ $publishNow = $sanitizedParams->getCheckbox('publishNow');
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ // if we have publish date update it in database
+ if (isset($publishDate) && !$publishNow) {
+ $layout->setPublishedDate($publishDate);
+ }
+
+ // We want to take the draft layout, and update the campaign links to point to the draft, then remove the
+ // parent.
+ if ($publishNow || (isset($publishDate) && $publishDate->format('U') < Carbon::now()->format('U'))) {
+ $draft = $this->layoutFactory->getByParentId($id);
+ $draft->publishDraft();
+ $draft->load();
+
+ // Make sure actions from all levels are valid before allowing publish
+ // Layout Actions
+ foreach ($draft->actions as $action) {
+ $action->validate();
+ }
+
+ /** @var Region[] $allRegions */
+ $allRegions = array_merge($draft->regions, $draft->drawers);
+
+ // Region Actions
+ foreach ($allRegions as $region) {
+ // Interactive Actions on Region
+ foreach ($region->actions as $action) {
+ $action->validate();
+ }
+
+ // Widget Actions
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ // Interactive Actions on Widget
+ foreach ($widget->actions as $action) {
+ $action->validate();
+ }
+ }
+ }
+
+ // We also build the XLF at this point, and if we have a problem we prevent publishing and raise as an
+ // error message
+ $draft->xlfToDisk(['notify' => true, 'exceptionOnError' => true, 'exceptionOnEmptyRegion' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Published %s'), $draft->layout),
+ 'data' => $draft
+ ]);
+ } else {
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Layout will be published on %s'), $publishDate),
+ 'data' => $layout
+ ]);
+ }
+
+ Profiler::end('Layout::publish', $this->getLog());
+ } finally {
+ // Release lock
+ $this->layoutFactory->concurrentRequestRelease($layout, true);
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Discard Layout Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function discardForm(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ $data = ['layout' => $layout];
+
+ $this->getState()->template = 'layout-form-discard';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Discard Layout
+ *
+ * @SWG\Put(
+ * path="/layout/discard/{layoutId}",
+ * operationId="layoutDiscard",
+ * tags={"layout"},
+ * summary="Discard Layout",
+ * description="Discard a Layout restoring the original",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function discard(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to edit this layout'));
+ }
+
+ // Make sure the Layout is checked out to begin with
+ if (!$layout->isEditable()) {
+ throw new InvalidArgumentException(__('Layout is not checked out'), 'statusId');
+ }
+
+ $draft = $this->layoutFactory->getByParentId($id);
+ $draft->discardDraft();
+
+ // The parent is no longer a draft
+ $layout->publishedStatusId = 1;
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Discarded %s'), $draft->layout),
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Query the Database for all Code identifiers assigned to Layouts.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getLayoutCodes(Request $request, Response $response)
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+
+ $codes = $this->layoutFactory->getLayoutCodes($this->gridRenderFilter([
+ 'code' => $parsedParams->getString('code')
+ ], $parsedParams));
+
+ // Store the table rows
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->layoutFactory->countLast();
+ $this->getState()->setData($codes);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Release the Layout Lock on specified layoutId
+ * Available only to the User that currently has the Layout locked.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function releaseLock(Request $request, Response $response, $id)
+ {
+ /** @var Item $lock */
+ $lock = $this->pool->getItem('locks/layout/' . $id);
+ $lockUserId = $lock->get()->userId;
+
+ if ($this->getUser()->userId !== $lockUserId) {
+ throw new InvalidArgumentException(__('This function is available only to User who originally locked this Layout.'));
+ }
+
+ $lock->set([]);
+ $lock->save();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a thumbnail
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ */
+ public function addThumbnail(Request $request, Response $response, $id): Response
+ {
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ MediaService::ensureLibraryExists($libraryLocation);
+
+ // Check the Layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Make sure we have edit permissions
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Where would we save this to?
+ if ($layout->isChild()) {
+ // A draft
+ $saveTo = $libraryLocation . 'thumbs/' . $layout->campaignId . '_layout_thumb.png';
+ } else {
+ // Published
+ // we would usually expect this to be copied over when published.
+ $saveTo = $libraryLocation . 'thumbs/' . $layout->campaignId . '_campaign_thumb.png';
+ }
+
+ // Load this Layout
+ $layout->load();
+
+ // Create a thumbnail image
+ try {
+ Img::configure(['driver' => 'gd']);
+
+ if ($layout->backgroundImageId !== null && $layout->backgroundImageId !== 0) {
+ // Start from a background image
+ $media = $this->mediaFactory->getById($layout->backgroundImageId);
+ $image = Img::make($libraryLocation . $media->storedAs);
+
+ // Resize this image (without cropping it) to the size of this layout
+ $image->resize($layout->width, $layout->height);
+ } else {
+ // Start from a Canvas
+ $image = Img::canvas($layout->width, $layout->height, $layout->backgroundColor);
+ }
+
+ $countRegions = count($layout->regions);
+
+ // Draw some regions on it.
+ foreach ($layout->regions as $region) {
+ try {
+ // We don't do this for the canvas region.
+ if ($countRegions > 1 && $region->type === 'canvas') {
+ continue;
+ }
+
+ // Get widgets in this region
+ $playlist = $region->getPlaylist()->setModuleFactory($this->moduleFactory);
+ $widgets = $playlist->expandWidgets();
+
+ if (count($widgets) <= 0) {
+ // Render the region (draw a grey box)
+ $image->rectangle(
+ $region->left,
+ $region->top,
+ $region->left + $region->width,
+ $region->top + $region->height,
+ function ($draw) {
+ $draw->background('rgba(196, 196, 196, 0.6)');
+ }
+ );
+ if ($region->width >= 400) {
+ $image->text(
+ __('Empty Region'),
+ $region->left + ($region->width / 2),
+ $region->top + ($region->height / 2),
+ function ($font) {
+ $font->file(PROJECT_ROOT . '/web/theme/default/fonts/Railway.ttf');
+ $font->size(84);
+ $font->color('#000000');
+ $font->align('center');
+ $font->valign('center');
+ }
+ );
+ }
+ } else {
+ // Render just the first widget in the appropriate place
+ $widget = $widgets[0];
+ if ($widget->type === 'image') {
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ $cover = Img::make($libraryLocation . $media->storedAs);
+ $proportional = $widget->getOptionValue('scaleType', 'stretch') !== 'stretch';
+ $fit = $widget->getOptionValue('scaleType', 'stretch') === 'fit';
+
+ if ($fit) {
+ $cover->fit($region->width, $region->height);
+ } else {
+ $cover->resize(
+ $region->width,
+ $region->height,
+ function ($constraint) use ($proportional) {
+ if ($proportional) {
+ $constraint->aspectRatio();
+ }
+ }
+ );
+ }
+ if ($proportional) {
+ $cover->resizeCanvas($region->width, $region->height);
+ }
+ $image->insert($cover, 'top-left', $region->left, $region->top);
+ } else if ($widget->type === 'video'
+ && file_exists($libraryLocation . $widget->getPrimaryMediaId() . '_videocover.png')
+ ) {
+ // Render the video cover
+ $cover = Img::make($libraryLocation . $widget->getPrimaryMediaId() . '_videocover.png');
+ $cover->resize($region->width, $region->height, function ($constraint) {
+ $constraint->aspectRatio();
+ });
+ $cover->resizeCanvas($region->width, $region->height);
+ $image->insert($cover, 'top-left', $region->left, $region->top);
+ } else {
+ // Draw the region in the widget colouring
+ $image->rectangle(
+ $region->left,
+ $region->top,
+ $region->left + $region->width,
+ $region->top + $region->height,
+ function ($draw) {
+ $draw->background('rgba(196, 196, 196, 0.6)');
+ }
+ );
+ $module = $this->moduleFactory->getByType($widget->type);
+ if ($region->width >= 400) {
+ $image->text(
+ $widget->getOptionValue('name', $module->name),
+ $region->left + ($region->width / 2),
+ $region->top + ($region->height / 2),
+ function ($font) {
+ $font->file(PROJECT_ROOT . '/web/theme/default/fonts/Railway.ttf');
+ $font->size(84);
+ $font->color('#000000');
+ $font->align('center');
+ $font->valign('center');
+ }
+ );
+ }
+ }
+
+ // Put a number of widgets counter in the bottom
+ $image->text(
+ '1 / ' . count($widgets),
+ $region->left + $region->width - 10,
+ $region->top + $region->height - 10,
+ function ($font) {
+ $font->file(PROJECT_ROOT . '/web/theme/default/fonts/Railway.ttf');
+ $font->size(36);
+ $font->color('#000000');
+ $font->align('right');
+ $font->valign('bottom');
+ }
+ );
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Problem generating region in thumbnail. e: ' . $e->getMessage());
+ }
+ }
+
+ // Resize the entire layout down to a thumbnail
+ $image->widen(1080);
+
+ // Save the file
+ $image->save($saveTo);
+
+ return $response->withStatus(204);
+ } catch (\Exception $e) {
+ $this->getLog()->error('Exception adding thumbnail to Layout. e = ' . $e->getMessage());
+ throw new InvalidArgumentException(__('Incorrect image data'));
+ }
+ }
+
+ /**
+ * Download the Layout Thumbnail
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function downloadThumbnail(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug('Layout thumbnail request for layoutId ' . $id);
+
+ $layout = $this->layoutFactory->getById($id);
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get thumbnail uri
+ $uri = $layout->getThumbnailUri();
+
+ if (!file_exists($uri)) {
+ throw new NotFoundException(__('Thumbnail not found for Layout'));
+ }
+
+ $response = $response
+ ->withHeader('Content-Length', filesize($uri))
+ ->withHeader('Content-Type', (new MimeTypes())->getMimeType('png'));
+
+ $sendFileMode = $this->getConfig()->getSetting('SENDFILE_MODE');
+ if ($sendFileMode == 'Apache') {
+ $response = $response->withHeader('X-Sendfile', $uri);
+ } else if ($sendFileMode == 'Nginx') {
+ $response = $response->withHeader('X-Accel-Redirect', '/download/thumbs/' . basename($uri));
+ } else {
+ // Return the file with PHP
+ $response = $response->withBody(new Stream(fopen($uri, 'r')));
+ }
+
+ $this->setNoOutput();
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Create a Layout with full screen Region with Media/Playlist specific Widget
+ * This is called as a first step when scheduling Media/Playlist eventType
+ * @SWG\Post(
+ * path="/layout/fullscreen",
+ * operationId="layoutAddFullScreen",
+ * tags={"layout"},
+ * summary="Add a Full Screen Layout",
+ * description="Add a new full screen Layout with specified Media/Playlist",
+ * @SWG\Parameter(
+ * name="id",
+ * in="formData",
+ * description="The Media or Playlist ID that should be added to this Layout",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="formData",
+ * description="The type of Layout to be created = media or playlist",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="formData",
+ * description="The Id of the resolution for this Layout, defaults to 1080p for playlist and closest resolution match for Media",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="backgroundColor",
+ * in="formData",
+ * description="A HEX color to use as the background color of this Layout. Default is black #000",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="layoutDuration",
+ * in="formData",
+ * description="Use with media type, to specify the duration this Media should play in one loop",
+ * type="boolean",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return Response|ResponseInterface
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function createFullScreenLayout(Request $request, Response $response): Response|ResponseInterface
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $type = $params->getString('type');
+ $id = $params->getInt('id');
+ $resolutionId = $params->getInt('resolutionId');
+ $backgroundColor = $params->getString('backgroundColor');
+ $duration = $params->getInt('layoutDuration');
+
+ if (empty($id)) {
+ throw new InvalidArgumentException(sprintf(__('Please select %s'), ucfirst($type)));
+ }
+
+ // We only create fullscreen layout from media files or playlist
+ if (!in_array($type, ['media', 'playlist'], true)) {
+ throw new InvalidArgumentException(__('Invalid type'));
+ }
+
+ $fullscreenLayout = $this->layoutFactory->createFullScreenLayout(
+ $type,
+ $id,
+ $resolutionId,
+ $backgroundColor,
+ $duration
+ );
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Created %s'), $fullscreenLayout->layout),
+ 'data' => $fullscreenLayout
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Library.php b/lib/Controller/Library.php
new file mode 100644
index 0000000..4817a52
--- /dev/null
+++ b/lib/Controller/Library.php
@@ -0,0 +1,3007 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use Illuminate\Support\Str;
+use Intervention\Image\ImageManagerStatic as Img;
+use Psr\Http\Message\ResponseInterface;
+use Respect\Validation\Validator as v;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Connector\ProviderDetails;
+use Xibo\Connector\ProviderImport;
+use Xibo\Entity\Media;
+use Xibo\Entity\SearchResult;
+use Xibo\Entity\SearchResults;
+use Xibo\Event\LibraryProviderEvent;
+use Xibo\Event\LibraryProviderImportEvent;
+use Xibo\Event\LibraryProviderListEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Event\MediaFullLoadEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\LinkSigner;
+use Xibo\Helper\XiboUploadHandler;
+use Xibo\Service\MediaService;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\LibraryFullException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Render\WidgetDownloader;
+
+/**
+ * Class Library
+ * @package Xibo\Controller
+ */
+class Library extends Base
+{
+ /** @var EventDispatcherInterface */
+ private $dispatcher;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var ModuleFactory
+ */
+ private $moduleFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var WidgetFactory
+ */
+ private $widgetFactory;
+
+ /**
+ * @var PlaylistFactory
+ */
+ private $playlistFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var UserGroupFactory
+ */
+ private $userGroupFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+ /**
+ * @var MediaServiceInterface
+ */
+ private $mediaService;
+
+ /**
+ * Set common dependencies.
+ * @param UserFactory $userFactory
+ * @param ModuleFactory $moduleFactory
+ * @param TagFactory $tagFactory
+ * @param MediaFactory $mediaFactory
+ * @param WidgetFactory $widgetFactory
+ * @param PermissionFactory $permissionFactory
+ * @param LayoutFactory $layoutFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param DisplayFactory $displayFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct(
+ $userFactory,
+ $moduleFactory,
+ $tagFactory,
+ $mediaFactory,
+ $widgetFactory,
+ $permissionFactory,
+ $layoutFactory,
+ $playlistFactory,
+ $userGroupFactory,
+ $displayFactory,
+ $scheduleFactory,
+ $folderFactory
+ ) {
+ $this->moduleFactory = $moduleFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->userFactory = $userFactory;
+ $this->tagFactory = $tagFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->displayFactory = $displayFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * Get Module Factory
+ * @return ModuleFactory
+ */
+ public function getModuleFactory()
+ {
+ return $this->moduleFactory;
+ }
+
+ /**
+ * Get Media Factory
+ * @return MediaFactory
+ */
+ public function getMediaFactory()
+ {
+ return $this->mediaFactory;
+ }
+
+ /**
+ * Get Permission Factory
+ * @return PermissionFactory
+ */
+ public function getPermissionFactory()
+ {
+ return $this->permissionFactory;
+ }
+
+ /**
+ * Get Widget Factory
+ * @return WidgetFactory
+ */
+ public function getWidgetFactory()
+ {
+ return $this->widgetFactory;
+ }
+
+ /**
+ * Get Layout Factory
+ * @return LayoutFactory
+ */
+ public function getLayoutFactory()
+ {
+ return $this->layoutFactory;
+ }
+
+ /**
+ * Get Playlist Factory
+ * @return PlaylistFactory
+ */
+ public function getPlaylistFactory()
+ {
+ return $this->playlistFactory;
+ }
+
+ /**
+ * @return TagFactory
+ */
+ public function getTagFactory()
+ {
+ return $this->tagFactory;
+ }
+
+ /**
+ * @return FolderFactory
+ */
+ public function getFolderFactory()
+ {
+ return $this->folderFactory;
+ }
+
+ public function useMediaService(MediaServiceInterface $mediaService)
+ {
+ $this->mediaService = $mediaService;
+ }
+
+ public function getMediaService()
+ {
+ return $this->mediaService->setUser($this->getUser());
+ }
+
+ /**
+ * Displays the page logic
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getQueryParams());
+ $mediaId = $sanitizedParams->getInt('mediaId');
+
+ if ($mediaId !== null) {
+ $media = $this->mediaFactory->getById($mediaId);
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ // Thumbnail
+ $module = $this->moduleFactory->getByType($media->mediaType);
+ $media->setUnmatchedProperty('thumbnail', '');
+ if ($module->hasThumbnail) {
+ $media->setUnmatchedProperty(
+ 'thumbnail',
+ $this->urlFor($request, 'library.download', [
+ 'id' => $media->mediaId
+ ], [
+ 'preview' => 1
+ ])
+ );
+ }
+ $media->setUnmatchedProperty('fileSizeFormatted', ByteFormatter::format($media->fileSize));
+
+ $this->getState()->template = 'library-direct-media-details';
+ $this->getState()->setData([
+ 'media' => $media
+ ]);
+ } else {
+ // Users we have permission to see
+ $this->getState()->template = 'library-page';
+ $this->getState()->setData([
+ 'modules' => $this->moduleFactory->getLibraryModules(),
+ 'validExt' => implode('|', $this->moduleFactory->getValidExtensions([]))
+ ]);
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Enable Stats Collection of a media
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Put(
+ * path="/library/setenablestat/{mediaId}",
+ * operationId="mediaSetEnableStat",
+ * tags={"library"},
+ * summary="Enable Stats Collection",
+ * description="Set Enable Stats Collection? to use for the collection of Proof of Play statistics for a media.",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="enableStat",
+ * in="formData",
+ * description="The option to enable the collection of Media Proof of Play statistics",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function setEnableStat(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $enableStat = $this->getSanitizer($request->getParams())->getString('enableStat');
+
+ $media->enableStat = $enableStat;
+ $media->save(['saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('For Media %s Enable Stats Collection is set to %s'), $media->name, __($media->enableStat))
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Enable Stat Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function setEnableStatForm(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'media' => $media,
+ ];
+
+ $this->getState()->template = 'library-form-setenablestat';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Prints out a Table of all media items
+ *
+ * @SWG\Get(
+ * path="/library",
+ * operationId="librarySearch",
+ * tags={"library"},
+ * summary="Library Search",
+ * description="Search the Library for this user",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="query",
+ * description="Filter by Media Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="media",
+ * in="query",
+ * description="Filter by Media Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="query",
+ * description="Filter by Media Type",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ownerId",
+ * in="query",
+ * description="Filter by Owner Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="query",
+ * description="Filter by Retired",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="query",
+ * description="Filter by Tags - comma seperated",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="query",
+ * description="A flag indicating whether to treat the tags filter as an exact match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="query",
+ * description="When filtering by multiple Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="duration",
+ * in="query",
+ * description="Filter by Duration - a number or less-than,greater-than,less-than-equal or great-than-equal followed by a | followed by a number",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="fileSize",
+ * in="query",
+ * description="Filter by File Size - a number or less-than,greater-than,less-than-equal or great-than-equal followed by a | followed by a number",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ownerUserGroupId",
+ * in="query",
+ * description="Filter by users in this UserGroupId",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isReturnPublicUrls",
+ * in="query",
+ * description="Should the thumbail URLs be authenticated S3 style public URL, default = false",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Media")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ // Variables used for link signing
+ $isReturnPublicUrls = $parsedQueryParams->getCheckbox('isReturnPublicUrls') == 1;
+ $thumbnailRouteName = $isReturnPublicUrls ? 'library.public.thumbnail' : 'library.thumbnail';
+ $encryptionKey = $this->getConfig()->getApiKeyDetails()['encryptionKey'];
+ $rootUrl = (new HttpsDetect())->getUrl();
+
+ // Construct the SQL
+ $mediaList = $this->mediaFactory->query($this->gridRenderSort($parsedQueryParams), $this->gridRenderFilter([
+ 'mediaId' => $parsedQueryParams->getInt('mediaId'),
+ 'name' => $parsedQueryParams->getString('media'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'nameExact' => $parsedQueryParams->getString('nameExact'),
+ 'type' => $parsedQueryParams->getString('type'),
+ 'types' => $parsedQueryParams->getArray('types'),
+ 'tags' => $parsedQueryParams->getString('tags'),
+ 'exactTags' => $parsedQueryParams->getCheckbox('exactTags'),
+ 'ownerId' => $parsedQueryParams->getInt('ownerId'),
+ 'retired' => $parsedQueryParams->getInt('retired'),
+ 'duration' => $parsedQueryParams->getInt('duration'),
+ 'fileSize' => $parsedQueryParams->getString('fileSize'),
+ 'ownerUserGroupId' => $parsedQueryParams->getInt('ownerUserGroupId'),
+ 'assignable' => $parsedQueryParams->getInt('assignable'),
+ 'folderId' => $parsedQueryParams->getInt('folderId'),
+ 'onlyMenuBoardAllowed' => $parsedQueryParams->getInt('onlyMenuBoardAllowed'),
+ 'layoutId' => $parsedQueryParams->getInt('layoutId'),
+ 'includeLayoutBackgroundImage' => ($parsedQueryParams->getInt('layoutId') != null) ? 1 : 0,
+ 'orientation' => $parsedQueryParams->getString('orientation', ['defaultOnEmptyString' => true]),
+ 'logicalOperator' => $parsedQueryParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $parsedQueryParams->getString('logicalOperatorName'),
+ 'unreleasedOnly' => $parsedQueryParams->getCheckbox('unreleasedOnly'),
+ 'unusedOnly' => $parsedQueryParams->getCheckbox('unusedOnly'),
+ ], $parsedQueryParams));
+
+ // Add some additional row content
+ foreach ($mediaList as $media) {
+ $media->setUnmatchedProperty('revised', ($media->parentId != 0) ? 1 : 0);
+
+ // Thumbnail
+ $media->setUnmatchedProperty('thumbnail', '');
+ try {
+ $module = $this->moduleFactory->getByType($media->mediaType);
+ if ($module->hasThumbnail) {
+ $renderThumbnail = true;
+ // for video, check if the cover image exists here.
+ if ($media->mediaType === 'video') {
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ $renderThumbnail = file_exists($libraryLocation . $media->mediaId . '_videocover.png');
+ }
+
+ if ($renderThumbnail) {
+ $thumbnailUrl = $this->urlFor($request, $thumbnailRouteName, [
+ 'id' => $media->mediaId,
+ ]);
+
+ if ($isReturnPublicUrls) {
+ // If we are coming from the API we should remove the /api part of the URL
+ if ($this->isApi($request)) {
+ $thumbnailUrl = str_replace('/api/', '/', $thumbnailUrl);
+ }
+
+ // Sign the link.
+ $thumbnailUrl = $rootUrl . $thumbnailUrl . '?' . LinkSigner::getSignature(
+ $rootUrl,
+ $thumbnailUrl,
+ time() + 3600,
+ $encryptionKey,
+ );
+ }
+
+ $media->setUnmatchedProperty('thumbnail', $thumbnailUrl);
+ }
+ }
+ } catch (NotFoundException) {
+ $this->getLog()->error('Module ' . $media->mediaType . ' not found');
+ }
+
+ $media->setUnmatchedProperty('fileSizeFormatted', ByteFormatter::format($media->fileSize));
+
+ // Media expiry
+ $media->setUnmatchedProperty('mediaExpiresIn', __('Expires %s'));
+ $media->setUnmatchedProperty('mediaExpiryFailed', __('Expired '));
+ $media->setUnmatchedProperty('mediaNoExpiryDate', __('Never'));
+
+ if ($this->isApi($request)) {
+ $media->excludeProperty('mediaExpiresIn');
+ $media->excludeProperty('mediaExpiryFailed');
+ $media->excludeProperty('mediaNoExpiryDate');
+ $media->expires = ($media->expires == 0)
+ ? 0
+ : Carbon::createFromTimestamp($media->expires)->format(DateFormatHelper::getSystemFormat());
+ continue;
+ }
+
+ $media->includeProperty('buttons');
+
+ switch ($media->released) {
+ case 1:
+ $media->setUnmatchedProperty('releasedDescription', '');
+ break;
+
+ case 2:
+ $media->setUnmatchedProperty(
+ 'releasedDescription',
+ __('The uploaded image is too large and cannot be processed, please use another image.')
+ );
+ break;
+
+ default:
+ $media->setUnmatchedProperty(
+ 'releasedDescription',
+ __('This image will be resized according to set thresholds and limits.')
+ );
+ }
+
+ switch ($media->enableStat) {
+ case 'On':
+ $media->setUnmatchedProperty(
+ 'enableStatDescription',
+ __('This Media has enable stat collection set to ON')
+ );
+ break;
+
+ case 'Off':
+ $media->setUnmatchedProperty(
+ 'enableStatDescription',
+ __('This Media has enable stat collection set to OFF')
+ );
+ break;
+
+ default:
+ $media->setUnmatchedProperty(
+ 'enableStatDescription',
+ __('This Media has enable stat collection set to INHERIT')
+ );
+ }
+
+ if ($parsedQueryParams->getCheckbox('fullScreenScheduleCheck')) {
+ $fullScreenCampaignId = $this->hasFullScreenLayout($media);
+ $media->setUnmatchedProperty('hasFullScreenLayout', (!empty($fullScreenCampaignId)));
+ $media->setUnmatchedProperty('fullScreenCampaignId', $fullScreenCampaignId);
+ }
+
+ $media->buttons = [];
+
+ // Buttons
+ if ($this->getUser()->featureEnabled('library.modify')
+ && $user->checkEditable($media)
+ ) {
+ // Edit
+ $media->buttons[] = array(
+ 'id' => 'content_button_edit',
+ 'url' => $this->urlFor($request, 'library.edit.form', ['id' => $media->mediaId]),
+ 'text' => __('Edit')
+ );
+
+ // Copy Button
+ $media->buttons[] = array(
+ 'id' => 'media_button_copy',
+ 'url' => $this->urlFor($request, 'library.copy.form', ['id' => $media->mediaId]),
+ 'text' => __('Copy')
+ );
+
+ // Select Folder
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ $media->buttons[] = [
+ 'id' => 'library_button_selectfolder',
+ 'url' => $this->urlFor($request, 'library.selectfolder.form', ['id' => $media->mediaId]),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url', 'value' => $this->urlFor($request, 'library.selectfolder', [
+ 'id' => $media->mediaId
+ ])
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'library_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $media->name],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+ }
+
+ if ($this->getUser()->featureEnabled('library.modify')
+ && $user->checkDeleteable($media)
+ ) {
+ // Delete Button
+ $media->buttons[] = [
+ 'id' => 'content_button_delete',
+ 'url' => $this->urlFor($request,'library.delete.form', ['id' => $media->mediaId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'library.delete', ['id' => $media->mediaId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'content_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $media->name],
+ ['name' => 'form-callback', 'value' => 'setDefaultMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('library.modify')
+ && $user->checkPermissionsModifyable($media)
+ ) {
+ // Permissions
+ $media->buttons[] = [
+ 'id' => 'content_button_permissions',
+ 'url' => $this->urlFor($request,'user.permissions.form', ['entity' => 'Media', 'id' => $media->mediaId]),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'user.permissions.multi', ['entity' => 'Media', 'id' => $media->mediaId])],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'content_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $media->name],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ ['name' => 'custom-handler-url', 'value' => $this->urlFor($request,'user.permissions.multi.form', ['entity' => 'Media'])],
+ ['name' => 'content-id-name', 'value' => 'mediaId']
+ ]
+ ];
+ }
+
+ // Download
+ // No feature permissions here, anyone can get a file based on sharing.
+ $media->buttons[] = ['divider' => true];
+ $media->buttons[] = array(
+ 'id' => 'content_button_download',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'library.download', ['id' => $media->mediaId]) . '?attachment=' . urlencode($media->fileName),
+ 'text' => __('Download')
+ );
+
+ // Set Enable Stat
+ if ($this->getUser()->featureEnabled('library.modify')
+ && $this->getUser()->checkEditable($media)
+ ) {
+ $media->buttons[] = ['divider' => true];
+
+ $media->buttons[] = array(
+ 'id' => 'library_button_setenablestat',
+ 'url' => $this->urlFor($request,'library.setenablestat.form', ['id' => $media->mediaId]),
+ 'text' => __('Enable stats collection?'),
+ 'multi-select' => true,
+ 'dataAttributes' => array(
+ array('name' => 'commit-url', 'value' => $this->urlFor($request,'library.setenablestat', ['id' => $media->mediaId])),
+ array('name' => 'commit-method', 'value' => 'put'),
+ array('name' => 'id', 'value' => 'library_button_setenablestat'),
+ array('name' => 'text', 'value' => __('Enable stats collection?')),
+ array('name' => 'rowtitle', 'value' => $media->name),
+ ['name' => 'form-callback', 'value' => 'setEnableStatMultiSelectFormOpen']
+ )
+ );
+ }
+
+ if ($this->getUser()->featureEnabled(['schedule.view', 'layout.view'])) {
+ $media->buttons[] = ['divider' => true];
+
+ $media->buttons[] = array(
+ 'id' => 'usage_report_button',
+ 'url' => $this->urlFor($request, 'library.usage.form', ['id' => $media->mediaId]),
+ 'text' => __('Usage Report')
+ );
+ }
+
+ // Schedule
+ if ($this->getUser()->featureEnabled('schedule.add')
+ && in_array($media->mediaType, ['image', 'video'])
+ && ($this->getUser()->checkEditable($media)
+ || $this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION') == 1)
+ ) {
+ $media->buttons[] = [
+ 'id' => 'library_button_schedule',
+ 'url' => $this->urlFor(
+ $request,
+ 'schedule.add.form',
+ ['id' => $media->mediaId, 'from' => 'Library']
+ ),
+ 'text' => __('Schedule')
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->mediaFactory->countLast();
+ $this->getState()->setData($mediaList);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/library/search",
+ * operationId="librarySearchAll",
+ * tags={"library"},
+ * summary="Library Search All",
+ * description="Search all library files from local and connectors",
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/SearchResult")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function search(Request $request, Response $response): Response
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+ $provider = $parsedQueryParams->getString('provider', ['default' => 'local']);
+
+ $searchResults = new SearchResults();
+ if ($provider === 'local') {
+ // Sorting options.
+ // only allow from a preset list
+ $sortCol = match ($parsedQueryParams->getString('sortCol')) {
+ 'mediaId' => '`media`.`mediaId`',
+ 'orientation' => '`media`.`orientation`',
+ 'width' => '`media`.`width`',
+ 'height' => '`media`.`height`',
+ 'duration' => '`media`.`duration`',
+ 'fileSize' => '`media`.`fileSize`',
+ 'createdDt' => '`media`.`createdDt`',
+ 'modifiedDt' => '`media`.`modifiedDt`',
+ default => '`media`.`name`',
+ };
+ $sortDir = match ($parsedQueryParams->getString('sortDir')) {
+ 'DESC' => ' DESC',
+ default => ' ASC'
+ };
+
+ $mediaList = $this->mediaFactory->query([$sortCol . $sortDir], $this->gridRenderFilter([
+ 'name' => $parsedQueryParams->getString('media'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'nameExact' => $parsedQueryParams->getString('nameExact'),
+ 'type' => $parsedQueryParams->getString('type'),
+ 'types' => $parsedQueryParams->getArray('types'),
+ 'tags' => $parsedQueryParams->getString('tags'),
+ 'exactTags' => $parsedQueryParams->getCheckbox('exactTags'),
+ 'ownerId' => $parsedQueryParams->getInt('ownerId'),
+ 'folderId' => $parsedQueryParams->getInt('folderId'),
+ 'assignable' => 1,
+ 'retired' => 0,
+ 'orientation' => $parsedQueryParams->getString('orientation', ['defaultOnEmptyString' => true])
+ ], $parsedQueryParams));
+
+ // Add some additional row content
+ foreach ($mediaList as $media) {
+ $searchResult = new SearchResult();
+ $searchResult->id = $media->mediaId;
+ $searchResult->source = 'local';
+ $searchResult->type = $media->mediaType;
+ $searchResult->title = $media->name;
+ $searchResult->width = $media->width;
+ $searchResult->height = $media->height;
+ $searchResult->description = '';
+ $searchResult->duration = $media->duration;
+
+ // Thumbnail
+ $module = $this->moduleFactory->getByType($media->mediaType);
+ if ($module->hasThumbnail) {
+ $searchResult->thumbnail = $this->urlFor($request, 'library.download', [
+ 'id' => $media->mediaId
+ ], [
+ 'preview' => 1,
+ 'isThumb' => 1
+ ]);
+ }
+
+ // Add the result
+ $searchResults->data[] = $searchResult;
+ }
+ } else {
+ $this->getLog()->debug('Dispatching event, for provider ' . $provider);
+
+ // Do we have a type filter
+ $types = $parsedQueryParams->getArray('types');
+ $type = $parsedQueryParams->getString('type');
+ if ($type !== null) {
+ $types[] = $type;
+ }
+
+ // Hand off to any other providers that may want to provide results.
+ $event = new LibraryProviderEvent(
+ $searchResults,
+ $parsedQueryParams->getInt('start', ['default' => 0]),
+ $parsedQueryParams->getInt('length', ['default' => 10]),
+ $parsedQueryParams->getString('media'),
+ $types,
+ $parsedQueryParams->getString('orientation'),
+ $provider
+ );
+
+ try {
+ $this->getDispatcher()->dispatch($event, $event->getName());
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Library search: Exception in dispatched event: ' . $exception->getMessage());
+ $this->getLog()->debug($exception->getTraceAsString());
+ }
+ }
+
+ return $response->withJson($searchResults);
+ }
+
+ /**
+ * Get list of Library providers with their details.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return Response|ResponseInterface
+ */
+ public function providersList(Request $request, Response $response): Response|\Psr\Http\Message\ResponseInterface
+ {
+ $event = new LibraryProviderListEvent();
+ $this->getDispatcher()->dispatch($event, $event->getName());
+
+ $providers = $event->getProviders();
+
+ return $response->withJson($providers);
+ }
+
+ /**
+ * Media Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getDispatcher()->dispatch(MediaFullLoadEvent::$NAME, new MediaFullLoadEvent($media));
+ $media->load(['deleting' => true]);
+
+ $this->getState()->template = 'library-form-delete';
+ $this->getState()->setData([
+ 'media' => $media,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Media
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Delete(
+ * path="/library/{mediaId}",
+ * operationId="libraryDelete",
+ * tags={"library"},
+ * summary="Delete Media",
+ * description="Delete Media from the Library",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="forceDelete",
+ * in="formData",
+ * description="If the media item has been used should it be force removed from items that uses it?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="purge",
+ * in="formData",
+ * description="Should this Media be added to the Purge List for all Displays?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+ $params = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkDeleteable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check
+ $this->getDispatcher()->dispatch(new MediaFullLoadEvent($media), MediaFullLoadEvent::$NAME);
+ $media->load(['deleting' => true]);
+
+ if ($media->isUsed() && $params->getCheckbox('forceDelete') == 0) {
+ throw new InvalidArgumentException(__('This library item is in use.'));
+ }
+
+ $this->getDispatcher()->dispatch(
+ new MediaDeleteEvent($media, null, $params->getCheckbox('purge')),
+ MediaDeleteEvent::$NAME
+ );
+
+ // Delete
+ $media->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $media->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a file to the library
+ * expects to be fed by the blueimp file upload handler
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Post(
+ * path="/library",
+ * operationId="libraryAdd",
+ * tags={"library"},
+ * summary="Add Media",
+ * description="Add Media to the Library, optionally replacing an existing media item, optionally adding to a playlist.",
+ * @SWG\Parameter(
+ * name="files",
+ * in="formData",
+ * description="The Uploaded File",
+ * type="file",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Optional Media Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="oldMediaId",
+ * in="formData",
+ * description="Id of an existing media file which should be replaced with the new upload",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="updateInLayouts",
+ * in="formData",
+ * description="Flag (0, 1), set to 1 to update this media in all layouts (use with oldMediaId) ",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="deleteOldRevisions",
+ * in="formData",
+ * description="Flag (0 , 1), to either remove or leave the old file revisions (use with oldMediaId)",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="Comma separated string of Tags that should be assigned to uploaded Media",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="expires",
+ * in="formData",
+ * description="Date in Y-m-d H:i:s format, will set expiration date on the uploaded Media",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="formData",
+ * description="A playlistId to add this uploaded media to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="widgetFromDt",
+ * in="formData",
+ * description="Date in Y-m-d H:i:s format, will set widget start date. Requires a playlistId.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="widgetToDt",
+ * in="formData",
+ * description="Date in Y-m-d H:i:s format, will set widget end date. Requires a playlistId.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="deleteOnExpiry",
+ * in="formData",
+ * description="Flag (0, 1), set to 1 to remove the Widget from the Playlist when the widgetToDt has been reached",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="applyToMedia",
+ * in="formData",
+ * description="Flag (0, 1), set to 1 to apply the widgetFromDt as the expiry date on the Media",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function add(Request $request, Response $response)
+ {
+ $parsedBody = $this->getSanitizer($request->getParams());
+ $options = $parsedBody->getArray('options', ['default' => []]);
+
+ // Folders
+ $folderId = $parsedBody->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ if ($parsedBody->getInt('playlistId') !== null) {
+ $playlist = $this->playlistFactory->getById($parsedBody->getInt('playlistId'));
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+ }
+
+ $options = array_merge([
+ 'oldMediaId' => null,
+ 'updateInLayouts' => 0,
+ 'deleteOldRevisions' => 0,
+ 'allowMediaTypeChange' => 0
+ ], $options);
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Handle any expiry date provided.
+ // this can come from the API via `expires` or via a widgetToDt
+ $expires = $parsedBody->getDate('expires');
+ $widgetFromDt = $parsedBody->getDate('widgetFromDt');
+ $widgetToDt = $parsedBody->getDate('widgetToDt');
+
+ // If applyToMedia has been selected, and we have a widgetToDt, then use that as our expiry
+ if ($widgetToDt !== null && $parsedBody->getCheckbox('applyToMedia', ['checkboxReturnInteger' => false])) {
+ $expires = $widgetToDt;
+ }
+
+ // Validate that this date is in the future.
+ if ($expires !== null && $expires->isBefore(Carbon::now())) {
+ throw new InvalidArgumentException(__('Cannot set Expiry date in the past'), 'expires');
+ }
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($libraryFolder);
+
+ // Get Valid Extensions
+ if ($parsedBody->getInt('oldMediaId', ['default' => $options['oldMediaId']]) !== null) {
+ $media = $this->mediaFactory->getById($parsedBody->getInt('oldMediaId', ['default' => $options['oldMediaId']]));
+ $folderId = $media->folderId;
+ $validExt = $this->moduleFactory->getValidExtensions(['type' => $media->mediaType, 'allowMediaTypeChange' => $options['allowMediaTypeChange']]);
+ } else {
+ $validExt = $this->moduleFactory->getValidExtensions();
+ }
+
+ // Make sure there is room in the library
+ $libraryLimit = $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+
+ $options = [
+ 'userId' => $this->getUser()->userId,
+ 'controller' => $this,
+ 'oldMediaId' => $parsedBody->getInt('oldMediaId', ['default' => $options['oldMediaId']]),
+ 'widgetId' => $parsedBody->getInt('widgetId'),
+ 'updateInLayouts' => $parsedBody->getCheckbox('updateInLayouts', ['default' => $options['updateInLayouts']]),
+ 'deleteOldRevisions' => $parsedBody->getCheckbox('deleteOldRevisions', ['default' => $options['deleteOldRevisions']]),
+ 'allowMediaTypeChange' => $options['allowMediaTypeChange'],
+ 'displayOrder' => $parsedBody->getInt('displayOrder'),
+ 'playlistId' => $parsedBody->getInt('playlistId'),
+ 'accept_file_types' => '/\.' . implode('|', $validExt) . '$/i',
+ 'libraryLimit' => $libraryLimit,
+ 'libraryQuotaFull' => ($libraryLimit > 0 && $this->getMediaService()->libraryUsage() > $libraryLimit),
+ 'expires' => $expires === null ? null : $expires->format('U'),
+ 'widgetFromDt' => $widgetFromDt === null ? null : $widgetFromDt->format('U'),
+ 'widgetToDt' => $widgetToDt === null ? null : $widgetToDt->format('U'),
+ 'deleteOnExpiry' => $parsedBody->getCheckbox('deleteOnExpiry', ['checkboxReturnInteger' => true]),
+ 'oldFolderId' => $folderId,
+ ];
+
+ // Output handled by UploadHandler
+ $this->setNoOutput(true);
+
+ $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options));
+
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ new XiboUploadHandler($libraryFolder . 'temp/', $this->getLog()->getLoggerInterface(), $options);
+
+ // Explicitly set the Content-Type header to application/json
+ $response = $response->withHeader('Content-Type', 'application/json');
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $media->enableStat = ($media->enableStat == null) ? $this->getConfig()->getSetting('MEDIA_STATS_ENABLED_DEFAULT') : $media->enableStat;
+
+ $this->getState()->template = 'library-form-edit';
+ $this->getState()->setData([
+ 'media' => $media,
+ 'validExtensions' => implode('|', $this->moduleFactory->getValidExtensions(['type' => $media->mediaType])),
+ 'expiryDate' => ($media->expires == 0 ) ? null : Carbon::createFromTimestamp($media->expires)->format(DateFormatHelper::getSystemFormat(), $media->expires)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Media
+ *
+ * @SWG\Put(
+ * path="/library/{mediaId}",
+ * operationId="libraryEdit",
+ * tags={"library"},
+ * summary="Edit Media",
+ * description="Edit a Media Item in the Library",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Media Item Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="duration",
+ * in="formData",
+ * description="The duration in seconds for this Media Item",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="formData",
+ * description="Flag indicating if this media is retired",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="Comma separated list of Tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="updateInLayouts",
+ * in="formData",
+ * description="Flag indicating whether to update the duration in all Layouts the Media is assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="expires",
+ * in="formData",
+ * description="Date in Y-m-d H:i:s format, will set expiration date on the Media item",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this media should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Media")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($media->mediaType == 'font') {
+ throw new InvalidArgumentException(__('Sorry, Fonts do not have any editable properties.'));
+ }
+
+ $media->name = $sanitizedParams->getString('name');
+ $media->duration = $sanitizedParams->getInt('duration');
+ $media->retired = $sanitizedParams->getCheckbox('retired');
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+
+ $media->updateTagLinks($tags);
+ }
+
+ $media->enableStat = $sanitizedParams->getString('enableStat');
+ $media->folderId = $sanitizedParams->getInt('folderId', ['default' => $media->folderId]);
+ $media->orientation = $sanitizedParams->getString('orientation', ['default' => $media->orientation]);
+
+ if ($media->hasPropertyChanged('folderId')) {
+ if ($media->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folder = $this->folderFactory->getById($media->folderId);
+ $media->permissionsFolderId = ($folder->getPermissionFolderId() == null)
+ ? $folder->id
+ : $folder->getPermissionFolderId();
+ }
+
+ if ($sanitizedParams->getDate('expires') != null) {
+ if ($sanitizedParams->getDate('expires')->format('U') > Carbon::now()->format('U')) {
+ $media->expires = $sanitizedParams->getDate('expires')->format('U');
+ } else {
+ throw new InvalidArgumentException(__('Cannot set Expiry date in the past'), 'expires');
+ }
+ } else {
+ $media->expires = 0;
+ }
+
+ // Should we update the media in all layouts?
+ if ($sanitizedParams->getCheckbox('updateInLayouts') == 1
+ || $media->hasPropertyChanged('enableStat')
+ ) {
+ foreach ($this->widgetFactory->getByMediaId($media->mediaId, 0) as $widget) {
+ if ($widget->useDuration == 1) {
+ $widget->calculateDuration($this->moduleFactory->getByType($widget->type));
+ } else {
+ $widget->calculatedDuration = $media->duration;
+ }
+ $widget->save();
+ }
+ }
+
+ $media->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $media->name),
+ 'id' => $media->mediaId,
+ 'data' => $media
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Tidy Library
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function tidyForm(Request $request, Response $response)
+ {
+ if ($this->getConfig()->getSetting('SETTING_LIBRARY_TIDY_ENABLED') != 1) {
+ throw new ConfigurationException(__('Sorry this function is disabled.'));
+ }
+
+ // Work out how many files there are
+ $media = $this->mediaFactory->query(null, ['unusedOnly' => 1, 'ownerId' => $this->getUser()->userId]);
+
+ $sumExcludingGeneric = 0;
+ $countExcludingGeneric = 0;
+ $sumGeneric = 0;
+ $countGeneric = 0;
+
+ foreach ($media as $item) {
+ if ($item->mediaType == 'genericfile') {
+ $countGeneric++;
+ $sumGeneric = $sumGeneric + $item->fileSize;
+ }
+ else {
+ $countExcludingGeneric++;
+ $sumExcludingGeneric = $sumExcludingGeneric + $item->fileSize;
+ }
+ }
+
+ $this->getState()->template = 'library-form-tidy';
+ $this->getState()->setData([
+ 'sumExcludingGeneric' => ByteFormatter::format($sumExcludingGeneric),
+ 'sumGeneric' => ByteFormatter::format($sumGeneric),
+ 'countExcludingGeneric' => $countExcludingGeneric,
+ 'countGeneric' => $countGeneric,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Tidies up the library
+ *
+ * @SWG\Delete(
+ * path="/library/tidy",
+ * operationId="libraryTidy",
+ * tags={"library"},
+ * summary="Tidy Library",
+ * description="Routine tidy of the library, removing unused files.",
+ * @SWG\Parameter(
+ * name="tidyGenericFiles",
+ * in="formData",
+ * description="Also delete generic files?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function tidy(Request $request, Response $response)
+ {
+ if ($this->getConfig()->getSetting('SETTING_LIBRARY_TIDY_ENABLED') != 1) {
+ throw new ConfigurationException(__('Sorry this function is disabled.'));
+ }
+
+ $tidyGenericFiles = $this->getSanitizer($request->getParams())->getCheckbox('tidyGenericFiles');
+
+ $this->getLog()->audit('Media', 0, 'Tidy library started', [
+ 'tidyGenericFiles' => $tidyGenericFiles,
+ 'initiator' => $this->getUser()->userId
+ ]);
+
+ // Get a list of media that is not in use (for this user)
+ $media = $this->mediaFactory->query(null, ['unusedOnly' => 1, 'ownerId' => $this->getUser()->userId]);
+
+ $i = 0;
+ foreach ($media as $item) {
+ if ($tidyGenericFiles != 1 && $item->mediaType == 'genericfile') {
+ continue;
+ }
+
+ // Eligible for delete
+ $i++;
+ $this->getDispatcher()->dispatch(new MediaDeleteEvent($item), MediaDeleteEvent::$NAME);
+ $item->delete();
+ }
+
+ $this->getLog()->audit('Media', 0, 'Tidy library complete', [
+ 'countDeleted' => $i,
+ 'initiator' => $this->getUser()->userId
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Library Tidy Complete'),
+ 'countDeleted' => $i
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @return string
+ */
+ public function getLibraryCacheUri()
+ {
+ return $this->getConfig()->getSetting('LIBRARY_LOCATION') . '/cache';
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/library/download/{mediaId}/{type}",
+ * operationId="libraryDownload",
+ * tags={"library"},
+ * summary="Download Media",
+ * description="Download a Media file from the Library",
+ * produces={"application/octet-stream"},
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media ID to Download",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="path",
+ * description="The Module Type of the Download",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(type="file"),
+ * @SWG\Header(
+ * header="X-Sendfile",
+ * description="Apache Send file header - if enabled.",
+ * type="string"
+ * ),
+ * @SWG\Header(
+ * header="X-Accel-Redirect",
+ * description="nginx send file header - if enabled.",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function download(Request $request, Response $response, $id)
+ {
+ $this->setNoOutput();
+
+ // We can download by mediaId or by mediaName.
+ if (is_numeric($id)) {
+ $media = $this->mediaFactory->getById($id);
+ } else {
+ $media = $this->mediaFactory->getByName($id);
+ }
+
+ $this->getLog()->debug('download: Download request for mediaId ' . $id
+ . '. Media is a ' . $media->mediaType . ', is system file:' . $media->moduleSystemFile);
+
+ // Create the appropriate module
+ if ($media->mediaType === 'module') {
+ $module = $this->moduleFactory->getByType('image');
+ } else {
+ $module = $this->moduleFactory->getByType($media->mediaType);
+ }
+
+ // We are not able to download region specific modules
+ if ($module->regionSpecific == 1) {
+ throw new NotFoundException(__('Cannot download region specific module'));
+ }
+
+ // Hand over to the widget downloader
+ $downloader = new WidgetDownloader(
+ $this->getConfig()->getSetting('LIBRARY_LOCATION'),
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $this->getConfig()->getSetting('DEFAULT_RESIZE_LIMIT', 6000)
+ );
+ $downloader->useLogger($this->getLog()->getLoggerInterface());
+
+ $params = $this->getSanitizer($request->getParams());
+
+ // Check if preview is allowed for the module
+ if ($params->getCheckbox('preview') == 1 && $module->allowPreview === 1) {
+ $this->getLog()->debug('download: preview mode, seeing if we can output an image/video');
+
+ // Output a 1px image if we're not allowed to see the media.
+ if (!$this->getUser()->checkViewable($media)) {
+ echo Img::make($this->getConfig()->uri('img/1x1.png', true))->encode();
+ return $this->render($request, $response->withHeader('Content-Type', 'image/png'));
+ }
+
+ // Various different behaviours for the different types of file.
+ if ($module->type === 'image') {
+ $response = $downloader->imagePreview(
+ $params,
+ $media->storedAs,
+ $response,
+ $this->getConfig()->uri('img/error.png', true),
+ );
+ } else if ($module->type === 'video') {
+ $response = $downloader->imagePreview(
+ $params,
+ $media->mediaId . '_videocover.png',
+ $response,
+ $this->getConfig()->uri('img/1x1.png', true),
+ );
+ } else {
+ $response = $downloader->download($media, $request, $response, $media->getMimeType());
+ }
+ } else {
+ $this->getLog()->debug('download: not preview mode, expect a full download');
+
+ // We are not a preview, and therefore we ought to check sharing before we download
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $response = $downloader->download($media, $request, $response, null, $params->getString('attachment'));
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Thumbnail for the libary page
+ * this is called by library-page datatable
+ *
+ * @SWG\Get(
+ * path="/library/thumbnail/{mediaId}",
+ * operationId="libraryThumbnail",
+ * tags={"library"},
+ * summary="Download Thumbnail",
+ * description="Download thumbnail for a Media file from the Library",
+ * produces={"application/octet-stream"},
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media ID to Download",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(type="file"),
+ * @SWG\Header(
+ * header="X-Sendfile",
+ * description="Apache Send file header - if enabled.",
+ * type="string"
+ * ),
+ * @SWG\Header(
+ * header="X-Accel-Redirect",
+ * description="nginx send file header - if enabled.",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function thumbnail(Request $request, Response $response, $id, bool $isForceGrantAccess = false)
+ {
+ $this->setNoOutput();
+
+ // We can download by mediaId or by mediaName.
+ if (is_numeric($id)) {
+ $media = $this->mediaFactory->getById($id);
+ } else {
+ $media = $this->mediaFactory->getByName($id);
+ }
+
+ $this->getLog()->debug('thumbnail: Thumbnail request for mediaId ' . $id
+ . '. Media is a ' . $media->mediaType);
+
+ // Permissions.
+ if (!$this->getUser()->checkViewable($media) && !$isForceGrantAccess) {
+ // Output a 1px image if we're not allowed to see the media.
+ echo Img::make($this->getConfig()->uri('img/1x1.png', true))->encode();
+ return $this->render($request, $response);
+ }
+
+ // Hand over to the widget downloader
+ $downloader = new WidgetDownloader(
+ $this->getConfig()->getSetting('LIBRARY_LOCATION'),
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $this->getConfig()->getSetting('DEFAULT_RESIZE_LIMIT', 6000)
+ );
+ $downloader->useLogger($this->getLog()->getLoggerInterface());
+
+ $response = $downloader->thumbnail(
+ $media,
+ $response,
+ $this->getConfig()->uri('img/error.png', true)
+ );
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Public Thumbnail
+ * this is an unauthenticated route (publicRoutes)
+ * we need to authenticate using the S3 link signing
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function thumbnailPublic(Request $request, Response $response, $id): Response
+ {
+ // Authenticate.
+ $params = $this->getSanitizer($request->getParams());
+
+ // Has the URL expired
+ if (time() > $params->getInt('X-Amz-Expires')) {
+ throw new AccessDeniedException(__('Expired'));
+ }
+
+ // Validate the URL.
+ $encryptionKey = $this->getConfig()->getApiKeyDetails()['encryptionKey'];
+ $signature = $params->getString('X-Amz-Signature');
+
+ $calculatedSignature = \Xibo\Helper\LinkSigner::getSignature(
+ (new HttpsDetect())->getUrl(),
+ $request->getUri()->getPath(),
+ $params->getInt('X-Amz-Expires'),
+ $encryptionKey,
+ $params->getString('X-Amz-Date'),
+ true,
+ );
+
+ if ($signature !== $calculatedSignature) {
+ throw new AccessDeniedException(__('Invalid URL'));
+ }
+
+ $this->getLog()->debug('thumbnailPublic: authorised for ' . $id);
+
+ $res = $this->thumbnail($request, $response, $id, true);
+
+ // Pass to the thumbnail route
+ return $res->withHeader('Access-Control-Allow-Origin', '*');
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function mcaas(Request $request, Response $response, $id)
+ {
+ // This is only available through the API
+ if (!$this->isApi($request)) {
+ throw new AccessDeniedException(__('Route is available through the API'));
+ }
+
+ $options = [
+ 'oldMediaId' => $id,
+ 'updateInLayouts' => 1,
+ 'deleteOldRevisions' => 1,
+ 'allowMediaTypeChange' => 1
+ ];
+
+ // Call Add with the oldMediaId
+ return $this->add($request->withParsedBody(['options' => $options]), $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/library/{mediaId}/tag",
+ * operationId="mediaTag",
+ * tags={"library"},
+ * summary="Tag Media",
+ * description="Tag a Media with one or more tags",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media Id to Tag",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tag",
+ * in="formData",
+ * description="An array of tags",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Media")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function tag(Request $request, Response $response, $id)
+ {
+ // Edit permission
+ // Get the media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $tags = $this->getSanitizer($request->getParams())->getArray('tag');
+
+ if (count($tags) <= 0) {
+ throw new InvalidArgumentException(__('No tags to assign'));
+ }
+
+ foreach ($tags as $tag) {
+ $media->assignTag($this->tagFactory->tagFromString($tag));
+ }
+
+ $media->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Tagged %s'), $media->name),
+ 'id' => $media->mediaId,
+ 'data' => $media
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/library/{mediaId}/untag",
+ * operationId="mediaUntag",
+ * tags={"library"},
+ * summary="Untag Media",
+ * description="Untag a Media with one or more tags",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media Id to Untag",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tag",
+ * in="formData",
+ * description="An array of tags",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Media")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function untag(Request $request, Response $response, $id)
+ {
+ // Edit permission
+ // Get the media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $tags = $this->getSanitizer($request->getParams())->getArray('tag');
+
+ if (count($tags) <= 0) {
+ throw new InvalidArgumentException(__('No tags to unassign'), 'tag');
+ }
+
+ foreach ($tags as $tag) {
+ $media->unassignTag($this->tagFactory->tagFromString($tag));
+ }
+
+ $media->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Untagged %s'), $media->name),
+ 'id' => $media->mediaId,
+ 'data' => $media
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Library Usage Report Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function usageForm(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get a list of displays that this mediaId is used on
+ $displays = $this->displayFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter(['disableUserCheck' => 1, 'mediaId' => $id], $sanitizedParams));
+
+ $this->getState()->template = 'library-form-usage';
+ $this->getState()->setData([
+ 'media' => $media,
+ 'countDisplays' => count($displays)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/library/usage/{mediaId}",
+ * operationId="libraryUsageReport",
+ * tags={"library"},
+ * summary="Get Library Item Usage Report",
+ * description="Get the records for the library item usage report",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function usage(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get a list of displays that this mediaId is used on by direct assignment
+ $displays = $this->displayFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter(['mediaId' => $id], $sanitizedParams));
+
+ // have we been provided with a date/time to restrict the scheduled events to?
+ $mediaFromDate = $sanitizedParams->getDate('mediaEventFromDate');
+ $mediaToDate = $sanitizedParams->getDate('mediaEventToDate');
+
+ // Media query array
+ $mediaQuery = [
+ 'mediaId' => $id
+ ];
+
+ if ($mediaFromDate !== null) {
+ $mediaQuery['futureSchedulesFrom'] = $mediaFromDate->format('U');
+ }
+
+ if ($mediaToDate !== null) {
+ $mediaQuery['futureSchedulesTo'] = $mediaToDate->format('U');
+ }
+
+ // Query for events
+ $events = $this->scheduleFactory->query(null, $mediaQuery);
+
+ // Total records returned from the schedules query
+ $totalRecords = $this->scheduleFactory->countLast();
+
+ foreach ($events as $row) {
+ /* @var \Xibo\Entity\Schedule $row */
+
+ // Generate this event
+ // Assess the date?
+ if ($mediaFromDate !== null && $mediaToDate !== null) {
+ try {
+ $scheduleEvents = $row->getEvents($mediaFromDate, $mediaToDate);
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Unable to getEvents for ' . $row->eventId);
+ continue;
+ }
+
+ // Skip events that do not fall within the specified days
+ if (count($scheduleEvents) <= 0)
+ continue;
+
+ $this->getLog()->debug('EventId ' . $row->eventId . ' as events: ' . json_encode($scheduleEvents));
+ }
+
+ // Load the display groups
+ $row->load();
+
+ foreach ($row->displayGroups as $displayGroup) {
+ foreach ($this->displayFactory->getByDisplayGroupId($displayGroup->displayGroupId) as $display) {
+ $found = false;
+
+ // Check to see if our ID is already in our list
+ foreach ($displays as $existing) {
+ if ($existing->displayId === $display->displayId) {
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found)
+ $displays[] = $display;
+ }
+ }
+ }
+
+ if ($this->isApi($request) && $displays == []) {
+ $displays = [
+ 'data' =>__('Specified Media item is not in use.')];
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $totalRecords;
+ $this->getState()->setData($displays);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/library/usage/layouts/{mediaId}",
+ * operationId="libraryUsageLayoutsReport",
+ * tags={"library"},
+ * summary="Get Library Item Usage Report for Layouts",
+ * description="Get the records for the library item usage report for Layouts",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function usageLayouts(Request $request, Response $response, $id)
+ {
+ $media = $this->mediaFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $layouts = $this->layoutFactory->query(
+ $this->gridRenderSort($sanitizedParams),
+ $this->gridRenderFilter([
+ 'mediaId' => $id,
+ 'showDrafts' => 1
+ ], $sanitizedParams)
+ );
+
+ if (!$this->isApi($request)) {
+ foreach ($layouts as $layout) {
+ $layout->includeProperty('buttons');
+
+ // Add some buttons for this row
+ if ($this->getUser()->checkEditable($layout)) {
+ // Design Button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_design',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request,'layout.designer', ['id' => $layout->layoutId]),
+ 'text' => __('Design')
+ );
+ }
+
+ // Preview
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_preview',
+ 'external' => true,
+ 'url' => '#',
+ 'onclick' => 'createMiniLayoutPreview',
+ 'onclickParam' => $this->urlFor($request, 'layout.preview', ['id' => $layout->layoutId]),
+ 'text' => __('Preview Layout')
+ );
+ }
+ }
+
+ if ($this->isApi($request) && $layouts == []) {
+ $layouts = [
+ 'data' =>__('Specified Media item is not in use.')
+ ];
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->layoutFactory->countLast();
+ $this->getState()->setData($layouts);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy Media form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'library-form-copy';
+ $this->getState()->setData([
+ 'media' => $media,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copies a Media
+ *
+ * @SWG\Post(
+ * path="/library/copy/{mediaId}",
+ * operationId="mediaCopy",
+ * tags={"library"},
+ * summary="Copy Media",
+ * description="Copy a Media, providing a new name and tags if applicable",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The media ID to Copy",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The name for the new Media",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="The Optional tags for new Media",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Media"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the media for Copy
+ $media = clone $media;
+
+ // Set new Name and tags
+ $media->name = $sanitizedParams->getString('name');
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+ $media->updateTagLinks($tags);
+ }
+
+ // Set the Owner to user making the Copy
+ $media->setOwner($this->getUser()->userId);
+
+ // Set from global setting
+ if ($media->enableStat == null) {
+ $media->enableStat = $this->getConfig()->getSetting('MEDIA_STATS_ENABLED_DEFAULT');
+ }
+
+ // Save the new Media
+ $media->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Copied as %s'), $media->name),
+ 'id' => $media->mediaId,
+ 'data' => $media
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * @SWG\Get(
+ * path="/library/{mediaId}/isused/",
+ * operationId="mediaIsUsed",
+ * tags={"library"},
+ * summary="Media usage check",
+ * description="Checks if a Media is being used",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function isUsed(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+ $this->getDispatcher()->dispatch(new MediaFullLoadEvent($media), MediaFullLoadEvent::$NAME);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get count, being the number of times the media needs to appear to be true ( or use the default 0)
+ $count = $this->getSanitizer($request->getParams())->getInt('count', ['default' => 0]);
+
+ // Check and return result
+ $this->getState()->setData([
+ 'isUsed' => $media->isUsed($count)
+ ]);
+
+ return $this->render($request, $response);
+
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function uploadFromUrlForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'library-form-uploadFromUrl';
+
+ $this->getState()->setData([
+ 'uploadSizeMessage' => sprintf(__('This form accepts files up to a maximum size of %s'), Environment::getMaxUploadSize())
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Upload Media via URL
+ *
+ * @SWG\Post(
+ * path="/library/uploadUrl",
+ * operationId="uploadFromUrl",
+ * tags={"library"},
+ * summary="Upload Media from URL",
+ * description="Upload Media to CMS library from an external URL",
+ * @SWG\Parameter(
+ * name="url",
+ * in="formData",
+ * description="The URL to the media",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="formData",
+ * description="The type of the media, image, video etc",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="extension",
+ * in="formData",
+ * description="Optional extension of the media, jpg, png etc. If not set in the request it will be retrieved from the headers",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="enableStat",
+ * in="formData",
+ * description="The option to enable the collection of Media Proof of Play statistics, On, Off or Inherit.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="optionalName",
+ * in="formData",
+ * description="An optional name for this media file, if left empty it will default to the file name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="expires",
+ * in="formData",
+ * description="Date in Y-m-d H:i:s format, will set expiration date on the Media item",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this media should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Media"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws LibraryFullException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function uploadFromUrl(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Params
+ $url = $sanitizedParams->getString('url');
+ $type = $sanitizedParams->getString('type');
+ $optionalName = $sanitizedParams->getString('optionalName');
+ $extension = $sanitizedParams->getString('extension');
+ $enableStat = $sanitizedParams->getString('enableStat', [
+ 'default' => $this->getConfig()->getSetting('MEDIA_STATS_ENABLED_DEFAULT')
+ ]);
+
+ // Folders
+ $folderId = $sanitizedParams->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ if ($sanitizedParams->hasParam('expires')) {
+ if ($sanitizedParams->getDate('expires')->format('U') > Carbon::now()->format('U')) {
+ $expires = $sanitizedParams->getDate('expires')->format('U');
+ } else {
+ throw new InvalidArgumentException(__('Cannot set Expiry date in the past'), 'expires');
+ }
+ } else {
+ $expires = 0;
+ }
+
+ // Validate the URL
+ if (!v::url()->notEmpty()->validate($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
+ throw new InvalidArgumentException(__('Provided URL is invalid'), 'url');
+ }
+
+ // remote file size
+ $downloadInfo = $this->getMediaService()->getDownloadInfo($url);
+
+ // check if we have extension provided in the request (available via API)
+ // if not get it from the headers
+ if (!empty($extension)) {
+ $ext = $extension;
+ } else {
+ $ext = $downloadInfo['extension'];
+ }
+
+ // Unsupported links (ie Youtube links, etc) will return a null extension, thus, throw an error
+ if (is_null($ext)) {
+ throw new NotFoundException(sprintf(__('Extension %s is not supported.'), $ext));
+ }
+
+ // Initialise the library and do some checks
+ $this->getMediaService()
+ ->initLibrary()
+ ->checkLibraryOrQuotaFull(true)
+ ->checkMaxUploadSize($downloadInfo['size']);
+
+ // check if we have type provided in the request (available via API), if not get the module type from
+ // the extension
+ if (!empty($type)) {
+ $module = $this->getModuleFactory()->getByType($type);
+ } else {
+ $module = $this->getModuleFactory()->getByExtension($ext);
+ $module = $this->getModuleFactory()->getByType($module->type);
+ }
+
+ // if we were provided with optional Media name set it here, otherwise get it from download info
+ $name = empty($optionalName) ? htmlspecialchars($downloadInfo['filename']) : $optionalName;
+
+ // double check that provided Module Type and Extension are valid
+ if (!Str::contains($module->getSetting('validExtensions'), $ext)) {
+ throw new NotFoundException(
+ sprintf(
+ __('Invalid Module type or extension. Module type %s does not allow for %s extension'),
+ $module->type,
+ $ext
+ )
+ );
+ }
+
+ // add our media to queueDownload and process the downloads
+ $media = $this->mediaFactory->queueDownload(
+ $name,
+ str_replace(' ', '%20', htmlspecialchars_decode($url)),
+ $expires,
+ [
+ 'fileType' => strtolower($module->type),
+ 'duration' => $module->defaultDuration,
+ 'extension' => $ext,
+ 'enableStat' => $enableStat,
+ 'folderId' => $folder->getId(),
+ 'permissionsFolderId' => $folder->getPermissionFolderIdOrThis()
+ ]
+ );
+
+ $this->mediaFactory->processDownloads(
+ function (Media $media) use ($module) {
+ // Success
+ $this->getLog()->debug('Successfully uploaded Media from URL, Media Id is ' . $media->mediaId);
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ $realDuration = $module->fetchDurationOrDefaultFromFile($libraryFolder . $media->storedAs);
+ if ($realDuration !== $media->duration) {
+ $media->updateDuration($realDuration);
+ }
+ },
+ function (Media $media) {
+ throw new InvalidArgumentException(__('Download rejected for an unknown reason.'));
+ },
+ function ($message) {
+ // Download rejected.
+ throw new InvalidArgumentException(sprintf(__('Download rejected due to %s'), $message));
+ }
+ );
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Media upload from URL was successful'),
+ 'id' => $media->mediaId,
+ 'data' => $media
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * This is called when video finishes uploading.
+ * Saves provided base64 image as an actual image to the library
+ *
+ * @param Request $request
+ * @param Response $response
+ *
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function addThumbnail($request, $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ MediaService::ensureLibraryExists($libraryLocation);
+
+ $imageData = $request->getParam('image');
+ $mediaId = $sanitizedParams->getInt('mediaId');
+ $media = $this->mediaFactory->getById($mediaId);
+
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ try {
+ Img::configure(array('driver' => 'gd'));
+
+ // Load the image
+ $image = Img::make($imageData);
+ $image->save($libraryLocation . $mediaId . '_' . $media->mediaType . 'cover.png');
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Exception adding Video cover image. e = ' . $exception->getMessage());
+ throw new InvalidArgumentException(__('Invalid image data'));
+ }
+
+ $media->width = $image->getWidth();
+ $media->height = $image->getHeight();
+ $media->orientation = ($media->width >= $media->height) ? 'landscape' : 'portrait';
+ $media->save(['saveTags' => false, 'validate' => false]);
+
+ return $response->withStatus(204);
+ }
+
+ /**
+ * Select Folder Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolderForm(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'media' => $media
+ ];
+
+ $this->getState()->template = 'library-form-selectfolder';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/library/{id}/selectfolder",
+ * operationId="librarySelectFolder",
+ * tags={"library"},
+ * summary="Media Select folder",
+ * description="Select Folder for Media",
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="path",
+ * description="The Media ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Campaign")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function selectFolder(Request $request, Response $response, $id)
+ {
+ // Get the Media
+ $media = $this->mediaFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $folderId = $this->getSanitizer($request->getParams())->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ $media->folderId = $folderId;
+ $folder = $this->folderFactory->getById($media->folderId);
+ $media->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+
+ $media->save(['saveTags' => false]);
+
+ if ($media->parentId != 0) {
+ $this->updateMediaRevision($media, $folderId);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Media %s moved to Folder %s'), $media->name, $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Connector import.
+ *
+ * Note: this doesn't have a Swagger document because it is only available via the web UI.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function connectorImport(Request $request, Response $response)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $items = $params->getArray('items');
+
+ // Folders
+ $folderId = $params->getInt('folderId');
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ // Stats
+ $enableStat = $params->getString('enableStat', [
+ 'default' => $this->getConfig()->getSetting('MEDIA_STATS_ENABLED_DEFAULT')
+ ]);
+
+ // Initialise the library.
+ $this->getMediaService()
+ ->initLibrary()
+ ->checkLibraryOrQuotaFull(true);
+
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Hand these off to the connector to format into a downloadable response.
+ $importQueue = [];
+ foreach ($items as $item) {
+ $import = new ProviderImport();
+ $import->searchResult = new SearchResult();
+ $import->searchResult->provider = new ProviderDetails();
+ $import->searchResult->provider->id = $item['provider']['id'];
+ $import->searchResult->title = $item['title'];
+ $import->searchResult->id = $item['id'];
+ $import->searchResult->type = $item['type'];
+ $import->searchResult->download = $item['download'];
+ $import->searchResult->duration = (int)$item['duration'];
+ $import->searchResult->videoThumbnailUrl = $item['videoThumbnailUrl'];
+ $importQueue[] = $import;
+ }
+ $event = new LibraryProviderImportEvent($importQueue);
+ $this->getDispatcher()->dispatch($event, $event->getName());
+
+ // Pull out our events and upload
+ foreach ($importQueue as $import) {
+ try {
+ // Has this been configured for upload?
+ if ($import->isConfigured) {
+ // Make sure we have a URL
+ if (empty($import->url)) {
+ throw new InvalidArgumentException('Missing or invalid URL', 'url');
+ }
+
+ // This ensures that apiRef will be unique for each provider and resource id
+ $apiRef = $import->searchResult->provider->id . '_' . $import->searchResult->id;
+
+ // Queue this for upload.
+ // Use a module to make sure our type, etc is supported.
+ // make sure the name is not longer than 100 characters.
+ $name = $import->searchResult->title;
+ if (strlen($name) >= 100) {
+ $name = trim(preg_replace('/\s+?(\S+)?$/', '', substr($name, 0, 95)), ', ');
+ }
+ $module = $this->getModuleFactory()->getByType($import->searchResult->type);
+ $import->media = $this->mediaFactory->queueDownload(
+ $name,
+ str_replace(' ', '%20', htmlspecialchars_decode($import->url)),
+ 0,
+ [
+ 'fileType' => strtolower($module->type),
+ 'duration' => !(empty($import->searchResult->duration))
+ ? $import->searchResult->duration
+ : $module->defaultDuration,
+ 'enableStat' => $enableStat,
+ 'folderId' => $folder->getId(),
+ 'permissionsFolderId' => $folder->permissionsFolderId,
+ 'apiRef' => $apiRef
+ ]
+ );
+ } else {
+ throw new GeneralException(__('Not configured by any active connector.'));
+ }
+ } catch (\Exception $e) {
+ $import->setError($e->getMessage());
+ }
+ }
+
+ // Process all of those downloads
+ $this->mediaFactory->processDownloads(
+ function (Media $media) use ($importQueue, $libraryLocation) {
+ // Success
+ // if we have video thumbnail url from provider, download it now
+ foreach ($importQueue as $import) {
+ /** @var ProviderImport $import */
+ if ($import->media->getId() === $media->getId()
+ && $media->mediaType === 'video'
+ && !empty($import->searchResult->videoThumbnailUrl)
+ ) {
+ try {
+ $filePath = $libraryLocation . $media->getId() . '_' . $media->mediaType . 'cover.png';
+
+ // Expect a quick download.
+ $client = new Client($this->getConfig()->getGuzzleProxy(['timeout' => 20]));
+ $client->request(
+ 'GET',
+ $import->searchResult->videoThumbnailUrl,
+ ['sink' => $filePath]
+ );
+
+ list($imgWidth, $imgHeight) = @getimagesize($filePath);
+ $media->updateOrientation($imgWidth, $imgHeight);
+ } catch (\Exception $exception) {
+ // if we failed, corrupted file might still be created, remove it here
+ unlink($libraryLocation . $media->getId() . '_' . $media->mediaType . 'cover.png');
+ $this->getLog()->error(sprintf(
+ 'Downloading thumbnail for video %s, from url %s, failed with message %s',
+ $media->name,
+ $import->searchResult->videoThumbnailUrl,
+ $exception->getMessage()
+ ));
+ }
+ }
+ }
+ },
+ function ($media) use ($importQueue) {
+ // Failure
+ // Pull out the import which failed.
+ foreach ($importQueue as $import) {
+ /** @var ProviderImport $import */
+ if ($import->media->getId() === $media->getId()) {
+ $import->setError(__('Download failed'));
+ }
+ }
+ }
+ );
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => __('Imported'),
+ 'data' => $event->getItems()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Check if we already have a full screen Layout for this Media
+ * @param Media $media
+ * @return int|null
+ * @throws NotFoundException
+ */
+ private function hasFullScreenLayout(Media $media): ?int
+ {
+ return $this->layoutFactory->getLinkedFullScreenLayout('media', $media->mediaId)?->campaignId;
+ }
+
+ /**
+ * Update media files with revisions
+ * @param Media $media
+ * @param $folderId
+ */
+ private function updateMediaRevision(Media $media, $folderId)
+ {
+ $oldMedia = $this->mediaFactory->getParentById($media->mediaId);
+ $oldMedia->folderId = $folderId;
+ $folder = $this->folderFactory->getById($oldMedia->folderId);
+ $folder->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+
+ $oldMedia->save(['saveTags' => false, 'validate' => false]);
+ }
+}
diff --git a/lib/Controller/Logging.php b/lib/Controller/Logging.php
new file mode 100644
index 0000000..488e132
--- /dev/null
+++ b/lib/Controller/Logging.php
@@ -0,0 +1,166 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\LogFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class Logging
+ * @package Xibo\Controller
+ */
+class Logging extends Base
+{
+ /**
+ * @var LogFactory
+ */
+ private $logFactory;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param LogFactory $logFactory
+ * @param UserFactory $userFactory
+ */
+ public function __construct($store, $logFactory, $userFactory)
+ {
+ $this->store = $store;
+ $this->logFactory = $logFactory;
+ $this->userFactory = $userFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'log-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function grid(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ // Date time criteria
+ $seconds = $parsedQueryParams->getInt('seconds', ['default' => 120]);
+ $intervalType = $parsedQueryParams->getInt('intervalType', ['default' => 1]);
+ $fromDt = $parsedQueryParams->getDate('fromDt', ['default' => Carbon::now()]);
+
+ $logs = $this->logFactory->query($this->gridRenderSort($parsedQueryParams), $this->gridRenderFilter([
+ 'fromDt' => $fromDt->clone()->subSeconds($seconds * $intervalType)->format('U'),
+ 'toDt' => $fromDt->format('U'),
+ 'type' => $parsedQueryParams->getString('level'),
+ 'page' => $parsedQueryParams->getString('page'),
+ 'channel' => $parsedQueryParams->getString('channel'),
+ 'function' => $parsedQueryParams->getString('function'),
+ 'displayId' => $parsedQueryParams->getInt('displayId'),
+ 'userId' => $parsedQueryParams->getInt('userId'),
+ 'excludeLog' => $parsedQueryParams->getCheckbox('excludeLog'),
+ 'runNo' => $parsedQueryParams->getString('runNo'),
+ 'message' => $parsedQueryParams->getString('message'),
+ 'display' => $parsedQueryParams->getString('display'),
+ 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'),
+ 'displayGroupId' => $parsedQueryParams->getInt('displayGroupId'),
+ ], $parsedQueryParams));
+
+ foreach ($logs as $log) {
+ // Normalise the date
+ $log->logDate = Carbon::createFromTimeString($log->logDate)->format(DateFormatHelper::getSystemFormat());
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->logFactory->countLast();
+ $this->getState()->setData($logs);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Truncate Log Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function truncateForm(Request $request, Response $response)
+ {
+ if ($this->getUser()->userTypeId != 1) {
+ throw new AccessDeniedException(__('Only Administrator Users can truncate the log'));
+ }
+
+ $this->getState()->template = 'log-form-truncate';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('truncateForm');
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Truncate the Log
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function truncate(Request $request, Response $response)
+ {
+ if ($this->getUser()->userTypeId != 1) {
+ throw new AccessDeniedException(__('Only Administrator Users can truncate the log'));
+ }
+
+ $this->store->update('TRUNCATE TABLE log', array());
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Log Truncated')
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Login.php b/lib/Controller/Login.php
new file mode 100644
index 0000000..29d451e
--- /dev/null
+++ b/lib/Controller/Login.php
@@ -0,0 +1,707 @@
+.
+ */
+namespace Xibo\Controller;
+
+use RobThree\Auth\TwoFactorAuth;
+use Slim\Flash\Messages;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Slim\Routing\RouteContext;
+use Xibo\Entity\User;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\Environment;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\LogoutTrait;
+use Xibo\Helper\Random;
+use Xibo\Helper\Session;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\ExpiredException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Login
+ * @package Xibo\Controller
+ */
+class Login extends Base
+{
+ use LogoutTrait;
+
+ /** @var Session */
+ private $session;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+ /**
+ * @var Messages
+ */
+ private $flash;
+
+ /**
+ * Set common dependencies.
+ * @param Session $session
+ * @param UserFactory $userFactory
+ * @param \Stash\Interfaces\PoolInterface $pool
+ */
+ public function __construct($session, $userFactory, $pool)
+ {
+ $this->session = $session;
+ $this->userFactory = $userFactory;
+ $this->pool = $pool;
+ }
+
+ /**
+ * Get Flash Message
+ *
+ * @return Messages
+ */
+ protected function getFlash()
+ {
+ return $this->flash;
+ }
+
+ public function setFlash(Messages $messages)
+ {
+ $this->flash = $messages;
+ }
+
+ /**
+ * Output a login form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function loginForm(Request $request, Response $response)
+ {
+ // Sanitize the body
+ $sanitizedRequestBody = $this->getSanitizer($request->getParams());
+
+ // Check to see if the user has provided a special token
+ $nonce = $sanitizedRequestBody->getString('nonce');
+
+ if ($nonce != '') {
+ // We have a nonce provided, so validate that in preference to showing the form.
+ $nonce = explode('::', $nonce);
+ $this->getLog()->debug('Nonce is ' . var_export($nonce, true));
+
+ $cache = $this->pool->getItem('/nonce/' . $nonce[0]);
+
+ $validated = $cache->get();
+
+ if ($cache->isMiss()) {
+ $this->getLog()->error('Expired nonce used.');
+ $this->getFlash()->addMessageNow('login_message', __('This link has expired.'));
+ } else if (!password_verify($nonce[1], $validated['hash'])) {
+ $this->getLog()->error('Invalid nonce used.');
+ $this->getFlash()->addMessageNow('login_message', __('This link has expired.'));
+ } else {
+ // We're valid.
+ $this->pool->deleteItem('/nonce/' . $nonce[0]);
+
+ try {
+ $user = $this->userFactory->getById($validated['userId']);
+
+ // Log in this user
+ $user->touch(true);
+
+ $this->getLog()->info($user->userName . ' user logged in via token.');
+
+ // Set the userId on the log object
+ $this->getLog()->setUserId($user->userId);
+ $this->getLog()->setIpAddress($request->getAttribute('ip_address'));
+
+ // Expire all sessions
+ $session = $this->session;
+
+ // this is a security measure in case the user is logged in somewhere else.
+ // (not this one though, otherwise we will deadlock
+ $session->expireAllSessionsForUser($user->userId);
+
+ // Switch Session ID's
+ $session->setIsExpired(0);
+ $session->regenerateSessionId();
+ $session->setUser($user->userId);
+ $this->getLog()->setSessionHistoryId($session->get('sessionHistoryId'));
+
+ // Audit Log
+ $this->getLog()->audit('User', $user->userId, 'Login Granted via token', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+
+ return $response->withRedirect($this->urlFor($request, 'home'));
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error('Valid nonce for non-existing user');
+ $this->getFlash()->addMessageNow('login_message', __('This link has expired.'));
+ }
+ }
+ }
+
+ // Check to see if the password reminder functionality is enabled.
+ $passwordReminderEnabled = $this->getConfig()->getSetting('PASSWORD_REMINDER_ENABLED');
+ $mailFrom = $this->getConfig()->getSetting('mail_from');
+ $authCASEnabled = isset($this->getConfig()->casSettings);
+
+ // Template
+ $this->getState()->template = 'login';
+ $this->getState()->setData([
+ 'passwordReminderEnabled' => (($passwordReminderEnabled === 'On' || $passwordReminderEnabled === 'On except Admin') && $mailFrom != ''),
+ 'authCASEnabled' => $authCASEnabled,
+ 'version' => Environment::$WEBSITE_VERSION_NAME
+ ]);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Login
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function login(Request $request, Response $response): Response
+ {
+ $parsedRequest = $this->getSanitizer($request->getParsedBody());
+ $routeParser = RouteContext::fromRequest($request)->getRouteParser();
+
+ // Capture the prior route (if there is one)
+ $redirect = $this->urlFor($request, 'login');
+ $priorRoute = $parsedRequest->getString('priorRoute');
+
+ try {
+ // Get our username and password
+ $username = $parsedRequest->getString('username');
+ $password = $parsedRequest->getString('password');
+
+ $this->getLog()->debug('Login with username ' . $username);
+
+ // Get our user
+ try {
+ $user = $this->userFactory->getByName($username);
+
+ // Retired user
+ if ($user->retired === 1) {
+ throw new AccessDeniedException(
+ __('Sorry this account does not exist or does not have permission to access the web portal.')
+ );
+ }
+
+ // Check password
+ $user->checkPassword($password);
+
+ // check if 2FA is enabled
+ if ($user->twoFactorTypeId != 0) {
+ $_SESSION['tfaUsername'] = $user->userName;
+ $this->getFlash()->addMessage('priorRoute', $priorRoute);
+ return $response->withRedirect($routeParser->urlFor('tfa'));
+ }
+
+ // We are logged in, so complete the login flow
+ $this->completeLoginFlow($user, $request);
+ } catch (NotFoundException) {
+ throw new AccessDeniedException(__('User not found'));
+ }
+
+ $redirect = $this->getRedirect($request, $priorRoute);
+ } catch (AccessDeniedException $e) {
+ $this->getLog()->warning($e->getMessage());
+ $this->getFlash()->addMessage('login_message', __('Username or Password incorrect'));
+ $this->getFlash()->addMessage('priorRoute', $priorRoute);
+ } catch (ExpiredException $e) {
+ $this->getFlash()->addMessage('priorRoute', $priorRoute);
+ }
+ $this->setNoOutput(true);
+ $this->getLog()->debug('Redirect to ' . $redirect);
+ return $response->withRedirect($redirect);
+ }
+
+ /**
+ * Forgotten password link requested
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws \PHPMailer\PHPMailer\Exception
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ */
+ public function forgottenPassword(Request $request, Response $response)
+ {
+ // Is this functionality enabled?
+ $passwordReminderEnabled = $this->getConfig()->getSetting('PASSWORD_REMINDER_ENABLED');
+ $mailFrom = $this->getConfig()->getSetting('mail_from');
+
+ $parsedRequest = $this->getSanitizer($request->getParsedBody());
+ $routeParser = RouteContext::fromRequest($request)->getRouteParser();
+
+ if (!(($passwordReminderEnabled === 'On' || $passwordReminderEnabled === 'On except Admin') && $mailFrom != '')) {
+ throw new ConfigurationException(__('This feature has been disabled by your administrator'));
+ }
+
+ // Get our username
+ $username = $parsedRequest->getString('username');
+
+ // Log
+ $this->getLog()->info('Forgotten Password Request for ' . $username);
+
+ // Check to see if the provided username is valid, and if so, record a nonce and send them a link
+ try {
+ // Get our user
+ /* @var User $user */
+ $user = $this->userFactory->getByName($username);
+
+ // Does this user have an email address associated to their user record?
+ if ($user->email == '') {
+ throw new NotFoundException(__('No email'));
+ }
+
+ // Nonce parts (nonce isn't ever stored, only the hash of it is stored, it only exists in the email)
+ $action = 'user-reset-password-' . Random::generateString(10);
+ $nonce = Random::generateString(20);
+
+ // Create a nonce for this user and store it somewhere
+ $cache = $this->pool->getItem('/nonce/' . $action);
+
+ $cache->set([
+ 'action' => $action,
+ 'hash' => password_hash($nonce, PASSWORD_DEFAULT),
+ 'userId' => $user->userId
+ ]);
+ $cache->expiresAfter(1800); // 30 minutes?
+
+ // Save cache
+ $this->pool->save($cache);
+
+ // Make a link
+ $link = ((new HttpsDetect())->getRootUrl()) . $routeParser->urlFor('login') . '?nonce=' . $action . '::' . $nonce;
+
+ // Uncomment this to get a debug message showing the link.
+ //$this->getLog()->debug('Link is:' . $link);
+
+ // Send the mail
+ $mail = new \PHPMailer\PHPMailer\PHPMailer();
+ $mail->CharSet = 'UTF-8';
+ $mail->Encoding = 'base64';
+ $mail->From = $mailFrom;
+ $msgFromName = $this->getConfig()->getSetting('mail_from_name');
+
+ if ($msgFromName != null) {
+ $mail->FromName = $msgFromName;
+ }
+
+ $mail->Subject = __('Password Reset');
+ $mail->addAddress($user->email);
+
+ // Body
+ $mail->isHTML(true);
+
+ // We need to specify the style for the pw reset button since mailers usually ignore bootstrap classes
+ $linkButton = '
+ ' . __('Reset Password') . '
+ ';
+
+ $mail->Body = $this->generateEmailBody(
+ $mail->Subject,
+ '
' . __('You are receiving this email because a password reminder was requested for your account.
+ If you did not make this request, please report this email to your administrator immediately.') . '
'
+ . $linkButton
+ . '
'
+ . __('If the button does not work, copy and paste the following URL into your browser:')
+ . ' ' . $link . '
'
+ );
+
+ if (!$mail->send()) {
+ throw new ConfigurationException('Unable to send password reminder to ' . $user->email);
+ } else {
+ $this->getFlash()->addMessage(
+ 'login_message',
+ __('A reminder email will been sent to this user if they exist'),
+ );
+ }
+
+ // Audit Log
+ $this->getLog()->audit('User', $user->userId, 'Password Reset Link Granted', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ } catch (GeneralException) {
+ $this->getFlash()->addMessage(
+ 'login_message',
+ __('A reminder email will been sent to this user if they exist'),
+ );
+ }
+
+ $this->setNoOutput(true);
+ return $response->withRedirect($routeParser->urlFor('login'));
+ }
+
+ /**
+ * Log out
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ */
+ public function logout(Request $request, Response $response)
+ {
+ $redirect = true;
+
+ if ($request->getQueryParam('redirect') != null) {
+ $redirect = $request->getQueryParam('redirect');
+ }
+
+ $this->completeLogoutFlow($this->getUser(), $this->session, $this->getLog(), $request);
+
+ if ($redirect) {
+ return $response->withRedirect($this->urlFor($request, 'home'));
+ }
+
+ return $response->withStatus(200);
+ }
+
+ /**
+ * Ping Pong
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function PingPong(Request $request, Response $response)
+ {
+ $parseRequest = $this->getSanitizer($request->getQueryParams());
+ $this->session->refreshExpiry = ($parseRequest->getCheckbox('refreshSession') == 1);
+ $this->getState()->success = true;
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows information about Xibo
+ *
+ * @SWG\Get(
+ * path="/about",
+ * operationId="about",
+ * tags={"misc"},
+ * summary="About",
+ * description="Information about this API, such as Version code, etc",
+ * @SWG\Response(
+ * response=200,
+ * description="successful response",
+ * @SWG\Schema(
+ * type="object",
+ * additionalProperties={
+ * "title"="version",
+ * "type"="string"
+ * }
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function about(Request $request, Response $response)
+ {
+ $state = $this->getState();
+
+ if ($request->isXhr()) {
+ $state->template = 'about-text';
+ } else {
+ $state->template = 'about-page';
+ }
+
+ $state->setData(['version' => Environment::$WEBSITE_VERSION_NAME, 'sourceUrl' => $this->getConfig()->getThemeConfig('cms_source_url')]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Generate an email body
+ * @param $subject
+ * @param $body
+ * @return string
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ */
+ private function generateEmailBody($subject, $body)
+ {
+ return $this->renderTemplateToString('email-template', [
+ 'config' => $this->getConfig(),
+ 'subject' => $subject, 'body' => $body
+ ]);
+ }
+
+ /**
+ * 2FA Auth required
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \PHPMailer\PHPMailer\Exception
+ * @throws \RobThree\Auth\TwoFactorAuthException
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function twoFactorAuthForm(Request $request, Response $response)
+ {
+ if (!isset($_SESSION['tfaUsername'])) {
+ $this->getFlash()->addMessage('login_message', __('Session has expired, please log in again'));
+ return $response->withRedirect($this->urlFor($request, 'login'));
+ }
+
+ $user = $this->userFactory->getByName($_SESSION['tfaUsername']);
+ $message = '';
+
+ // if our user has email two factor enabled, we need to send the email with code now
+ if ($user->twoFactorTypeId === 1) {
+
+ if ($user->email == '') {
+ throw new NotFoundException(__('No email'));
+ }
+
+ $mailFrom = $this->getConfig()->getSetting('mail_from');
+ $issuerSettings = $this->getConfig()->getSetting('TWOFACTOR_ISSUER');
+ $appName = $this->getConfig()->getThemeConfig('app_name');
+
+ if ($issuerSettings !== '') {
+ $issuer = $issuerSettings;
+ } else {
+ $issuer = $appName;
+ }
+
+ if ($mailFrom == '') {
+ throw new InvalidArgumentException(__('Sending email address in CMS Settings is not configured'), 'mail_from');
+ }
+
+ $tfa = new TwoFactorAuth($issuer);
+
+ // Nonce parts (nonce isn't ever stored, only the hash of it is stored, it only exists in the email)
+ $action = 'user-tfa-email-auth' . Random::generateString(10);
+ $nonce = Random::generateString(20);
+
+ // Create a nonce for this user and store it somewhere
+ $cache = $this->pool->getItem('/nonce/' . $action);
+
+ $cache->set([
+ 'action' => $action,
+ 'hash' => password_hash($nonce, PASSWORD_DEFAULT),
+ 'userId' => $user->userId
+ ]);
+ $cache->expiresAfter(1800); // 30 minutes?
+
+ // Save cache
+ $this->pool->save($cache);
+
+ // Make a link
+ $code = $tfa->getCode($user->twoFactorSecret);
+
+ // Send the mail
+ $mail = new \PHPMailer\PHPMailer\PHPMailer();
+ $mail->CharSet = 'UTF-8';
+ $mail->Encoding = 'base64';
+ $mail->From = $mailFrom;
+ $msgFromName = $this->getConfig()->getSetting('mail_from_name');
+
+ if ($msgFromName != null) {
+ $mail->FromName = $msgFromName;
+ }
+
+ $mail->Subject = __('Two Factor Authentication');
+ $mail->addAddress($user->email);
+
+ // Body
+ $mail->isHTML(true);
+ $mail->Body = $this->generateEmailBody($mail->Subject,
+ '
' . __('You are receiving this email because two factor email authorisation is enabled in your CMS user account. If you did not make this request, please report this email to your administrator immediately.') . '
' . '
' . $code . '
');
+
+ if (!$mail->send()) {
+ $message = __('Unable to send two factor code to email address associated with this user');
+ } else {
+ $message = __('Two factor code email has been sent to your email address');
+
+ // Audit Log
+ $this->getLog()->audit('User', $user->userId, 'Two Factor Code email sent', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ }
+ }
+
+ // Template
+ $this->getState()->template = 'tfa';
+
+ // the flash message do not work well here - need to reload the page to see the message, hence the below
+ $this->getState()->setData(['message' => $message]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \RobThree\Auth\TwoFactorAuthException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function twoFactorAuthValidate(Request $request, Response $response): Response
+ {
+ $user = $this->userFactory->getByName($_SESSION['tfaUsername']);
+ $result = false;
+ $updatedCodes = [];
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (isset($_POST['code'])) {
+ $issuerSettings = $this->getConfig()->getSetting('TWOFACTOR_ISSUER');
+ $appName = $this->getConfig()->getThemeConfig('app_name');
+
+ if ($issuerSettings !== '') {
+ $issuer = $issuerSettings;
+ } else {
+ $issuer = $appName;
+ }
+
+ $tfa = new TwoFactorAuth($issuer);
+
+ if ($user->twoFactorTypeId === 1 && $user->email !== '') {
+ $result = $tfa->verifyCode($user->twoFactorSecret, $sanitizedParams->getString('code'), 9);
+ } else {
+ $result = $tfa->verifyCode($user->twoFactorSecret, $sanitizedParams->getString('code'), 3);
+ }
+ } elseif (isset($_POST['recoveryCode'])) {
+ // get the array of recovery codes, go through them and try to match provided code
+ $codes = $user->twoFactorRecoveryCodes;
+
+ foreach (json_decode($codes) as $code) {
+ // if the provided recovery code matches one stored in the database, we want to log in the user
+ if ($code === $sanitizedParams->getString('recoveryCode')) {
+ $result = true;
+ }
+
+ if ($code !== $sanitizedParams->getString('recoveryCode')) {
+ $updatedCodes[] = $code;
+ }
+ }
+
+ // recovery codes are one time use, as such we want to update user recovery codes and remove the one that
+ // was just used.
+ $user->updateRecoveryCodes(json_encode($updatedCodes));
+ }
+
+ if ($result) {
+ // We are logged in at this point
+ $this->completeLoginFlow($user, $request);
+
+ $this->setNoOutput(true);
+
+ //unset the session tfaUsername
+ unset($_SESSION['tfaUsername']);
+
+ return $response->withRedirect($this->getRedirect($request, $sanitizedParams->getString('priorRoute')));
+ } else {
+ $this->getLog()->error('Authentication code incorrect, redirecting to login page');
+ $this->getFlash()->addMessage('login_message', __('Authentication code incorrect'));
+ return $response->withRedirect($this->urlFor($request, 'login'));
+ }
+ }
+
+ /**
+ * @param \Xibo\Entity\User $user
+ * @param Request $request
+ */
+ private function completeLoginFlow(User $user, Request $request): void
+ {
+ $user->touch();
+
+ $this->getLog()->info($user->userName . ' user logged in.');
+
+ // Set the userId on the log object
+ $this->getLog()->setUserId($user->userId);
+ $this->getLog()->setIpAddress($request->getAttribute('ip_address'));
+
+ // Switch Session ID's
+ $session = $this->session;
+ $session->setIsExpired(0);
+ $session->regenerateSessionId();
+ $session->setUser($user->userId);
+
+ $this->getLog()->setSessionHistoryId($session->get('sessionHistoryId'));
+
+ // Audit Log
+ $this->getLog()->audit('User', $user->userId, 'Login Granted', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ }
+
+ /**
+ * Get a redirect link from the given request and prior route
+ * validate the prior route by only taking its path
+ * @param \Slim\Http\ServerRequest $request
+ * @param string|null $priorRoute
+ * @return string
+ */
+ private function getRedirect(Request $request, ?string $priorRoute): string
+ {
+ $home = $this->urlFor($request, 'home');
+
+ // Parse the prior route
+ $parsedPriorRoute = parse_url($priorRoute);
+ if (!$parsedPriorRoute) {
+ $priorRoute = $home;
+ } else {
+ $priorRoute = $parsedPriorRoute['path'];
+ }
+
+ // Certain routes always lead home
+ if ($priorRoute == ''
+ || $priorRoute == '/'
+ || str_contains($priorRoute, $this->urlFor($request, 'login'))
+ ) {
+ $redirectTo = $home;
+ } else {
+ $redirectTo = $priorRoute;
+ }
+
+ return $redirectTo;
+ }
+}
diff --git a/lib/Controller/Maintenance.php b/lib/Controller/Maintenance.php
new file mode 100644
index 0000000..e415020
--- /dev/null
+++ b/lib/Controller/Maintenance.php
@@ -0,0 +1,288 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Factory\MediaFactory;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Class Maintenance
+ * @package Xibo\Controller
+ */
+class Maintenance extends Base
+{
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var MediaServiceInterface */
+ private $mediaService;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param MediaFactory $mediaFactory
+ * @param MediaServiceInterface $mediaService
+ */
+ public function __construct($store, $mediaFactory, MediaServiceInterface $mediaService)
+ {
+ $this->store = $store;
+ $this->mediaFactory = $mediaFactory;
+ $this->mediaService = $mediaService;
+ }
+
+ /**
+ * Tidy Library Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function tidyLibraryForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'maintenance-form-tidy';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Tidies up the library
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function tidyLibrary(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $tidyOldRevisions = $sanitizedParams->getCheckbox('tidyOldRevisions');
+ $cleanUnusedFiles = $sanitizedParams->getCheckbox('cleanUnusedFiles');
+ $tidyGenericFiles = $sanitizedParams->getCheckbox('tidyGenericFiles');
+
+ if ($this->getConfig()->getSetting('SETTING_LIBRARY_TIDY_ENABLED') != 1) {
+ throw new AccessDeniedException(__('Sorry this function is disabled.'));
+ }
+
+ $this->getLog()->audit('Media', 0, 'Tidy library started from Settings', [
+ 'tidyOldRevisions' => $tidyOldRevisions,
+ 'cleanUnusedFiles' => $cleanUnusedFiles,
+ 'tidyGenericFiles' => $tidyGenericFiles,
+ 'initiator' => $this->getUser()->userId
+ ]);
+
+ // Also run a script to tidy up orphaned media in the library
+ $library = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ $this->getLog()->debug('Library Location: ' . $library);
+
+ // Remove temporary files
+ $this->mediaService->removeTempFiles();
+
+ $media = [];
+ $unusedMedia = [];
+ $unusedRevisions = [];
+
+ // DataSets with library images
+ $dataSetSql = '
+ SELECT dataset.dataSetId, datasetcolumn.heading
+ FROM dataset
+ INNER JOIN datasetcolumn
+ ON datasetcolumn.DataSetID = dataset.DataSetID
+ WHERE DataTypeID = 5 AND DataSetColumnTypeID <> 2;
+ ';
+
+ $dataSets = $this->store->select($dataSetSql, []);
+
+ // Run a query to get an array containing all of the media in the library
+ // this must contain ALL media, so that we can delete files in the storage that aren;t in the table
+ $sql = '
+ SELECT media.mediaid, media.storedAs, media.type, media.isedited,
+ SUM(CASE WHEN IFNULL(lkwidgetmedia.widgetId, 0) = 0 THEN 0 ELSE 1 END) AS UsedInLayoutCount,
+ SUM(CASE WHEN IFNULL(lkmediadisplaygroup.mediaId, 0) = 0 THEN 0 ELSE 1 END) AS UsedInDisplayCount,
+ SUM(CASE WHEN IFNULL(layout.layoutId, 0) = 0 THEN 0 ELSE 1 END) AS UsedInBackgroundImageCount,
+ SUM(CASE WHEN IFNULL(menu_category.menuCategoryId, 0) = 0 THEN 0 ELSE 1 END) AS UsedInMenuBoardCategoryCount,
+ SUM(CASE WHEN IFNULL(menu_product.menuProductId, 0) = 0 THEN 0 ELSE 1 END) AS UsedInMenuBoardProductCount
+ ';
+
+ if (count($dataSets) > 0) {
+ $sql .= ' , SUM(CASE WHEN IFNULL(dataSetImages.mediaId, 0) = 0 THEN 0 ELSE 1 END) AS UsedInDataSetCount ';
+ } else {
+ $sql .= ' , 0 AS UsedInDataSetCount ';
+ }
+
+ $sql .= '
+ FROM `media`
+ LEFT OUTER JOIN `lkwidgetmedia`
+ ON lkwidgetmedia.mediaid = media.mediaid
+ LEFT OUTER JOIN `lkmediadisplaygroup`
+ ON lkmediadisplaygroup.mediaid = media.mediaid
+ LEFT OUTER JOIN `layout`
+ ON `layout`.backgroundImageId = `media`.mediaId
+ LEFT OUTER JOIN `menu_category`
+ ON `menu_category`.mediaId = `media`.mediaId
+ LEFT OUTER JOIN `menu_product`
+ ON `menu_product`.mediaId = `media`.mediaId
+ ';
+
+ if (count($dataSets) > 0) {
+
+ $sql .= ' LEFT OUTER JOIN (';
+
+ $first = true;
+ foreach ($dataSets as $dataSet) {
+ $sanitizedDataSet = $this->getSanitizer($dataSet);
+ if (!$first)
+ $sql .= ' UNION ALL ';
+
+ $first = false;
+
+ $dataSetId = $sanitizedDataSet->getInt('dataSetId');
+ $heading = $sanitizedDataSet->getString('heading');
+
+ $sql .= ' SELECT `' . $heading . '` AS mediaId FROM `dataset_' . $dataSetId . '`';
+ }
+
+ $sql .= ') dataSetImages
+ ON dataSetImages.mediaId = `media`.mediaId
+ ';
+ }
+
+ $sql .= '
+ GROUP BY media.mediaid, media.storedAs, media.type, media.isedited
+ ';
+
+ foreach ($this->store->select($sql, []) as $row) {
+ $media[$row['storedAs']] = $row;
+ $sanitizedRow = $this->getSanitizer($row);
+
+ $type = $sanitizedRow->getString('type');
+
+ // Ignore any module files or fonts
+ if ($type == 'module'
+ || $type == 'font'
+ || $type == 'playersoftware'
+ || ($type == 'genericfile' && $tidyGenericFiles != 1)
+ ) {
+ continue;
+ }
+
+ // Collect media revisions that aren't used
+ if ($tidyOldRevisions && $this->isSafeToDelete($row) && $row['isedited'] > 0) {
+ $unusedRevisions[$row['storedAs']] = $row;
+ }
+ // Collect any files that aren't used
+ else if ($cleanUnusedFiles && $this->isSafeToDelete($row)) {
+ $unusedMedia[$row['storedAs']] = $row;
+ }
+ }
+
+ $i = 0;
+
+ // Library location
+ $libraryLocation = $this->getConfig()->getSetting("LIBRARY_LOCATION");
+
+ // Get a list of all media files
+ foreach(scandir($library) as $file) {
+
+ if ($file == '.' || $file == '..')
+ continue;
+
+ if (is_dir($library . $file))
+ continue;
+
+ // Ignore thumbnails
+ if (strstr($file, 'tn_'))
+ continue;
+
+ // Ignore XLF files
+ if (strstr($file, '.xlf'))
+ continue;
+
+ $i++;
+
+ // Is this file in the system anywhere?
+ if (!array_key_exists($file, $media)) {
+ // Totally missing
+ $this->getLog()->alert('tidyLibrary: Deleting file which is not in the media table: ' . $file);
+
+ // If not, delete it
+ unlink($libraryLocation . $file);
+ } else if (array_key_exists($file, $unusedRevisions)) {
+ // It exists but isn't being used anymore
+ $this->getLog()->alert('tidyLibrary: Deleting unused revision media: ' . $media[$file]['mediaid']);
+
+ $mediaToDelete = $this->mediaFactory->getById($media[$file]['mediaid']);
+ $this->getDispatcher()->dispatch(new MediaDeleteEvent($mediaToDelete), MediaDeleteEvent::$NAME);
+ $mediaToDelete->delete();
+ } else if (array_key_exists($file, $unusedMedia)) {
+ // It exists but isn't being used anymore
+ $this->getLog()->alert('tidyLibrary: Deleting unused media: ' . $media[$file]['mediaid']);
+
+ $mediaToDelete = $this->mediaFactory->getById($media[$file]['mediaid']);
+ $this->getDispatcher()->dispatch(new MediaDeleteEvent($mediaToDelete), MediaDeleteEvent::$NAME);
+ $mediaToDelete->delete();
+ } else {
+ $i--;
+ }
+ }
+
+ $this->getLog()->audit('Media', 0, 'Tidy library from settings complete', [
+ 'countDeleted' => $i,
+ 'initiator' => $this->getUser()->userId
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Library Tidy Complete'),
+ 'data' => [
+ 'tidied' => $i
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ private function isSafeToDelete($row): bool
+ {
+ return ($row['UsedInLayoutCount'] <= 0
+ && $row['UsedInDisplayCount'] <= 0
+ && $row['UsedInBackgroundImageCount'] <= 0
+ && $row['UsedInDataSetCount'] <= 0
+ && $row['UsedInMenuBoardCategoryCount'] <= 0
+ && $row['UsedInMenuBoardProductCount'] <= 0
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/MediaManager.php b/lib/Controller/MediaManager.php
new file mode 100644
index 0000000..e8dda7d
--- /dev/null
+++ b/lib/Controller/MediaManager.php
@@ -0,0 +1,137 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class MediaManager
+ * @package Xibo\Controller
+ */
+class MediaManager extends Base
+{
+ private StorageServiceInterface $store;
+ private ModuleFactory $moduleFactory;
+ private MediaFactory $mediaFactory;
+
+ /**
+ * Set common dependencies.
+ */
+ public function __construct(
+ StorageServiceInterface $store,
+ ModuleFactory $moduleFactory,
+ MediaFactory $mediaFactory
+ ) {
+ $this->store = $store;
+ $this->moduleFactory = $moduleFactory;
+ $this->mediaFactory = $mediaFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'media-manager-page';
+ $this->getState()->setData([
+ 'library' => $this->getLibraryUsage()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Get the library usage
+ * @return array
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function getLibraryUsage(): array
+ {
+ // Set up some suffixes
+ $suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+ $params = [];
+
+ // Library Size in Bytes
+ $sql = '
+ SELECT COUNT(`mediaId`) AS countOf,
+ IFNULL(SUM(`FileSize`), 0) AS SumSize,
+ `type`
+ FROM `media`
+ WHERE 1 = 1 ';
+
+ $this->mediaFactory->viewPermissionSql(
+ 'Xibo\Entity\Media',
+ $sql,
+ $params,
+ '`media`.mediaId',
+ '`media`.userId',
+ [],
+ 'media.permissionsFolderId'
+ );
+ $sql .= ' GROUP BY type ';
+ $sql .= ' ORDER BY 2 ';
+
+ $results = $this->store->select($sql, $params);
+
+ $libraryUsage = [];
+ $totalCount = 0;
+ $totalSize = 0;
+ foreach ($results as $library) {
+ $bytes = doubleval($library['SumSize']);
+ $totalSize += $bytes;
+ $totalCount += $library['countOf'];
+
+ try {
+ $title = $this->moduleFactory->getByType($library['type'])->name;
+ } catch (NotFoundException) {
+ $title = $library['type'] === 'module' ? __('Widget cache') : ucfirst($library['type']);
+ }
+ $libraryUsage[] = [
+ 'title' => $title,
+ 'count' => $library['countOf'],
+ 'size' => $bytes,
+ ];
+ }
+
+ // Decide what our units are going to be, based on the size
+ $base = ($totalSize === 0) ? 0 : floor(log($totalSize) / log(1024));
+
+ return [
+ 'countOf' => $totalCount,
+ 'size' => ByteFormatter::format($totalSize, 1, true),
+ 'types' => $libraryUsage,
+ 'typesSuffix' => $suffixes[$base],
+ 'typesBase' => $base,
+ ];
+ }
+}
diff --git a/lib/Controller/MenuBoard.php b/lib/Controller/MenuBoard.php
new file mode 100644
index 0000000..3564bf0
--- /dev/null
+++ b/lib/Controller/MenuBoard.php
@@ -0,0 +1,629 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\MenuBoardFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Menu Board Controller
+ */
+class MenuBoard extends Base
+{
+ /**
+ * Set common dependencies.
+ * @param MenuBoardFactory $menuBoardFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct(
+ private readonly MenuBoardFactory $menuBoardFactory,
+ private readonly FolderFactory $folderFactory
+ ) {
+ }
+
+ /**
+ * Displays the Menu Board Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ // Call to render the template
+ $this->getState()->template = 'menuboard-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns a Grid of Menu Boards
+ *
+ * @SWG\Get(
+ * path="/menuboards",
+ * operationId="menuBoardSearch",
+ * tags={"menuBoard"},
+ * summary="Search Menu Boards",
+ * description="Search all Menu Boards this user has access to",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="query",
+ * description="Filter by Menu board Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userId",
+ * in="query",
+ * description="Filter by Owner Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="query",
+ * description="Filter by code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/MenuBoard")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function grid(Request $request, Response $response): Response
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'menuId' => $parsedParams->getInt('menuId'),
+ 'userId' => $parsedParams->getInt('userId'),
+ 'name' => $parsedParams->getString('name'),
+ 'code' => $parsedParams->getString('code'),
+ 'folderId' => $parsedParams->getInt('folderId'),
+ 'logicalOperatorName' => $parsedParams->getString('logicalOperatorName'),
+ ];
+
+ $menuBoards = $this->menuBoardFactory->query(
+ $this->gridRenderSort($parsedParams),
+ $this->gridRenderFilter($filter, $parsedParams)
+ );
+
+ foreach ($menuBoards as $menuBoard) {
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $menuBoard->includeProperty('buttons');
+ $menuBoard->buttons = [];
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify') && $this->getUser()->checkEditable($menuBoard)) {
+ $menuBoard->buttons[] = [
+ 'id' => 'menuBoard_button_viewcategories',
+ 'url' => $this->urlFor($request, 'menuBoard.category.view', ['id' => $menuBoard->menuId]),
+ 'class' => 'XiboRedirectButton',
+ 'text' => __('View Categories')
+ ];
+
+ $menuBoard->buttons[] = [
+ 'id' => 'menuBoard_edit_button',
+ 'url' => $this->urlFor($request, 'menuBoard.edit.form', ['id' => $menuBoard->menuId]),
+ 'text' => __('Edit')
+ ];
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $menuBoard->buttons[] = [
+ 'id' => 'menuBoard_button_selectfolder',
+ 'url' => $this->urlFor($request, 'menuBoard.selectfolder.form', ['id' => $menuBoard->menuId]),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'menuBoard.selectfolder', ['id' => $menuBoard->menuId])
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'menuBoard_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $menuBoard->name],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+ }
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify') && $this->getUser()->checkPermissionsModifyable($menuBoard)) {
+ $menuBoard->buttons[] = ['divider' => true];
+
+ // Share button
+ $menuBoard->buttons[] = [
+ 'id' => 'menuBoard_button_permissions',
+ 'url' => $this->urlFor($request, 'user.permissions.form', ['entity' => 'MenuBoard', 'id' => $menuBoard->menuId]),
+ 'text' => __('Share'),
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'user.permissions.multi', ['entity' => 'MenuBoard', 'id' => $menuBoard->menuId])
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'menuBoard_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $menuBoard->name],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor($request, 'user.permissions.multi.form', ['entity' => 'MenuBoard'])
+ ],
+ ['name' => 'content-id-name', 'value' => 'menuId']
+ ]
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify')
+ && $this->getUser()->checkDeleteable($menuBoard)
+ ) {
+ $menuBoard->buttons[] = ['divider' => true];
+
+ $menuBoard->buttons[] = [
+ 'id' => 'menuBoard_delete_button',
+ 'url' => $this->urlFor($request, 'menuBoard.delete.form', ['id' => $menuBoard->menuId]),
+ 'text' => __('Delete')
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->menuBoardFactory->countLast();
+ $this->getState()->setData($menuBoards);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Menu Board Add Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function addForm(Request $request, Response $response): Response
+ {
+ $this->getState()->template = 'menuboard-form-add';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a new Menu Board
+ *
+ * @SWG\Post(
+ * path="/menuboard",
+ * operationId="menuBoardAdd",
+ * tags={"menuBoard"},
+ * summary="Add Menu Board",
+ * description="Add a new Menu Board",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Menu Board name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="Menu Board description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Menu Board code identifier",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Menu Board Folder Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/MenuBoard"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response): Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $name = $sanitizedParams->getString('name');
+ $description = $sanitizedParams->getString('description');
+ $code = $sanitizedParams->getString('code');
+ $folderId = $sanitizedParams->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ $menuBoard = $this->menuBoardFactory->create($name, $description, $code);
+ $menuBoard->folderId = $folder->getId();
+ $menuBoard->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ $menuBoard->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Added Menu Board'),
+ 'httpStatus' => 201,
+ 'id' => $menuBoard->menuId,
+ 'data' => $menuBoard,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'menuboard-form-edit';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/menuboard/{menuId}",
+ * operationId="menuBoardEdit",
+ * tags={"menuBoard"},
+ * summary="Edit Menu Board",
+ * description="Edit existing Menu Board",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="The Menu Board ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Menu Board name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="Menu Board description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Menu Board code identifier",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Menu Board Folder Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $menuBoard->name = $sanitizedParams->getString('name');
+ $menuBoard->description = $sanitizedParams->getString('description');
+ $menuBoard->code = $sanitizedParams->getString('code');
+ $menuBoard->folderId = $sanitizedParams->getInt('folderId', ['default' => $menuBoard->folderId]);
+
+ if ($menuBoard->hasPropertyChanged('folderId')) {
+ if ($menuBoard->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folder = $this->folderFactory->getById($menuBoard->folderId);
+ $menuBoard->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+ }
+
+ $menuBoard->save();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $menuBoard->name),
+ 'id' => $menuBoard->menuId,
+ 'data' => $menuBoard
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'menuboard-form-delete';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Delete(
+ * path="/menuboard/{menuId}",
+ * operationId="menuBoardDelete",
+ * tags={"menuBoard"},
+ * summary="Delete Menu Board",
+ * description="Delete existing Menu Board",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="The Menu Board ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function delete(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ // Issue the delete
+ $menuBoard->delete();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $menuBoard->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Select Folder Form
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolderForm(Request $request, Response $response, $id)
+ {
+ // Get the Menu Board
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'menuBoard' => $menuBoard
+ ];
+
+ $this->getState()->template = 'menuboard-form-selectfolder';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/menuboard/{id}/selectfolder",
+ * operationId="menuBoardSelectFolder",
+ * tags={"menuBoard"},
+ * summary="Menu Board Select folder",
+ * description="Select Folder for Menu Board",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="The Menu Board ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/MenuBoard")
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolder(Request $request, Response $response, $id)
+ {
+ // Get the Menu Board
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $folderId = $this->getSanitizer($request->getParams())->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ $menuBoard->folderId = $folderId;
+ $folder = $this->folderFactory->getById($menuBoard->folderId);
+ $menuBoard->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+
+ // Save
+ $menuBoard->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Menu Board %s moved to Folder %s'), $menuBoard->name, $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/MenuBoardCategory.php b/lib/Controller/MenuBoardCategory.php
new file mode 100644
index 0000000..99cf0b4
--- /dev/null
+++ b/lib/Controller/MenuBoardCategory.php
@@ -0,0 +1,524 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\MenuBoardCategoryFactory;
+use Xibo\Factory\MenuBoardFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+class MenuBoardCategory extends Base
+{
+ /**
+ * @var MenuBoardFactory
+ */
+ private $menuBoardFactory;
+
+ /**
+ * @var MenuBoardCategoryFactory
+ */
+ private $menuBoardCategoryFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * Set common dependencies.
+ * @param MenuBoardFactory $menuBoardFactory
+ * @param $menuBoardCategoryFactory
+ * @param MediaFactory $mediaFactory
+ */
+ public function __construct(
+ $menuBoardFactory,
+ $menuBoardCategoryFactory,
+ $mediaFactory
+ ) {
+ $this->menuBoardFactory = $menuBoardFactory;
+ $this->menuBoardCategoryFactory = $menuBoardCategoryFactory;
+ $this->mediaFactory = $mediaFactory;
+ }
+
+ /**
+ * Displays the Menu Board Categories Page
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response, $id)
+ {
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ // Call to render the template
+ $this->getState()->template = 'menuboard-category-page';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns a Grid of Menu Board Categories
+ *
+ * @SWG\Get(
+ * path="/menuboard/{menuId}/categories",
+ * operationId="menuBoardCategorySearch",
+ * tags={"menuBoard"},
+ * summary="Search Menu Board Categories",
+ * description="Search all Menu Boards Categories this user has access to",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="Filter by Menu board Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="menuCategoryId",
+ * in="query",
+ * description="Filter by Menu Board Category Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="query",
+ * description="Filter by code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/MenuBoard")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function grid(Request $request, Response $response, $id): Response
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ $filter = [
+ 'menuId' => $menuBoard->menuId,
+ 'menuCategoryId' => $parsedParams->getInt('menuCategoryId'),
+ 'name' => $parsedParams->getString('name'),
+ 'code' => $parsedParams->getString('code')
+ ];
+
+ $menuBoardCategories = $this->menuBoardCategoryFactory->query(
+ $this->gridRenderSort($parsedParams),
+ $this->gridRenderFilter($filter, $parsedParams)
+ );
+
+
+ foreach ($menuBoardCategories as $menuBoardCategory) {
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ if ($menuBoardCategory->mediaId != 0) {
+ $menuBoardCategory->setUnmatchedProperty(
+ 'thumbnail',
+ $this->urlFor(
+ $request,
+ 'library.download',
+ ['id' => $menuBoardCategory->mediaId],
+ ['preview' => 1],
+ )
+ );
+ }
+
+ $menuBoardCategory->includeProperty('buttons');
+ $menuBoardCategory->buttons = [];
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify') && $this->getUser()->checkEditable($menuBoard)) {
+ $menuBoardCategory->buttons[] = [
+ 'id' => 'menuBoardCategory_button_viewproducts',
+ 'url' => $this->urlFor($request, 'menuBoard.product.view', ['id' => $menuBoardCategory->menuCategoryId]),
+ 'class' => 'XiboRedirectButton',
+ 'text' => __('View Products')
+ ];
+
+ $menuBoardCategory->buttons[] = [
+ 'id' => 'menuBoardCategory_edit_button',
+ 'url' => $this->urlFor($request, 'menuBoard.category.edit.form', ['id' => $menuBoardCategory->menuCategoryId]),
+ 'text' => __('Edit')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify') && $this->getUser()->checkDeleteable($menuBoard)) {
+ $menuBoardCategory->buttons[] = ['divider' => true];
+
+ $menuBoardCategory->buttons[] = [
+ 'id' => 'menuBoardCategory_delete_button',
+ 'url' => $this->urlFor($request, 'menuBoard.category.delete.form', ['id' => $menuBoardCategory->menuCategoryId]),
+ 'text' => __('Delete')
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->menuBoardCategoryFactory->countLast();
+ $this->getState()->setData($menuBoardCategories);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Menu Board Category Add Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function addForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'menuboard-category-form-add';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a new Menu Board Category
+ *
+ * @SWG\Post(
+ * path="/menuboard/{menuId}/category",
+ * operationId="menuBoardCategoryAdd",
+ * tags={"menuBoard"},
+ * summary="Add Menu Board",
+ * description="Add a new Menu Board Category",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="The Menu Board ID to which we want to add this Category to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Menu Board Category name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="Media ID associated with this Menu Board Category",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Menu Board Category code identifier",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="Menu Board Category description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/MenuBoard"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ */
+ public function add(Request $request, Response $response, $id): Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $menuBoard = $this->menuBoardFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $name = $sanitizedParams->getString('name');
+ $mediaId = $sanitizedParams->getInt('mediaId');
+ $code = $sanitizedParams->getString('code');
+ $description = $sanitizedParams->getString('description');
+
+ $menuBoardCategory = $this->menuBoardCategoryFactory->create($id, $name, $mediaId, $code, $description);
+ $menuBoardCategory->save();
+ $menuBoard->save(['audit' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Added Menu Board Category'),
+ 'httpStatus' => 201,
+ 'id' => $menuBoardCategory->menuCategoryId,
+ 'data' => $menuBoardCategory,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+
+ $this->getState()->template = 'menuboard-category-form-edit';
+ $this->getState()->setData([
+ 'menuBoardCategory' => $menuBoardCategory,
+ 'media' => $menuBoardCategory->mediaId != null ? $this->mediaFactory->getById($menuBoardCategory->mediaId) : null
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/menuboard/{menuCategoryId}/category",
+ * operationId="menuBoardCategoryEdit",
+ * tags={"menuBoard"},
+ * summary="Edit Menu Board Category",
+ * description="Edit existing Menu Board Category",
+ * @SWG\Parameter(
+ * name="menuCategoryId",
+ * in="path",
+ * description="The Menu Board Category ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Menu Board name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="Media ID from CMS Library to associate with this Menu Board Category",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Menu Board Category code identifier",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="Menu Board Category description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+
+ $menuBoardCategory->name = $sanitizedParams->getString('name');
+ $menuBoardCategory->mediaId = $sanitizedParams->getInt('mediaId');
+ $menuBoardCategory->code = $sanitizedParams->getString('code');
+ $menuBoardCategory->description = $sanitizedParams->getString('description');
+ $menuBoardCategory->save();
+ $menuBoard->save();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $menuBoardCategory->name),
+ 'id' => $menuBoardCategory->menuCategoryId,
+ 'data' => $menuBoardCategory
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws GeneralException
+ */
+ public function deleteForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ if (!$this->getUser()->checkDeleteable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+
+ $this->getState()->template = 'menuboard-category-form-delete';
+ $this->getState()->setData([
+ 'menuBoardCategory' => $menuBoardCategory
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Delete(
+ * path="/menuboard/{menuCategoryId}/category",
+ * operationId="menuBoardCategoryDelete",
+ * tags={"menuBoard"},
+ * summary="Delete Menu Board Category",
+ * description="Delete existing Menu Board Category",
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="path",
+ * description="The menuId to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function delete(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ if (!$this->getUser()->checkDeleteable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+
+ // Issue the delete
+ $menuBoardCategory->delete();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $menuBoardCategory->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/MenuBoardProduct.php b/lib/Controller/MenuBoardProduct.php
new file mode 100644
index 0000000..a5250df
--- /dev/null
+++ b/lib/Controller/MenuBoardProduct.php
@@ -0,0 +1,727 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\MenuBoardCategoryFactory;
+use Xibo\Factory\MenuBoardFactory;
+use Xibo\Factory\MenuBoardProductOptionFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+class MenuBoardProduct extends Base
+{
+ /**
+ * @var MenuBoardFactory
+ */
+ private $menuBoardFactory;
+
+ /**
+ * @var MenuBoardCategoryFactory
+ */
+ private $menuBoardCategoryFactory;
+
+ /**
+ * @var MenuBoardProductOptionFactory
+ */
+ private $menuBoardProductOptionFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * Set common dependencies.
+ * @param MenuBoardFactory $menuBoardFactory
+ * @param MenuBoardCategoryFactory $menuBoardCategoryFactory
+ * @param MenuBoardProductOptionFactory $menuBoardProductOptionFactory
+ * @param MediaFactory $mediaFactory
+ */
+ public function __construct(
+ $menuBoardFactory,
+ $menuBoardCategoryFactory,
+ $menuBoardProductOptionFactory,
+ $mediaFactory
+ ) {
+ $this->menuBoardFactory = $menuBoardFactory;
+ $this->menuBoardCategoryFactory = $menuBoardCategoryFactory;
+ $this->menuBoardProductOptionFactory = $menuBoardProductOptionFactory;
+ $this->mediaFactory = $mediaFactory;
+ }
+
+ /**
+ * Displays the Menu Board Page
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response, $id)
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+ $categories = $this->menuBoardCategoryFactory->getByMenuId($menuBoard->menuId);
+
+ // Call to render the template
+ $this->getState()->template = 'menuboard-product-page';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard,
+ 'menuBoardCategory' => $menuBoardCategory,
+ 'categories' => $categories
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns a Grid of Menu Board Products
+ *
+ * @SWG\Get(
+ * path="/menuboard/{menuCategoryId}/products",
+ * operationId="menuBoardProductsSearch",
+ * tags={"menuBoard"},
+ * summary="Search Menu Board Products",
+ * description="Search all Menu Boards Products this user has access to",
+ * @SWG\Parameter(
+ * name="menuCategoryId",
+ * in="path",
+ * description="Filter by Menu Board Category Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="menuId",
+ * in="query",
+ * description="Filter by Menu board Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="query",
+ * description="Filter by code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/MenuBoard")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ */
+ public function grid(Request $request, Response $response, $id): Response
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ $filter = [
+ 'menuProductId' => $parsedParams->getInt('menuProductId'),
+ 'menuCategoryId' => $id,
+ 'name' => $parsedParams->getString('name'),
+ 'code' => $parsedParams->getString('code')
+ ];
+
+ $menuBoardProducts = $this->menuBoardCategoryFactory->getProductData(
+ $this->gridRenderSort($parsedParams),
+ $this->gridRenderFilter($filter, $parsedParams)
+ );
+
+ foreach ($menuBoardProducts as $menuBoardProduct) {
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $menuBoardProduct->includeProperty('buttons');
+ $menuBoardProduct->buttons = [];
+
+ if ($menuBoardProduct->mediaId != 0) {
+ $menuBoardProduct->setUnmatchedProperty(
+ 'thumbnail',
+ $this->urlFor($request, 'library.download', ['id' => $menuBoardProduct->mediaId], ['preview' => 1]),
+ );
+ }
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify') && $this->getUser()->checkEditable($menuBoard)) {
+ $menuBoardProduct->buttons[] = [
+ 'id' => 'menuBoardProduct_edit_button',
+ 'url' => $this->urlFor($request, 'menuBoard.product.edit.form', ['id' => $menuBoardProduct->menuProductId]),
+ 'text' => __('Edit')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('menuBoard.modify') && $this->getUser()->checkDeleteable($menuBoard)) {
+ $menuBoardProduct->buttons[] = ['divider' => true];
+
+ $menuBoardProduct->buttons[] = [
+ 'id' => 'menuBoardProduct_delete_button',
+ 'url' => $this->urlFor($request, 'menuBoard.product.delete.form', ['id' => $menuBoardProduct->menuProductId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'menuBoard.product.delete', ['id' => $menuBoardProduct->menuProductId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'menuBoardProduct_delete_button'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $menuBoardProduct->name]
+ ]
+ ];
+ }
+ }
+
+ $menuBoard->setActive();
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->menuBoardCategoryFactory->countLast();
+ $this->getState()->setData($menuBoardProducts);
+
+ return $this->render($request, $response);
+ }
+
+ public function productsForWidget(Request $request, Response $response): Response
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+ $categories = $parsedParams->getString('categories');
+
+ $filter = [
+ 'menuId' => $parsedParams->getInt('menuId'),
+ 'menuProductId' => $parsedParams->getInt('menuProductId'),
+ 'menuCategoryId' => $parsedParams->getInt('menuCategoryId'),
+ 'name' => $parsedParams->getString('name'),
+ 'availability' => $parsedParams->getInt('availability'),
+ 'categories' => $categories
+ ];
+
+ $menuBoardProducts = $this->menuBoardCategoryFactory->getProductData(
+ $this->gridRenderSort($parsedParams),
+ $this->gridRenderFilter($filter, $parsedParams)
+ );
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->menuBoardCategoryFactory->countLast();
+ $this->getState()->setData($menuBoardProducts);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Menu Board Category Add Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function addForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+
+ $this->getState()->template = 'menuboard-product-form-add';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard,
+ 'menuBoardCategory' => $menuBoardCategory
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a new Menu Board Product
+ *
+ * @SWG\Post(
+ * path="/menuboard/{menuCategoryId}/product",
+ * operationId="menuBoardProductAdd",
+ * tags={"menuBoard"},
+ * summary="Add Menu Board Product",
+ * description="Add a new Menu Board Product",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Menu Board Product name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="Menu Board Product description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="price",
+ * in="formData",
+ * description="Menu Board Product price",
+ * type="number",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="allergyInfo",
+ * in="formData",
+ * description="Menu Board Product allergyInfo",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="calories",
+ * in="formData",
+ * description="Menu Board Product calories",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="Menu Board Product Display Order, used for sorting",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="availability",
+ * in="formData",
+ * description="Menu Board Product availability",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="Media ID from CMS Library to associate with this Menu Board Product",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Menu Board Product code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="productOptions",
+ * in="formData",
+ * description="An array of optional Product Option names",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="productValues",
+ * in="formData",
+ * description="An array of optional Product Option values",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/MenuBoard"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ */
+ public function add(Request $request, Response $response, $id): Response
+ {
+ $menuBoard = $this->menuBoardFactory->getByMenuCategoryId($id);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $name = $sanitizedParams->getString('name');
+ $mediaId = $sanitizedParams->getInt('mediaId');
+ $price = $sanitizedParams->getDouble('price');
+ $description = $sanitizedParams->getString('description');
+ $allergyInfo = $sanitizedParams->getString('allergyInfo');
+ $calories = $sanitizedParams->getInt('calories');
+ $displayOrder = $sanitizedParams->getInt('displayOrder');
+ $availability = $sanitizedParams->getCheckbox('availability');
+ $productOptions = $sanitizedParams->getArray('productOptions', ['default' => []]);
+ $productValues = $sanitizedParams->getArray('productValues', ['default' => []]);
+ $code = $sanitizedParams->getString('code');
+
+ // If the display order is empty, get the next highest one.
+ if ($displayOrder === null) {
+ $displayOrder = $this->menuBoardCategoryFactory->getNextDisplayOrder($menuBoardCategory->menuCategoryId);
+ }
+
+ $menuBoardProduct = $this->menuBoardCategoryFactory->createProduct(
+ $menuBoard->menuId,
+ $menuBoardCategory->menuCategoryId,
+ $name,
+ $price,
+ $description,
+ $allergyInfo,
+ $calories,
+ $displayOrder,
+ $availability,
+ $mediaId,
+ $code
+ );
+ $menuBoardProduct->save();
+
+ if (!empty(array_filter($productOptions)) && !empty(array_filter($productValues))) {
+ $productDetails = array_filter(array_combine($productOptions, $productValues));
+ $parsedDetails = $this->getSanitizer($productDetails);
+
+ foreach ($productDetails as $option => $value) {
+ $productOption = $this->menuBoardProductOptionFactory->create(
+ $menuBoardProduct->menuProductId,
+ $option,
+ $parsedDetails->getDouble($option)
+ );
+ $productOption->save();
+ }
+ }
+ $menuBoardProduct->productOptions = $menuBoardProduct->getOptions();
+ $menuBoard->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Added Menu Board Product'),
+ 'httpStatus' => 201,
+ 'id' => $menuBoardProduct->menuProductId,
+ 'data' => $menuBoardProduct
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoardProduct = $this->menuBoardCategoryFactory->getByProductId($id);
+ $menuBoard = $this->menuBoardFactory->getById($menuBoardProduct->menuId);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'menuboard-product-form-edit';
+ $this->getState()->setData([
+ 'menuBoardProduct' => $menuBoardProduct,
+ 'media' => $menuBoardProduct->mediaId != null ? $this->mediaFactory->getById($menuBoardProduct->mediaId) : null
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/menuboard/{menuProductId}/product",
+ * operationId="menuBoardProductEdit",
+ * tags={"menuBoard"},
+ * summary="Edit Menu Board Product",
+ * description="Edit existing Menu Board Product",
+ * @SWG\Parameter(
+ * name="menuProductId",
+ * in="path",
+ * description="The Menu Board Product ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Menu Board Product name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="Menu Board Product description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="price",
+ * in="formData",
+ * description="Menu Board Product price",
+ * type="number",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="allergyInfo",
+ * in="formData",
+ * description="Menu Board Product allergyInfo",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="calories",
+ * in="formData",
+ * description="Menu Board Product calories",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="Menu Board Product Display Order, used for sorting",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="availability",
+ * in="formData",
+ * description="Menu Board Product availability",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="Media ID from CMS Library to associate with this Menu Board Product",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="Menu Board Product code",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="productOptions",
+ * in="formData",
+ * description="An array of optional Product Option names",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="productValues",
+ * in="formData",
+ * description="An array of optional Product Option values",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id): Response
+ {
+ $menuBoardProduct = $this->menuBoardCategoryFactory->getByProductId($id);
+ $menuBoard = $this->menuBoardFactory->getById($menuBoardProduct->menuId);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $menuBoardProduct->name = $sanitizedParams->getString('name');
+ $menuBoardProduct->description = $sanitizedParams->getString('description');
+ $menuBoardProduct->price = $sanitizedParams->getDouble('price');
+ $menuBoardProduct->allergyInfo = $sanitizedParams->getString('allergyInfo');
+ $menuBoardProduct->calories = $sanitizedParams->getInt('calories');
+ $menuBoardProduct->displayOrder = $sanitizedParams->getInt('displayOrder');
+ $menuBoardProduct->availability = $sanitizedParams->getCheckbox('availability');
+ $menuBoardProduct->mediaId = $sanitizedParams->getInt('mediaId');
+ $menuBoardProduct->code = $sanitizedParams->getString('code');
+ $productOptions = $sanitizedParams->getArray('productOptions', ['default' => []]);
+ $productValues = $sanitizedParams->getArray('productValues', ['default' => []]);
+
+ if (!empty(array_filter($productOptions)) && !empty(array_filter($productValues))) {
+ $productDetails = array_filter(array_combine($productOptions, $productValues));
+ $parsedDetails = $this->getSanitizer($productDetails);
+ if (count($menuBoardProduct->getOptions()) > count($productDetails)) {
+ $menuBoardProduct->removeOptions();
+ }
+
+ foreach ($productDetails as $option => $value) {
+ $productOption = $this->menuBoardProductOptionFactory->create(
+ $menuBoardProduct->menuProductId,
+ $option,
+ $parsedDetails->getDouble($option)
+ );
+ $productOption->save();
+ }
+ } else {
+ $menuBoardProduct->removeOptions();
+ }
+ $menuBoardProduct->productOptions = $menuBoardProduct->getOptions();
+ $menuBoardProduct->save();
+ $menuBoard->save();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $menuBoardProduct->name),
+ 'id' => $menuBoardProduct->menuProductId,
+ 'data' => $menuBoardProduct
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id): Response
+ {
+ $menuBoardProduct = $this->menuBoardCategoryFactory->getByProductId($id);
+ $menuBoardCategory = $this->menuBoardCategoryFactory->getById($menuBoardProduct->menuCategoryId);
+ $menuBoard = $this->menuBoardFactory->getById($menuBoardProduct->menuId);
+
+ if (!$this->getUser()->checkEditable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'menuboard-product-form-delete';
+ $this->getState()->setData([
+ 'menuBoard' => $menuBoard,
+ 'menuBoardCategory' => $menuBoardCategory,
+ 'menuBoardProduct' => $menuBoardProduct
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Delete(
+ * path="/menuboard/{menuProductId}/product",
+ * operationId="menuBoardProductDelete",
+ * tags={"menuBoard"},
+ * summary="Delete Menu Board",
+ * description="Delete existing Menu Board Product",
+ * @SWG\Parameter(
+ * name="menuProductId",
+ * in="path",
+ * description="The Menu Board Product ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function delete(Request $request, Response $response, $id): Response
+ {
+ $menuBoardProduct = $this->menuBoardCategoryFactory->getByProductId($id);
+ $menuBoard = $this->menuBoardFactory->getById($menuBoardProduct->menuId);
+
+ if (!$this->getUser()->checkDeleteable($menuBoard)) {
+ throw new AccessDeniedException();
+ }
+
+ // Issue the delete
+ $menuBoardProduct->delete();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $menuBoardProduct->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Module.php b/lib/Controller/Module.php
new file mode 100644
index 0000000..4955466
--- /dev/null
+++ b/lib/Controller/Module.php
@@ -0,0 +1,464 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\ModuleTemplateFactory;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Module
+ * @package Xibo\Controller
+ */
+class Module extends Base
+{
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Factory\ModuleTemplateFactory */
+ private $moduleTemplateFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param ModuleFactory $moduleFactory
+ */
+ public function __construct(
+ ModuleFactory $moduleFactory,
+ ModuleTemplateFactory $moduleTemplateFactory
+ ) {
+ $this->moduleFactory = $moduleFactory;
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ }
+
+ /**
+ * Display the module page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'module-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/module",
+ * operationId="moduleSearch",
+ * tags={"module"},
+ * summary="Module Search",
+ * description="Get a list of all modules available to this CMS",
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Module")
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'name' => $parsedQueryParams->getString('name'),
+ 'extension' => $parsedQueryParams->getString('extension'),
+ 'moduleId' => $parsedQueryParams->getInt('moduleId')
+ ];
+
+ $modules = $this->moduleFactory->getAllExceptCanvas($filter);
+
+ foreach ($modules as $module) {
+ /* @var \Xibo\Entity\Module $module */
+
+ if ($this->isApi($request)) {
+ break;
+ }
+
+ $module->includeProperty('buttons');
+
+ // Edit button
+ $module->buttons[] = [
+ 'id' => 'module_button_edit',
+ 'url' => $this->urlFor($request, 'module.settings.form', ['id' => $module->moduleId]),
+ 'text' => __('Configure')
+ ];
+
+ // Clear cache
+ if ($module->regionSpecific == 1) {
+ $module->buttons[] = [
+ 'id' => 'module_button_clear_cache',
+ 'url' => $this->urlFor($request, 'module.clear.cache.form', ['id' => $module->moduleId]),
+ 'text' => __('Clear Cache'),
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'module.clear.cache', ['id' => $module->moduleId])
+ ],
+ ['name' => 'commit-method', 'value' => 'PUT']
+ ]
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = 0;
+ $this->getState()->setData($modules);
+
+ return $this->render($request, $response);
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Get(
+ * path="/module/properties/{id}",
+ * operationId="getModuleProperties",
+ * tags={"module"},
+ * summary="Get Module Properties",
+ * description="Get a module properties which are needed to for the editWidget call",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The ModuleId",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Property")
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ // phpcs:enable
+ public function getProperties(Request $request, Response $response, $id)
+ {
+ // Get properties, but return a key->value object for easy parsing.
+ $props = [];
+ foreach ($this->moduleFactory->getById($id)->properties as $property) {
+ $props[$property->id] = [
+ 'type' => $property->type,
+ 'title' => $property->title,
+ 'helpText' => $property->helpText,
+ 'options' => $property->options,
+ ];
+ }
+
+ $this->getState()->setData($props);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Settings Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function settingsForm(Request $request, Response $response, $id)
+ {
+ // Can we edit?
+ if (!$this->getUser()->userTypeId == 1) {
+ throw new AccessDeniedException();
+ }
+
+ $module = $this->moduleFactory->getById($id);
+
+ // Pass to view
+ $this->getState()->template = 'module-form-settings';
+ $this->getState()->setData([
+ 'moduleId' => $id,
+ 'module' => $module,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Settings
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function settings(Request $request, Response $response, $id)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get the module
+ $module = $this->moduleFactory->getById($id);
+
+ // Default settings
+ $module->enabled = $sanitizedParams->getCheckbox('enabled');
+ $module->previewEnabled = $sanitizedParams->getCheckbox('previewEnabled');
+ $module->defaultDuration = $sanitizedParams->getInt('defaultDuration');
+
+ // Parse out any settings we ought to expect.
+ foreach ($module->settings as $setting) {
+ $setting->setValueByType($sanitizedParams, null, true);
+ }
+
+ // Preview is not allowed for generic file type
+ if ($module->allowPreview === 0 && $sanitizedParams->getCheckbox('previewEnabled') == 1) {
+ throw new InvalidArgumentException(__('Preview is disabled'));
+ }
+
+ // Save
+ $module->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Configured %s'), $module->name),
+ 'id' => $module->moduleId,
+ 'data' => $module
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Clear Cache Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function clearCacheForm(Request $request, Response $response, $id)
+ {
+ $module = $this->moduleFactory->getById($id);
+
+ $this->getState()->template = 'module-form-clear-cache';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('clearCache');
+ $this->getState()->setData([
+ 'module' => $module,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Clear Cache
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function clearCache(Request $request, Response $response, $id)
+ {
+ $module = $this->moduleFactory->getById($id);
+ if ($module->isDataProviderExpected()) {
+ $this->moduleFactory->clearCacheForDataType($module->dataType);
+ }
+
+ $this->getState()->hydrate([
+ 'message' => __('Cleared the Cache')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/module/templates/{dataType}",
+ * operationId="moduleTemplateSearch",
+ * tags={"module"},
+ * summary="Module Template Search",
+ * description="Get a list of templates available for a particular data type",
+ * @SWG\Parameter(
+ * name="dataType",
+ * in="path",
+ * description="DataType to return templates for",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="query",
+ * description="Type to return templates for",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="An array of module templates for the provided datatype",
+ * @SWG\Schema(ref="#/definitions/ModuleTemplate")
+ * )
+ * )
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @param string $dataType
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function templateGrid(Request $request, Response $response, string $dataType): Response
+ {
+ if (empty($dataType)) {
+ throw new InvalidArgumentException(__('Please provide a datatype'), 'dataType');
+ }
+
+ $params = $this->getSanitizer($request->getParams());
+ $type = $params->getString('type');
+
+ $templates = !empty($type)
+ ? $this->moduleTemplateFactory->getByTypeAndDataType($type, $dataType)
+ : $this->moduleTemplateFactory->getByDataType($dataType);
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = 0;
+ $this->getState()->setData($templates);
+ return $this->render($request, $response);
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Get(
+ * path="/module/template/{dataType}/properties/{id}",
+ * operationId="getModuleProperties",
+ * tags={"module"},
+ * summary="Get Module Template Properties",
+ * description="Get a module template properties which are needed to for the editWidget call",
+ * @SWG\Parameter(
+ * name="dataType",
+ * in="path",
+ * description="The Template DataType",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Template Id",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="object",
+ * additionalProperties={"id":"string", "type":"string", "title":"string", "helpText":"string", "options":"array"}
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param string $dataType
+ * @param string $id
+ * @return ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ */
+ // phpcs:enable
+ public function getTemplateProperties(Request $request, Response $response, string $dataType, string $id)
+ {
+ // Get properties, but return a key->value object for easy parsing.
+ $props = [];
+ foreach ($this->moduleTemplateFactory->getByDataTypeAndId($dataType, $id)->properties as $property) {
+ $props[$property->id] = [
+ 'id' => $property->id,
+ 'type' => $property->type,
+ 'title' => $property->title,
+ 'helpText' => $property->helpText,
+ 'options' => $property->options,
+ ];
+ }
+
+ $this->getState()->setData($props);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Serve an asset
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @param string $assetId the ID of the asset to serve
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function assetDownload(Request $request, Response $response, string $assetId): Response
+ {
+ if (empty($assetId)) {
+ throw new InvalidArgumentException(__('Please provide an assetId'), 'assetId');
+ }
+
+ // Get this asset from somewhere
+ $asset = $this->moduleFactory->getAssetsFromAnywhereById(
+ $assetId,
+ $this->moduleTemplateFactory,
+ $this->getSanitizer($request->getParams())->getCheckbox('isAlias')
+ );
+ $asset->updateAssetCache($this->getConfig()->getSetting('LIBRARY_LOCATION'));
+
+ $this->getLog()->debug('assetDownload: found appropriate asset for assetId ' . $assetId);
+
+ // The asset can serve itself.
+ return $asset->psrResponse($request, $response, $this->getConfig()->getSetting('SENDFILE_MODE'));
+ }
+}
diff --git a/lib/Controller/Notification.php b/lib/Controller/Notification.php
new file mode 100644
index 0000000..e602747
--- /dev/null
+++ b/lib/Controller/Notification.php
@@ -0,0 +1,850 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\UserGroup;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\UserNotificationFactory;
+use Xibo\Helper\AttachmentUploadHandler;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SendFile;
+use Xibo\Service\DisplayNotifyService;
+use Xibo\Service\MediaService;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+
+/**
+ * Class Notification
+ * @package Xibo\Controller
+ */
+class Notification extends Base
+{
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @var UserNotificationFactory */
+ private $userNotificationFactory;
+
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var DisplayNotifyService */
+ private $displayNotifyService;
+
+ /**
+ * Notification constructor.
+ * @param NotificationFactory $notificationFactory
+ * @param UserNotificationFactory $userNotificationFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param DisplayNotifyService $displayNotifyService
+ */
+ public function __construct(
+ $notificationFactory,
+ $userNotificationFactory,
+ $displayGroupFactory,
+ $userGroupFactory,
+ $displayNotifyService
+ ) {
+ $this->notificationFactory = $notificationFactory;
+ $this->userNotificationFactory = $userNotificationFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ // Call to render the template
+ $this->getState()->template = 'notification-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Show a notification
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function interrupt(Request $request, Response $response, $id)
+ {
+ $notification = $this->userNotificationFactory->getByNotificationId($id);
+
+ // Mark it as read
+ $notification->setRead(Carbon::now()->format('U'));
+ $notification->save();
+
+ $this->getState()->template = 'notification-interrupt';
+ $this->getState()->setData(['notification' => $notification]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Show a notification
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function show(Request $request, Response $response, $id)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $notification = $this->userNotificationFactory->getByNotificationId($id);
+
+ // Mark it as read
+ $notification->setRead(Carbon::now()->format('U'));
+ $notification->save();
+
+ if ($params->getCheckbox('multiSelect')) {
+ return $response->withStatus(201);
+ } else {
+ $this->getState()->template = 'notification-form-show';
+ $this->getState()->setData(['notification' => $notification]);
+
+ return $this->render($request, $response);
+ }
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/notification",
+ * operationId="notificationSearch",
+ * tags={"notification"},
+ * summary="Notification Search",
+ * description="Search this users Notifications",
+ * @SWG\Parameter(
+ * name="notificationId",
+ * in="query",
+ * description="Filter by Notification Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="subject",
+ * in="query",
+ * description="Filter by Subject",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as userGroups,displayGroups",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Notification")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function grid(Request $request, Response $response): Response|\Psr\Http\Message\ResponseInterface
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'notificationId' => $sanitizedQueryParams->getInt('notificationId'),
+ 'subject' => $sanitizedQueryParams->getString('subject'),
+ 'read' => $sanitizedQueryParams->getInt('read'),
+ 'releaseDt' => $sanitizedQueryParams->getDate('releaseDt')?->format('U'),
+ 'type' => $sanitizedQueryParams->getString('type'),
+ ];
+ $embed = ($sanitizedQueryParams->getString('embed') != null)
+ ? explode(',', $sanitizedQueryParams->getString('embed'))
+ : [];
+
+ $notifications = $this->notificationFactory->query(
+ $this->gridRenderSort($sanitizedQueryParams),
+ $this->gridRenderFilter($filter, $sanitizedQueryParams)
+ );
+
+ foreach ($notifications as $notification) {
+ if (in_array('userGroups', $embed) || in_array('displayGroups', $embed)) {
+ $notification->load([
+ 'loadUserGroups' => in_array('userGroups', $embed),
+ 'loadDisplayGroups' => in_array('displayGroups', $embed),
+ ]);
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $notification->includeProperty('buttons');
+
+ // View Notification
+ $notification->buttons[] = [
+ 'id' => 'notification_button_view',
+ 'url' => $this->urlFor(
+ $request,
+ 'notification.show',
+ ['id' => $notification->notificationId]
+ ),
+ 'text' => __('View'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'notification.show',
+ ['id' => $notification->notificationId, 'multiSelect' => true]
+ ),
+ ],
+ ['name' => 'commit-method', 'value' => 'get'],
+ ['name' => 'id', 'value' => 'notification_button_view'],
+ ['name' => 'text', 'value' => __('Mark as read?')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $notification->subject]
+ ]
+ ];
+
+
+ // Edit Notification
+ if ($this->getUser()->checkEditable($notification) &&
+ $this->getUser()->featureEnabled('notification.modify')
+ ) {
+ $notification->buttons[] = [
+ 'id' => 'notification_button_edit',
+ 'url' => $this->urlFor(
+ $request,
+ 'notification.edit.form',
+ ['id' => $notification->notificationId]
+ ),
+ 'text' => __('Edit')
+ ];
+ }
+
+ // Delete Notifications
+ if ($this->getUser()->checkDeleteable($notification) &&
+ $this->getUser()->featureEnabled('notification.modify')
+ ) {
+ $notification->buttons[] = ['divider' => true];
+
+ $notification->buttons[] = [
+ 'id' => 'notification_button_delete',
+ 'url' => $this->urlFor(
+ $request,
+ 'notification.delete.form',
+ ['id' => $notification->notificationId]
+ ),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'notification.delete',
+ ['id' => $notification->notificationId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'notification_button_delete'],
+ ['name' => 'text', 'value' => __('Delete?')],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'rowtitle', 'value' => $notification->subject]
+ ]
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->notificationFactory->countLast();
+ $this->getState()->setData($notifications);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Notification Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $groups = [];
+ $displays = [];
+ $userGroups = [];
+ $users = [];
+
+ foreach ($this->displayGroupFactory->query(['displayGroup'], ['isDisplaySpecific' => -1]) as $displayGroup) {
+ /* @var \Xibo\Entity\DisplayGroup $displayGroup */
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ $displays[] = $displayGroup;
+ } else {
+ $groups[] = $displayGroup;
+ }
+ }
+
+ foreach ($this->userGroupFactory->query(['`group`'], ['isUserSpecific' => -1]) as $userGroup) {
+ /* @var UserGroup $userGroup */
+
+ if ($userGroup->isUserSpecific == 0) {
+ $userGroups[] = $userGroup;
+ } else {
+ $users[] = $userGroup;
+ }
+ }
+
+ $this->getState()->template = 'notification-form-add';
+ $this->getState()->setData([
+ 'displays' => $displays,
+ 'displayGroups' => $groups,
+ 'users' => $users,
+ 'userGroups' => $userGroups,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Notification Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $notification = $this->notificationFactory->getById($id);
+ $notification->load();
+
+ // Adjust the dates
+ $notification->createDt = Carbon::createFromTimestamp($notification->createDt)
+ ->format(DateFormatHelper::getSystemFormat());
+ $notification->releaseDt = Carbon::createFromTimestamp($notification->releaseDt)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ if (!$this->getUser()->checkEditable($notification)) {
+ throw new AccessDeniedException();
+ }
+
+ $groups = [];
+ $displays = [];
+ $userGroups = [];
+ $users = [];
+
+ foreach ($this->displayGroupFactory->query(['displayGroup'], ['isDisplaySpecific' => -1]) as $displayGroup) {
+ /* @var \Xibo\Entity\DisplayGroup $displayGroup */
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ $displays[] = $displayGroup;
+ } else {
+ $groups[] = $displayGroup;
+ }
+ }
+
+ foreach ($this->userGroupFactory->query(['`group`'], ['isUserSpecific' => -1]) as $userGroup) {
+ /* @var UserGroup $userGroup */
+
+ if ($userGroup->isUserSpecific == 0) {
+ $userGroups[] = $userGroup;
+ } else {
+ $users[] = $userGroup;
+ }
+ }
+
+ $this->getState()->template = 'notification-form-edit';
+ $this->getState()->setData([
+ 'notification' => $notification,
+ 'displays' => $displays,
+ 'displayGroups' => $groups,
+ 'users' => $users,
+ 'userGroups' => $userGroups,
+ 'displayGroupIds' => array_map(function ($element) {
+ return $element->displayGroupId;
+ }, $notification->displayGroups),
+ 'userGroupIds' => array_map(function ($element) {
+ return $element->groupId;
+ }, $notification->userGroups)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Notification Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $notification = $this->notificationFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($notification)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'notification-form-delete';
+ $this->getState()->setData([
+ 'notification' => $notification
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add attachment
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addAttachment(Request $request, Response $response)
+ {
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($this->getConfig()->getSetting('LIBRARY_LOCATION'));
+
+ $options = [
+ 'userId' => $this->getUser()->userId,
+ 'controller' => $this,
+ 'accept_file_types' => '/\.jpg|.jpeg|.png|.bmp|.gif|.zip|.pdf/i'
+ ];
+
+ // Output handled by UploadHandler
+ $this->setNoOutput(true);
+
+ $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options));
+
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ new AttachmentUploadHandler($libraryFolder . 'temp/', $this->getLog()->getLoggerInterface(), $options);
+
+ // Explicitly set the Content-Type header to application/json
+ $response = $response->withHeader('Content-Type', 'application/json');
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Notification
+ *
+ * @SWG\Post(
+ * path="/notification",
+ * operationId="notificationAdd",
+ * tags={"notification"},
+ * summary="Notification Add",
+ * description="Add a Notification",
+ * @SWG\Parameter(
+ * name="subject",
+ * in="formData",
+ * description="The Subject",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="body",
+ * in="formData",
+ * description="The Body",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="releaseDt",
+ * in="formData",
+ * description="ISO date representing the release date for this notification",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isInterrupt",
+ * in="formData",
+ * description="Flag indication whether this notification should interrupt the web portal nativation/login",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupIds",
+ * in="formData",
+ * description="The display group ids to assign this notification to",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="userGroupIds",
+ * in="formData",
+ * description="The user group ids to assign to this notification",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Notification"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $notification = $this->notificationFactory->createEmpty();
+ $notification->subject = $sanitizedParams->getString('subject');
+ $notification->body = $request->getParam('body', '');
+ $notification->createDt = Carbon::now()->format('U');
+ $notification->releaseDt = $sanitizedParams->getDate('releaseDt');
+
+ if ($notification->releaseDt !== null) {
+ $notification->releaseDt = $notification->releaseDt->format('U');
+ } else {
+ $notification->releaseDt = $notification->createDt;
+ }
+
+ $notification->isInterrupt = $sanitizedParams->getCheckbox('isInterrupt');
+ $notification->userId = $this->getUser()->userId;
+ $notification->nonusers = $sanitizedParams->getString('nonusers');
+ $notification->type = 'custom';
+
+ // Displays and Users to link
+ foreach ($sanitizedParams->getIntArray('displayGroupIds', ['default' => [] ]) as $displayGroupId) {
+ $notification->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
+
+ // Notify (don't collect)
+ $this->displayNotifyService->collectLater()->notifyByDisplayGroupId($displayGroupId);
+ }
+
+ foreach ($sanitizedParams->getIntArray('userGroupIds', ['default' => [] ]) as $userGroupId) {
+ $notification->assignUserGroup($this->userGroupFactory->getById($userGroupId));
+ }
+
+ $notification->save();
+
+ $attachedFilename = $sanitizedParams->getString('attachedFilename', ['defaultOnEmptyString' => true]);
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ if (!empty($attachedFilename)) {
+ $saveName = $notification->notificationId .'_' .$attachedFilename;
+ $notification->filename = $saveName;
+ $notification->originalFileName = $attachedFilename;
+ // Move the file into the library
+ // Try to move the file first
+ $from = $libraryFolder . 'temp/' . $attachedFilename;
+ $to = $libraryFolder . 'attachment/' . $saveName;
+
+ $moved = rename($from, $to);
+
+ if (!$moved) {
+ $this->getLog()->info(
+ 'Cannot move file: ' . $from . ' to ' . $to . ', will try and copy/delete instead.'
+ );
+
+ // Copy
+ $moved = copy($from, $to);
+
+ // Delete
+ if (!@unlink($from)) {
+ $this->getLog()->error('Cannot delete file: ' . $from . ' after copying to ' . $to);
+ }
+ }
+
+ if (!$moved) {
+ throw new ConfigurationException(__('Problem moving uploaded file into the Attachment Folder'));
+ }
+
+ $notification->save();
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $notification->subject),
+ 'id' => $notification->notificationId,
+ 'data' => $notification
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Notification
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Put(
+ * path="/notification/{notificationId}",
+ * operationId="notificationEdit",
+ * tags={"notification"},
+ * summary="Notification Edit",
+ * description="Edit a Notification",
+ * @SWG\Parameter(
+ * name="notificationId",
+ * in="path",
+ * description="The NotificationId",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="subject",
+ * in="formData",
+ * description="The Subject",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="body",
+ * in="formData",
+ * description="The Body",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="releaseDt",
+ * in="formData",
+ * description="ISO date representing the release date for this notification",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isInterrupt",
+ * in="formData",
+ * description="Flag indication whether this notification should interrupt the web portal nativation/login",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupIds",
+ * in="formData",
+ * description="The display group ids to assign this notification to",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="userGroupIds",
+ * in="formData",
+ * description="The user group ids to assign to this notification",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Notification")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $notification = $this->notificationFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $notification->load();
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($notification)) {
+ throw new AccessDeniedException();
+ }
+
+ $notification->subject = $sanitizedParams->getString('subject');
+ $notification->body = $request->getParam('body', '');
+ $notification->createDt = Carbon::now()->format('U');
+ $notification->releaseDt = $sanitizedParams->getDate('releaseDt')->format('U');
+ $notification->isInterrupt = $sanitizedParams->getCheckbox('isInterrupt');
+ $notification->userId = $this->getUser()->userId;
+ $notification->nonusers = $sanitizedParams->getString('nonusers');
+
+ // Clear existing assignments
+ $notification->displayGroups = [];
+ $notification->userGroups = [];
+
+ // Displays and Users to link
+ foreach ($sanitizedParams->getIntArray('displayGroupIds', ['default' => []]) as $displayGroupId) {
+ $notification->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
+
+ // Notify (don't collect)
+ $this->displayNotifyService->collectLater()->notifyByDisplayGroupId($displayGroupId);
+ }
+
+ foreach ($sanitizedParams->getIntArray('userGroupIds', ['default' => []]) as $userGroupId) {
+ $notification->assignUserGroup($this->userGroupFactory->getById($userGroupId));
+ }
+
+ $notification->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Edited %s'), $notification->subject),
+ 'id' => $notification->notificationId,
+ 'data' => $notification
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Notification
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Delete(
+ * path="/notification/{notificationId}",
+ * operationId="notificationDelete",
+ * tags={"notification"},
+ * summary="Delete Notification",
+ * description="Delete the provided notification",
+ * @SWG\Parameter(
+ * name="notificationId",
+ * in="path",
+ * description="The Notification Id to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $notification = $this->notificationFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($notification)) {
+ throw new AccessDeniedException();
+ }
+
+ $notification->delete();
+
+ /*Delete the attachment*/
+ if (!empty($notification->filename)) {
+ // Library location
+ $attachmentLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION'). 'attachment/';
+ if (file_exists($attachmentLocation . $notification->filename)) {
+ unlink($attachmentLocation . $notification->filename);
+ }
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $notification->subject)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function exportAttachment(Request $request, Response $response, $id)
+ {
+ $notification = $this->notificationFactory->getById($id);
+
+ $fileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'attachment/' . $notification->filename;
+
+ // Return the file with PHP
+ $this->setNoOutput(true);
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $fileName
+ ));
+ }
+}
diff --git a/lib/Controller/PlayerFault.php b/lib/Controller/PlayerFault.php
new file mode 100644
index 0000000..453f78f
--- /dev/null
+++ b/lib/Controller/PlayerFault.php
@@ -0,0 +1,73 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\PlayerFaultFactory;
+
+class PlayerFault extends Base
+{
+ /** @var PlayerFaultFactory */
+ private $playerFaultFactory;
+
+ /**
+ * PlayerFault constructor.
+ * @param PlayerFaultFactory $playerFaultFactory
+ */
+ public function __construct(PlayerFaultFactory $playerFaultFactory)
+ {
+ $this->playerFaultFactory = $playerFaultFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param int $displayId
+ * @return Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response, int $displayId) : Response
+ {
+ $parsedParams = $this->getSanitizer($request->getQueryParams());
+
+ if ($displayId != null) {
+ $playerFaults = $this->playerFaultFactory->getByDisplayId($displayId, $this->gridRenderSort($parsedParams));
+ } else {
+ $filter = [
+ 'code' => $parsedParams->getInt('code'),
+ 'incidentDt' => $parsedParams->getDate('incidentDt'),
+ 'displayId' => $parsedParams->getInt('displayId')
+ ];
+
+ $playerFaults = $this->playerFaultFactory->query($this->gridRenderSort($parsedParams), $this->gridRenderFilter($filter, $parsedParams));
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->playerFaultFactory->countLast();
+ $this->getState()->setData($playerFaults);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/PlayerSoftware.php b/lib/Controller/PlayerSoftware.php
new file mode 100644
index 0000000..f9db347
--- /dev/null
+++ b/lib/Controller/PlayerSoftware.php
@@ -0,0 +1,752 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayProfileFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Service\DownloadService;
+use Xibo\Service\MediaService;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Service\UploadService;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+* Class PlayerSoftware
+* @package Xibo\Controller
+*/
+class PlayerSoftware extends Base
+{
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+
+ /** @var DisplayProfileFactory */
+ private $displayProfileFactory;
+
+ /** @var PlayerVersionFactory */
+ private $playerVersionFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+ /**
+ * @var MediaServiceInterface
+ */
+ private $mediaService;
+
+ /**
+ * Notification constructor.
+ * @param MediaFactory $mediaFactory
+ * @param PlayerVersionFactory $playerVersionFactory
+ * @param DisplayProfileFactory $displayProfileFactory
+ * @param ModuleFactory $moduleFactory
+ * @param DisplayFactory $displayFactory
+ */
+ public function __construct($pool, $playerVersionFactory, $displayProfileFactory, $displayFactory)
+ {
+ $this->pool = $pool;
+ $this->playerVersionFactory = $playerVersionFactory;
+ $this->displayProfileFactory = $displayProfileFactory;
+ $this->displayFactory = $displayFactory;
+ }
+
+ public function getPlayerVersionFactory() : PlayerVersionFactory
+ {
+ return $this->playerVersionFactory;
+ }
+
+ public function useMediaService(MediaServiceInterface $mediaService)
+ {
+ $this->mediaService = $mediaService;
+ }
+
+ public function getMediaService(): MediaServiceInterface
+ {
+ return $this->mediaService->setUser($this->getUser());
+ }
+
+ /**
+ * Displays the page logic
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'playersoftware-page';
+ $this->getState()->setData([
+ 'types' => array_map(function ($element) {
+ return $element->jsonSerialize();
+ }, $this->playerVersionFactory->getDistinctType()),
+ 'versions' => $this->playerVersionFactory->getDistinctVersion(),
+ 'validExt' => implode('|', $this->getValidExtensions()),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getParams());
+
+ $filter = [
+ 'playerType' => $sanitizedQueryParams->getString('playerType'),
+ 'playerVersion' => $sanitizedQueryParams->getString('playerVersion'),
+ 'playerCode' => $sanitizedQueryParams->getInt('playerCode'),
+ 'versionId' => $sanitizedQueryParams->getInt('versionId'),
+ 'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
+ 'playerShowVersion' => $sanitizedQueryParams->getString('playerShowVersion')
+ ];
+
+ $versions = $this->playerVersionFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter($filter, $sanitizedQueryParams));
+
+ // add row buttons
+ foreach ($versions as $version) {
+ $version->setUnmatchedProperty('fileSizeFormatted', ByteFormatter::format($version->size));
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $version->includeProperty('buttons');
+ $version->buttons = [];
+
+ // Buttons
+
+ // Edit
+ $version->buttons[] = [
+ 'id' => 'content_button_edit',
+ 'url' => $this->urlFor($request, 'playersoftware.edit.form', ['id' => $version->versionId]),
+ 'text' => __('Edit')
+ ];
+
+ // Delete Button
+ $version->buttons[] = [
+ 'id' => 'content_button_delete',
+ 'url' => $this->urlFor($request, 'playersoftware.delete.form', ['id' => $version->versionId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'playersoftware.delete', ['id' => $version->versionId])
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'content_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $version->fileName]
+ ]
+ ];
+
+
+ // Download
+ $version->buttons[] = array(
+ 'id' => 'content_button_download',
+ 'linkType' => '_self',
+ 'external' => true,
+ 'url' => $this->urlFor($request, 'playersoftware.download', ['id' => $version->versionId]) . '?attachment=' . $version->fileName,
+ 'text' => __('Download')
+ );
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->playerVersionFactory->countLast();
+ $this->getState()->setData($versions);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Version Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $version = $this->playerVersionFactory->getById($id);
+
+ $version->load();
+
+ $this->getState()->template = 'playersoftware-form-delete';
+ $this->getState()->setData([
+ 'version' => $version,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Version
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/playersoftware/{versionId}",
+ * operationId="playerSoftwareDelete",
+ * tags={"Player Software"},
+ * summary="Delete Version",
+ * description="Delete Version file from the Library and Player Versions table",
+ * @SWG\Parameter(
+ * name="versionId",
+ * in="path",
+ * description="The Version ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $version = $this->playerVersionFactory->getById($id);
+
+ $version->load();
+
+ // Unset player version from Display Profile
+ $displayProfiles = $this->displayProfileFactory->query();
+
+ foreach ($displayProfiles as $displayProfile) {
+ if (in_array($displayProfile->type, ['android', 'lg', 'sssp'])) {
+ $currentVersionId = $displayProfile->getSetting('versionMediaId');
+
+ if ($currentVersionId === $version->versionId) {
+ $displayProfile->setSetting('versionMediaId', null);
+ $displayProfile->save();
+ }
+ } else if ($displayProfile->type === 'chromeOS') {
+ $currentVersionId = $displayProfile->getSetting('playerVersionId');
+
+ if ($currentVersionId === $version->versionId) {
+ $displayProfile->setSetting('playerVersionId', null);
+ $displayProfile->save();
+ }
+ }
+ }
+
+ // Delete
+ $version->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $version->playerShowVersion)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $version = $this->playerVersionFactory->getById($id);
+
+ $this->getState()->template = 'playersoftware-form-edit';
+ $this->getState()->setData([
+ 'version' => $version,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Player Version
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @SWG\Put(
+ * path="/playersoftware/{versionId}",
+ * operationId="playersoftwareEdit",
+ * tags={"Player Software"},
+ * summary="Edit Player Version",
+ * description="Edit a Player Version file information",
+ * @SWG\Parameter(
+ * name="versionId",
+ * in="path",
+ * description="The Version ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="playerShowVersion",
+ * in="formData",
+ * description="The Name of the player version application, this will be displayed in Version dropdowns in Display Profile and Display",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="version",
+ * in="formData",
+ * description="The Version number",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="code",
+ * in="formData",
+ * description="The Code number",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Media")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $version = $this->playerVersionFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $version->version = $sanitizedParams->getString('version');
+ $version->code = $sanitizedParams->getInt('code');
+ $version->playerShowVersion = $sanitizedParams->getString('playerShowVersion');
+ $version->modifiedBy = $this->getUser()->userName;
+
+ $version->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $version->playerShowVersion),
+ 'id' => $version->versionId,
+ 'data' => $version
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Install Route for SSSP XML
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getSsspInstall(Request $request, Response $response)
+ {
+ // Get the default SSSP display profile
+ $profile = $this->displayProfileFactory->getDefaultByType('sssp');
+
+ // See if it has a version file (if not or we can't load it, 404)
+ $versionId = $profile->getSetting('versionMediaId');
+
+ if ($versionId !== null) {
+ $version = $this->playerVersionFactory->getById($versionId);
+
+ $xml = $this->outputSsspXml($version->version . '.' . $version->code, $version->size);
+ $response = $response
+ ->withHeader('Content-Type', 'application/xml')
+ ->write($xml);
+ } else {
+ return $response->withStatus(404);
+ }
+
+ $this->setNoOutput(true);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Install Route for SSSP WGT
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getSsspInstallDownload(Request $request, Response $response)
+ {
+ // Get the default SSSP display profile
+ $profile = $this->displayProfileFactory->getDefaultByType('sssp');
+
+ // See if it has a version file (if not, or we can't load it, 404)
+ $versionId = $profile->getSetting('versionMediaId');
+
+ if ($versionId !== null) {
+ $response = $this->download($request, $response, $versionId);
+ } else {
+ return $response->withStatus(404);
+ }
+
+ $this->setNoOutput();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Upgrade Route for SSSP XML
+ * @param Request $request
+ * @param Response $response
+ * @param $nonce
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getSssp(Request $request, Response $response, $nonce)
+ {
+ // Use the cache to get the displayId for this nonce
+ $cache = $this->pool->getItem('/playerVersion/' . $nonce);
+
+ if ($cache->isMiss()) {
+ $response = $response->withStatus(404);
+ $this->setNoOutput(true);
+ return $this->render($request, $response);
+ }
+
+ $displayId = $cache->get();
+
+ // Get the Display
+ $display = $this->displayFactory->getById($displayId);
+
+ // Check if display is SSSP, throw Exception if it's not
+ if ($display->clientType != 'sssp') {
+ throw new InvalidArgumentException(__('File available only for SSSP displays'), 'clientType');
+ }
+
+ // Add the correct header
+ $response = $response->withHeader('content-type', 'application/xml');
+
+ // get the media ID from display profile
+ $versionId = $display->getSetting('versionMediaId', null, ['displayOverride' => true]);
+
+ if ($versionId !== null) {
+ $versionInformation = $this->playerVersionFactory->getById($versionId);
+
+ $xml = $this->outputSsspXml($versionInformation->version . '.' . $versionInformation->code, $versionInformation->size);
+ $response = $response->write($xml);
+ } else {
+ return $response->withStatus(404);
+ }
+
+ $this->setNoOutput(true);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Upgrade Route for SSSP WGT
+ * @param Request $request
+ * @param Response $response
+ * @param $nonce
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getVersionFile(Request $request, Response $response, $nonce)
+ {
+ // Use the cache to get the displayId for this nonce
+ $cache = $this->pool->getItem('/playerVersion/' . $nonce);
+
+ if ($cache->isMiss()) {
+ $response = $response->withStatus(404);
+ $this->setNoOutput(true);
+ return $this->render($request, $response);
+ }
+
+ $displayId = $cache->get();
+
+ // Get display and media
+ $display = $this->displayFactory->getById($displayId);
+ $versionId = $display->getSetting('versionMediaId', null, ['displayOverride' => true]);
+
+ if ($versionId !== null) {
+ $response = $this->download($request, $response, $versionId);
+ } else {
+ return $response->withStatus(404);
+ }
+
+ $this->setNoOutput(true);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Player Software Upload
+ *
+ * @SWG\Post(
+ * path="/playersoftware",
+ * operationId="playersoftwareUpload",
+ * tags={"Player Software"},
+ * summary="Player Software Upload",
+ * description="Upload a new Player version file",
+ * @SWG\Parameter(
+ * name="files",
+ * in="formData",
+ * description="The Uploaded File",
+ * type="file",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function add(Request $request, Response $response)
+ {
+ if (!$this->getUser()->featureEnabled('playersoftware.add')) {
+ throw new AccessDeniedException();
+ }
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($libraryFolder);
+ $validExt = $this->getValidExtensions();
+
+ // Make sure there is room in the library
+ $libraryLimit = $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+
+ $options = [
+ 'accept_file_types' => '/\.' . implode('|', $validExt) . '$/i',
+ 'libraryLimit' => $libraryLimit,
+ 'libraryQuotaFull' => ($libraryLimit > 0 && $this->getMediaService()->libraryUsage() > $libraryLimit),
+ ];
+
+ // Output handled by UploadHandler
+ $this->setNoOutput(true);
+
+ $this->getLog()->debug('Hand off to Upload Handler with options: ' . json_encode($options));
+
+ // Hand off to the Upload Handler provided by jquery-file-upload
+ $uploadService = new UploadService($libraryFolder . 'temp/', $options, $this->getLog(), $this->getState());
+ $uploadHandler = $uploadService->createUploadHandler();
+
+ $uploadHandler->setPostProcessor(function ($file, $uploadHandler) use ($libraryFolder, $request) {
+ // Return right away if the file already has an error.
+ if (!empty($file->error)) {
+ $this->getState()->setCommitState(false);
+ return $file;
+ }
+
+ $this->getUser()->isQuotaFullByUser(true);
+
+ // Get the uploaded file and move it to the right place
+ $filePath = $libraryFolder . 'temp/' . $file->fileName;
+
+ // Add the Player Software record
+ $playerSoftware = $this->getPlayerVersionFactory()->createEmpty();
+ $playerSoftware->modifiedBy = $this->getUser()->userName;
+
+ // SoC players have issues parsing fileNames with spaces in them
+ // replace any unexpected character in fileName with -
+ $playerSoftware->fileName = preg_replace('/[^a-zA-Z0-9_.]+/', '-', $file->fileName);
+ $playerSoftware->size = filesize($filePath);
+ $playerSoftware->md5 = md5_file($filePath);
+ $playerSoftware->decorateRecord();
+
+ // if the name was provided on upload use that here.
+ if (!empty($file->name)) {
+ $playerSoftware->playerShowVersion = $file->name;
+ }
+
+ $playerSoftware->save();
+
+ // Test to ensure the final file size is the same as the file size we're expecting
+ if ($file->size != $playerSoftware->size) {
+ throw new InvalidArgumentException(
+ __('Sorry this is a corrupted upload, the file size doesn\'t match what we\'re expecting.'),
+ 'size'
+ );
+ }
+
+ // everything is fine, move the file from temp folder.
+ rename($filePath, $libraryFolder . 'playersoftware/' . $playerSoftware->fileName);
+
+ // Unpack if necessary
+ $playerSoftware->unpack($libraryFolder, $request);
+
+ // return
+ $file->id = $playerSoftware->versionId;
+ $file->md5 = $playerSoftware->md5;
+ $file->name = $playerSoftware->fileName;
+
+ return $file;
+ });
+
+ $uploadHandler->post();
+
+ // Explicitly set the Content-Type header to application/json
+ $response = $response->withHeader('Content-Type', 'application/json');
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/playersoftware/download/{id}",
+ * operationId="playersoftwareDownload",
+ * tags={"Player Software"},
+ * summary="Download Player Version file",
+ * description="Download Player Version file",
+ * produces={"application/octet-stream"},
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Player Version ID to Download",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(type="file"),
+ * @SWG\Header(
+ * header="X-Sendfile",
+ * description="Apache Send file header - if enabled.",
+ * type="string"
+ * ),
+ * @SWG\Header(
+ * header="X-Accel-Redirect",
+ * description="nginx send file header - if enabled.",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function download(Request $request, Response $response, $id)
+ {
+ $playerVersion = $this->playerVersionFactory->getById($id);
+
+ $this->getLog()->debug('Download request for player software versionId: ' . $id);
+
+ $library = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ $sendFileMode = $this->getConfig()->getSetting('SENDFILE_MODE');
+ $libraryPath = $library . 'playersoftware' . DIRECTORY_SEPARATOR . $playerVersion->fileName;
+ $attachmentName = urlencode($playerVersion->fileName);
+
+ $downLoadService = new DownloadService($libraryPath, $sendFileMode);
+ $downLoadService->useLogger($this->getLog()->getLoggerInterface());
+
+ return $downLoadService->returnFile(
+ $response,
+ $attachmentName,
+ '/download/playersoftware/' . $playerVersion->fileName
+ );
+ }
+
+ /**
+ * Output the SSSP XML
+ * @param $version
+ * @param $size
+ * @return string
+ */
+ private function outputSsspXml($version, $size)
+ {
+ // create sssp_config XML file with provided information
+ $ssspDocument = new \DOMDocument('1.0', 'UTF-8');
+ $versionNode = $ssspDocument->createElement('widget');
+ $version = $ssspDocument->createElement('ver', $version);
+ $size = $ssspDocument->createElement('size', $size);
+
+ // Our widget name is always sssp_dl (this is appended to both the install and upgrade routes)
+ $name = $ssspDocument->createElement('widgetname', 'sssp_dl');
+
+ $ssspDocument->appendChild($versionNode);
+ $versionNode->appendChild($version);
+ $versionNode->appendChild($size);
+ $versionNode->appendChild($name);
+ $versionNode->appendChild($ssspDocument->createElement('webtype', 'tizen'));
+ $ssspDocument->formatOutput = true;
+
+ return $ssspDocument->saveXML();
+ }
+
+ /**
+ * @return string[]
+ */
+ private function getValidExtensions()
+ {
+ return ['apk', 'ipk', 'wgt', 'chrome'];
+ }
+}
diff --git a/lib/Controller/Playlist.php b/lib/Controller/Playlist.php
new file mode 100644
index 0000000..1a144df
--- /dev/null
+++ b/lib/Controller/Playlist.php
@@ -0,0 +1,2113 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\SubPlaylistItem;
+
+/**
+ * Class Playlist
+ * @package Xibo\Controller
+ */
+class Playlist extends Base
+{
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var TagFactory */
+ private $tagFactory;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /** @var RegionFactory */
+ private $regionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param PlaylistFactory $playlistFactory
+ * @param MediaFactory $mediaFactory
+ * @param WidgetFactory $widgetFactory
+ * @param ModuleFactory $moduleFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param UserFactory $userFactory
+ * @param TagFactory $tagFactory
+ * @param LayoutFactory $layoutFactory
+ * @param DisplayFactory $displayFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param FolderFactory $folderFactory
+ * @param RegionFactory $regionFactory
+ */
+ public function __construct(
+ $playlistFactory,
+ $mediaFactory,
+ $widgetFactory,
+ $moduleFactory,
+ $userGroupFactory,
+ $userFactory,
+ $tagFactory,
+ $layoutFactory,
+ $displayFactory,
+ $scheduleFactory,
+ $folderFactory,
+ $regionFactory
+ ) {
+ $this->playlistFactory = $playlistFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->userFactory = $userFactory;
+ $this->tagFactory = $tagFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->displayFactory = $displayFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->folderFactory = $folderFactory;
+ $this->regionFactory = $regionFactory;
+ }
+
+ /**
+ * Display Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $moduleFactory = $this->moduleFactory;
+
+ // Call to render the template
+ $this->getState()->template = 'playlist-page';
+ $this->getState()->setData([
+ 'modules' => $moduleFactory->getAssignableModules()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Playlist Search
+ *
+ * @SWG\Get(
+ * path="/playlist",
+ * operationId="playlistSearch",
+ * tags={"playlist"},
+ * summary="Search Playlists",
+ * description="Search for Playlists viewable by this user",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="query",
+ * description="Filter by Playlist Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by partial Playlist name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userId",
+ * in="query",
+ * description="Filter by user Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="query",
+ * description="Filter by tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="query",
+ * description="A flag indicating whether to treat the tags filter as an exact match",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="query",
+ * description="When filtering by multiple Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ownerUserGroupId",
+ * in="query",
+ * description="Filter by users in this UserGroupId",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Embed related data such as regions, widgets, permissions, tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Playlist")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $this->getState()->template = 'grid';
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Embed?
+ $embed = ($sanitizedParams->getString('embed') != null)
+ ? explode(',', $sanitizedParams->getString('embed'))
+ : [];
+
+ // Playlists
+ $playlists = $this->playlistFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter([
+ 'name' => $sanitizedParams->getString('name'),
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'),
+ 'userId' => $sanitizedParams->getInt('userId'),
+ 'tags' => $sanitizedParams->getString('tags'),
+ 'exactTags' => $sanitizedParams->getCheckbox('exactTags'),
+ 'playlistId' => $sanitizedParams->getInt('playlistId'),
+ 'notPlaylistId' => $sanitizedParams->getInt('notPlaylistId'),
+ 'ownerUserGroupId' => $sanitizedParams->getInt('ownerUserGroupId'),
+ 'mediaLike' => $sanitizedParams->getString('mediaLike'),
+ 'regionSpecific' => $sanitizedParams->getInt('regionSpecific', ['default' => 0]),
+ 'folderId' => $sanitizedParams->getInt('folderId'),
+ 'layoutId' => $sanitizedParams->getInt('layoutId'),
+ 'logicalOperator' => $sanitizedParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $sanitizedParams->getString('logicalOperatorName'),
+ ], $sanitizedParams));
+
+ foreach ($playlists as $playlist) {
+ // Handle embeds
+ if (in_array('widgets', $embed)) {
+ $loadPermissions = in_array('permissions', $embed);
+ $loadTags = in_array('tags', $embed);
+ $loadActions = in_array('actions', $embed);
+
+ $playlist->load([
+ 'loadPermissions' => $loadPermissions,
+ 'loadWidgets' => true,
+ 'loadTags' => $loadTags,
+ 'loadActions' => $loadActions
+ ]);
+
+ foreach ($playlist->widgets as $widget) {
+ $widget->setUnmatchedProperty('tags', []);
+
+ try {
+ $module = $this->moduleFactory->getByType($widget->type);
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error('Module not found for widget: ' . $widget->type);
+ continue;
+ }
+
+ // Embed the name of this widget
+ $widget->setUnmatchedProperty('moduleName', $module->name);
+ $widgetName = $widget->getOptionValue('name', null);
+
+ if ($module->regionSpecific == 0) {
+ // Use the media assigned to this widget
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ $media->load();
+ $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', null) ?: $media->name);
+
+ // Augment with tags
+ $widget->setUnmatchedProperty('tags', $media->tags);
+ } else {
+ $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', null) ?: $module->name);
+ $widget->setUnmatchedProperty('tags', []);
+ }
+
+ // Sub-playlists should calculate a fresh duration
+ if ($widget->type === 'subplaylist') {
+ $widget->calculateDuration($module);
+ }
+
+ // Get transitions
+ $widget->transitionIn = $widget->getOptionValue('transIn', null);
+ $widget->transitionOut = $widget->getOptionValue('transOut', null);
+ $widget->transitionDurationIn = $widget->getOptionValue('transInDuration', null);
+ $widget->transitionDurationOut = $widget->getOptionValue('transOutDuration', null);
+
+ // Permissions?
+ if ($loadPermissions) {
+ // Augment with editable flag
+ $widget->setUnmatchedProperty('isEditable', $this->getUser()->checkEditable($widget));
+
+ // Augment with deletable flag
+ $widget->setUnmatchedProperty('isDeletable', $this->getUser()->checkDeleteable($widget));
+
+ // Augment with viewable flag
+ $widget->setUnmatchedProperty('isViewable', $this->getUser()->checkViewable($widget));
+
+ // Augment with permissions flag
+ $widget->setUnmatchedProperty(
+ 'isPermissionsModifiable',
+ $this->getUser()->checkPermissionsModifyable($widget)
+ );
+ }
+ }
+ }
+
+
+ if ($sanitizedParams->getCheckbox('fullScreenScheduleCheck')) {
+ $fullScreenCampaignId = $this->hasFullScreenLayout($playlist);
+ $playlist->setUnmatchedProperty('hasFullScreenLayout', (!empty($fullScreenCampaignId)));
+ $playlist->setUnmatchedProperty('fullScreenCampaignId', $fullScreenCampaignId);
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $playlist->includeProperty('buttons');
+
+ switch ($playlist->enableStat) {
+ case 'On':
+ $playlist->setUnmatchedProperty(
+ 'enableStatDescription',
+ __('This Playlist has enable stat collection set to ON')
+ );
+ break;
+
+ case 'Off':
+ $playlist->setUnmatchedProperty(
+ 'enableStatDescription',
+ __('This Playlist has enable stat collection set to OFF')
+ );
+ break;
+
+ default:
+ $playlist->setUnmatchedProperty(
+ 'enableStatDescription',
+ __('This Playlist has enable stat collection set to INHERIT')
+ );
+ }
+
+ // Only proceed if we have edit permissions
+ if ($this->getUser()->featureEnabled('playlist.modify')
+ && $this->getUser()->checkEditable($playlist)
+ ) {
+ if ($playlist->isDynamic === 0) {
+ // Timeline edit
+ $playlist->buttons[] = [
+ 'id' => 'playlist_timeline_button_edit',
+ 'class' => 'XiboCustomFormButton',
+ 'url' => $this->urlFor($request, 'playlist.timeline.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Timeline')
+ ];
+
+ $playlist->buttons[] = ['divider' => true];
+ }
+
+ // Edit Button
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_edit',
+ 'url' => $this->urlFor($request, 'playlist.edit.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Edit')
+ ];
+
+ // Copy Button
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_copy',
+ 'url' => $this->urlFor($request, 'playlist.copy.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Copy')
+ ];
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ // Select Folder
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_selectfolder',
+ 'url' => $this->urlFor($request, 'playlist.selectfolder.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'playlist.selectfolder', [
+ 'id' => $playlist->playlistId
+ ])
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'playlist_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $playlist->name],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ // Set Enable Stat
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_setenablestat',
+ 'url' => $this->urlFor($request, 'playlist.setenablestat.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Enable stats collection?'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'playlist.setenablestat', [
+ 'id' => $playlist->playlistId
+ ])
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'playlist_button_setenablestat'],
+ ['name' => 'text', 'value' => __('Enable stats collection?')],
+ ['name' => 'rowtitle', 'value' => $playlist->name],
+ ['name' => 'form-callback', 'value' => 'setEnableStatMultiSelectFormOpen']
+ ]
+ ];
+
+ $playlist->buttons[] = ['divider' => true];
+ }
+
+ // Extra buttons if have delete permissions
+ if ($this->getUser()->featureEnabled('playlist.modify')
+ && $this->getUser()->checkDeleteable($playlist)
+ ) {
+ // Delete Button
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_delete',
+ 'url' => $this->urlFor($request, 'playlist.delete.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'playlist.delete', [
+ 'id' => $playlist->playlistId
+ ])
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'playlist_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $playlist->name]
+ ]
+ ];
+
+ $playlist->buttons[] = ['divider' => true];
+ }
+
+ // Extra buttons if we have modify permissions
+ if ($this->getUser()->featureEnabled('playlist.modify')
+ && $this->getUser()->checkPermissionsModifyable($playlist)
+ ) {
+ // Permissions button
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_permissions',
+ 'url' => $this->urlFor($request, 'user.permissions.form', [
+ 'entity' => 'Playlist',
+ 'id' => $playlist->playlistId
+ ]),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'user.permissions.multi', [
+ 'entity' => 'Playlist',
+ 'id' => $playlist->playlistId
+ ])
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'playlist_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $playlist->name],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor($request, 'user.permissions.multi.form', [
+ 'entity' => 'Playlist'
+ ])
+ ],
+ ['name' => 'content-id-name', 'value' => 'playlistId']
+ ]
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled(['schedule.view', 'layout.view'])) {
+ $playlist->buttons[] = ['divider' => true];
+
+ $playlist->buttons[] = array(
+ 'id' => 'usage_report_button',
+ 'url' => $this->urlFor($request, 'playlist.usage.form', ['id' => $playlist->playlistId]),
+ 'text' => __('Usage Report')
+ );
+ }
+
+ // Schedule
+ if ($this->getUser()->featureEnabled('schedule.add')
+ && ($this->getUser()->checkEditable($playlist)
+ || $this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION') == 1)
+ ) {
+ $playlist->buttons[] = [
+ 'id' => 'playlist_button_schedule',
+ 'url' => $this->urlFor(
+ $request,
+ 'schedule.add.form',
+ ['id' => $playlist->playlistId, 'from' => 'Playlist']
+ ),
+ 'text' => __('Schedule')
+ ];
+ }
+ }
+
+ $this->getState()->recordsTotal = $this->playlistFactory->countLast();
+ $this->getState()->setData($playlists);
+
+ return $this->render($request, $response);
+ }
+
+ //
+
+ /**
+ * Add Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'playlist-form-add';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add
+ *
+ * @SWG\Post(
+ * path="/playlist",
+ * operationId="playlistAdd",
+ * tags={"playlist"},
+ * summary="Add a Playlist",
+ * description="Add a new Playlist",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Name for this Playlist",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="Tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDynamic",
+ * in="formData",
+ * description="Is this Playlist Dynamic?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="filterMediaName",
+ * in="formData",
+ * description="Add Library Media matching the name filter provided",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperatorName",
+ * in="formData",
+ * description="When filtering by multiple names in name filter, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="filterMediaTag",
+ * in="formData",
+ * description="Add Library Media matching the tag filter provided",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="formData",
+ * description="When filtering by Tags, should we use exact match?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="formData",
+ * description="When filtering by Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="maxNumberOfItems",
+ * in="formData",
+ * description="Maximum number of items that can be assigned to this Playlist (dynamic Playlist only)",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Playlist"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if ($sanitizedParams->getString('name') == '') {
+ throw new InvalidArgumentException(__('Please enter playlist name'), 'name');
+ }
+
+ $playlist = $this->playlistFactory->create($sanitizedParams->getString('name'), $this->getUser()->getId());
+ $playlist->isDynamic = $sanitizedParams->getCheckbox('isDynamic');
+ $playlist->enableStat = $sanitizedParams->getString('enableStat');
+
+ // Folders
+ $folderId = $sanitizedParams->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+ $playlist->folderId = $folder->id;
+ $playlist->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ // Tags
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+
+ $playlist->updateTagLinks($tags);
+ }
+
+ // Do we have a tag, name or folder filter?
+ $nameFilter = $sanitizedParams->getString('filterMediaName');
+ $nameFilterLogicalOperator = $sanitizedParams->getString('logicalOperatorName');
+ $tagFilter = $this->getUser()->featureEnabled('tag.tagging') ? $sanitizedParams->getString('filterMediaTag') : null;
+ $logicalOperator = $this->getUser()->featureEnabled('tag.tagging') ? $sanitizedParams->getString('logicalOperator') : 'OR';
+ $exactTags = $this->getUser()->featureEnabled('tag.tagging') ? $sanitizedParams->getCheckbox('exactTags') : 0;
+ $folderIdFilter = $this->getUser()->featureEnabled('folder.view') ? $sanitizedParams->getInt('filterFolderId') : null;
+
+ // Capture these as dynamic filter criteria
+ if ($playlist->isDynamic === 1) {
+ if (empty($nameFilter) && empty($tagFilter) && empty($folderIdFilter)) {
+ throw new InvalidArgumentException(__('No filters have been set for this dynamic Playlist, please click the Filters tab to define'));
+ }
+ $playlist->filterMediaName = $nameFilter;
+ $playlist->filterMediaNameLogicalOperator = $nameFilterLogicalOperator;
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ $playlist->filterMediaTags = $tagFilter;
+ $playlist->filterExactTags = $exactTags;
+ $playlist->filterMediaTagsLogicalOperator = $logicalOperator;
+ }
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ $playlist->filterFolderId = $folderIdFilter;
+ }
+
+ $playlist->maxNumberOfItems = $sanitizedParams->getInt('maxNumberOfItems', ['default' => $this->getConfig()->getSetting('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER')]);
+ }
+
+ $playlist->save();
+
+ // Should we assign any existing media
+ if (!empty($nameFilter) || !empty($tagFilter) || !empty($folderIdFilter)) {
+ $media = $this->mediaFactory->query(
+ null,
+ [
+ 'name' => $nameFilter,
+ 'tags' => $tagFilter,
+ 'folderId' => $folderIdFilter,
+ 'assignable' => 1,
+ 'exactTags' => $exactTags,
+ 'logicalOperator' => $logicalOperator,
+ 'logicalOperatorName' => $nameFilterLogicalOperator
+ ]
+ );
+
+ if (count($media) > 0) {
+ $widgets = [];
+
+ foreach ($media as $item) {
+ // Assign items from the library.
+ // Get a module to use
+ $module = $this->moduleFactory->getByType($item->mediaType);
+
+ // The item duration shouldn't ever be 0 in the library, but in case it is we set to the default
+ $itemDuration = ($item->duration == 0) ? $module->defaultDuration : $item->duration;
+
+ // Create a widget
+ $widget = $this->widgetFactory->create(
+ $this->getUser()->userId,
+ $playlist->playlistId,
+ $item->mediaType,
+ $itemDuration,
+ $module->schemaVersion
+ );
+ $widget->assignMedia($item->mediaId);
+
+ // Calculate the duration
+ $widget->calculateDuration($module);
+
+ // Assign the widget to the playlist
+ $playlist->assignWidget($widget);
+
+ // Add to a list of new widgets
+ $widgets[] = $widget;
+ if ($playlist->isDynamic && count($widgets) >= $playlist->maxNumberOfItems) {
+ $this->getLog()->debug(sprintf(
+ 'Dynamic Playlist ID %d, has reached the maximum number of items %d, finishing assignments',
+ $playlist->playlistId,
+ $playlist->maxNumberOfItems
+ ));
+ break;
+ }
+ }
+
+ // Save the playlist
+ $playlist->save();
+ }
+ }
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $playlist->name),
+ 'id' => $playlist->playlistId,
+ 'data' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'playlist-form-edit';
+ $this->getState()->setData([
+ 'playlist' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit
+ *
+ * @SWG\Put(
+ * path="/playlist/{playlistId}",
+ * operationId="playlistEdit",
+ * tags={"playlist"},
+ * summary="Edit a Playlist",
+ * description="Edit a Playlist",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The PlaylistId to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Name for this Playlist",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="Tags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDynamic",
+ * in="formData",
+ * description="Is this Playlist Dynamic?",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="filterMediaName",
+ * in="formData",
+ * description="Add Library Media matching the name filter provided",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperatorName",
+ * in="formData",
+ * description="When filtering by multiple names in name filter, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="filterMediaTag",
+ * in="formData",
+ * description="Add Library Media matching the tag filter provided",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTags",
+ * in="formData",
+ * description="When filtering by Tags, should we use exact match?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="logicalOperator",
+ * in="formData",
+ * description="When filtering by Tags, which logical operator should be used? AND|OR",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="maxNumberOfItems",
+ * in="formData",
+ * description="Maximum number of items that can be assigned to this Playlist (dynamic Playlist only)",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $playlist->name = $sanitizedParams->getString('name');
+ $playlist->isDynamic = $sanitizedParams->getCheckbox('isDynamic');
+ $playlist->enableStat = $sanitizedParams->getString('enableStat');
+ $playlist->folderId = $sanitizedParams->getInt('folderId', ['default' => $playlist->folderId]);
+
+ if ($playlist->hasPropertyChanged('folderId')) {
+ if ($playlist->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+ $folder = $this->folderFactory->getById($playlist->folderId);
+ $playlist->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ }
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ if (is_array($sanitizedParams->getParam('tags'))) {
+ $tags = $this->tagFactory->tagsFromJson($sanitizedParams->getArray('tags'));
+ } else {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ }
+
+ $playlist->updateTagLinks($tags);
+ }
+
+ // Do we have a tag or name filter?
+ // Capture these as dynamic filter criteria
+ if ($playlist->isDynamic === 1) {
+ $filterMediaName = $sanitizedParams->getString('filterMediaName');
+ $filterMediaTag = $sanitizedParams->getString('filterMediaTag');
+ $filterFolderId = $sanitizedParams->getString('filterFolderId');
+
+ if (empty($filterMediaName) && empty($filterMediaTag) && empty($filterFolderId)) {
+ throw new InvalidArgumentException(__('No filters have been set for this dynamic Playlist, please click the Filters tab to define'));
+ }
+ $playlist->filterMediaName = $filterMediaName;
+ $playlist->filterMediaNameLogicalOperator = $sanitizedParams->getString('logicalOperatorName');
+
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ $playlist->filterMediaTags = $filterMediaTag;
+ $playlist->filterExactTags = $sanitizedParams->getCheckbox('exactTags');
+ $playlist->filterMediaTagsLogicalOperator = $sanitizedParams->getString('logicalOperator');
+ }
+
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ $playlist->filterFolderId = $filterFolderId;
+ }
+
+ $playlist->maxNumberOfItems = $sanitizedParams->getInt('maxNumberOfItems');
+ }
+
+ $playlist->save();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $playlist->name),
+ 'id' => $playlist->playlistId,
+ 'data' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'playlist-form-delete';
+ $this->getState()->setData([
+ 'playlist' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete
+ *
+ * @SWG\Delete(
+ * path="/playlist/{playlistId}",
+ * operationId="playlistDelete",
+ * tags={"playlist"},
+ * summary="Delete a Playlist",
+ * description="Delete a Playlist",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The PlaylistId to delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ // Issue the delete
+ $playlist->setModuleFactory($this->moduleFactory);
+ $playlist->delete();
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $playlist->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copy playlist form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function copyForm(Request $request, Response $response, $id)
+ {
+ // Get the playlist
+ $playlist = $this->playlistFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'playlist-form-copy';
+ $this->getState()->setData([
+ 'playlist' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Copies a playlist
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/playlist/copy/{playlistId}",
+ * operationId="playlistCopy",
+ * tags={"playlist"},
+ * summary="Copy Playlist",
+ * description="Copy a Playlist, providing a new name if applicable",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID to Copy",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The name for the new Playlist",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="copyMediaFiles",
+ * in="formData",
+ * description="Flag indicating whether to make new Copies of all Media Files assigned to the Playlist being Copied",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Playlist"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ // Get the playlist
+ $originalPlaylist = $this->playlistFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($originalPlaylist)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the playlist for Copy
+ $originalPlaylist->load(['loadTags' => false]);
+
+ // Clone the original
+ $playlist = clone $originalPlaylist;
+
+ $playlist->name = $sanitizedParams->getString('name');
+ $playlist->setOwner($this->getUser()->userId);
+
+ // Copy the media on the playlist and change the assignments.
+ if ($sanitizedParams->getCheckbox('copyMediaFiles') == 1) {
+ foreach ($playlist->widgets as $widget) {
+ // Copy the media
+ $oldMedia = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ $media = clone $oldMedia;
+ $media->setOwner($this->getUser()->userId);
+ $media->save();
+
+ $widget->unassignMedia($oldMedia->mediaId);
+ $widget->assignMedia($media->mediaId);
+
+ // Update the widget option with the new ID
+ $widget->setOptionValue('uri', 'attrib', $media->storedAs);
+ }
+ }
+
+ // Set from global setting
+ if ($playlist->enableStat == null) {
+ $playlist->enableStat = $this->getConfig()->getSetting('PLAYLIST_STATS_ENABLED_DEFAULT');
+ }
+
+ // tags
+ $playlist->updateTagLinks($originalPlaylist->tags);
+
+ // Save the new playlist
+ $playlist->save();
+
+ // Clone the closure table for the original playlist
+ $originalPlaylist->cloneClosureTable($playlist->getId());
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Copied as %s'), $playlist->name),
+ 'id' => $playlist->playlistId,
+ 'data' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ //
+
+ /**
+ * Timeline Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function timelineForm(Request $request, Response $response, $id)
+ {
+ // Get a complex object of playlists and widgets
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get a list of timezones
+ $timeZones = [];
+ foreach (DateFormatHelper::timezoneList() as $key => $value) {
+ $timeZones[] = ['id' => $key, 'value' => $value];
+ }
+
+ // Pass to view
+ $this->getState()->template = 'playlist-form-timeline';
+ $this->getState()->setData([
+ 'playlist' => $playlist,
+ 'timeZones' => $timeZones,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Library items to a Playlist
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/playlist/library/assign/{playlistId}",
+ * operationId="playlistLibraryAssign",
+ * tags={"playlist"},
+ * summary="Assign Library Items",
+ * description="Assign Media from the Library to this Playlist",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID to assign to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="media",
+ * in="formData",
+ * description="Array of Media IDs to assign",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="duration",
+ * in="formData",
+ * description="Optional duration for all Media in this assignment to use on the Widget",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="useDuration",
+ * in="formData",
+ * description="Optional flag indicating whether to enable the useDuration field",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="Optional integer to say which position this assignment should occupy in the list. If more than one media item is being added, this will be the position of the first one.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Playlist")
+ * )
+ * )
+ */
+ public function libraryAssign(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($playlist))
+ throw new AccessDeniedException();
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable())
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+
+ if ($playlist->isDynamic === 1)
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+
+ // Expect a list of mediaIds
+ $media = $sanitizedParams->getIntArray('media');
+
+ if (empty($media)) {
+ throw new InvalidArgumentException(__('Please provide Media to Assign'), 'media');
+ }
+
+ // Optional Duration
+ $duration = ($sanitizedParams->getInt('duration'));
+
+ // Optional displayOrder
+ $displayOrder = $sanitizedParams->getInt('displayOrder');
+
+ $newWidgets = [];
+
+ // Loop through all the media
+ foreach ($media as $mediaId) {
+ $item = $this->mediaFactory->getById($mediaId);
+
+ if (!$this->getUser()->checkViewable($item)) {
+ throw new AccessDeniedException(__('You do not have permissions to use this media'));
+ }
+
+ if ($item->mediaType == 'genericfile' || $item->mediaType == 'font') {
+ throw new InvalidArgumentException(sprintf(
+ __('You cannot assign file type %s to a playlist'),
+ $item->mediaType
+ ), 'mediaType');
+ }
+
+ // Create a module
+ $module = $this->moduleFactory->getByType($item->mediaType);
+
+ // Determine the duration
+ // if we have a duration provided, then use it, otherwise use the duration recorded on the
+ // library item already
+ $itemDuration = ($duration !== null) ? $duration : $item->duration;
+
+ // If the library item duration (or provided duration) is 0, then default to the Module Default
+ // Duration as configured in settings.
+ $itemDuration = ($itemDuration == 0) ? $module->defaultDuration : $itemDuration;
+
+ // Create a widget
+ $widget = $this->widgetFactory->create($this->getUser()->userId, $id, $item->mediaType, $itemDuration, $module->schemaVersion);
+ $widget->assignMedia($item->mediaId);
+
+ // If a duration has been provided, then we want to use it, so set useDuration to 1.
+ if ($duration !== null || $sanitizedParams->getCheckbox('useDuration') == 1) {
+ $widget->useDuration = 1;
+ $widget->duration = $itemDuration;
+ $widget->calculateDuration($module);
+ } else {
+ $widget->calculatedDuration = $itemDuration;
+ }
+
+ // Assign the widget to the playlist
+ $playlist->assignWidget($widget, $displayOrder);
+
+ if ($playlist->isRegionPlaylist() && count($playlist->widgets) >= 2) {
+ // Convert this region to a `playlist` (if it is a zone)
+ $widgetRegion = $this->regionFactory->getById($playlist->regionId);
+ if ($widgetRegion->type === 'zone') {
+ $widgetRegion->type = 'playlist';
+ $widgetRegion->save();
+ }
+ }
+
+ // If we have one provided we should bump the display order by 1 so that if we have more than one
+ // media to assign, we don't put the second one in the same place as the first one.
+ if ($displayOrder !== null) {
+ $displayOrder++;
+ }
+
+ // Add to a list of new widgets
+ $newWidgets[] = $widget;
+ }
+
+ // Save the playlist
+ $playlist->save(['saveTags' => false]);
+
+ // Add new widgets to playlist for return values
+ $playlist->setUnmatchedProperty('newWidgets', $newWidgets);
+
+ // Success
+ $this->getState()->hydrate([
+ 'message' => __('Media Assigned'),
+ 'data' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Definition(
+ * definition="PlaylistWidgetList",
+ * @SWG\Property(
+ * property="widgetId",
+ * type="integer",
+ * description="Widget ID"
+ * ),
+ * @SWG\Property(
+ * property="position",
+ * type="integer",
+ * description="The position in the Playlist"
+ * )
+ * )
+ */
+
+ /**
+ * Order a playlist and its widgets
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Post(
+ * path="/playlist/order/{playlistId}",
+ * operationId="playlistOrder",
+ * tags={"playlist"},
+ * summary="Order Widgets",
+ * description="Set the order of widgets in the Playlist",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID to Order",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="widgets",
+ * in="formData",
+ * description="Array of widgetIds and positions - all widgetIds present in the playlist need to be passed in the call with their positions",
+ * type="array",
+ * required=true,
+ * @SWG\Items(
+ * ref="#/definitions/PlaylistWidgetList"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Playlist")
+ * )
+ * )
+ */
+ public function order(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Load the widgets
+ $playlist->load();
+
+ // Get our list of widget orders
+ $widgets = $request->getParam('widgets', null);
+
+ if ($widgets == null) {
+ throw new InvalidArgumentException(__('Cannot Save empty region playlist. Please add widgets'), 'widgets');
+ }
+
+ // Go through each one and move it
+ foreach ($widgets as $widgetId => $position) {
+ // Find this item in the existing list and add it to our new order
+ foreach ($playlist->widgets as $widget) {
+ if ($widget->getId() == $widgetId) {
+ $this->getLog()->debug('Setting Display Order ' . $position . ' on widgetId ' . $widgetId);
+ $widget->displayOrder = $position;
+ break;
+ }
+ }
+ }
+
+ $playlist->save(['saveTags' => false]);
+
+ // Success
+ $this->getState()->hydrate([
+ 'message' => __('Order Changed'),
+ 'data' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Playlist Usage Report Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function usageForm(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'playlist-form-usage';
+ $this->getState()->setData([
+ 'playlist' => $playlist
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/playlist/usage/{playlistId}",
+ * operationId="playlistUsageReport",
+ * tags={"playlist"},
+ * summary="Get Playlist Item Usage Report",
+ * description="Get the records for the playlist item usage report",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function usage(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkViewable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get a list of displays that this playlistId is used on
+ $displays = [];
+ $displayIds = [];
+
+ // have we been provided with a date/time to restrict the scheduled events to?
+ $playlistFromDate = $sanitizedParams->getDate('playlistEventFromDate');
+ $playlistToDate = $sanitizedParams->getDate('playlistEventToDate');
+
+ // Events query array
+ $eventsQuery = [
+ 'playlistId' => $id
+ ];
+
+ if ($playlistFromDate !== null) {
+ $eventsQuery['futureSchedulesFrom'] = $playlistFromDate->format('U');
+ }
+
+ if ($playlistToDate !== null) {
+ $eventsQuery['futureSchedulesTo'] = $playlistToDate->format('U');
+ }
+
+ // Query for events
+ $events = $this->scheduleFactory->query(null, $eventsQuery);
+
+ // Total records returned from the schedules query
+ $totalRecords = $this->scheduleFactory->countLast();
+
+ foreach ($events as $row) {
+ /* @var \Xibo\Entity\Schedule $row */
+
+ // Generate this event
+ // Assess the date?
+ if ($playlistFromDate !== null && $playlistToDate !== null) {
+ try {
+ $scheduleEvents = $row->getEvents($playlistFromDate, $playlistToDate);
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Unable to getEvents for ' . $row->eventId);
+ continue;
+ }
+
+ // Skip events that do not fall within the specified days
+ if (count($scheduleEvents) <= 0)
+ continue;
+
+ $this->getLog()->debug('EventId ' . $row->eventId . ' as events: ' . json_encode($scheduleEvents));
+ }
+
+ // Load the display groups
+ $row->load();
+
+ foreach ($row->displayGroups as $displayGroup) {
+ foreach ($this->displayFactory->getByDisplayGroupId($displayGroup->displayGroupId) as $display) {
+
+ if (in_array($display->displayId, $displayIds)) {
+ continue;
+ }
+
+ $displays[] = $display;
+ $displayIds = $display->displayId;
+
+ }
+ }
+ }
+
+ if ($this->isApi($request) && $displays == []) {
+ $displays = [
+ 'data' =>__('Specified Playlist item is not in use.')];
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $totalRecords;
+ $this->getState()->setData($displays);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/playlist/usage/layouts/{playlistId}",
+ * operationId="playlistUsageLayoutsReport",
+ * tags={"playlist"},
+ * summary="Get Playlist Item Usage Report for Layouts",
+ * description="Get the records for the playlist item usage report for Layouts",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist Id",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function usageLayouts(Request $request, Response $response, $id)
+ {
+ $playlist = $this->playlistFactory->getById($id);
+
+ if (!$this->getUser()->checkViewable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $layouts = $this->layoutFactory->query(null, ['playlistId' => $id]);
+
+ if (!$this->isApi($request)) {
+ foreach ($layouts as $layout) {
+ $layout->includeProperty('buttons');
+
+ // Add some buttons for this row
+ if ($this->getUser()->checkEditable($layout)) {
+ // Design Button
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_design',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request,'layout.designer', array('id' => $layout->layoutId)),
+ 'text' => __('Design')
+ );
+ }
+
+ // Preview
+ $layout->buttons[] = array(
+ 'id' => 'layout_button_preview',
+ 'external' => true,
+ 'url' => '#',
+ 'onclick' => 'createMiniLayoutPreview',
+ 'onclickParam' => $this->urlFor($request, 'layout.preview', ['id' => $layout->layoutId]),
+ 'text' => __('Preview Layout')
+ );
+ }
+ }
+
+ if ($this->isApi($request) && $layouts == []) {
+ $layouts = [
+ 'data' =>__('Specified Playlist item is not in use.')
+ ];
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->layoutFactory->countLast();
+ $this->getState()->setData($layouts);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Enable Stats Collection of a Playlist
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @SWG\Put(
+ * path="/playlist/setenablestat/{playlistId}",
+ * operationId="playlistSetEnableStat",
+ * tags={"playlist"},
+ * summary="Enable Stats Collection",
+ * description="Set Enable Stats Collection? to use for the collection of Proof of Play statistics for a Playlist.",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="enableStat",
+ * in="formData",
+ * description="The option to enable the collection of Media Proof of Play statistics, On, Off or Inherit.",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+
+ function setEnableStat(Request $request, Response $response, $id)
+ {
+ // Get the Playlist
+ $playlist = $this->playlistFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $enableStat = $this->getSanitizer($request->getParams())->getString('enableStat');
+
+ $playlist->enableStat = $enableStat;
+ $playlist->save(['saveTags' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('For Playlist %s Enable Stats Collection is set to %s'), $playlist->name, __($playlist->enableStat))
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set Enable Stat Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function setEnableStatForm(Request $request, Response $response, $id)
+ {
+ // Get the Playlist
+ $playlist = $this->playlistFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'playlist' => $playlist,
+ ];
+
+ $this->getState()->template = 'playlist-form-setenablestat';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+
+
+ /**
+ * Select Folder Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function selectFolderForm(Request $request, Response $response, $id)
+ {
+ // Get the Playlist
+ $playlist = $this->playlistFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $data = [
+ 'playlist' => $playlist
+ ];
+
+ $this->getState()->template = 'playlist-form-selectfolder';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/playlist/{id}/selectfolder",
+ * operationId="playlistSelectFolder",
+ * tags={"playlist"},
+ * summary="Playlist Select folder",
+ * description="Select Folder for Playlist",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function selectFolder(Request $request, Response $response, $id)
+ {
+ // Get the Layout
+ $playlist = $this->playlistFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $folderId = $this->getSanitizer($request->getParams())->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ $playlist->folderId = $folderId;
+ $folder = $this->folderFactory->getById($playlist->folderId);
+ $playlist->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ // Save
+ $playlist->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Playlist %s moved to Folder %s'), $playlist->name, $folder->text)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Check if we already have a full screen Layout for this Playlist
+ * @param \Xibo\Entity\Playlist $playlist
+ * @return ?int
+ */
+ private function hasFullScreenLayout(\Xibo\Entity\Playlist $playlist): ?int
+ {
+ return $this->layoutFactory->getLinkedFullScreenLayout('playlist', $playlist->playlistId)?->campaignId;
+ }
+
+ /**
+ * Convert Layout editor playlist to global playlist.
+ * Assign this Playlist to the original regionPlaylist via sub-playlist Widget.
+ * @SWG\Post(
+ * path="/playlist/{id}/convert",
+ * operationId="convert",
+ * tags={"playlist"},
+ * summary="Playlist Convert",
+ * description="Create a global playlist from inline editor Playlist.
+ * Assign created Playlist via sub-playlist Widget to region Playlist.",
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Optional name for the global Playlist.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function convert(Request $request, Response $response, $id): Response
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ // get region playlist
+ $regionPlaylist = $this->playlistFactory->getById($id);
+
+ // check if it is region playlist
+ if (!$regionPlaylist->isRegionPlaylist()) {
+ throw new InvalidArgumentException(__('Not a Region Playlist'), 'playlistId');
+ }
+
+ // get the region
+ $region = $this->regionFactory->getById($regionPlaylist->regionId);
+
+ // make sure this is playlist type region
+ if ($region->type !== 'playlist') {
+ throw new InvalidArgumentException(__('Not a Playlist'), 'playlistId');
+ }
+
+ // get Layout
+ $layout = $this->layoutFactory->getByRegionId($regionPlaylist->regionId);
+
+ // check permissions
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // check if it is a draft
+ if (!$layout->isEditable()) {
+ throw new InvalidArgumentException(
+ __('This Layout is not a Draft, please checkout.'),
+ 'layoutId'
+ );
+ }
+
+ $regionPlaylist->load();
+
+ // clone region playlist to a new Playlist object
+ $playlist = clone $regionPlaylist;
+ $name = $params->getString(
+ 'name',
+ ['default' => sprintf(__('Untitled %s'), Carbon::now()->format(DateFormatHelper::getSystemFormat()))]
+ );
+
+ $playlist->name = empty($playlist->name) ? $name : $playlist->name;
+ $playlist->setOwner($this->getUser()->userId);
+
+ if ($playlist->enableStat == null) {
+ $playlist->enableStat = $this->getConfig()->getSetting('PLAYLIST_STATS_ENABLED_DEFAULT');
+ }
+
+ // Save the new playlist
+ $playlist->save();
+ $playlist->updateDuration();
+
+ // Clone the closure table for the original playlist
+ $regionPlaylist->cloneClosureTable($playlist->getId());
+
+ // remove widgets on the region Playlist
+ foreach ($regionPlaylist->widgets as $widget) {
+ $widget->delete();
+ }
+ $regionPlaylist->widgets = [];
+
+ $module = $this->moduleFactory->getByType('subplaylist');
+
+ // create a new sub-playlist Widget
+ $widget = $this->widgetFactory->create(
+ $this->getUser()->userId,
+ $regionPlaylist->playlistId,
+ 'subplaylist',
+ $playlist->duration,
+ $module->schemaVersion
+ );
+
+ // save, simulate add
+ $widget->save();
+
+ // prepare sub-playlist item
+ $item = new SubPlaylistItem();
+ $item->rowNo = 1;
+ $item->playlistId = $playlist->playlistId;
+ $item->spotFill = 'repeat';
+ $item->spotLength = '';
+ $item->spots = '';
+
+ $playlistItems[] = $item;
+
+ // update Widget subPlaylists option
+ $widget->setOptionValue('subPlaylists', 'attrib', json_encode($playlistItems));
+
+ // Calculate the duration
+ $widget->calculateDuration($module);
+
+ // Assign the sub-playlist widget to the region playlist
+ $regionPlaylist->assignWidget($widget);
+ // Save the region playlist
+ $regionPlaylist->save();
+
+ // build Layout xlf
+ $layout->xlfToDisk(['notify' => true, 'exceptionOnError' => true, 'exceptionOnEmptyRegion' => false]);
+
+ // Success
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Conversion Successful'),
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/PlaylistDashboard.php b/lib/Controller/PlaylistDashboard.php
new file mode 100644
index 0000000..4e3c490
--- /dev/null
+++ b/lib/Controller/PlaylistDashboard.php
@@ -0,0 +1,261 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Psr\Container\ContainerInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\SubPlaylistItemsEvent;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class PlaylistDashboard
+ * @package Xibo\Controller
+ */
+class PlaylistDashboard extends Base
+{
+ /** @var \Xibo\Factory\PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var \Xibo\Factory\ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Factory\WidgetFactory */
+ private $widgetFactory;
+
+ /** @var \Xibo\Factory\MediaFactory */
+ private $mediaFactory;
+
+ /** @var ContainerInterface */
+ private $container;
+
+ /**
+ * PlaylistDashboard constructor.
+ * @param $playlistFactory
+ * @param $moduleFactory
+ * @param $widgetFactory
+ * @param \Xibo\Factory\MediaFactory $mediaFactory
+ * @param ContainerInterface $container
+ */
+ public function __construct($playlistFactory, $moduleFactory, $widgetFactory, $mediaFactory, ContainerInterface $container)
+ {
+ $this->playlistFactory = $playlistFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->container = $container;
+ }
+
+ /**
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ // Do we have a Playlist already in our User Preferences?
+ $playlist = null;
+ try {
+ $playlistId = $this->getUser()->getOption('playlistDashboardSelectedPlaylistId');
+ if ($playlistId->value != 0) {
+ $playlist = $this->playlistFactory->getById($playlistId->value);
+ }
+ } catch (NotFoundException $notFoundException) {
+ // this is fine, no need to throw errors here.
+ $this->getLog()->debug(
+ 'Problem getting playlistDashboardSelectedPlaylistId user option. e = ' .
+ $notFoundException->getMessage()
+ );
+ }
+
+ $this->getState()->template = 'playlist-dashboard';
+ $this->getState()->setData([
+ 'playlist' => $playlist,
+ 'validExtensions' => implode('|', $this->moduleFactory->getValidExtensions())
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Grid used for the Playlist drop down list
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Playlists
+ $playlists = $this->playlistFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter([
+ 'name' => $this->getSanitizer($request->getParams())->getString('name'),
+ 'regionSpecific' => 0
+ ], $sanitizedParams));
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->playlistFactory->countLast();
+ $this->getState()->setData($playlists);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Show a particular playlist
+ * the output from this is very much like a form.
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function show(Request $request, Response $response, $id)
+ {
+ // Record this Playlist as the one we have currently selected.
+ try {
+ $this->getUser()->setOptionValue('playlistDashboardSelectedPlaylistId', $id);
+ $this->getUser()->save();
+ } catch (GeneralException $exception) {
+ $this->getLog()->error('Problem setting playlistDashboardSelectedPlaylistId user option. e = ' . $exception->getMessage());
+ }
+
+ // Spots
+ $spotsFound = 0;
+
+ $playlist = $this->playlistFactory->getById($id);
+
+ // Only edit permissions
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getLog()->debug('show: testing to see if ' . $playlist->name . ' / ' . $playlist->playlistId
+ . ' is the first playlist in any other ones.');
+
+ // Work out the slot size of the first sub-playlist we are in.
+ foreach ($this->playlistFactory->query(null, [
+ 'childId' => $playlist->playlistId,
+ 'depth' => 1,
+ 'disableUserCheck' => 1
+ ]) as $parent) {
+ // $parent is a playlist to which we belong.
+ $this->getLog()->debug('show: This playlist is a sub-playlist in ' . $parent->name . '.');
+ $parent->load();
+
+ foreach ($parent->widgets as $parentWidget) {
+ if ($parentWidget->type === 'subplaylist') {
+ $this->getLog()->debug('show: matched against a sub playlist widget ' . $parentWidget->widgetId . '.');
+
+ // Get the sub-playlist widgets
+ $event = new SubPlaylistItemsEvent($parentWidget);
+ $this->getDispatcher()->dispatch($event, SubPlaylistItemsEvent::$NAME);
+
+ foreach ($event->getItems() as $subPlaylistItem) {
+ $this->getLog()->debug('show: Assessing playlist ' . $subPlaylistItem->playlistId . ' on ' . $playlist->name);
+ if ($subPlaylistItem->playlistId == $playlist->playlistId) {
+ // Take the highest number of Spots we can find out of all the assignments.
+ $spotsFound = max($subPlaylistItem->spots ?? 0, $spotsFound);
+
+ // Assume this one isn't in the list more than one time.
+ break 2;
+ }
+ }
+
+ $this->getLog()->debug('show: no matching playlists found.');
+ }
+ }
+ }
+
+ // Load my Playlist and information about its widgets
+ if ($spotsFound > 0) {
+ // We are in a sub-playlist with spots, so now we load our widgets.
+ $playlist->load();
+ $user = $this->getUser();
+
+ foreach ($playlist->widgets as $widget) {
+ // Create a module for the widget and load in some extra data
+ $module = $this->moduleFactory->getByType($widget->type);
+ $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', $module->name));
+ $widget->setUnmatchedProperty('regionSpecific', $module->regionSpecific);
+ $widget->setUnmatchedProperty('moduleIcon', $module->icon);
+
+ // Check my permissions
+ if ($module->regionSpecific == 0) {
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ $widget->setUnmatchedProperty('viewble', $user->checkViewable($media));
+ $widget->setUnmatchedProperty('editable', $user->checkEditable($media));
+ $widget->setUnmatchedProperty('deletable', $user->checkDeleteable($media));
+ } else {
+ $widget->setUnmatchedProperty('viewble', $user->checkViewable($widget));
+ $widget->setUnmatchedProperty('editable', $user->checkEditable($widget));
+ $widget->setUnmatchedProperty('deletable', $user->checkDeleteable($widget));
+ }
+ }
+ }
+
+ $this->getState()->template = 'playlist-dashboard-spots';
+ $this->getState()->setData([
+ 'playlist' => $playlist,
+ 'spotsFound' => $spotsFound
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Playlist Widget Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function deletePlaylistWidgetForm(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ if (!$this->getUser()->checkDeleteable($widget)) {
+ throw new AccessDeniedException();
+ }
+
+ // Pass to view
+ $this->getState()->template = 'playlist-module-form-delete';
+ $this->getState()->setData([
+ 'widget' => $widget,
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Preview.php b/lib/Controller/Preview.php
new file mode 100644
index 0000000..70edadb
--- /dev/null
+++ b/lib/Controller/Preview.php
@@ -0,0 +1,155 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class Preview
+ * @package Xibo\Controller
+ */
+class Preview extends Base
+{
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * Set common dependencies.
+ * @param LayoutFactory $layoutFactory
+ */
+ public function __construct($layoutFactory)
+ {
+ $this->layoutFactory = $layoutFactory;
+ }
+
+ /**
+ * Layout Preview
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function show(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get the layout
+ if ($sanitizedParams->getInt('findByCode') === 1) {
+ $layout = $this->layoutFactory->getByCode($id);
+ } else {
+ $layout = $this->layoutFactory->getById($id);
+ }
+
+ if (!$this->getUser()->checkViewable($layout)
+ || !$this->getUser()->featureEnabled(['layout.view', 'playlist.view', 'campaign.view'])
+ ) {
+ throw new AccessDeniedException();
+ }
+
+ // Do we want to preview the draft version of this Layout?
+ if ($sanitizedParams->getCheckbox('isPreviewDraft') && $layout->hasDraft()) {
+ $layout = $this->layoutFactory->getByParentId($layout->layoutId);
+ }
+
+ $this->getState()->template = 'layout-renderer';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ 'previewOptions' => [
+ 'getXlfUrl' => $this->urlFor($request, 'layout.getXlf', ['id' => $layout->layoutId]),
+ 'getResourceUrl' => $this->urlFor($request, 'module.getResource', [
+ 'regionId' => ':regionId', 'id' => ':id'
+ ]),
+ 'libraryDownloadUrl' => $this->urlFor($request, 'library.download', ['id' => ':id']),
+ 'layoutBackgroundDownloadUrl' => $this->urlFor($request, 'layout.download.background', ['id' => ':id']),
+ 'loaderUrl' => $this->getConfig()->uri('img/loader.gif'),
+ 'layoutPreviewUrl' => $this->urlFor($request, 'layout.preview', ['id' => '[layoutCode]'])
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Get the XLF for a Layout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getXlf(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($id));
+ try {
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ echo file_get_contents($layout->xlfToDisk([
+ 'notify' => false,
+ 'collectNow' => false,
+ ]));
+
+ $this->setNoOutput();
+ } finally {
+ // Release lock
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Return the player bundle
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ */
+ public function playerBundle(Request $request, Response $response)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $isMap = $params->getCheckbox('map');
+ if ($isMap) {
+ $bundle = file_get_contents(PROJECT_ROOT . '/modules/bundle.min.js.map');
+ } else {
+ $bundle = file_get_contents(PROJECT_ROOT . '/modules/bundle.min.js');
+ }
+
+ $response->getBody()->write($bundle);
+ return $response->withStatus(200)
+ ->withHeader('Content-Size', strlen($bundle))
+ ->withHeader('Content-Type', 'application/javascript');
+ }
+}
diff --git a/lib/Controller/Pwa.php b/lib/Controller/Pwa.php
new file mode 100644
index 0000000..2dfb7fa
--- /dev/null
+++ b/lib/Controller/Pwa.php
@@ -0,0 +1,207 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Psr\Container\ContainerInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Xmds\Soap7;
+
+/**
+ * PWA
+ * routes for a PWA to download resources which live in an iframe
+ */
+class Pwa extends Base
+{
+ public function __construct(
+ private readonly DisplayFactory $displayFactory,
+ private readonly ContainerInterface $container
+ ) {
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getResource(Request $request, Response $response): Response
+ {
+ // Create a Soap client and call it.
+ $params = $this->getSanitizer($request->getParams());
+
+ try {
+ // Which version are we?
+ $version = $params->getInt('v', [
+ 'default' => 7,
+ 'throw' => function () {
+ throw new InvalidArgumentException(__('Missing Version'), 'v');
+ }
+ ]);
+
+ if ($version < 7) {
+ throw new InvalidArgumentException(__('PWA supported from XMDS schema 7 onward.'), 'v');
+ }
+
+ // Validate that this display should call this service.
+ $hardwareKey = $params->getString('hardwareKey');
+ $display = $this->displayFactory->getByLicence($hardwareKey);
+ if (!$display->isPwa()) {
+ throw new AccessDeniedException(__('Please use XMDS API'), 'hardwareKey');
+ }
+
+ // Check it is still authorised.
+ if ($display->licensed == 0) {
+ throw new AccessDeniedException(__('Display unauthorised'));
+ }
+
+ /** @var Soap7 $soap */
+ $soap = $this->getSoap($version);
+
+ $this->getLog()->debug('getResource: passing to Soap class');
+
+ $body = $soap->GetResource(
+ $params->getString('serverKey'),
+ $params->getString('hardwareKey'),
+ $params->getInt('layoutId'),
+ $params->getInt('regionId') . '',
+ $params->getInt('mediaId') . '',
+ );
+
+ $response->getBody()->write($body);
+
+ return $response
+ ->withoutHeader('Content-Security-Policy');
+ } catch (\SoapFault $e) {
+ throw new GeneralException($e->getMessage());
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getData(Request $request, Response $response): Response
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ try {
+ $version = $params->getInt('v', [
+ 'default' => 7,
+ 'throw' => function () {
+ throw new InvalidArgumentException(__('Missing Version'), 'v');
+ }
+ ]);
+
+ if ($version < 7) {
+ throw new InvalidArgumentException(__('PWA supported from XMDS schema 7 onward.'), 'v');
+ }
+
+ // Validate that this display should call this service.
+ $hardwareKey = $params->getString('hardwareKey');
+ $display = $this->displayFactory->getByLicence($hardwareKey);
+ if (!$display->isPwa()) {
+ throw new AccessDeniedException(__('Please use XMDS API'), 'hardwareKey');
+ }
+
+ // Check it is still authorised.
+ if ($display->licensed == 0) {
+ throw new AccessDeniedException(__('Display unauthorised'));
+ }
+
+ /** @var Soap7 $soap */
+ $soap = $this->getSoap($version);
+ $body = $soap->GetData(
+ $params->getString('serverKey'),
+ $params->getString('hardwareKey'),
+ $params->getInt('widgetId'),
+ );
+
+ $response->getBody()->write($body);
+
+ return $response
+ ->withoutHeader('Content-Security-Policy');
+ } catch (\SoapFault $e) {
+ throw new GeneralException($e->getMessage());
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ */
+ private function getSoap(int $version): mixed
+ {
+ $class = '\Xibo\Xmds\Soap' . $version;
+ if (!class_exists($class)) {
+ throw new InvalidArgumentException(__('Unknown version'), 'version');
+ }
+
+ // Overwrite the logger
+ $uidProcessor = new \Monolog\Processor\UidProcessor(7);
+ $logProcessor = new \Xibo\Xmds\LogProcessor(
+ $this->container->get('logger'),
+ $uidProcessor->getUid()
+ );
+ $this->container->get('logger')->pushProcessor($logProcessor);
+
+ return new $class(
+ $logProcessor,
+ $this->container->get('pool'),
+ $this->container->get('store'),
+ $this->container->get('timeSeriesStore'),
+ $this->container->get('logService'),
+ $this->container->get('sanitizerService'),
+ $this->container->get('configService'),
+ $this->container->get('requiredFileFactory'),
+ $this->container->get('moduleFactory'),
+ $this->container->get('layoutFactory'),
+ $this->container->get('dataSetFactory'),
+ $this->displayFactory,
+ $this->container->get('userGroupFactory'),
+ $this->container->get('bandwidthFactory'),
+ $this->container->get('mediaFactory'),
+ $this->container->get('widgetFactory'),
+ $this->container->get('regionFactory'),
+ $this->container->get('notificationFactory'),
+ $this->container->get('displayEventFactory'),
+ $this->container->get('scheduleFactory'),
+ $this->container->get('dayPartFactory'),
+ $this->container->get('playerVersionFactory'),
+ $this->container->get('dispatcher'),
+ $this->container->get('campaignFactory'),
+ $this->container->get('syncGroupFactory'),
+ $this->container->get('playerFaultFactory')
+ );
+ }
+}
diff --git a/lib/Controller/Region.php b/lib/Controller/Region.php
new file mode 100644
index 0000000..642f993
--- /dev/null
+++ b/lib/Controller/Region.php
@@ -0,0 +1,836 @@
+.
+ */
+
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\RegionAddedEvent;
+use Xibo\Event\SubPlaylistWidgetsEvent;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\TransitionFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Region
+ * @package Xibo\Controller
+ */
+class Region extends Base
+{
+ /**
+ * @var RegionFactory
+ */
+ private $regionFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /**
+ * @var ModuleFactory
+ */
+ private $moduleFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var TransitionFactory
+ */
+ private $transitionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param RegionFactory $regionFactory
+ * @param WidgetFactory $widgetFactory
+ * @param TransitionFactory $transitionFactory
+ * @param ModuleFactory $moduleFactory
+ * @param LayoutFactory $layoutFactory
+ */
+ public function __construct(
+ $regionFactory,
+ $widgetFactory,
+ $transitionFactory,
+ $moduleFactory,
+ $layoutFactory
+ ) {
+ $this->regionFactory = $regionFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->transitionFactory = $transitionFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->moduleFactory = $moduleFactory;
+ }
+
+ /**
+ * Get region by id
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ */
+ public function get(Request $request, Response $response, $id)
+ {
+ $region = $this->regionFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($region)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->setData([
+ 'region' => $region,
+ 'layout' => $this->layoutFactory->getById($region->layoutId),
+ 'transitions' => $this->transitionData(),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a region
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Post(
+ * path="/region/{id}",
+ * operationId="regionAdd",
+ * tags={"layout"},
+ * summary="Add Region",
+ * description="Add a Region to a Layout",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Layout ID to add the Region to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="type",
+ * in="formData",
+ * description="The type of region this should be, zone, frame, playlist or canvas. Default = frame.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="width",
+ * in="formData",
+ * description="The Width, default 250",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="height",
+ * in="formData",
+ * description="The Height",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="top",
+ * in="formData",
+ * description="The Top Coordinate",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="left",
+ * in="formData",
+ * description="The Left Coordinate",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Region"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function add(Request $request, Response $response, $id)
+ {
+ $layout = $this->layoutFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ $layout->load([
+ 'loadPlaylists' => true,
+ 'loadTags' => false,
+ 'loadPermissions' => true,
+ 'loadCampaigns' => false
+ ]);
+
+ // Add a new region
+ $region = $this->regionFactory->create(
+ $sanitizedParams->getString('type', ['default' => 'frame']),
+ $this->getUser()->userId,
+ '',
+ $sanitizedParams->getInt('width', ['default' => 250]),
+ $sanitizedParams->getInt('height', ['default' => 250]),
+ $sanitizedParams->getInt('top', ['default' => 50]),
+ $sanitizedParams->getInt('left', ['default' => 50]),
+ $sanitizedParams->getInt('zIndex', ['default' => 0])
+ );
+
+ $layout->regions[] = $region;
+ $layout->save([
+ 'saveTags' => false
+ ]);
+
+ // Dispatch an event to say that we have added a region
+ $this->getDispatcher()->dispatch(new RegionAddedEvent($layout, $region), RegionAddedEvent::$NAME);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $region->name),
+ 'id' => $region->regionId,
+ 'data' => $region
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Put(
+ * path="/region/{id}",
+ * operationId="regionEdit",
+ * tags={"layout"},
+ * summary="Edit Region",
+ * description="Edit Region",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Region ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="width",
+ * in="formData",
+ * description="The Width, default 250",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="height",
+ * in="formData",
+ * description="The Height",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="top",
+ * in="formData",
+ * description="The Top Coordinate",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="left",
+ * in="formData",
+ * description="The Left Coordinate",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="zIndex",
+ * in="formData",
+ * description="The Layer for this Region",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="transitionType",
+ * in="formData",
+ * description="The Transition Type. Must be a valid transition code as returned by /transition",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="transitionDuration",
+ * in="formData",
+ * description="The transition duration in milliseconds if required by the transition type",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="transitionDirection",
+ * in="formData",
+ * description="The transition direction if required by the transition type.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="loop",
+ * in="formData",
+ * description="Flag indicating whether this region should loop if there is only 1 media item in the timeline",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Region")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $region = $this->regionFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($region)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Regions Layout is in an editable state
+ $layout = $this->layoutFactory->getById($region->layoutId);
+
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Load before we save
+ $region->load();
+
+ $region->name = $sanitizedParams->getString('name');
+ $region->width = $sanitizedParams->getDouble('width');
+ $region->height = $sanitizedParams->getDouble('height');
+ $region->top = $sanitizedParams->getDouble('top', ['default' => 0]);
+ $region->left = $sanitizedParams->getDouble('left', ['default' => 0]);
+ $region->zIndex = $sanitizedParams->getInt('zIndex');
+ $region->type = $sanitizedParams->getString('type');
+ $region->syncKey = $sanitizedParams->getString('syncKey', ['defaultOnEmptyString' => true]);
+
+ // Loop
+ $region->setOptionValue('loop', $sanitizedParams->getCheckbox('loop'));
+
+ // Transitions
+ $region->setOptionValue('transitionType', $sanitizedParams->getString('transitionType'));
+ $region->setOptionValue('transitionDuration', $sanitizedParams->getInt('transitionDuration'));
+ $region->setOptionValue('transitionDirection', $sanitizedParams->getString('transitionDirection'));
+
+ // Save
+ $region->save();
+
+ // Mark the layout as needing rebuild
+ $layout->load(\Xibo\Entity\Layout::$loadOptionsMinimum);
+
+ $saveOptions = \Xibo\Entity\Layout::$saveOptionsMinimum;
+ $saveOptions['setBuildRequired'] = true;
+
+ $layout->save($saveOptions);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $region->name),
+ 'id' => $region->regionId,
+ 'data' => $region
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete a region
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/region/{regionId}",
+ * operationId="regionDelete",
+ * tags={"layout"},
+ * summary="Region Delete",
+ * description="Delete an existing region",
+ * @SWG\Parameter(
+ * name="regionId",
+ * in="path",
+ * description="The Region ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $region = $this->regionFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($region)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Regions Layout is in an editable state
+ $layout = $this->layoutFactory->getById($region->layoutId);
+
+ if (!$layout->isChild())
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+
+ $region->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $region->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Update Positions
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Put(
+ * path="/region/position/all/{layoutId}",
+ * operationId="regionPositionAll",
+ * tags={"layout"},
+ * summary="Position Regions",
+ * description="Position all regions for a Layout",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="regions",
+ * in="formData",
+ * description="Array of regions and their new positions. Each array element should be json encoded and have regionId, top, left, width and height.",
+ * type="array",
+ * required=true,
+ * @SWG\Items(
+ * type="string"
+ * )
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout")
+ * )
+ * )
+ */
+ function positionAll(Request $request, Response $response, $id)
+ {
+ // Create the layout
+ $layout = $this->layoutFactory->loadById($id);
+
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Layout is a Draft
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Pull in the regions and convert them to stdObjects
+ $regions = $request->getParam('regions', null);
+
+ if ($regions == null) {
+ throw new InvalidArgumentException(__('No regions present'));
+ }
+ $regions = json_decode($regions);
+
+ // Go through each region and update the region in the layout we have
+ foreach ($regions as $newCoordinates) {
+ // TODO attempt to sanitize?
+ // Check that the properties we are expecting do actually exist
+ if (!property_exists($newCoordinates, 'regionid'))
+ throw new InvalidArgumentException(__('Missing regionid property'));
+
+ if (!property_exists($newCoordinates, 'top'))
+ throw new InvalidArgumentException(__('Missing top property'));
+
+ if (!property_exists($newCoordinates, 'left'))
+ throw new InvalidArgumentException(__('Missing left property'));
+
+ if (!property_exists($newCoordinates, 'width'))
+ throw new InvalidArgumentException(__('Missing width property'));
+
+ if (!property_exists($newCoordinates, 'height'))
+ throw new InvalidArgumentException(__('Missing height property'));
+
+ $regionId = $newCoordinates->regionid;
+
+ // Load the region
+ $region = $layout->getRegion($regionId);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($region)) {
+ throw new AccessDeniedException();
+ }
+
+ // New coordinates
+ $region->top = $newCoordinates->top;
+ $region->left = $newCoordinates->left;
+ $region->width = $newCoordinates->width;
+ $region->height = $newCoordinates->height;
+ $region->zIndex = $newCoordinates->zIndex;
+ $this->getLog()->debug('Set ' . $region);
+ }
+
+ // Mark the layout as having changed
+ $layout->status = 0;
+ $layout->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Represents the Preview inside the Layout Designer
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function preview(Request $request, Response $response, $id)
+ {
+ $sanitizedQuery = $this->getSanitizer($request->getParams());
+
+ $widgetId = $sanitizedQuery->getInt('widgetId', ['default' => null]);
+ $seq = $sanitizedQuery->getInt('seq', ['default' => 1]);
+
+ // Load our region
+ try {
+ $region = $this->regionFactory->getById($id);
+ $region->load();
+
+ // What type of region are we?
+ $additionalContexts = [];
+ if ($region->type === 'canvas' || $region->type === 'playlist') {
+ $this->getLog()->debug('preview: canvas or playlist region');
+
+ // Get the first playlist we can find
+ $playlist = $region->getPlaylist()->setModuleFactory($this->moduleFactory);
+
+ // Expand this Playlist out to its individual Widgets
+ $widgets = $playlist->expandWidgets();
+
+ $countWidgets = count($widgets);
+
+ // Select the widget at the required sequence
+ $widget = $playlist->getWidgetAt($seq, $widgets);
+ $widget->load();
+ } else {
+ $this->getLog()->debug('preview: single widget');
+
+ // Assume we're a frame, single Widget Requested
+ $widget = $this->widgetFactory->getById($widgetId);
+ $widget->load();
+
+ if ($widget->type === 'subplaylist') {
+ // Get the sub-playlist widgets
+ $event = new SubPlaylistWidgetsEvent($widget, $widget->tempId);
+ $this->getDispatcher()->dispatch($event, SubPlaylistWidgetsEvent::$NAME);
+ $additionalContexts['countSubPlaylistWidgets'] = count($event->getWidgets());
+ }
+
+ $countWidgets = 1;
+ }
+
+ $this->getLog()->debug('There are ' . $countWidgets . ' widgets.');
+
+ // Output a preview
+ $module = $this->moduleFactory->getByType($widget->type);
+ $this->getState()->html = $this->moduleFactory
+ ->createWidgetHtmlRenderer()
+ ->preview(
+ $module,
+ $region,
+ $widget,
+ $sanitizedQuery,
+ $this->urlFor(
+ $request,
+ 'library.download',
+ [
+ 'regionId' => $region->regionId,
+ 'id' => $widget->getPrimaryMedia()[0] ?? null
+ ]
+ ) . '?preview=1',
+ $additionalContexts
+ );
+ $this->getState()->extra['countOfWidgets'] = $countWidgets;
+ $this->getState()->extra['empty'] = false;
+ } catch (NotFoundException) {
+ $this->getState()->extra['empty'] = true;
+ $this->getState()->extra['text'] = __('Empty Playlist');
+ } catch (InvalidArgumentException $e) {
+ $this->getState()->extra['empty'] = true;
+ $this->getState()->extra['text'] = __('Please correct the error with this Widget');
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @return array
+ */
+ private function transitionData()
+ {
+ return [
+ 'in' => $this->transitionFactory->getEnabledByType('in'),
+ 'out' => $this->transitionFactory->getEnabledByType('out'),
+ 'compassPoints' => array(
+ array('id' => 'N', 'name' => __('North')),
+ array('id' => 'NE', 'name' => __('North East')),
+ array('id' => 'E', 'name' => __('East')),
+ array('id' => 'SE', 'name' => __('South East')),
+ array('id' => 'S', 'name' => __('South')),
+ array('id' => 'SW', 'name' => __('South West')),
+ array('id' => 'W', 'name' => __('West')),
+ array('id' => 'NW', 'name' => __('North West'))
+ )
+ ];
+ }
+
+ /**
+ * Add a drawer
+
+ * @SWG\Post(
+ * path="/region/drawer/{id}",
+ * operationId="regionDrawerAdd",
+ * tags={"layout"},
+ * summary="Add drawer Region",
+ * description="Add a drawer Region to a Layout",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Layout ID to add the Region to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Region"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ */
+ public function addDrawer(Request $request, Response $response, $id) :Response
+ {
+ $layout = $this->layoutFactory->getById($id);
+ if (!$this->getUser()->checkEditable($layout)) {
+ throw new AccessDeniedException();
+ }
+
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ $layout->load([
+ 'loadPlaylists' => true,
+ 'loadTags' => false,
+ 'loadPermissions' => true,
+ 'loadCampaigns' => false
+ ]);
+
+ // Add a new region
+ // we default to layout width/height/0/0
+ $drawer = $this->regionFactory->create(
+ 'drawer',
+ $this->getUser()->userId,
+ $layout->layout . '-' . (count($layout->regions) + 1 . ' - drawer'),
+ $layout->width,
+ $layout->height,
+ 0,
+ 0,
+ 0,
+ 1
+ );
+
+ $layout->drawers[] = $drawer;
+ $layout->save([
+ 'saveTags' => false
+ ]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added drawer %s'), $drawer->name),
+ 'id' => $drawer->regionId,
+ 'data' => $drawer
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ *
+ * @SWG\Put(
+ * path="/region/drawer/{id}",
+ * operationId="regionDrawerSave",
+ * tags={"layout"},
+ * summary="Save Drawer",
+ * description="Save Drawer",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Drawer ID to Save",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="width",
+ * in="formData",
+ * description="The Width, default 250",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="height",
+ * in="formData",
+ * description="The Height",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Region")
+ * )
+ * )
+ */
+ public function saveDrawer(Request $request, Response $response, $id)
+ {
+ $region = $this->regionFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($region)) {
+ throw new AccessDeniedException();
+ }
+
+ // Check that this Regions Layout is in an editable state
+ $layout = $this->layoutFactory->getById($region->layoutId);
+
+ if (!$layout->isChild()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Save
+ $region->load();
+ $region->width = $sanitizedParams->getDouble('width', ['default' => $layout->width]);
+ $region->height = $sanitizedParams->getDouble('height', ['default' => $layout->height]);
+ $region->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited Drawer %s'), $region->name),
+ 'id' => $region->regionId,
+ 'data' => $region
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Report.php b/lib/Controller/Report.php
new file mode 100644
index 0000000..b4fda76
--- /dev/null
+++ b/lib/Controller/Report.php
@@ -0,0 +1,122 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\ReportResult;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Class Report
+ * @package Xibo\Controller
+ */
+class Report extends Base
+{
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /**
+ * Set common dependencies.
+ * @param ReportServiceInterface $reportService
+ */
+ public function __construct($reportService)
+ {
+ $this->reportService = $reportService;
+ }
+
+ /// //
+
+ /**
+ * Displays an Ad Hoc Report form
+ * @param Request $request
+ * @param Response $response
+ * @param $name
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getReportForm(Request $request, Response $response, $name)
+ {
+ $this->getLog()->debug('Get report name: '. $name);
+
+ // Get the report Class from the Json File
+ $className = $this->reportService->getReportClass($name);
+
+ // Create the report object
+ $object = $this->reportService->createReportObject($className);
+
+ // We assert the user so that we can use getUser in the report class
+ $object->setUser($this->getUser());
+
+ // Get the twig file template and required data of the report form
+ $form = $object->getReportForm();
+
+ // Show the twig
+ $this->getState()->template = $form->template;
+ $this->getState()->setData([
+ 'reportName' => $form->reportName,
+ 'reportCategory' => $form->reportCategory,
+ 'reportAddBtnTitle' => $form->reportAddBtnTitle,
+ 'availableReports' => $this->reportService->listReports(),
+ 'defaults' => $form->defaults
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Displays Ad Hoc/ On demand Report data in charts
+ * @param Request $request
+ * @param Response $response
+ * @param $name
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function getReportData(Request $request, Response $response, $name)
+ {
+ $this->getLog()->debug('Get report name: '. $name);
+
+ // Get the report Class from the Json File
+ $className = $this->reportService->getReportClass($name);
+
+ // Create the report object
+ $object = $this->reportService->createReportObject($className)->setUser($this->getUser());
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Return data to build chart/table
+ $result = $object->getResults($sanitizedParams);
+
+ //
+ // Output Results
+ // --------------
+ return $response->withJson($result);
+ }
+
+ //
+}
diff --git a/lib/Controller/Resolution.php b/lib/Controller/Resolution.php
new file mode 100644
index 0000000..2ca1bf4
--- /dev/null
+++ b/lib/Controller/Resolution.php
@@ -0,0 +1,448 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\ResolutionFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class Resolution
+ * @package Xibo\Controller
+ */
+class Resolution extends Base
+{
+ /**
+ * @var ResolutionFactory
+ */
+ private $resolutionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param ResolutionFactory $resolutionFactory
+ */
+ public function __construct($resolutionFactory)
+ {
+ $this->resolutionFactory = $resolutionFactory;
+ }
+
+ /**
+ * Display the Resolution Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'resolution-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Resolution Grid
+ *
+ * @SWG\Get(
+ * path="/resolution",
+ * operationId="resolutionSearch",
+ * tags={"resolution"},
+ * summary="Resolution Search",
+ * description="Search Resolutions this user has access to",
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="query",
+ * description="Filter by Resolution Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="resolution",
+ * in="query",
+ * description="Filter by Resolution Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="partialResolution",
+ * in="query",
+ * description="Filter by Partial Resolution Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="enabled",
+ * in="query",
+ * description="Filter by Enabled",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="width",
+ * in="query",
+ * description="Filter by Resolution width",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="height",
+ * in="query",
+ * description="Filter by Resolution height",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Resolution")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+ // Show enabled
+ $filter = [
+ 'enabled' => $sanitizedQueryParams->getInt('enabled', ['default' => -1]),
+ 'resolutionId' => $sanitizedQueryParams->getInt('resolutionId'),
+ 'resolution' => $sanitizedQueryParams->getString('resolution'),
+ 'partialResolution' => $sanitizedQueryParams->getString('partialResolution'),
+ 'width' => $sanitizedQueryParams->getInt('width'),
+ 'height' => $sanitizedQueryParams->getInt('height'),
+ 'orientation' => $sanitizedQueryParams->getString('orientation')
+ ];
+
+ $resolutions = $this->resolutionFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter($filter, $sanitizedQueryParams));
+
+ foreach ($resolutions as $resolution) {
+ /* @var \Xibo\Entity\Resolution $resolution */
+
+ if ($this->isApi($request))
+ break;
+
+ $resolution->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('resolution.modify')
+ && $this->getUser()->checkEditable($resolution)
+ ) {
+ // Edit Button
+ $resolution->buttons[] = array(
+ 'id' => 'resolution_button_edit',
+ 'url' => $this->urlFor($request,'resolution.edit.form', ['id' => $resolution->resolutionId]),
+ 'text' => __('Edit')
+ );
+ }
+
+ if ($this->getUser()->featureEnabled('resolution.modify')
+ && $this->getUser()->checkDeleteable($resolution)
+ ) {
+ // Delete Button
+ $resolution->buttons[] = array(
+ 'id' => 'resolution_button_delete',
+ 'url' => $this->urlFor($request,'resolution.delete.form', ['id' => $resolution->resolutionId]),
+ 'text' => __('Delete')
+ );
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($resolutions);
+ $this->getState()->recordsTotal = $this->resolutionFactory->countLast();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Resolution Add
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'resolution-form-add';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Resolution Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function editForm(Request $request, Response $response, $id)
+ {
+ $resolution = $this->resolutionFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($resolution)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'resolution-form-edit';
+ $this->getState()->setData([
+ 'resolution' => $resolution,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Resolution Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $resolution = $this->resolutionFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($resolution)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'resolution-form-delete';
+ $this->getState()->setData([
+ 'resolution' => $resolution,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add Resolution
+ *
+ * @SWG\Post(
+ * path="/resolution",
+ * operationId="resolutionAdd",
+ * tags={"resolution"},
+ * summary="Add Resolution",
+ * description="Add new Resolution",
+ * @SWG\Parameter(
+ * name="resolution",
+ * in="formData",
+ * description="A name for the Resolution",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="width",
+ * in="formData",
+ * description="The Display Width of the Resolution",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="height",
+ * in="formData",
+ * description="The Display Height of the Resolution",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Resolution"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ /* @var \Xibo\Entity\Resolution $resolution */
+ $resolution = $this->resolutionFactory->create($sanitizedParams->getString('resolution'),
+ $sanitizedParams->getInt('width'),
+ $sanitizedParams->getInt('height'));
+
+ $resolution->userId = $this->getUser()->userId;
+ $resolution->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $resolution->resolution),
+ 'id' => $resolution->resolutionId,
+ 'data' => $resolution
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Resolution
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Put(
+ * path="/resolution/{resolutionId}",
+ * operationId="resolutionEdit",
+ * tags={"resolution"},
+ * summary="Edit Resolution",
+ * description="Edit new Resolution",
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="path",
+ * description="The Resolution ID to Edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="resolution",
+ * in="formData",
+ * description="A name for the Resolution",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="width",
+ * in="formData",
+ * description="The Display Width of the Resolution",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="height",
+ * in="formData",
+ * description="The Display Height of the Resolution",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Resolution")
+ * )
+ * )
+ */
+ function edit(Request $request, Response $response, $id)
+ {
+ $resolution = $this->resolutionFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($resolution)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $resolution->resolution = $sanitizedParams->getString('resolution');
+ $resolution->width = $sanitizedParams->getInt('width');
+ $resolution->height = $sanitizedParams->getInt('height');
+ $resolution->enabled = $sanitizedParams->getCheckbox('enabled');
+ $resolution->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $resolution->resolution),
+ 'id' => $resolution->resolutionId,
+ 'data' => $resolution
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Resolution
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Delete(
+ * path="/resolution/{resolutionId}",
+ * operationId="resolutionDelete",
+ * tags={"resolution"},
+ * summary="Delete Resolution",
+ * description="Delete Resolution",
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="path",
+ * description="The Resolution ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ function delete(Request $request, Response $response, $id)
+ {
+ $resolution = $this->resolutionFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($resolution)) {
+ throw new AccessDeniedException();
+ }
+
+ $resolution->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Deleted %s'), $resolution->resolution),
+ 'httpStatus' => 204,
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/SavedReport.php b/lib/Controller/SavedReport.php
new file mode 100644
index 0000000..68f3633
--- /dev/null
+++ b/lib/Controller/SavedReport.php
@@ -0,0 +1,476 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\ReportResult;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\SendFile;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class SavedReport
+ * @package Xibo\Controller
+ */
+class SavedReport extends Base
+{
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * Set common dependencies.
+ * @param ReportServiceInterface $reportService
+ * @param ReportScheduleFactory $reportScheduleFactory
+ * @param SavedReportFactory $savedReportFactory
+ * @param MediaFactory $mediaFactory
+ * @param UserFactory $userFactory
+ */
+ public function __construct($reportService, $reportScheduleFactory, $savedReportFactory, $mediaFactory, $userFactory)
+ {
+ $this->reportService = $reportService;
+ $this->reportScheduleFactory = $reportScheduleFactory;
+ $this->savedReportFactory = $savedReportFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->userFactory = $userFactory;
+ }
+
+ //
+
+ /**
+ * Saved report Grid
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function savedReportGrid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $savedReports = $this->savedReportFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter([
+ 'saveAs' => $sanitizedQueryParams->getString('saveAs'),
+ 'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
+ 'userId' => $sanitizedQueryParams->getInt('userId'),
+ 'reportName' => $sanitizedQueryParams->getString('reportName'),
+ 'onlyMyReport' => $sanitizedQueryParams->getCheckbox('onlyMyReport'),
+ 'logicalOperatorName' => $sanitizedQueryParams->getString('logicalOperatorName'),
+ ], $sanitizedQueryParams));
+
+ foreach ($savedReports as $savedReport) {
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $savedReport->includeProperty('buttons');
+
+ // If a report class does not comply (i.e., no category or route) we get an error when trying to get the email template
+ // Dont show any button if the report is not compatible
+ // This will also check whether the report feature is enabled or not.
+ $compatible = true;
+ try {
+ // Get report email template
+ $emailTemplate = $this->reportService->getReportEmailTemplate($savedReport->reportName);
+ } catch (NotFoundException $exception) {
+ $compatible = false;
+ }
+
+ if ($compatible) {
+ // Show only convert button for schema version 1
+ if ($savedReport->schemaVersion == 1) {
+ $savedReport->buttons[] = [
+ 'id' => 'button_convert_report',
+ 'url' => $this->urlFor($request, 'savedreport.convert.form', ['id' => $savedReport->savedReportId]),
+ 'text' => __('Convert')
+ ];
+ } else {
+ $savedReport->buttons[] = [
+ 'id' => 'button_show_report.now',
+ 'class' => 'XiboRedirectButton',
+ 'url' => $this->urlFor($request, 'savedreport.open', ['id' => $savedReport->savedReportId, 'name' => $savedReport->reportName]),
+ 'text' => __('Open')
+ ];
+ $savedReport->buttons[] = ['divider' => true];
+
+ $savedReport->buttons[] = [
+ 'id' => 'button_goto_report',
+ 'class' => 'XiboRedirectButton',
+ 'url' => $this->urlFor($request, 'report.form', ['name' => $savedReport->reportName]),
+ 'text' => __('Back to Reports')
+ ];
+
+ $savedReport->buttons[] = [
+ 'id' => 'button_goto_schedule',
+ 'class' => 'XiboRedirectButton',
+ 'url' => $this->urlFor($request, 'reportschedule.view') . '?reportScheduleId=' . $savedReport->reportScheduleId. '&reportName='.$savedReport->reportName,
+ 'text' => __('Go to schedule')
+ ];
+
+ $savedReport->buttons[] = ['divider' => true];
+
+ if (!empty($emailTemplate)) {
+ // Export Button
+ $savedReport->buttons[] = [
+ 'id' => 'button_export_report',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor($request, 'savedreport.export', ['id' => $savedReport->savedReportId, 'name' => $savedReport->reportName]),
+ 'text' => __('Export as PDF')
+ ];
+ }
+
+ // Delete
+ if ($this->getUser()->checkDeleteable($savedReport)) {
+ // Show the delete button
+ $savedReport->buttons[] = array(
+ 'id' => 'savedreport_button_delete',
+ 'url' => $this->urlFor($request, 'savedreport.delete.form', ['id' => $savedReport->savedReportId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => array(
+ array('name' => 'commit-url', 'value' => $this->urlFor($request, 'savedreport.delete', ['id' => $savedReport->savedReportId])),
+ array('name' => 'commit-method', 'value' => 'delete'),
+ array('name' => 'id', 'value' => 'savedreport_button_delete'),
+ array('name' => 'text', 'value' => __('Delete')),
+ array('name' => 'sort-group', 'value' => 1),
+ array('name' => 'rowtitle', 'value' => $savedReport->saveAs),
+ )
+ );
+ }
+ }
+ }
+ }
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->savedReportFactory->countLast();
+ $this->getState()->setData($savedReports);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Displays the Saved Report Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displaySavedReportPage(Request $request, Response $response)
+ {
+ $reportsList = $this->reportService->listReports();
+ $availableReports = [];
+ foreach ($reportsList as $reports) {
+ foreach ($reports as $report) {
+ $availableReports[] = $report;
+ }
+ }
+
+ // Call to render the template
+ $this->getState()->template = 'saved-report-page';
+ $this->getState()->setData([
+ 'availableReports' => $availableReports
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteSavedReportForm(Request $request, Response $response, $id)
+ {
+ $savedReport = $this->savedReportFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($savedReport)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete this report schedule'));
+ }
+
+ $data = [
+ 'savedReport' => $savedReport
+ ];
+
+ $this->getState()->template = 'savedreport-form-delete';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Saved Report Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function savedReportDelete(Request $request, Response $response, $id)
+ {
+
+ $savedReport = $this->savedReportFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($savedReport)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete this report schedule'));
+ }
+
+ $savedReport->load();
+
+ // Delete
+ $savedReport->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $savedReport->saveAs)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Returns a Saved Report's preview
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $name
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function savedReportOpen(Request $request, Response $response, $id, $name)
+ {
+ // Retrieve the saved report result in array
+ /* @var ReportResult $results */
+ $results = $this->reportService->getSavedReportResults($id, $name);
+
+ // Set Template
+ $this->getState()->template = $this->reportService->getSavedReportTemplate($name);
+
+ $this->getState()->setData($results->jsonSerialize());
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Exports saved report as a PDF file
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $name
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function savedReportExport(Request $request, Response $response, $id, $name)
+ {
+ $savedReport = $this->savedReportFactory->getById($id);
+
+ // Retrieve the saved report result in array
+ /* @var ReportResult $results */
+ $results = $this->reportService->getSavedReportResults($id, $name);
+
+ // Get the report config
+ $report = $this->reportService->getReportByName($name);
+ if ($report->output_type == 'both' || $report->output_type == 'chart') {
+ $quickChartUrl = $this->getConfig()->getSetting('QUICK_CHART_URL');
+ if (!empty($quickChartUrl)) {
+ $quickChartUrl .= '/chart?width=1000&height=300&c=';
+
+ $script = $this->reportService->getReportChartScript($id, $name);
+
+ // Replace " with ' for the quick chart URL
+ $src = $quickChartUrl . str_replace('"', '\'', $script);
+
+ // If multiple charts needs to be displayed
+ $multipleCharts = [];
+ $chartScriptArray = json_decode($script, true);
+ foreach ($chartScriptArray as $key => $chartData) {
+ $multipleCharts[$key] = $quickChartUrl . str_replace('"', '\'', json_encode($chartData));
+ }
+ } else {
+ $placeholder = __('Chart could not be drawn because the CMS has not been configured with a Quick Chart URL.');
+ }
+ }
+
+ if ($report->output_type == 'both' || $report->output_type == 'table') { // only for tablebased report
+ $tableData = $results->table;
+ }
+
+ // Get report email template to export
+ $emailTemplate = $this->reportService->getReportEmailTemplate($name);
+
+ if (!empty($emailTemplate)) {
+ // Save PDF attachment
+ $showLogo = $this->getConfig()->getSetting('REPORTS_EXPORT_SHOW_LOGO', 1) == 1;
+ $body = $this->getView()->fetch(
+ $emailTemplate,
+ [
+ 'header' => $report->description,
+ 'logo' => ($showLogo) ? $this->getConfig()->uri('img/xibologo.png', true) : null,
+ 'title' => $savedReport->saveAs,
+ 'metadata' => $results->metadata,
+ 'tableData' => $tableData ?? null,
+ 'src' => $src ?? null,
+ 'multipleCharts' => $multipleCharts ?? null,
+ 'placeholder' => $placeholder ?? null
+ ]
+ );
+
+ $fileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/saved_report_' . $id . '.pdf';
+
+ try {
+ $mpdf = new \Mpdf\Mpdf([
+ 'tempDir' => $this->getConfig()->getSetting('LIBRARY_LOCATION') . '/temp',
+ 'orientation' => 'L',
+ 'mode' => 'c',
+ 'margin_left' => 20,
+ 'margin_right' => 20,
+ 'margin_top' => 20,
+ 'margin_bottom' => 20,
+ 'margin_header' => 5,
+ 'margin_footer' => 15
+ ]);
+ $mpdf->setFooter('Page {PAGENO}') ;
+ $mpdf->SetDisplayMode('fullpage');
+ $stylesheet = file_get_contents($this->getConfig()->uri('css/email-report.css', true));
+ $mpdf->WriteHTML($stylesheet, 1);
+ $mpdf->WriteHTML($body);
+ $mpdf->Output($fileName, \Mpdf\Output\Destination::FILE);
+ } catch (\Exception $error) {
+ $this->getLog()->error($error->getMessage());
+ }
+ }
+
+ // Return the file with PHP
+ $this->setNoOutput(true);
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $fileName
+ ));
+ }
+
+ /**
+ * Saved Report Convert Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function convertSavedReportForm(Request $request, Response $response, $id)
+ {
+ $savedReport = $this->savedReportFactory->getById($id);
+
+ $data = [
+ 'savedReport' => $savedReport
+ ];
+
+ $this->getState()->template = 'savedreport-form-convert';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Converts a Saved Report from Schema Version 1 to 2
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param $name
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function savedReportConvert(Request $request, Response $response, $id, $name)
+ {
+ $savedReport = $this->savedReportFactory->getById($id);
+
+ if ($savedReport->schemaVersion == 2) {
+ throw new GeneralException(__('This report has already been converted to the latest version.'));
+ }
+
+ // Convert Result to schemaVersion 2
+ $this->reportService->convertSavedReportResults($id, $name);
+
+ $savedReport->schemaVersion = 2;
+ $savedReport->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Saved Report Converted to Schema Version 2'))
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ //
+}
diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php
new file mode 100644
index 0000000..cdbbdaf
--- /dev/null
+++ b/lib/Controller/Schedule.php
@@ -0,0 +1,2787 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Illuminate\Support\Str;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Entity\ScheduleReminder;
+use Xibo\Event\ScheduleCriteriaRequestEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\CommandFactory;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ScheduleCriteriaFactory;
+use Xibo\Factory\ScheduleExclusionFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\ScheduleReminderFactory;
+use Xibo\Factory\SyncGroupFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Session;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Schedule
+ * @package Xibo\Controller
+ */
+class Schedule extends Base
+{
+ /**
+ * @var Session
+ */
+ private $session;
+
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /**
+ * @var ScheduleReminderFactory
+ */
+ private $scheduleReminderFactory;
+
+ /**
+ * @var ScheduleExclusionFactory
+ */
+ private $scheduleExclusionFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
+ private SyncGroupFactory $syncGroupFactory;
+
+ /**
+ * Set common dependencies.
+ * @param Session $session
+ * @param ScheduleFactory $scheduleFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param CampaignFactory $campaignFactory
+ * @param CommandFactory $commandFactory
+ * @param DisplayFactory $displayFactory
+ * @param LayoutFactory $layoutFactory
+ * @param DayPartFactory $dayPartFactory
+ * @param ScheduleReminderFactory $scheduleReminderFactory
+ * @param ScheduleExclusionFactory $scheduleExclusionFactory
+ */
+
+ public function __construct(
+ $session,
+ $scheduleFactory,
+ $displayGroupFactory,
+ $campaignFactory,
+ $commandFactory,
+ $displayFactory,
+ $layoutFactory,
+ $dayPartFactory,
+ $scheduleReminderFactory,
+ $scheduleExclusionFactory,
+ SyncGroupFactory $syncGroupFactory,
+ private readonly ScheduleCriteriaFactory $scheduleCriteriaFactory
+ ) {
+ $this->session = $session;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->commandFactory = $commandFactory;
+ $this->displayFactory = $displayFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ $this->scheduleReminderFactory = $scheduleReminderFactory;
+ $this->scheduleExclusionFactory = $scheduleExclusionFactory;
+ $this->syncGroupFactory = $syncGroupFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws ControllerNotImplemented
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ // get the default longitude and latitude from CMS options
+ $defaultLat = (float)$this->getConfig()->getSetting('DEFAULT_LAT');
+ $defaultLong = (float)$this->getConfig()->getSetting('DEFAULT_LONG');
+
+ $data = [
+ 'defaultLat' => $defaultLat,
+ 'defaultLong' => $defaultLong,
+ 'eventTypes' => \Xibo\Entity\Schedule::getEventTypes(),
+ ];
+
+ // Render the Theme and output
+ $this->getState()->template = 'schedule-page';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Generates the calendar that we draw events on
+ * @deprecated - Deprecated API: This endpoint will be removed in v5.0
+ * @SWG\Get(
+ * path="/schedule/data/events",
+ * operationId="scheduleCalendarData",
+ * description="⚠️ This endpoint is deprecated and will be removed in v5.0.",
+ * tags={"schedule"},
+ * deprecated=true,
+ * @SWG\Parameter(
+ * name="displayGroupIds",
+ * description="The DisplayGroupIds to return the schedule for. [-1] for All.",
+ * in="query",
+ * type="array",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="from",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="From Date in Y-m-d H:i:s format, if not provided defaults to start of the current month"
+ * ),
+ * @SWG\Parameter(
+ * name="to",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="To Date in Y-m-d H:i:s format, if not provided defaults to start of the next month"
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful response",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/ScheduleCalendarData")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function eventData(Request $request, Response $response)
+ {
+ $response = $response
+ ->withHeader(
+ 'Warning',
+ '299 - "Deprecated API: /schedule/data/events will be removed in v5.0"'
+ );
+
+ $this->getLog()->error('Deprecated API called: /schedule/data/events');
+
+ $this->setNoOutput();
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupIds', ['default' => []]);
+ $displaySpecificDisplayGroupIds = $sanitizedParams->getIntArray('displaySpecificGroupIds', ['default' => []]);
+ $originalDisplayGroupIds = array_merge($displayGroupIds, $displaySpecificDisplayGroupIds);
+ $campaignId = $sanitizedParams->getInt('campaignId');
+
+ $start = $sanitizedParams->getDate('from', ['default' => Carbon::now()->startOfMonth()]);
+ $end = $sanitizedParams->getDate('to', ['default' => Carbon::now()->addMonth()->startOfMonth()]);
+
+ if (count($originalDisplayGroupIds) <= 0) {
+ return $response->withJson(['success' => 1, 'result' => []]);
+ }
+
+ // Setting for whether we show Layouts without permissions
+ $showLayoutName = ($this->getConfig()->getSetting('SCHEDULE_SHOW_LAYOUT_NAME') == 1);
+
+ // Permissions check the list of display groups with the user accessible list of display groups
+ $resolvedDisplayGroupIds = array_diff($originalDisplayGroupIds, [-1]);
+
+ if (!$this->getUser()->isSuperAdmin()) {
+ $userDisplayGroupIds = array_map(function ($element) {
+ /** @var \Xibo\Entity\DisplayGroup $element */
+ return $element->displayGroupId;
+ }, $this->displayGroupFactory->query(null, ['isDisplaySpecific' => -1]));
+
+ // Reset the list to only those display groups that intersect and if 0 have been provided, only those from
+ // the user list
+ $resolvedDisplayGroupIds = (count($originalDisplayGroupIds) > 0) ? array_intersect($originalDisplayGroupIds, $userDisplayGroupIds) : $userDisplayGroupIds;
+
+ $this->getLog()->debug('Resolved list of display groups ['
+ . json_encode($resolvedDisplayGroupIds) . '] from provided list ['
+ . json_encode($originalDisplayGroupIds) . '] and user list ['
+ . json_encode($userDisplayGroupIds) . ']');
+
+ // If we have none, then we do not return any events.
+ if (count($resolvedDisplayGroupIds) <= 0) {
+ return $response->withJson(['success' => 1, 'result' => []]);
+ }
+ }
+
+ $events = [];
+ $filter = [
+ 'futureSchedulesFrom' => $start->format('U'),
+ 'futureSchedulesTo' => $end->format('U'),
+ 'displayGroupIds' => $resolvedDisplayGroupIds,
+ 'geoAware' => $sanitizedParams->getInt('geoAware'),
+ 'recurring' => $sanitizedParams->getInt('recurring'),
+ 'eventTypeId' => $sanitizedParams->getInt('eventTypeId'),
+ 'name' => $sanitizedParams->getString('name'),
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'),
+ 'logicalOperatorName' => $sanitizedParams->getString('logicalOperatorName'),
+ ];
+
+ if ($campaignId != null) {
+ // Is this an ad campaign?
+ $campaign = $this->campaignFactory->getById($campaignId);
+ if ($campaign->type === 'ad') {
+ $filter['parentCampaignId'] = $campaignId;
+ } else {
+ $filter['campaignId'] = $campaignId;
+ }
+ }
+
+ foreach ($this->scheduleFactory->query(['FromDT'], $filter) as $row) {
+ /* @var \Xibo\Entity\Schedule $row */
+
+ // Generate this event
+ try {
+ $scheduleEvents = $row->getEvents($start, $end);
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Unable to getEvents for ' . $row->eventId);
+ continue;
+ }
+
+ if (count($scheduleEvents) <= 0) {
+ continue;
+ }
+
+ $this->getLog()->debug('EventId ' . $row->eventId . ' as events: ' . json_encode($scheduleEvents));
+
+ // Load the display groups
+ $row->load();
+
+ $displayGroupList = '';
+
+ if (count($row->displayGroups) >= 0) {
+ $array = array_map(function ($object) {
+ return $object->displayGroup;
+ }, $row->displayGroups);
+ $displayGroupList = implode(', ', $array);
+ }
+
+ // Event Permissions
+ $editable = $this->getUser()->featureEnabled('schedule.modify')
+ && $this->isEventEditable($row);
+
+ // Event Title
+ if ($this->isSyncEvent($row->eventTypeId)) {
+ $title = sprintf(
+ __('%s scheduled on sync group %s'),
+ $row->getSyncTypeForEvent(),
+ $row->getUnmatchedProperty('syncGroupName'),
+ );
+ } else if ($row->campaignId == 0) {
+ // Command
+ $title = __('%s scheduled on %s', $row->command, $displayGroupList);
+ } else {
+ // Should we show the Layout name, or not (depending on permission)
+ // Make sure we only run the below code if we have to, it's quite expensive
+ if (!$showLayoutName && !$this->getUser()->isSuperAdmin()) {
+ // Campaign
+ $campaign = $this->campaignFactory->getById($row->campaignId);
+
+ if (!$this->getUser()->checkViewable($campaign)) {
+ $row->campaign = __('Private Item');
+ }
+ }
+
+ $title = sprintf(
+ __('%s scheduled on %s'),
+ $row->getUnmatchedProperty('parentCampaignName', $row->campaign),
+ $displayGroupList
+ );
+
+ if ($row->eventTypeId === \Xibo\Entity\Schedule::$INTERRUPT_EVENT) {
+ $title .= __(' with Share of Voice %d seconds per hour', $row->shareOfVoice);
+ }
+ }
+
+ // Day diff from start date to end date
+ $diff = $end->diff($start)->days;
+
+ // Show all Hourly repeats on the day view
+ if ($row->recurrenceType == 'Minute' || ($diff > 1 && $row->recurrenceType == 'Hour')) {
+ $title .= __(', Repeats every %s %s', $row->recurrenceDetail, $row->recurrenceType);
+ }
+
+ // Event URL
+ $editUrlWeb = 'schedule.edit.form';
+ $editUrl = ($this->isApi($request)) ? 'schedule.edit' : $editUrlWeb;
+ $url = ($editable) ? $this->urlFor($request, $editUrl, ['id' => $row->eventId]) : '#';
+
+ $days = [];
+
+ // Event scheduled events
+ foreach ($scheduleEvents as $scheduleEvent) {
+ $this->getLog()->debug(sprintf('Parsing event dates from %s and %s', $scheduleEvent->fromDt, $scheduleEvent->toDt));
+
+ // Get the day of schedule start
+ $fromDtDay = Carbon::createFromTimestamp($scheduleEvent->fromDt)->format('Y-m-d');
+
+ // Handle command events which do not have a toDt
+ if ($row->eventTypeId == \Xibo\Entity\Schedule::$COMMAND_EVENT) {
+ $scheduleEvent->toDt = $scheduleEvent->fromDt;
+ }
+
+ // Parse our dates into a Date object, so that we convert to local time correctly.
+ $fromDt = Carbon::createFromTimestamp($scheduleEvent->fromDt);
+ $toDt = Carbon::createFromTimestamp($scheduleEvent->toDt);
+
+ // Set the row from/to date to be an ISO date for display
+ $scheduleEvent->fromDt =
+ Carbon::createFromTimestamp($scheduleEvent->fromDt)
+ ->format(DateFormatHelper::getSystemFormat());
+ $scheduleEvent->toDt =
+ Carbon::createFromTimestamp($scheduleEvent->toDt)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $this->getLog()->debug(sprintf('Start date is ' . $fromDt->toRssString() . ' ' . $scheduleEvent->fromDt));
+ $this->getLog()->debug(sprintf('End date is ' . $toDt->toRssString() . ' ' . $scheduleEvent->toDt));
+
+ // For a minute/hourly repeating events show only 1 event per day
+ if ($row->recurrenceType == 'Minute' || ($diff > 1 && $row->recurrenceType == 'Hour')) {
+ if (array_key_exists($fromDtDay, $days)) {
+ continue;
+ } else {
+ $days[$fromDtDay] = $scheduleEvent->fromDt;
+ }
+ }
+
+ /**
+ * @SWG\Definition(
+ * definition="ScheduleCalendarData",
+ * @SWG\Property(
+ * property="id",
+ * type="integer",
+ * description="Event ID"
+ * ),
+ * @SWG\Property(
+ * property="title",
+ * type="string",
+ * description="Event Title"
+ * ),
+ * @SWG\Property(
+ * property="sameDay",
+ * type="integer",
+ * description="Does this event happen only on 1 day"
+ * ),
+ * @SWG\Property(
+ * property="event",
+ * ref="#/definitions/Schedule"
+ * )
+ * )
+ */
+ $events[] = [
+ 'id' => $row->eventId,
+ 'title' => $title,
+ 'url' => ($editable) ? $url : null,
+ 'start' => $fromDt->format('U') * 1000,
+ 'end' => $toDt->format('U') * 1000,
+ 'sameDay' => ($fromDt->day == $toDt->day && $fromDt->month == $toDt->month && $fromDt->year == $toDt->year),
+ 'editable' => $editable,
+ 'event' => $row,
+ 'scheduleEvent' => $scheduleEvent,
+ 'recurringEvent' => $row->recurrenceType != ''
+ ];
+ }
+ }
+
+ return $response->withJson(['success' => 1, 'result' => $events]);
+ }
+
+ /**
+ * Event List
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Get(
+ * path="/schedule/{displayGroupId}/events",
+ * operationId="scheduleCalendarDataDisplayGroup",
+ * tags={"schedule"},
+ * @SWG\Parameter(
+ * name="displayGroupId",
+ * description="The DisplayGroupId to return the event list for.",
+ * in="path",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="singlePointInTime",
+ * in="query",
+ * required=false,
+ * type="integer",
+ * ),
+ * @SWG\Parameter(
+ * name="date",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="Date in Y-m-d H:i:s"
+ * ),
+ * @SWG\Parameter(
+ * name="startDate",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="Date in Y-m-d H:i:s"
+ * ),
+ * @SWG\Parameter(
+ * name="endDate",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="Date in Y-m-d H:i:s"
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful response"
+ * )
+ * )
+ */
+ public function eventList(Request $request, Response $response, $id)
+ {
+ $displayGroup = $this->displayGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkViewable($displayGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Setting for whether we show Layouts with out permissions
+ $showLayoutName = ($this->getConfig()->getSetting('SCHEDULE_SHOW_LAYOUT_NAME') == 1);
+
+ $singlePointInTime = $sanitizedParams->getInt('singlePointInTime');
+ if ($singlePointInTime == 1) {
+ $startDate = $sanitizedParams->getDate('date');
+ $endDate = $sanitizedParams->getDate('date');
+ } else {
+ $startDate = $sanitizedParams->getDate('startDate');
+ $endDate = $sanitizedParams->getDate('endDate');
+ }
+
+ // Reset the seconds
+ $startDate->second(0);
+ $endDate->second(0);
+
+ $this->getLog()->debug(
+ sprintf(
+ 'Generating eventList for DisplayGroupId ' . $id . ' from date '
+ . $startDate->format(DateFormatHelper::getSystemFormat()) . ' to '
+ . $endDate->format(DateFormatHelper::getSystemFormat())
+ )
+ );
+
+ // Get a list of scheduled events
+ $events = [];
+ $displayGroups = [];
+ $layouts = [];
+ $campaigns = [];
+
+ // Add the displayGroupId I am filtering for to the displayGroup object
+ $displayGroups[$displayGroup->displayGroupId] = $displayGroup;
+
+ // Is this group a display specific group, or a standalone?
+ $options = [];
+ /** @var \Xibo\Entity\Display $display */
+ $display = null;
+ if ($displayGroup->isDisplaySpecific == 1) {
+ // We should lookup the displayId for this group.
+ $display = $this->displayFactory->getByDisplayGroupId($id)[0];
+ } else {
+ $options['useGroupId'] = true;
+ $options['displayGroupId'] = $id;
+ }
+
+ // Get list of events
+ $scheduleForXmds = $this->scheduleFactory->getForXmds(
+ ($display === null) ? null : $display->displayId,
+ $startDate,
+ $endDate,
+ $options
+ );
+
+ $this->getLog()->debug(count($scheduleForXmds) . ' events returned for displaygroup and date');
+
+ foreach ($scheduleForXmds as $event) {
+
+ // Ignore command events
+ if ($event['eventTypeId'] == \Xibo\Entity\Schedule::$COMMAND_EVENT)
+ continue;
+
+ // Ignore events that have a campaignId, but no layoutId (empty Campaigns)
+ if ($event['layoutId'] == 0 && $event['campaignId'] != 0)
+ continue;
+
+ // Assess schedules
+ $schedule = $this->scheduleFactory->createEmpty()->hydrate($event, ['intProperties' => ['isPriority', 'syncTimezone', 'displayOrder', 'fromDt', 'toDt']]);
+ $schedule->load();
+
+ $this->getLog()->debug('EventId ' . $schedule->eventId . ' exists in the schedule window, checking its instances for activity');
+
+ // Get scheduled events based on recurrence
+ try {
+ $scheduleEvents = $schedule->getEvents($startDate, $endDate);
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Unable to getEvents for ' . $schedule->eventId);
+ continue;
+ }
+
+ // If this event is active, collect extra information and add to the events list
+ if (count($scheduleEvents) > 0) {
+ // Add the link to the schedule
+ if (!$this->isApi($request)) {
+ $route = 'schedule.edit.form';
+ $schedule->setUnmatchedProperty(
+ 'link',
+ $this->urlFor($request, $route, ['id' => $schedule->eventId])
+ );
+ }
+
+ // Add the Layout
+ if ($event['eventTypeId'] == \Xibo\Entity\Schedule::$SYNC_EVENT) {
+ $layoutId = $event['syncLayoutId'];
+ } else {
+ $layoutId = $event['layoutId'];
+ }
+
+ $this->getLog()->debug('Adding this events layoutId [' . $layoutId . '] to list');
+
+ if ($layoutId != 0 && !array_key_exists($layoutId, $layouts)) {
+ // Look up the layout details
+ $layout = $this->layoutFactory->getById($layoutId);
+
+ // Add the link to the layout
+ if (!$this->isApi($request)) {
+ // do not link to Layout Designer for Full screen Media/Playlist Layout.
+ $link = (in_array($event['eventTypeId'], [7, 8]))
+ ? ''
+ : $this->urlFor($request, 'layout.designer', ['id' => $layout->layoutId]);
+
+ $layout->setUnmatchedProperty(
+ 'link',
+ $link
+ );
+ }
+ if ($showLayoutName || $this->getUser()->checkViewable($layout)) {
+ $layouts[$layoutId] = $layout;
+ } else {
+ $layouts[$layoutId] = [
+ 'layout' => __('Private Item')
+ ];
+ }
+
+ // Add the Campaign
+ $layout->campaigns = $this->campaignFactory->getByLayoutId($layout->layoutId);
+
+ if (count($layout->campaigns) > 0) {
+ // Add to the campaigns array
+ foreach ($layout->campaigns as $campaign) {
+ if (!array_key_exists($campaign->campaignId, $campaigns)) {
+ $campaigns[$campaign->campaignId] = $campaign;
+ }
+ }
+ }
+ }
+
+ $event['campaign'] = is_object($layouts[$layoutId]) ? $layouts[$layoutId]->layout : $layouts[$layoutId];
+
+ // Display Group details
+ $this->getLog()->debug('Adding this events displayGroupIds to list');
+ $schedule->excludeProperty('displayGroups');
+
+ foreach ($schedule->displayGroups as $scheduleDisplayGroup) {
+ if (!array_key_exists($scheduleDisplayGroup->displayGroupId, $displayGroups)) {
+ $displayGroups[$scheduleDisplayGroup->displayGroupId] = $scheduleDisplayGroup;
+ }
+ }
+
+ // Determine the intermediate display groups
+ $this->getLog()->debug('Adding this events intermediateDisplayGroupIds to list');
+ $schedule->setUnmatchedProperty(
+ 'intermediateDisplayGroupIds',
+ $this->calculateIntermediates($display, $displayGroup, $event['displayGroupId'])
+ );
+
+ foreach ($schedule->getUnmatchedProperty('intermediateDisplayGroupIds') as $intermediate) {
+ if (!array_key_exists($intermediate, $displayGroups)) {
+ $displayGroups[$intermediate] = $this->displayGroupFactory->getById($intermediate);
+ }
+ }
+
+ $this->getLog()->debug(sprintf('Adding scheduled events: ' . json_encode($scheduleEvents)));
+
+ // We will never save this and we need the eventId on the agenda view
+ $eventId = $schedule->eventId;
+
+ foreach ($scheduleEvents as $scheduleEvent) {
+ $schedule = clone $schedule;
+ $schedule->eventId = $eventId;
+ $schedule->fromDt = $scheduleEvent->fromDt;
+ $schedule->toDt = $scheduleEvent->toDt;
+ $schedule->setUnmatchedProperty('layoutId', intval($layoutId));
+ $schedule->setUnmatchedProperty('displayGroupId', intval($event['displayGroupId']));
+
+ $events[] = $schedule;
+ }
+ } else {
+ $this->getLog()->debug('No activity inside window');
+ }
+ }
+
+ $this->getState()->hydrate([
+ 'data' => [
+ 'events' => $events,
+ 'displayGroups' => $displayGroups,
+ 'layouts' => $layouts,
+ 'campaigns' => $campaigns
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param \Xibo\Entity\Display $display
+ * @param \Xibo\Entity\DisplayGroup $displayGroup
+ * @param int $eventDisplayGroupId
+ * @return array
+ * @throws NotFoundException
+ */
+ private function calculateIntermediates($display, $displayGroup, $eventDisplayGroupId)
+ {
+ $this->getLog()->debug('Calculating intermediates for events displayGroupId ' . $eventDisplayGroupId . ' viewing displayGroupId ' . $displayGroup->displayGroupId);
+
+ $intermediates = [];
+ $eventDisplayGroup = $this->displayGroupFactory->getById($eventDisplayGroupId);
+
+ // Is the event scheduled directly on the displayGroup in question?
+ if ($displayGroup->displayGroupId == $eventDisplayGroupId)
+ return $intermediates;
+
+ // Is the event scheduled directly on the display in question?
+ if ($eventDisplayGroup->isDisplaySpecific == 1)
+ return $intermediates;
+
+ $this->getLog()->debug('Event isnt directly scheduled to a display or to the current displaygroup ');
+
+ // There are nested groups involved, so we need to trace the relationship tree.
+ if ($display === null) {
+ $this->getLog()->debug('We are looking at a DisplayGroup');
+ // We are on a group.
+
+ // Get the relationship tree for this display group
+ $tree = $this->displayGroupFactory->getRelationShipTree($displayGroup->displayGroupId);
+
+ foreach ($tree as $branch) {
+ $this->getLog()->debug(
+ 'Branch found: ' . $branch->displayGroup .
+ ' [' . $branch->displayGroupId . '], ' .
+ $branch->getUnmatchedProperty('depth') . '-' .
+ $branch->getUnmatchedProperty('level')
+ );
+
+ if ($branch->getUnmatchedProperty('depth') < 0 &&
+ $branch->displayGroupId != $eventDisplayGroup->displayGroupId
+ ) {
+ $intermediates[] = $branch->displayGroupId;
+ }
+ }
+ } else {
+ // We are on a display.
+ $this->getLog()->debug('We are looking at a Display');
+
+ // We will need to get all of this displays groups and then add only those ones that give us an eventual
+ // match on the events display group (complicated or what!)
+ $display->load();
+
+ foreach ($display->displayGroups as $displayDisplayGroup) {
+
+ // Ignore the display specific group
+ if ($displayDisplayGroup->isDisplaySpecific == 1)
+ continue;
+
+ // Get the relationship tree for this display group
+ $tree = $this->displayGroupFactory->getRelationShipTree($displayDisplayGroup->displayGroupId);
+
+ $found = false;
+ $possibleIntermediates = [];
+
+ foreach ($tree as $branch) {
+ $this->getLog()->debug(
+ 'Branch found: ' . $branch->displayGroup .
+ ' [' . $branch->displayGroupId . '], ' .
+ $branch->getUnmatchedProperty('depth') . '-' .
+ $branch->getUnmatchedProperty('level')
+ );
+
+ if ($branch->displayGroupId != $eventDisplayGroup->displayGroupId) {
+ $possibleIntermediates[] = $branch->displayGroupId;
+ }
+
+ if ($branch->displayGroupId != $eventDisplayGroup->displayGroupId && count($possibleIntermediates) > 0)
+ $found = true;
+ }
+
+ if ($found) {
+ $this->getLog()->debug('We have found intermediates ' . json_encode($possibleIntermediates) . ' for display when looking at displayGroupId ' . $displayDisplayGroup->displayGroupId);
+ $intermediates = array_merge($intermediates, $possibleIntermediates);
+ }
+ }
+ }
+
+ $this->getLog()->debug('Returning intermediates: ' . json_encode($intermediates));
+
+ return $intermediates;
+ }
+
+ /**
+ * Shows a form to add an event
+ * @param Request $request
+ * @param Response $response
+ * @param string|null $from
+ * @param int|null $id
+ * @return ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function addForm(Request $request, Response $response, ?string $from, ?int $id): Response|ResponseInterface
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // get the default longitude and latitude from CMS options
+ $defaultLat = (float)$this->getConfig()->getSetting('DEFAULT_LAT');
+ $defaultLong = (float)$this->getConfig()->getSetting('DEFAULT_LONG');
+
+ // Dispatch an event to initialize schedule criteria
+ $event = new ScheduleCriteriaRequestEvent();
+ $this->getDispatcher()->dispatch($event, ScheduleCriteriaRequestEvent::$NAME);
+
+ // Retrieve the criteria data from the event
+ $criteria = $event->getCriteria();
+ $criteriaDefaultCondition = $event->getCriteriaDefaultCondition();
+
+ $addFormData = [
+ 'dayParts' => $this->dayPartFactory->allWithSystem(['isRetired' => 0]),
+ 'reminders' => [],
+ 'defaultLat' => $defaultLat,
+ 'defaultLong' => $defaultLong,
+ 'eventTypes' => \Xibo\Entity\Schedule::getEventTypes(),
+ 'addForm' => true,
+ 'relativeTime' => 0,
+ 'setDisplaysFromFilter' => true,
+ 'scheduleCriteria' => $criteria,
+ 'criteriaDefaultCondition' => $criteriaDefaultCondition
+ ];
+ $formNowData = [];
+
+ if (!empty($from) && !empty($id)) {
+ $formNowData = [
+ 'event' => [
+ 'eventTypeId' => $this->getEventTypeId($from),
+ ],
+ 'campaign' => (
+ ($from == 'Campaign' || $from == 'Layout')
+ ? $this->campaignFactory->getById($id)
+ : null
+ ),
+ 'displayGroups' => (($from == 'DisplayGroup') ? [$this->displayGroupFactory->getById($id)] : null),
+ 'displayGroupIds' => (($from == 'DisplayGroup') ? [$id] : [0]),
+ 'mediaId' => (($from === 'Library') ? $id : null),
+ 'playlistId' => (($from === 'Playlist') ? $id : null),
+ // Lock for layout editor only
+ 'readonlySelect' => ($from == 'Layout' && $sanitizedParams->getString('fromLayoutEditor') === '1'),
+ // Hide event type, except for Display Groups
+ 'hideEventType' => !($from == 'DisplayGroup'),
+ // Skip first step, except for Display Groups
+ 'skipFirstStep' => !($from == 'DisplayGroup'),
+ // If coming from display page, don't show syncEvent type
+ 'eventTypes' => \Xibo\Entity\Schedule::getEventTypes((($from === 'DisplayGroup') ? [9] : [])),
+ 'addForm' => true,
+ 'fromCampaign' => ($from == 'Campaign'),
+ 'relativeTime' => 1,
+ 'setDisplaysFromFilter' => false,
+ ];
+ }
+
+ $formData = array_merge($addFormData, $formNowData);
+
+ $this->getState()->template = 'schedule-form-edit';
+ $this->getState()->setData($formData);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Model to use for supplying key/value pairs to arrays
+ * @SWG\Definition(
+ * definition="ScheduleReminderArray",
+ * @SWG\Property(
+ * property="reminder_value",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="reminder_type",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="reminder_option",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="reminder_isEmailHidden",
+ * type="integer"
+ * )
+ * )
+ */
+
+ /**
+ * Add Event
+ * @SWG\Post(
+ * path="/schedule",
+ * operationId="scheduleAdd",
+ * tags={"schedule"},
+ * summary="Add Schedule Event",
+ * description="Add a new scheduled event for a Campaign/Layout to be shown on a Display Group/Display.",
+ * @SWG\Parameter(
+ * name="eventTypeId",
+ * in="formData",
+ * description="The Event Type Id to use for this Event.
+ * 1=Layout, 2=Command, 3=Overlay, 4=Interrupt, 5=Campaign, 6=Action, 7=Media Library, 8=Playlist",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="formData",
+ * description="The Campaign ID to use for this Event.
+ * If a Layout is needed then the Campaign specific ID for that Layout should be used.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="fullScreenCampaignId",
+ * in="formData",
+ * description="For Media or Playlist event Type. The Layout specific Campaign ID to use for this Event.
+ * This needs to be the Layout created with layout/fullscreen function",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="commandId",
+ * in="formData",
+ * description="The Command ID to use for this Event.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="The Media ID to use for this Event.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="The display order for this event. ",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isPriority",
+ * in="formData",
+ * description="An integer indicating the priority of this event. Normal events have a priority of 0.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupIds",
+ * in="formData",
+ * description="The Display Group IDs for this event. Display specific Group IDs should be used to schedule on single displays.",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="dayPartId",
+ * in="formData",
+ * description="The Day Part for this event. Overrides supported are 0(custom) and 1(always). Defaulted to 0.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="syncTimezone",
+ * in="formData",
+ * description="Should this schedule be synced to the resulting Display timezone?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="formData",
+ * description="The from date for this event.",
+ * type="string",
+ * format="date-time",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="formData",
+ * description="The to date for this event.",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceType",
+ * in="formData",
+ * description="The type of recurrence to apply to this event.",
+ * type="string",
+ * required=false,
+ * enum={"", "Minute", "Hour", "Day", "Week", "Month", "Year"}
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceDetail",
+ * in="formData",
+ * description="The interval for the recurrence.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceRange",
+ * in="formData",
+ * description="The end date for this events recurrence.",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceRepeatsOn",
+ * in="formData",
+ * description="The days of the week that this event repeats - weekly only",
+ * type="string",
+ * format="array",
+ * required=false,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="scheduleReminders",
+ * in="formData",
+ * description="Array of Reminders for this event",
+ * type="array",
+ * required=false,
+ * @SWG\Items(
+ * ref="#/definitions/ScheduleReminderArray"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="isGeoAware",
+ * in="formData",
+ * description="Flag (0-1), whether this event is using Geo Location",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="geoLocation",
+ * in="formData",
+ * description="Array of comma separated strings each with comma separated pair of coordinates",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="geoLocationJson",
+ * in="formData",
+ * description="Valid GeoJSON string, use as an alternative to geoLocation parameter",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionType",
+ * in="formData",
+ * description="For Action eventTypeId, the type of the action - command or navLayout",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionTriggerCode",
+ * in="formData",
+ * description="For Action eventTypeId, the webhook trigger code for the Action",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionLayoutCode",
+ * in="formData",
+ * description="For Action eventTypeId and navLayout actionType, the Layout Code identifier",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="formData",
+ * description="For Data Connector eventTypeId, the DataSet ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetParams",
+ * in="formData",
+ * description="For Data Connector eventTypeId, the DataSet params",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Schedule"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $this->getLog()->debug('Add Schedule');
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $embed = ($sanitizedParams->getString('embed') != null)
+ ? explode(',', $sanitizedParams->getString('embed'))
+ : [];
+
+ // Get the custom day part to use as a default day part
+ $customDayPart = $this->dayPartFactory->getCustomDayPart();
+
+ $schedule = $this->scheduleFactory->createEmpty();
+ $schedule->userId = $this->getUser()->userId;
+ $schedule->eventTypeId = $sanitizedParams->getInt('eventTypeId');
+ $schedule->campaignId = $this->isFullScreenSchedule($schedule->eventTypeId)
+ ? $sanitizedParams->getInt('fullScreenCampaignId')
+ : $sanitizedParams->getInt('campaignId');
+ $schedule->commandId = $sanitizedParams->getInt('commandId');
+ $schedule->displayOrder = $sanitizedParams->getInt('displayOrder', ['default' => 0]);
+ $schedule->isPriority = $sanitizedParams->getInt('isPriority', ['default' => 0]);
+ $schedule->dayPartId = $sanitizedParams->getInt('dayPartId', ['default' => $customDayPart->dayPartId]);
+ $schedule->isGeoAware = $sanitizedParams->getCheckbox('isGeoAware');
+ $schedule->actionType = $sanitizedParams->getString('actionType');
+ $schedule->actionTriggerCode = $sanitizedParams->getString('actionTriggerCode');
+ $schedule->actionLayoutCode = $sanitizedParams->getString('actionLayoutCode');
+ $schedule->maxPlaysPerHour = $sanitizedParams->getInt('maxPlaysPerHour', ['default' => 0]);
+ $schedule->syncGroupId = $sanitizedParams->getInt('syncGroupId');
+ $schedule->name = $sanitizedParams->getString('name');
+
+ // Set the parentCampaignId for campaign events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$CAMPAIGN_EVENT) {
+ $schedule->parentCampaignId = $schedule->campaignId;
+
+ // Make sure we're not directly scheduling an ad campaign
+ $campaign = $this->campaignFactory->getById($schedule->campaignId);
+ if ($campaign->type === 'ad') {
+ throw new InvalidArgumentException(
+ __('Direct scheduling of an Ad Campaign is not allowed'),
+ 'campaignId'
+ );
+ }
+ }
+
+ // Fields only collected for interrupt events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$INTERRUPT_EVENT) {
+ $schedule->shareOfVoice = $sanitizedParams->getInt('shareOfVoice', [
+ 'throw' => function () {
+ new InvalidArgumentException(
+ __('Share of Voice must be a whole number between 0 and 3600'),
+ 'shareOfVoice'
+ );
+ }
+ ]);
+ } else {
+ $schedule->shareOfVoice = null;
+ }
+
+ // Fields only collected for data connector events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$DATA_CONNECTOR_EVENT) {
+ $schedule->dataSetId = $sanitizedParams->getInt('dataSetId', [
+ 'throw' => function () {
+ new InvalidArgumentException(
+ __('Please select a DataSet'),
+ 'dataSetId'
+ );
+ }
+ ]);
+ $schedule->dataSetParams = $sanitizedParams->getString('dataSetParams');
+ }
+
+ // Create fullscreen layout for media/playlist events
+ if ($this->isFullScreenSchedule($schedule->eventTypeId)) {
+ $type = $schedule->eventTypeId === \Xibo\Entity\Schedule::$MEDIA_EVENT ? 'media' : 'playlist';
+ $id = ($type === 'media') ? $sanitizedParams->getInt('mediaId') : $sanitizedParams->getInt('playlistId');
+
+ if (!$id) {
+ throw new InvalidArgumentException(
+ sprintf('%sId is required when scheduling %s events.', ucfirst($type), $type)
+ );
+ }
+
+ $fsLayout = $this->layoutFactory->createFullScreenLayout(
+ $type,
+ $id,
+ $sanitizedParams->getInt('resolutionId'),
+ $sanitizedParams->getString('backgroundColor'),
+ $sanitizedParams->getInt('layoutDuration'),
+ );
+
+ $schedule->campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($fsLayout->layoutId);
+ $schedule->parentCampaignId = $schedule->campaignId;
+ }
+
+ // API request can provide an array of coordinates or valid GeoJSON, handle both cases here.
+ if ($this->isApi($request) && $schedule->isGeoAware === 1) {
+ if ($sanitizedParams->getArray('geoLocation') != null) {
+ // get string array from API
+ $coordinates = $sanitizedParams->getArray('geoLocation');
+ // generate GeoJSON and assign to Schedule
+ $schedule->geoLocation = $this->createGeoJson($coordinates);
+ } else {
+ // we were provided with GeoJSON
+ $schedule->geoLocation = $sanitizedParams->getString('geoLocationJson');
+ }
+ } else {
+ // if we are not using API, then valid GeoJSON is created in the front end.
+ $schedule->geoLocation = $sanitizedParams->getString('geoLocation');
+ }
+
+ // Workaround for cases where we're supplied 0 as the dayPartId (legacy custom dayPart)
+ if ($schedule->dayPartId === 0) {
+ $schedule->dayPartId = $customDayPart->dayPartId;
+ }
+
+ $schedule->syncTimezone = $sanitizedParams->getCheckbox('syncTimezone');
+ $schedule->syncEvent = $this->isSyncEvent($schedule->eventTypeId);
+ $schedule->recurrenceType = $sanitizedParams->getString('recurrenceType');
+ $schedule->recurrenceDetail = $sanitizedParams->getInt('recurrenceDetail');
+ $recurrenceRepeatsOn = $sanitizedParams->getIntArray('recurrenceRepeatsOn');
+ $schedule->recurrenceRepeatsOn = (empty($recurrenceRepeatsOn)) ? null : implode(',', $recurrenceRepeatsOn);
+ $schedule->recurrenceMonthlyRepeatsOn = $sanitizedParams->getInt(
+ 'recurrenceMonthlyRepeatsOn',
+ ['default' => 0]
+ );
+
+ foreach ($sanitizedParams->getIntArray('displayGroupIds', ['default' => []]) as $displayGroupId) {
+ $schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
+ }
+
+ if (!$schedule->isAlwaysDayPart()) {
+ // Handle the dates
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+ $recurrenceRange = $sanitizedParams->getDate('recurrenceRange');
+
+ if ($fromDt === null) {
+ throw new InvalidArgumentException(__('Please enter a from date'), 'fromDt');
+ }
+
+ $logToDt = $toDt?->format(DateFormatHelper::getSystemFormat());
+ $logRecurrenceRange = $recurrenceRange?->format(DateFormatHelper::getSystemFormat());
+ $this->getLog()->debug(
+ 'Times received are: FromDt=' . $fromDt->format(DateFormatHelper::getSystemFormat())
+ . '. ToDt=' . $logToDt . '. recurrenceRange=' . $logRecurrenceRange
+ );
+
+ if (!$schedule->isCustomDayPart() && !$schedule->isAlwaysDayPart()) {
+ // Daypart selected
+ // expect only a start date (no time)
+ $schedule->fromDt = $fromDt->startOfDay()->format('U');
+ $schedule->toDt = null;
+
+ if ($recurrenceRange != null) {
+ $schedule->recurrenceRange = $recurrenceRange->format('U');
+ }
+
+ } else if (!($this->isApi($request) || Str::contains($this->getConfig()->getSetting('DATE_FORMAT'), 's'))) {
+ // In some circumstances we want to trim the seconds from the provided dates.
+ // this happens when the date format provided does not include seconds and when the add
+ // event comes from the UI.
+ $this->getLog()->debug('Date format does not include seconds, removing them');
+ $schedule->fromDt = $fromDt->setTime($fromDt->hour, $fromDt->minute, 0)->format('U');
+
+ if ($toDt !== null) {
+ $schedule->toDt = $toDt->setTime($toDt->hour, $toDt->minute, 0)->format('U');
+ }
+
+ if ($recurrenceRange != null) {
+ $schedule->recurrenceRange =
+ $recurrenceRange->setTime(
+ $recurrenceRange->hour,
+ $recurrenceRange->minute,
+ 0
+ )->format('U');
+ }
+ } else {
+ $schedule->fromDt = $fromDt->format('U');
+
+ if ($toDt !== null) {
+ $schedule->toDt = $toDt->format('U');
+ }
+
+ if ($recurrenceRange != null) {
+ $schedule->recurrenceRange = $recurrenceRange->format('U');
+ }
+ }
+
+ $logToDt = $toDt?->format(DateFormatHelper::getSystemFormat());
+ $logRecurrenceRange = $recurrenceRange?->format(DateFormatHelper::getSystemFormat());
+ $this->getLog()->debug(
+ 'Processed times are: FromDt=' . $fromDt->format(DateFormatHelper::getSystemFormat())
+ . '. ToDt=' . $logToDt . '. recurrenceRange=' . $logRecurrenceRange
+ );
+ }
+
+ // Schedule Criteria
+ $criteria = $sanitizedParams->getArray('criteria');
+ if (is_array($criteria) && count($criteria) > 0) {
+ foreach ($criteria as $item) {
+ $itemParams = $this->getSanitizer($item);
+ $criterion = $this->scheduleCriteriaFactory->createEmpty();
+ $criterion->metric = $itemParams->getString('metric');
+ $criterion->type = $itemParams->getString('type');
+ $criterion->condition = $itemParams->getString('condition');
+ $criterion->value = $itemParams->getString('value');
+ $schedule->addOrUpdateCriteria($criterion);
+ }
+ }
+
+ // Ready to do the add
+ $schedule->setDisplayNotifyService($this->displayFactory->getDisplayNotifyService());
+ if ($schedule->campaignId != null) {
+ $schedule->setCampaignFactory($this->campaignFactory);
+ }
+ $schedule->save();
+
+ $this->getLog()->debug('Add Schedule Reminder');
+
+ // API Request
+ $rows = [];
+ if ($this->isApi($request)) {
+ $reminders = $sanitizedParams->getArray('scheduleReminders', ['default' => []]);
+ foreach ($reminders as $i => $reminder) {
+ $rows[$i]['reminder_value'] = (int) $reminder['reminder_value'];
+ $rows[$i]['reminder_type'] = (int) $reminder['reminder_type'];
+ $rows[$i]['reminder_option'] = (int) $reminder['reminder_option'];
+ $rows[$i]['reminder_isEmailHidden'] = (int) $reminder['reminder_isEmailHidden'];
+ }
+ } else {
+ for ($i=0; $i < count($sanitizedParams->getIntArray('reminder_value', ['default' => []])); $i++) {
+ $rows[$i]['reminder_value'] = $sanitizedParams->getIntArray('reminder_value')[$i];
+ $rows[$i]['reminder_type'] = $sanitizedParams->getIntArray('reminder_type')[$i];
+ $rows[$i]['reminder_option'] = $sanitizedParams->getIntArray('reminder_option')[$i];
+ $rows[$i]['reminder_isEmailHidden'] = $sanitizedParams->getIntArray('reminder_isEmailHidden')[$i];
+ }
+ }
+
+ // Save new reminders
+ foreach ($rows as $reminder) {
+ // Do not add reminder if empty value provided for number of minute/hour
+ if ($reminder['reminder_value'] == 0) {
+ continue;
+ }
+
+ $scheduleReminder = $this->scheduleReminderFactory->createEmpty();
+ $scheduleReminder->scheduleReminderId = null;
+ $scheduleReminder->eventId = $schedule->eventId;
+ $scheduleReminder->value = $reminder['reminder_value'];
+ $scheduleReminder->type = $reminder['reminder_type'];
+ $scheduleReminder->option = $reminder['reminder_option'];
+ $scheduleReminder->isEmail = $reminder['reminder_isEmailHidden'];
+
+ $this->saveReminder($schedule, $scheduleReminder);
+ }
+
+ // We can get schedule reminders in an array
+ if ($this->isApi($request)) {
+ $schedule = $this->scheduleFactory->getById($schedule->eventId);
+ $schedule->load([
+ 'loadScheduleReminders' => in_array('scheduleReminders', $embed),
+ ]);
+ }
+
+ if ($this->isSyncEvent($schedule->eventTypeId)) {
+ $syncGroup = $this->syncGroupFactory->getById($schedule->syncGroupId);
+ $syncGroup->validateForSchedule($sanitizedParams);
+ $schedule->updateSyncLinks($syncGroup, $sanitizedParams);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Added Event'),
+ 'id' => $schedule->eventId,
+ 'data' => $schedule
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows a form to edit an event
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ */
+ function editForm(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Recurring event start/end
+ $eventStart = $sanitizedParams->getInt('eventStart', ['default' => 1000]) / 1000;
+ $eventEnd = $sanitizedParams->getInt('eventEnd', ['default' => 1000]) / 1000;
+
+ $schedule = $this->scheduleFactory->getById($id);
+ $schedule->load();
+
+ // Dispatch an event to retrieve criteria for scheduling from the OpenWeatherMap connector.
+ $event = new ScheduleCriteriaRequestEvent();
+ $this->getDispatcher()->dispatch($event, ScheduleCriteriaRequestEvent::$NAME);
+
+ // Retrieve the data from the event
+ $criteria = $event->getCriteria();
+ $criteriaDefaultCondition = $event->getCriteriaDefaultCondition();
+
+ if (!$this->isEventEditable($schedule)) {
+ throw new AccessDeniedException();
+ }
+
+ // Fix the event dates for display
+ if ($schedule->isAlwaysDayPart()) {
+ $schedule->fromDt = '';
+ $schedule->toDt = '';
+ } else {
+ $schedule->fromDt =
+ Carbon::createFromTimestamp($schedule->fromDt)
+ ->format(DateFormatHelper::getSystemFormat());
+ if ($schedule->toDt != null) {
+ $schedule->toDt =
+ Carbon::createFromTimestamp($schedule->toDt)
+ ->format(DateFormatHelper::getSystemFormat());
+ }
+ }
+
+ if ($schedule->recurrenceRange != null) {
+ $schedule->recurrenceRange =
+ Carbon::createFromTimestamp($schedule->recurrenceRange)
+ ->format(DateFormatHelper::getSystemFormat());
+ }
+ // Get all reminders
+ $scheduleReminders = $this->scheduleReminderFactory->query(null, ['eventId' => $id]);
+
+ // get the default longitude and latitude from CMS options
+ $defaultLat = (float)$this->getConfig()->getSetting('DEFAULT_LAT');
+ $defaultLong = (float)$this->getConfig()->getSetting('DEFAULT_LONG');
+
+ if ($this->isFullScreenSchedule($schedule->eventTypeId)) {
+ $schedule->setUnmatchedProperty('fullScreenCampaignId', $schedule->campaignId);
+
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$MEDIA_EVENT) {
+ $schedule->setUnmatchedProperty(
+ 'mediaId',
+ $this->layoutFactory->getLinkedFullScreenMediaId($schedule->campaignId)
+ );
+ } else if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$PLAYLIST_EVENT) {
+ $schedule->setUnmatchedProperty(
+ 'playlistId',
+ $this->layoutFactory->getLinkedFullScreenPlaylistId($schedule->campaignId)
+ );
+ }
+
+ // Get the associated fullscreen layout
+ $fsLayout = $this->layoutFactory->getById(
+ $this->campaignFactory->getLinkedLayouts($schedule->campaignId)[0]->layoutId
+ );
+
+ // Set the layout properties
+ $schedule->backgroundColor = $fsLayout->backgroundColor;
+ $schedule->layoutDuration = $fsLayout->duration;
+ $schedule->resolutionId = $this->layoutFactory->getLayoutResolutionId($fsLayout)->resolutionId;
+ }
+
+ $this->getState()->template = 'schedule-form-edit';
+ $this->getState()->setData([
+ 'event' => $schedule,
+ 'dayParts' => $this->dayPartFactory->allWithSystem(['isRetired' => 0]),
+ 'displayGroups' => $schedule->displayGroups,
+ 'campaign' => !empty($schedule->campaignId) ? $this->campaignFactory->getById($schedule->campaignId) : null,
+ 'displayGroupIds' => array_map(function ($element) {
+ return $element->displayGroupId;
+ }, $schedule->displayGroups),
+ 'addForm' => false,
+ 'reminders' => $scheduleReminders,
+ 'defaultLat' => $defaultLat,
+ 'defaultLong' => $defaultLong,
+ 'recurringEvent' => $schedule->recurrenceType != '',
+ 'eventStart' => $eventStart,
+ 'eventEnd' => $eventEnd,
+ 'eventTypes' => \Xibo\Entity\Schedule::getEventTypes(),
+ 'scheduleCriteria' => $criteria,
+ 'criteriaDefaultCondition' => $criteriaDefaultCondition
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the Delete a Recurring Event form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ */
+ public function deleteRecurrenceForm(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Recurring event start/end
+ $eventStart = $sanitizedParams->getInt('eventStart', ['default' => 1000]);
+ $eventEnd = $sanitizedParams->getInt('eventEnd', ['default' => 1000]);
+
+ $schedule = $this->scheduleFactory->getById($id);
+ $schedule->load();
+
+ if (!$this->isEventEditable($schedule)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'schedule-recurrence-form-delete';
+ $this->getState()->setData([
+ 'event' => $schedule,
+ 'eventStart' => $eventStart,
+ 'eventEnd' => $eventEnd,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Deletes a recurring Event from all displays
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/schedulerecurrence/{eventId}",
+ * operationId="schedulerecurrenceDelete",
+ * tags={"schedule"},
+ * summary="Delete a Recurring Event",
+ * description="Delete a Recurring Event of a Scheduled Event",
+ * @SWG\Parameter(
+ * name="eventId",
+ * in="path",
+ * description="The Scheduled Event ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function deleteRecurrence(Request $request, Response $response, $id)
+ {
+ $schedule = $this->scheduleFactory->getById($id);
+ $schedule->load();
+
+ if (!$this->isEventEditable($schedule)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Recurring event start/end
+ $eventStart = $sanitizedParams->getInt('eventStart', ['default' => 1000]);
+ $eventEnd = $sanitizedParams->getInt('eventEnd', ['default' => 1000]);
+ $scheduleExclusion = $this->scheduleExclusionFactory->create($schedule->eventId, $eventStart, $eventEnd);
+
+ $this->getLog()->debug('Create a schedule exclusion record');
+ $scheduleExclusion->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Deleted Event')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edits an event
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Put(
+ * path="/schedule/{eventId}",
+ * operationId="scheduleEdit",
+ * tags={"schedule"},
+ * summary="Edit Schedule Event",
+ * description="Edit a scheduled event for a Campaign/Layout to be shown on a Display Group/Display.",
+ * @SWG\Parameter(
+ * name="eventId",
+ * in="path",
+ * description="The Scheduled Event ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="eventTypeId",
+ * in="formData",
+ * description="The Event Type Id to use for this Event.
+ * 1=Layout, 2=Command, 3=Overlay, 4=Interrupt, 5=Campaign, 6=Action",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="formData",
+ * description="The Campaign ID to use for this Event.
+ * If a Layout is needed then the Campaign specific ID for that Layout should be used.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="commandId",
+ * in="formData",
+ * description="The Command ID to use for this Event.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="The Media ID to use for this Event.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="The display order for this event. ",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isPriority",
+ * in="formData",
+ * description="An integer indicating the priority of this event. Normal events have a priority of 0.",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupIds",
+ * in="formData",
+ * description="The Display Group IDs for this event.
+ * Display specific Group IDs should be used to schedule on single displays.",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="dayPartId",
+ * in="formData",
+ * description="The Day Part for this event. Overrides supported are 0(custom) and 1(always). Defaulted to 0.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="syncTimezone",
+ * in="formData",
+ * description="Should this schedule be synced to the resulting Display timezone?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="formData",
+ * description="The from date for this event.",
+ * type="string",
+ * format="date-time",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="formData",
+ * description="The to date for this event.",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceType",
+ * in="formData",
+ * description="The type of recurrence to apply to this event.",
+ * type="string",
+ * required=false,
+ * enum={"", "Minute", "Hour", "Day", "Week", "Month", "Year"}
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceDetail",
+ * in="formData",
+ * description="The interval for the recurrence.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceRange",
+ * in="formData",
+ * description="The end date for this events recurrence.",
+ * type="string",
+ * format="date-time",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="recurrenceRepeatsOn",
+ * in="formData",
+ * description="The days of the week that this event repeats - weekly only",
+ * type="string",
+ * format="array",
+ * required=false,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Parameter(
+ * name="scheduleReminders",
+ * in="formData",
+ * description="Array of Reminders for this event",
+ * type="array",
+ * required=false,
+ * @SWG\Items(
+ * ref="#/definitions/ScheduleReminderArray"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="isGeoAware",
+ * in="formData",
+ * description="Flag (0-1), whether this event is using Geo Location",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="geoLocation",
+ * in="formData",
+ * description="Array of comma separated strings each with comma separated pair of coordinates",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="geoLocationJson",
+ * in="formData",
+ * description="Valid GeoJSON string, use as an alternative to geoLocation parameter",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionType",
+ * in="formData",
+ * description="For Action eventTypeId, the type of the action - command or navLayout",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionTriggerCode",
+ * in="formData",
+ * description="For Action eventTypeId, the webhook trigger code for the Action",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="actionLayoutCode",
+ * in="formData",
+ * description="For Action eventTypeId and navLayout actionType, the Layout Code identifier",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetId",
+ * in="formData",
+ * description="For Data Connector eventTypeId, the DataSet ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="dataSetParams",
+ * in="formData",
+ * description="For Data Connector eventTypeId, the DataSet params",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Schedule")
+ * )
+ * )
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $embed = ($sanitizedParams->getString('embed') != null) ? explode(',', $sanitizedParams->getString('embed')) : [];
+
+ $schedule = $this->scheduleFactory->getById($id);
+ $oldSchedule = clone $schedule;
+
+ $schedule->load([
+ 'loadScheduleReminders' => in_array('scheduleReminders', $embed),
+ ]);
+
+ if (!$this->isEventEditable($schedule)) {
+ throw new AccessDeniedException();
+ }
+
+ $schedule->eventTypeId = $sanitizedParams->getInt('eventTypeId');
+ $schedule->campaignId = $this->isFullScreenSchedule($schedule->eventTypeId)
+ ? $sanitizedParams->getInt('fullScreenCampaignId')
+ : $sanitizedParams->getInt('campaignId');
+ // displayOrder and isPriority: if present but empty (""): set to 0
+ // if missing from form: keep existing value (fallback to 0 if unset)
+ $schedule->displayOrder = $sanitizedParams->hasParam('displayOrder')
+ ? $sanitizedParams->getInt('displayOrder', ['default' => 0])
+ : ($schedule->displayOrder ?? 0);
+
+ $schedule->isPriority = $sanitizedParams->hasParam('isPriority')
+ ? $sanitizedParams->getInt('isPriority', ['default' => 0])
+ : ($schedule->isPriority ?? 0);
+
+ $schedule->dayPartId = $sanitizedParams->getInt('dayPartId', ['default' => $schedule->dayPartId]);
+ $schedule->syncTimezone = $sanitizedParams->getCheckbox('syncTimezone');
+ $schedule->syncEvent = $this->isSyncEvent($schedule->eventTypeId);
+ $schedule->recurrenceType = $sanitizedParams->getString('recurrenceType');
+ $schedule->recurrenceDetail = $sanitizedParams->getInt('recurrenceDetail');
+ $recurrenceRepeatsOn = $sanitizedParams->getIntArray('recurrenceRepeatsOn');
+ $schedule->recurrenceRepeatsOn = (empty($recurrenceRepeatsOn)) ? null : implode(',', $recurrenceRepeatsOn);
+ $schedule->recurrenceMonthlyRepeatsOn = $sanitizedParams->getInt(
+ 'recurrenceMonthlyRepeatsOn',
+ ['default' => 0]
+ );
+ $schedule->displayGroups = [];
+ $schedule->isGeoAware = $sanitizedParams->getCheckbox('isGeoAware');
+ $schedule->maxPlaysPerHour = $sanitizedParams->getInt('maxPlaysPerHour', ['default' => 0]);
+ $schedule->syncGroupId = $sanitizedParams->getInt('syncGroupId');
+ $schedule->name = $sanitizedParams->getString('name');
+ $schedule->modifiedBy = $this->getUser()->getId();
+
+ // collect action event relevant properties only on action event
+ // null these properties otherwise
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$ACTION_EVENT) {
+ $schedule->actionType = $sanitizedParams->getString('actionType');
+ $schedule->actionTriggerCode = $sanitizedParams->getString('actionTriggerCode');
+ $schedule->commandId = $sanitizedParams->getInt('commandId');
+ $schedule->actionLayoutCode = $sanitizedParams->getString('actionLayoutCode');
+ $schedule->campaignId = null;
+ } else {
+ $schedule->actionType = null;
+ $schedule->actionTriggerCode = null;
+ $schedule->commandId = null;
+ $schedule->actionLayoutCode = null;
+ }
+
+ // collect commandId on Command event
+ // Retain existing commandId value otherwise
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$COMMAND_EVENT) {
+ $schedule->commandId = $sanitizedParams->getInt('commandId');
+ $schedule->campaignId = null;
+ }
+
+ // Set the parentCampaignId for campaign events
+ // null parentCampaignId on other events
+ // make sure correct Layout/Campaign is selected for relevant event.
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$CAMPAIGN_EVENT) {
+ $schedule->parentCampaignId = $schedule->campaignId;
+
+ // Make sure we're not directly scheduling an ad campaign
+ $campaign = $this->campaignFactory->getById($schedule->campaignId);
+ if ($campaign->type === 'ad') {
+ throw new InvalidArgumentException(
+ __('Direct scheduling of an Ad Campaign is not allowed'),
+ 'campaignId'
+ );
+ }
+
+ if ($campaign->isLayoutSpecific === 1) {
+ throw new InvalidArgumentException(
+ __('Cannot schedule Layout as a Campaign, please select a Campaign instead.'),
+ 'campaignId'
+ );
+ }
+ } else {
+ $schedule->parentCampaignId = null;
+ if (!empty($schedule->campaignId)) {
+ $campaign = $this->campaignFactory->getById($schedule->campaignId);
+ if ($campaign->isLayoutSpecific === 0) {
+ throw new InvalidArgumentException(
+ __('Cannot schedule Campaign in selected event type, please select a Layout instead.'),
+ 'campaignId'
+ );
+ }
+ }
+ }
+
+ // Fields only collected for interrupt events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$INTERRUPT_EVENT) {
+ $schedule->shareOfVoice = $sanitizedParams->getInt('shareOfVoice', [
+ 'throw' => function () {
+ new InvalidArgumentException(
+ __('Share of Voice must be a whole number between 0 and 3600'),
+ 'shareOfVoice'
+ );
+ }
+ ]);
+ } else {
+ $schedule->shareOfVoice = null;
+ }
+
+ // Fields only collected for data connector events
+ if ($schedule->eventTypeId === \Xibo\Entity\Schedule::$DATA_CONNECTOR_EVENT) {
+ $schedule->dataSetId = $sanitizedParams->getInt('dataSetId', [
+ 'throw' => function () {
+ new InvalidArgumentException(
+ __('Please select a DataSet'),
+ 'dataSetId'
+ );
+ }
+ ]);
+ $schedule->dataSetParams = $sanitizedParams->getString('dataSetParams');
+ }
+
+ // Get the campaignId for media/playlist events
+ if ($this->isFullScreenSchedule($schedule->eventTypeId)) {
+ $type = $schedule->eventTypeId === \Xibo\Entity\Schedule::$MEDIA_EVENT ? 'media' : 'playlist';
+ $id = ($type === 'media') ? $sanitizedParams->getInt('mediaId') : $sanitizedParams->getInt('playlistId');
+
+ if (!$id) {
+ throw new InvalidArgumentException(
+ sprintf('%sId is required when scheduling %s events.', ucfirst($type), $type)
+ );
+ }
+
+ // Create a full screen layout for this event
+ $fsLayout = $this->layoutFactory->createFullScreenLayout(
+ $type,
+ $id,
+ $sanitizedParams->getInt('resolutionId'),
+ $sanitizedParams->getString('backgroundColor'),
+ $sanitizedParams->getInt('layoutDuration'),
+ );
+
+ $schedule->campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($fsLayout->layoutId);
+ $schedule->parentCampaignId = $schedule->campaignId;
+ }
+
+ // API request can provide an array of coordinates or valid GeoJSON, handle both cases here.
+ if ($this->isApi($request) && $schedule->isGeoAware === 1) {
+ if ($sanitizedParams->getArray('geoLocation') != null) {
+ // get string array from API
+ $coordinates = $sanitizedParams->getArray('geoLocation');
+ // generate GeoJSON and assign to Schedule
+ $schedule->geoLocation = $this->createGeoJson($coordinates);
+ } else {
+ // we were provided with GeoJSON
+ $schedule->geoLocation = $sanitizedParams->getString('geoLocationJson');
+ }
+ } else {
+ // if we are not using API, then valid GeoJSON is created in the front end.
+ $schedule->geoLocation = $sanitizedParams->getString('geoLocation');
+ }
+
+ // if we are editing Layout/Campaign event that was set with Always daypart and change it to Command event type
+ // the daypartId will remain as always, which will then cause the event to "disappear" from calendar
+ // https://github.com/xibosignage/xibo/issues/1982
+ if ($schedule->eventTypeId == \Xibo\Entity\Schedule::$COMMAND_EVENT) {
+ $schedule->dayPartId = $this->dayPartFactory->getCustomDayPart()->dayPartId;
+ }
+
+ foreach ($sanitizedParams->getIntArray('displayGroupIds', ['default' => []]) as $displayGroupId) {
+ $schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
+ }
+
+ if (!$schedule->isAlwaysDayPart()) {
+ // Handle the dates
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+ $recurrenceRange = $sanitizedParams->getDate('recurrenceRange');
+
+ if ($fromDt === null) {
+ throw new InvalidArgumentException(__('Please enter a from date'). 'fromDt');
+ }
+
+ $logToDt = $toDt?->format(DateFormatHelper::getSystemFormat());
+ $logRecurrenceRange = $recurrenceRange?->format(DateFormatHelper::getSystemFormat());
+ $this->getLog()->debug(
+ 'Times received are: FromDt=' . $fromDt->format(DateFormatHelper::getSystemFormat())
+ . '. ToDt=' . $logToDt . '. recurrenceRange=' . $logRecurrenceRange
+ );
+
+ if (!$schedule->isCustomDayPart() && !$schedule->isAlwaysDayPart()) {
+ // Daypart selected
+ // expect only a start date (no time)
+ $schedule->fromDt = $fromDt->startOfDay()->format('U');
+ $schedule->toDt = null;
+ $schedule->recurrenceRange = ($recurrenceRange === null) ? null : $recurrenceRange->format('U');
+
+ } else if (!($this->isApi($request) || Str::contains($this->getConfig()->getSetting('DATE_FORMAT'), 's'))) {
+ // In some circumstances we want to trim the seconds from the provided dates.
+ // this happens when the date format provided does not include seconds and when the add
+ // event comes from the UI.
+ $this->getLog()->debug('Date format does not include seconds, removing them');
+ $schedule->fromDt = $fromDt->setTime($fromDt->hour, $fromDt->minute, 0)->format('U');
+
+ // If we have a toDt
+ if ($toDt !== null) {
+ $schedule->toDt = $toDt->setTime($toDt->hour, $toDt->minute, 0)->format('U');
+ }
+
+ $schedule->recurrenceRange = ($recurrenceRange === null)
+ ? null
+ : $recurrenceRange->setTime($recurrenceRange->hour, $recurrenceRange->minute, 0)->format('U');
+ } else {
+ $schedule->fromDt = $fromDt->format('U');
+
+ if ($toDt !== null) {
+ $schedule->toDt = $toDt->format('U');
+ }
+
+ $schedule->recurrenceRange = ($recurrenceRange === null) ? null : $recurrenceRange->format('U');
+ }
+
+ $this->getLog()->debug('Processed start is: FromDt=' . $fromDt->toRssString());
+ } else {
+ // This is an always day part, which cannot be recurring, make sure we clear the recurring type if it has been set
+ $schedule->recurrenceType = null;
+ }
+
+ // Schedule Criteria
+ $schedule->criteria = [];
+ $criteria = $sanitizedParams->getArray('criteria');
+ if (is_array($criteria)) {
+ foreach ($criteria as $item) {
+ $itemParams = $this->getSanitizer($item);
+ $criterion = $this->scheduleCriteriaFactory->createEmpty();
+ $criterion->metric = $itemParams->getString('metric');
+ $criterion->type = $itemParams->getString('type');
+ $criterion->condition = $itemParams->getString('condition');
+ $criterion->value = $itemParams->getString('value');
+ $schedule->addOrUpdateCriteria($criterion, $itemParams->getInt('id'));
+ }
+ }
+
+ // Ready to do the add
+ $schedule->setDisplayNotifyService($this->displayFactory->getDisplayNotifyService());
+ if ($schedule->campaignId != null) {
+ $schedule->setCampaignFactory($this->campaignFactory);
+ }
+ $schedule->save();
+
+ if ($this->isSyncEvent($schedule->eventTypeId)) {
+ $syncGroup = $this->syncGroupFactory->getById($schedule->syncGroupId);
+ $syncGroup->validateForSchedule($sanitizedParams);
+ $schedule->updateSyncLinks($syncGroup, $sanitizedParams);
+ }
+
+ // Get form reminders
+ $rows = [];
+ for ($i=0; $i < count($sanitizedParams->getIntArray('reminder_value',['default' => []])); $i++) {
+ $entry = [];
+
+ if ($sanitizedParams->getIntArray('reminder_scheduleReminderId')[$i] == null) {
+ continue;
+ }
+
+ $entry['reminder_scheduleReminderId'] = $sanitizedParams->getIntArray('reminder_scheduleReminderId')[$i];
+ $entry['reminder_value'] = $sanitizedParams->getIntArray('reminder_value')[$i];
+ $entry['reminder_type'] = $sanitizedParams->getIntArray('reminder_type')[$i];
+ $entry['reminder_option'] = $sanitizedParams->getIntArray('reminder_option')[$i];
+ $entry['reminder_isEmail'] = $sanitizedParams->getIntArray('reminder_isEmailHidden')[$i];
+
+ $rows[$sanitizedParams->getIntArray('reminder_scheduleReminderId')[$i]] = $entry;
+ }
+ $formReminders = $rows;
+
+ // Compare to delete
+ // Get existing db reminders
+ $scheduleReminders = $this->scheduleReminderFactory->query(null, ['eventId' => $id]);
+
+ $rows = [];
+ foreach ($scheduleReminders as $reminder) {
+
+ $entry = [];
+ $entry['reminder_scheduleReminderId'] = $reminder->scheduleReminderId;
+ $entry['reminder_value'] = $reminder->value;
+ $entry['reminder_type'] = $reminder->type;
+ $entry['reminder_option'] = $reminder->option;
+ $entry['reminder_isEmail'] = $reminder->isEmail;
+
+ $rows[$reminder->scheduleReminderId] = $entry;
+ }
+ $dbReminders = $rows;
+
+ $deleteReminders = $schedule->compareMultidimensionalArrays($dbReminders, $formReminders, false);
+ foreach ($deleteReminders as $reminder) {
+ $reminder = $this->scheduleReminderFactory->getById($reminder['reminder_scheduleReminderId']);
+ $reminder->delete();
+ }
+
+ // API Request
+ $rows = [];
+ if ($this->isApi($request)) {
+
+ $reminders = $sanitizedParams->getArray('scheduleReminders', ['default' => []]);
+ foreach ($reminders as $i => $reminder) {
+
+ $rows[$i]['reminder_scheduleReminderId'] = isset($reminder['reminder_scheduleReminderId']) ? (int) $reminder['reminder_scheduleReminderId'] : null;
+ $rows[$i]['reminder_value'] = (int) $reminder['reminder_value'];
+ $rows[$i]['reminder_type'] = (int) $reminder['reminder_type'];
+ $rows[$i]['reminder_option'] = (int) $reminder['reminder_option'];
+ $rows[$i]['reminder_isEmailHidden'] = (int) $reminder['reminder_isEmailHidden'];
+ }
+ } else {
+
+ for ($i=0; $i < count($sanitizedParams->getIntArray('reminder_value', ['default' => []])); $i++) {
+ $rows[$i]['reminder_scheduleReminderId'] = $sanitizedParams->getIntArray('reminder_scheduleReminderId')[$i];
+ $rows[$i]['reminder_value'] = $sanitizedParams->getIntArray('reminder_value')[$i];
+ $rows[$i]['reminder_type'] = $sanitizedParams->getIntArray('reminder_type')[$i];
+ $rows[$i]['reminder_option'] = $sanitizedParams->getIntArray('reminder_option')[$i];
+ $rows[$i]['reminder_isEmailHidden'] = $sanitizedParams->getIntArray('reminder_isEmailHidden')[$i];
+ }
+
+ }
+
+ // Save rest of the reminders
+ foreach ($rows as $reminder) {
+
+ // Do not add reminder if empty value provided for number of minute/hour
+ if ($reminder['reminder_value'] == 0) {
+ continue;
+ }
+
+ $scheduleReminderId = isset($reminder['reminder_scheduleReminderId']) ? $reminder['reminder_scheduleReminderId'] : null;
+
+ try {
+ $scheduleReminder = $this->scheduleReminderFactory->getById($scheduleReminderId);
+ $scheduleReminder->load();
+ } catch (NotFoundException $e) {
+ $scheduleReminder = $this->scheduleReminderFactory->createEmpty();
+ $scheduleReminder->scheduleReminderId = null;
+ $scheduleReminder->eventId = $id;
+ }
+
+ $scheduleReminder->value = $reminder['reminder_value'];
+ $scheduleReminder->type = $reminder['reminder_type'];
+ $scheduleReminder->option = $reminder['reminder_option'];
+ $scheduleReminder->isEmail = $reminder['reminder_isEmailHidden'];
+
+ $this->saveReminder($schedule, $scheduleReminder);
+ }
+
+ // If this is a recurring event delete all schedule exclusions
+ if ($schedule->recurrenceType != '') {
+ // Delete schedule exclusions
+ $scheduleExclusions = $this->scheduleExclusionFactory->query(null, ['eventId' => $schedule->eventId]);
+ foreach ($scheduleExclusions as $exclusion) {
+ $exclusion->delete();
+ }
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Edited Event'),
+ 'id' => $schedule->eventId,
+ 'data' => $schedule
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the DeleteEvent form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $schedule = $this->scheduleFactory->getById($id);
+ $schedule->load();
+
+ if (!$this->isEventEditable($schedule)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'schedule-form-delete';
+ $this->getState()->setData([
+ 'event' => $schedule,
+ ]);
+
+ return $this->render($request,$response);
+ }
+
+ /**
+ * Deletes an Event from all displays
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws ControllerNotImplemented
+ * @SWG\Delete(
+ * path="/schedule/{eventId}",
+ * operationId="scheduleDelete",
+ * tags={"schedule"},
+ * summary="Delete Event",
+ * description="Delete a Scheduled Event",
+ * @SWG\Parameter(
+ * name="eventId",
+ * in="path",
+ * description="The Scheduled Event ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $schedule = $this->scheduleFactory->getById($id);
+ $schedule->load();
+
+ if (!$this->isEventEditable($schedule)) {
+ throw new AccessDeniedException();
+ }
+
+ $schedule
+ ->setDisplayNotifyService($this->displayFactory->getDisplayNotifyService())
+ ->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Deleted Event')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Is this event editable?
+ * @param \Xibo\Entity\Schedule $event
+ * @return bool
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function isEventEditable(\Xibo\Entity\Schedule $event): bool
+ {
+ if (!$this->getUser()->featureEnabled('schedule.modify')) {
+ return false;
+ }
+
+ // Is this an event coming from an ad campaign?
+ if (!empty($event->parentCampaignId) && $event->eventTypeId === \Xibo\Entity\Schedule::$INTERRUPT_EVENT) {
+ return false;
+ }
+
+ $scheduleWithView = ($this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION') == 1);
+
+ // Work out if this event is editable or not. To do this we need to compare the permissions
+ // of each display group this event is associated with
+ foreach ($event->displayGroups as $displayGroup) {
+ // Can schedule with view, but no view permissions
+ if ($scheduleWithView && !$this->getUser()->checkViewable($displayGroup)) {
+ return false;
+ }
+
+ // Can't schedule with view, but no edit permissions
+ if (!$scheduleWithView && !$this->getUser()->checkEditable($displayGroup)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Return Schedule eventTypeId based on the grid the requests is coming from
+ * @param $from
+ * @return int
+ */
+ private function getEventTypeId($from)
+ {
+ return match ($from) {
+ 'Campaign' => \Xibo\Entity\Schedule::$CAMPAIGN_EVENT,
+ 'Library' => \Xibo\Entity\Schedule::$MEDIA_EVENT,
+ 'Playlist' => \Xibo\Entity\Schedule::$PLAYLIST_EVENT,
+ default => \Xibo\Entity\Schedule::$LAYOUT_EVENT
+ };
+ }
+
+ /**
+ * Generates the Schedule events grid
+ *
+ * @SWG\Get(
+ * path="/schedule",
+ * operationId="scheduleSearch",
+ * tags={"schedule"},
+ * @SWG\Parameter(
+ * name="eventTypeId",
+ * in="query",
+ * required=false,
+ * type="integer",
+ * description="Filter grid by eventTypeId.
+ * 1=Layout, 2=Command, 3=Overlay, 4=Interrupt, 5=Campaign, 6=Action, 7=Media Library, 8=Playlist"
+ * ),
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="From Date in Y-m-d H:i:s format"
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="query",
+ * required=false,
+ * type="string",
+ * description="To Date in Y-m-d H:i:s format"
+ * ),
+ * @SWG\Parameter(
+ * name="geoAware",
+ * in="query",
+ * required=false,
+ * type="integer",
+ * description="Flag (0-1), whether to return events using Geo Location"
+ * ),
+ * @SWG\Parameter(
+ * name="recurring",
+ * in="query",
+ * required=false,
+ * type="integer",
+ * description="Flag (0-1), whether to return Recurring events"
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="query",
+ * required=false,
+ * type="integer",
+ * description="Filter events by specific campaignId"
+ * ),
+ * @SWG\Parameter(
+ * name="displayGroupIds",
+ * in="query",
+ * required=false,
+ * type="array",
+ * description="Filter events by an array of Display Group Ids",
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Schedule")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return ResponseInterface|Response
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ $displayGroupIds = $params->getIntArray('displayGroupIds', ['default' => []]);
+ $displaySpecificDisplayGroupIds = $params->getIntArray('displaySpecificGroupIds', ['default' => []]);
+ $originalDisplayGroupIds = array_merge($displayGroupIds, $displaySpecificDisplayGroupIds);
+
+ if (!$this->getUser()->isSuperAdmin()) {
+ $userDisplayGroupIds = array_map(function ($element) {
+ /** @var \Xibo\Entity\DisplayGroup $element */
+ return $element->displayGroupId;
+ }, $this->displayGroupFactory->query(null, ['isDisplaySpecific' => -1]));
+
+ // Reset the list to only those display groups that intersect and if 0 have been provided, only those from
+ // the user list
+ $resolvedDisplayGroupIds = (count($originalDisplayGroupIds) > 0)
+ ? array_intersect($originalDisplayGroupIds, $userDisplayGroupIds)
+ : $userDisplayGroupIds;
+
+ $this->getLog()->debug('Resolved list of display groups ['
+ . json_encode($displayGroupIds) . '] from provided list ['
+ . json_encode($originalDisplayGroupIds) . '] and user list ['
+ . json_encode($userDisplayGroupIds) . ']');
+
+ // If we have none, then we do not return any events.
+ if (count($resolvedDisplayGroupIds) <= 0) {
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->scheduleFactory->countLast();
+ $this->getState()->setData([]);
+
+ return $this->render($request, $response);
+ }
+ } else {
+ $resolvedDisplayGroupIds = $originalDisplayGroupIds;
+ }
+
+ $events = $this->scheduleFactory->query(
+ $this->gridRenderSort($params),
+ $this->gridRenderFilter([
+ 'eventTypeId' => $params->getInt('eventTypeId'),
+ 'futureSchedulesFrom' => $params->getDate('fromDt')?->format('U'),
+ 'futureSchedulesTo' => $params->getDate('toDt')?->format('U'),
+ 'geoAware' => $params->getInt('geoAware'),
+ 'recurring' => $params->getInt('recurring'),
+ 'campaignId' => $params->getInt('campaignId'),
+ 'displayGroupIds' => $resolvedDisplayGroupIds,
+ 'name' => $params->getString('name'),
+ 'useRegexForName' => $params->getCheckbox('useRegexForName'),
+ 'logicalOperatorName' => $params->getString('logicalOperatorName'),
+ 'directSchedule' => $params->getCheckbox('directSchedule'),
+ 'sharedSchedule' => $params->getCheckbox('sharedSchedule'),
+ 'gridFilter' => 1,
+ ], $params)
+ );
+
+ // Grab some settings which determine how events are displayed.
+ $showLayoutName = ($this->getConfig()->getSetting('SCHEDULE_SHOW_LAYOUT_NAME') == 1);
+ $defaultTimezone = $this->getConfig()->getSetting('defaultTimezone');
+
+ foreach ($events as $event) {
+ $event->load();
+
+ if (count($event->displayGroups) > 0) {
+ $array = array_map(function ($object) {
+ return $object->displayGroup;
+ }, $event->displayGroups);
+ $displayGroupList = implode(', ', $array);
+ } else {
+ $displayGroupList = '';
+ }
+
+ $eventTypes = \Xibo\Entity\Schedule::getEventTypes();
+ foreach ($eventTypes as $eventType) {
+ if ($eventType['eventTypeId'] === $event->eventTypeId) {
+ $event->setUnmatchedProperty('eventTypeName', $eventType['eventTypeName']);
+ }
+ }
+
+ $event->setUnmatchedProperty('displayGroupList', $displayGroupList);
+ $event->setUnmatchedProperty('recurringEvent', !empty($event->recurrenceType));
+
+ if ($this->isSyncEvent($event->eventTypeId)) {
+ $event->setUnmatchedProperty(
+ 'displayGroupList',
+ $event->getUnmatchedProperty('syncGroupName')
+ );
+ $event->setUnmatchedProperty(
+ 'syncType',
+ $event->getSyncTypeForEvent()
+ );
+ }
+
+ if (!$showLayoutName && !$this->getUser()->isSuperAdmin() && !empty($event->campaignId)) {
+ // Campaign
+ $campaign = $this->campaignFactory->getById($event->campaignId);
+
+ if (!$this->getUser()->checkViewable($campaign)) {
+ $event->campaign = __('Private Item');
+ }
+ }
+
+ if (!empty($event->recurrenceType)) {
+ $repeatsOn = '';
+ $repeatsUntil = '';
+
+ if ($event->recurrenceType === 'Week' && !empty($event->recurrenceRepeatsOn)) {
+ $weekdays = Carbon::getDays();
+ $repeatDays = explode(',', $event->recurrenceRepeatsOn);
+ $i = 0;
+ foreach ($repeatDays as $repeatDay) {
+ // Carbon getDays starts with Sunday,
+ // return first element from that array if in our array we have 7 (Sunday)
+ $repeatDay = ($repeatDay == 7) ? 0 : $repeatDay;
+ $repeatsOn .= $weekdays[$repeatDay];
+ if ($i < count($repeatDays) - 1) {
+ $repeatsOn .= ', ';
+ }
+ $i++;
+ }
+ } else if ($event->recurrenceType === 'Month') {
+ // Force the timezone for this date (schedule from/to dates are timezone agnostic, but this
+ // date still has timezone information, which could lead to use formatting as the wrong day)
+ $date = Carbon::parse($event->fromDt)->tz($defaultTimezone);
+ $this->getLog()->debug('grid: setting description for monthly event with date: '
+ . $date->toAtomString());
+
+ if ($event->recurrenceMonthlyRepeatsOn === 0) {
+ $repeatsOn = 'the ' . $date->format('jS') . ' day of the month';
+ } else {
+ // Which day of the month is this?
+ $firstDay = Carbon::parse('first ' . $date->format('l') . ' of ' . $date->format('F'));
+
+ $this->getLog()->debug('grid: the first day of the month for this date is: '
+ . $firstDay->toAtomString());
+
+ $nth = $firstDay->diffInDays($date) / 7 + 1;
+ $repeatWeekDayDate = $date->copy()->setDay($nth)->format('jS');
+ $repeatsOn = 'the ' . $repeatWeekDayDate . ' '
+ . $date->format('l')
+ . ' of the month';
+ }
+ }
+
+ if (!empty($event->recurrenceRange)) {
+ $repeatsUntil = Carbon::createFromTimestamp($event->recurrenceRange)
+ ->format(DateFormatHelper::getSystemFormat());
+ }
+
+ $event->setUnmatchedProperty(
+ 'recurringEventDescription',
+ __(sprintf(
+ 'Repeats every %d %s %s %s',
+ $event->recurrenceDetail,
+ $event->recurrenceType . ($event->recurrenceDetail > 1 ? 's' : ''),
+ !empty($repeatsOn) ? 'on ' . $repeatsOn : '',
+ !empty($repeatsUntil) ? ' until ' . $repeatsUntil : ''
+ ))
+ );
+ } else {
+ $event->setUnmatchedProperty('recurringEventDescription', '');
+ }
+
+ if (!$event->isAlwaysDayPart() && !$event->isCustomDayPart()) {
+ $dayPart = $this->dayPartFactory->getById($event->dayPartId);
+ $dayPart->adjustForDate(Carbon::createFromTimestamp($event->fromDt));
+ $event->fromDt = $dayPart->adjustedStart->format('U');
+ $event->toDt = $dayPart->adjustedEnd->format('U');
+ }
+
+ if ($event->eventTypeId == \Xibo\Entity\Schedule::$COMMAND_EVENT) {
+ $event->toDt = $event->fromDt;
+ }
+
+ // Set the row from/to date to be an ISO date for display (no timezone)
+ $event->setUnmatchedProperty(
+ 'displayFromDt',
+ Carbon::createFromTimestamp($event->fromDt)->format(DateFormatHelper::getSystemFormat())
+ );
+ $event->setUnmatchedProperty(
+ 'displayToDt',
+ Carbon::createFromTimestamp($event->toDt)->format(DateFormatHelper::getSystemFormat())
+ );
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $event->includeProperty('buttons');
+ $event->setUnmatchedProperty('isEditable', $this->isEventEditable($event));
+ if ($this->isEventEditable($event)) {
+ $event->buttons[] = [
+ 'id' => 'schedule_button_edit',
+ 'url' => $this->urlFor(
+ $request,
+ 'schedule.edit.form',
+ ['id' => $event->eventId]
+ ),
+ 'dataAttributes' => [
+ ['name' => 'event-id', 'value' => $event->eventId],
+ ['name' => 'event-start', 'value' => $event->fromDt * 1000],
+ ['name' => 'event-end', 'value' => $event->toDt * 1000]
+ ],
+ 'text' => __('Edit')
+ ];
+
+ $event->buttons[] = [
+ 'id' => 'schedule_button_delete',
+ 'url' => $this->urlFor($request, 'schedule.delete.form', ['id' => $event->eventId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'schedule.delete', ['id' => $event->eventId])
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'schedule_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $event->eventId]
+ ]
+ ];
+ }
+ }
+
+ // Store the table rows
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->scheduleFactory->countLast();
+ $this->getState()->setData($events);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param \Xibo\Entity\Schedule $schedule
+ * @param ScheduleReminder $scheduleReminder
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function saveReminder($schedule, $scheduleReminder)
+ {
+ // if someone changes from custom to always
+ // we should keep the definitions, but make sure they don't get executed in the task
+ if ($schedule->isAlwaysDayPart()) {
+ $scheduleReminder->reminderDt = 0;
+ $scheduleReminder->save();
+ return;
+ }
+
+ switch ($scheduleReminder->type) {
+ case ScheduleReminder::$TYPE_MINUTE:
+ $type = ScheduleReminder::$MINUTE;
+ break;
+ case ScheduleReminder::$TYPE_HOUR:
+ $type = ScheduleReminder::$HOUR;
+ break;
+ case ScheduleReminder::$TYPE_DAY:
+ $type = ScheduleReminder::$DAY;
+ break;
+ case ScheduleReminder::$TYPE_WEEK:
+ $type = ScheduleReminder::$WEEK;
+ break;
+ case ScheduleReminder::$TYPE_MONTH:
+ $type = ScheduleReminder::$MONTH;
+ break;
+ default:
+ throw new NotFoundException(__('Unknown type'));
+ }
+
+ // Remind seconds that we will subtract/add from schedule fromDt/toDt to get reminderDt
+ $remindSeconds = $scheduleReminder->value * $type;
+
+ // Set reminder date
+ if ($scheduleReminder->option == ScheduleReminder::$OPTION_BEFORE_START) {
+ $scheduleReminder->reminderDt = $schedule->fromDt - $remindSeconds;
+ } elseif ($scheduleReminder->option == ScheduleReminder::$OPTION_AFTER_START) {
+ $scheduleReminder->reminderDt = $schedule->fromDt + $remindSeconds;
+ } elseif ($scheduleReminder->option == ScheduleReminder::$OPTION_BEFORE_END) {
+ $scheduleReminder->reminderDt = $schedule->toDt - $remindSeconds;
+ } elseif ($scheduleReminder->option == ScheduleReminder::$OPTION_AFTER_END) {
+ $scheduleReminder->reminderDt = $schedule->toDt + $remindSeconds;
+ }
+
+ // Is recurring event?
+ $now = Carbon::now();
+ if ($schedule->recurrenceType != '') {
+
+ // find the next event from now
+ try {
+ $nextReminderDate = $schedule->getNextReminderDate($now, $scheduleReminder, $remindSeconds);
+ } catch (NotFoundException $error) {
+ $nextReminderDate = 0;
+ $this->getLog()->debug('No next occurrence of reminderDt found. ReminderDt set to 0.');
+ }
+
+ if ($nextReminderDate != 0) {
+
+ if ($nextReminderDate < $scheduleReminder->lastReminderDt) {
+
+ // handle if someone edit in frontend after notifications were created
+ // we cannot have a reminderDt set to below the lastReminderDt
+ // so we make the lastReminderDt 0
+ $scheduleReminder->lastReminderDt = 0;
+ $scheduleReminder->reminderDt = $nextReminderDate;
+
+ } else {
+ $scheduleReminder->reminderDt = $nextReminderDate;
+ }
+
+ } else {
+ // next event is not found
+ // we make the reminderDt and lastReminderDt as 0
+ $scheduleReminder->lastReminderDt = 0;
+ $scheduleReminder->reminderDt = 0;
+ }
+
+ // Save
+ $scheduleReminder->save();
+
+ } else { // one off event
+
+ $scheduleReminder->save();
+
+ }
+ }
+
+ private function createGeoJson($coordinates)
+ {
+ $properties = new \StdClass();
+ $convertedCoordinates = [];
+
+
+ // coordinates come as array of strings, we need convert that to array of arrays with float values for the Geo JSON
+ foreach ($coordinates as $coordinate) {
+
+ // each $coordinate is a comma separated string with 2 coordinates
+ // make it into an array
+ $explodedCords = explode(',', $coordinate);
+
+ // prepare a new array, we will add float values to it, need to be cleared for each set of coordinates
+ $floatCords = [];
+
+ // iterate through the exploded array, change the type to float store in a new array
+ foreach ($explodedCords as $explodedCord) {
+ $explodedCord = (float)$explodedCord;
+ $floatCords[] = $explodedCord;
+ }
+
+ // each set of coordinates will be added to this new array, which we will use in the geo json
+ $convertedCoordinates[] = $floatCords;
+ }
+
+ $geometry = [
+ 'type' => 'Polygon',
+ 'coordinates' => [
+ $convertedCoordinates
+ ]
+ ];
+
+ $geoJson = [
+ 'type' => 'Feature',
+ 'properties' => $properties,
+ 'geometry' => $geometry
+ ];
+
+ return json_encode($geoJson);
+ }
+
+ /**
+ * Check if this is event is using full screen schedule with Media or Playlist
+ * @param $eventTypeId
+ * @return bool
+ */
+ private function isFullScreenSchedule($eventTypeId)
+ {
+ return in_array($eventTypeId, [\Xibo\Entity\Schedule::$MEDIA_EVENT, \Xibo\Entity\Schedule::$PLAYLIST_EVENT]);
+ }
+
+ /**
+ * @param $eventTypeId
+ * @return int
+ */
+ private function isSyncEvent($eventTypeId): int
+ {
+ return ($eventTypeId === \Xibo\Entity\Schedule::$SYNC_EVENT) ? 1 : 0;
+ }
+}
diff --git a/lib/Controller/ScheduleReport.php b/lib/Controller/ScheduleReport.php
new file mode 100644
index 0000000..a285b12
--- /dev/null
+++ b/lib/Controller/ScheduleReport.php
@@ -0,0 +1,763 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Slim\Views\Twig;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Report
+ * @package Xibo\Controller
+ */
+class ScheduleReport extends Base
+{
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var Twig
+ */
+ private $view;
+
+ /**
+ * Set common dependencies.
+ * @param LogServiceInterface $log
+ * @param SanitizerService $sanitizerService
+ * @param \Xibo\Helper\ApplicationState $state
+ * @param \Xibo\Entity\User $user
+ * @param \Xibo\Service\HelpServiceInterface $help
+ * @param ConfigServiceInterface $config
+ * @param Twig $view
+ * @param ReportServiceInterface $reportService
+ * @param ReportScheduleFactory $reportScheduleFactory
+ * @param SavedReportFactory $savedReportFactory
+ * @param MediaFactory $mediaFactory
+ * @param UserFactory $userFactory
+ */
+ public function __construct($reportService, $reportScheduleFactory, $savedReportFactory, $mediaFactory, $userFactory)
+ {
+ $this->reportService = $reportService;
+ $this->reportScheduleFactory = $reportScheduleFactory;
+ $this->savedReportFactory = $savedReportFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->userFactory = $userFactory;
+ }
+
+ /// //
+
+ /**
+ * Report Schedule Grid
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleGrid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $reportSchedules = $this->reportScheduleFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter([
+ 'name' => $sanitizedQueryParams->getString('name'),
+ 'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
+ 'userId' => $sanitizedQueryParams->getInt('userId'),
+ 'reportScheduleId' => $sanitizedQueryParams->getInt('reportScheduleId'),
+ 'reportName' => $sanitizedQueryParams->getString('reportName'),
+ 'onlyMySchedules' => $sanitizedQueryParams->getCheckbox('onlyMySchedules'),
+ 'logicalOperatorName' => $sanitizedQueryParams->getString('logicalOperatorName'),
+ ], $sanitizedQueryParams));
+
+ /** @var \Xibo\Entity\ReportSchedule $reportSchedule */
+ foreach ($reportSchedules as $reportSchedule) {
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $reportSchedule->includeProperty('buttons');
+
+ $cron = \Cron\CronExpression::factory($reportSchedule->schedule);
+
+ if ($reportSchedule->lastRunDt == 0) {
+ $nextRunDt = Carbon::now()->format('U');
+ } else {
+ $nextRunDt = $cron->getNextRunDate(Carbon::createFromTimestamp($reportSchedule->lastRunDt))->format('U');
+ }
+
+ $reportSchedule->setUnmatchedProperty('nextRunDt', $nextRunDt);
+
+ // Ad hoc report name
+ $adhocReportName = $reportSchedule->reportName;
+
+ // We get the report description
+ try {
+ $reportSchedule->reportName = $this->reportService->getReportByName($reportSchedule->reportName)->description;
+ } catch (NotFoundException $notFoundException) {
+ $reportSchedule->reportName = __('Unknown or removed report.');
+ }
+
+ switch ($reportSchedule->schedule) {
+ case ReportSchedule::$SCHEDULE_DAILY:
+ $reportSchedule->schedule = __('Run once a day, midnight');
+ break;
+
+ case ReportSchedule::$SCHEDULE_WEEKLY:
+ $reportSchedule->schedule = __('Run once a week, midnight on Monday');
+
+ break;
+
+ case ReportSchedule::$SCHEDULE_MONTHLY:
+ $reportSchedule->schedule = __('Run once a month, midnight, first of month');
+
+ break;
+
+ case ReportSchedule::$SCHEDULE_YEARLY:
+ $reportSchedule->schedule = __('Run once a year, midnight, Jan. 1');
+
+ break;
+ }
+
+ switch ($reportSchedule->isActive) {
+ case 1:
+ $reportSchedule->setUnmatchedProperty('isActiveDescription', __('This report schedule is active'));
+ break;
+
+ default:
+ $reportSchedule->setUnmatchedProperty('isActiveDescription', __('This report schedule is paused'));
+ }
+
+ if ($reportSchedule->getLastSavedReportId() > 0) {
+ $lastSavedReport = $this->savedReportFactory->getById($reportSchedule->getLastSavedReportId());
+
+ // Hide this for schema version 1
+ if ($lastSavedReport->schemaVersion != 1) {
+ // Open Last Saved Report
+ $reportSchedule->buttons[] = [
+ 'id' => 'reportSchedule_lastsaved_report_button',
+ 'class' => 'XiboRedirectButton',
+ 'url' => $this->urlFor($request, 'savedreport.open', ['id' => $lastSavedReport->savedReportId, 'name' => $lastSavedReport->reportName]),
+ 'text' => __('Open last saved report')
+ ];
+ }
+ }
+
+ // Back to Reports
+ $reportSchedule->buttons[] = [
+ 'id' => 'reportSchedule_goto_report_button',
+ 'class' => 'XiboRedirectButton',
+ 'url' => $this->urlFor($request, 'report.form', ['name' => $adhocReportName]),
+ 'text' => __('Back to Reports')
+ ];
+ $reportSchedule->buttons[] = ['divider' => true];
+
+ // Edit
+ if ($this->getUser()->featureEnabled('report.scheduling')) {
+ $reportSchedule->buttons[] = [
+ 'id' => 'reportSchedule_edit_button',
+ 'url' => $this->urlFor($request, 'reportschedule.edit.form', ['id' => $reportSchedule->reportScheduleId]),
+ 'text' => __('Edit')
+ ];
+ }
+
+ // Reset to previous run
+ if ($this->getUser()->isSuperAdmin()) {
+ $reportSchedule->buttons[] = [
+ 'id' => 'reportSchedule_reset_button',
+ 'url' => $this->urlFor($request, 'reportschedule.reset.form', ['id' => $reportSchedule->reportScheduleId]),
+ 'text' => __('Reset to previous run')
+ ];
+ }
+
+ // Delete
+ if ($this->getUser()->featureEnabled('report.scheduling')
+ && $this->getUser()->checkDeleteable($reportSchedule)) {
+ // Show the delete button
+ $reportSchedule->buttons[] = [
+ 'id' => 'reportschedule_button_delete',
+ 'url' => $this->urlFor($request, 'reportschedule.delete.form', ['id' => $reportSchedule->reportScheduleId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'reportschedule.delete', ['id' => $reportSchedule->reportScheduleId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'reportschedule_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $reportSchedule->name]
+ ]
+ ];
+ }
+
+ // Toggle active
+ if ($this->getUser()->featureEnabled('report.scheduling')) {
+ $reportSchedule->buttons[] = [
+ 'id' => 'reportSchedule_toggleactive_button',
+ 'url' => $this->urlFor($request, 'reportschedule.toggleactive.form', ['id' => $reportSchedule->reportScheduleId]),
+ 'text' => ($reportSchedule->isActive == 1) ? __('Pause') : __('Resume')
+ ];
+ }
+
+ // Delete all saved report
+ $savedreports = $this->savedReportFactory->query(null, ['reportScheduleId'=> $reportSchedule->reportScheduleId]);
+ if ((count($savedreports) > 0)
+ && $this->getUser()->checkDeleteable($reportSchedule)
+ && $this->getUser()->featureEnabled('report.saving')
+ ) {
+ $reportSchedule->buttons[] = ['divider' => true];
+
+ $reportSchedule->buttons[] = array(
+ 'id' => 'reportschedule_button_delete_all',
+ 'url' => $this->urlFor($request, 'reportschedule.deleteall.form', ['id' => $reportSchedule->reportScheduleId]),
+ 'text' => __('Delete all saved reports'),
+ );
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->reportScheduleFactory->countLast();
+ $this->getState()->setData($reportSchedules);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Reset
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleReset(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id);
+
+ $this->getLog()->debug('Reset Report Schedule: '.$reportSchedule->name);
+
+ // Go back to previous run date
+ $reportSchedule->lastSavedReportId = 0;
+ $reportSchedule->lastRunDt = $reportSchedule->previousRunDt;
+ $reportSchedule->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => 'Success'
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Add
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleAdd(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $name = $sanitizedParams->getString('name');
+ $reportName = $request->getParam('reportName', null);
+ $fromDt = $sanitizedParams->getDate('fromDt', ['default' => 0]);
+ $toDt = $sanitizedParams->getDate('toDt', ['default' => 0]);
+ $today = Carbon::now()->startOfDay()->format('U');
+
+ // from and todt should be greater than today
+ if (!empty($fromDt)) {
+ $fromDt = $fromDt->format('U');
+ if ($fromDt < $today) {
+ throw new InvalidArgumentException(__('Start time cannot be earlier than today'), 'fromDt');
+ }
+ }
+ if (!empty($toDt)) {
+ $toDt = $toDt->format('U');
+ if ($toDt < $today) {
+ throw new InvalidArgumentException(__('End time cannot be earlier than today'), 'toDt');
+ }
+ }
+
+ $this->getLog()->debug('Add Report Schedule: '. $name);
+
+ // Set Report Schedule form data
+ $result = $this->reportService->setReportScheduleFormData($reportName, $request);
+
+ $reportSchedule = $this->reportScheduleFactory->createEmpty();
+ $reportSchedule->name = $name;
+ $reportSchedule->lastSavedReportId = 0;
+ $reportSchedule->reportName = $reportName;
+ $reportSchedule->filterCriteria = $result['filterCriteria'];
+ $reportSchedule->schedule = $result['schedule'];
+ $reportSchedule->lastRunDt = 0;
+ $reportSchedule->previousRunDt = 0;
+ $reportSchedule->fromDt = $fromDt;
+ $reportSchedule->toDt = $toDt;
+ $reportSchedule->userId = $this->getUser()->userId;
+ $reportSchedule->createdDt = Carbon::now()->format('U');
+
+ $reportSchedule->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Added Report Schedule'),
+ 'id' => $reportSchedule->reportScheduleId,
+ 'data' => $reportSchedule
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Edit
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleEdit(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id, 0);
+
+ if ($reportSchedule->getOwnerId() != $this->getUser()->userId && $this->getUser()->userTypeId != 1) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $name = $sanitizedParams->getString('name');
+ $reportName = $request->getParam('reportName', null);
+ $fromDt = $sanitizedParams->getDate('fromDt', ['default' => 0]);
+ $toDt = $sanitizedParams->getDate('toDt', ['default' => 0]);
+ $today = Carbon::now()->startOfDay()->format('U');
+
+ // from and todt should be greater than today
+ if (!empty($fromDt)) {
+ $fromDt = $fromDt->format('U');
+ if ($fromDt < $today) {
+ throw new InvalidArgumentException(__('Start time cannot be earlier than today'), 'fromDt');
+ }
+ }
+ if (!empty($toDt)) {
+ $toDt = $toDt->format('U');
+ if ($toDt < $today) {
+ throw new InvalidArgumentException(__('End time cannot be earlier than today'), 'toDt');
+ }
+ }
+
+ $reportSchedule->name = $name;
+ $reportSchedule->fromDt = $fromDt;
+ $reportSchedule->toDt = $toDt;
+ $reportSchedule->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $reportSchedule->name),
+ 'id' => $reportSchedule->reportScheduleId,
+ 'data' => $reportSchedule
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Delete
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleDelete(Request $request, Response $response, $id)
+ {
+
+ $reportSchedule = $this->reportScheduleFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($reportSchedule)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete this report schedule'));
+ }
+
+ try {
+ $reportSchedule->delete();
+ } catch (\RuntimeException $e) {
+ throw new InvalidArgumentException(__('Report schedule cannot be deleted. Please ensure there are no saved reports against the schedule.'), 'reportScheduleId');
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $reportSchedule->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Delete All Saved Report
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleDeleteAllSavedReport(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $reportSchedule = $this->reportScheduleFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($reportSchedule)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete the saved report of this report schedule'));
+ }
+
+ // Get all saved reports of the report schedule
+ $savedReports = $this->savedReportFactory->query(
+ null,
+ [
+ 'reportScheduleId' => $reportSchedule->reportScheduleId
+ ]
+ );
+
+
+ foreach ($savedReports as $savedreport) {
+ try {
+ $savedreport->load();
+
+ // Delete
+ $savedreport->delete();
+ } catch (\RuntimeException $e) {
+ throw new InvalidArgumentException(__('Saved report cannot be deleted'), 'savedReportId');
+ }
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted all saved reports of %s'), $reportSchedule->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Toggle Active
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function reportScheduleToggleActive(Request $request, Response $response, $id)
+ {
+
+ $reportSchedule = $this->reportScheduleFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($reportSchedule)) {
+ throw new AccessDeniedException(__('You do not have permissions to pause/resume this report schedule'));
+ }
+
+ if ($reportSchedule->isActive == 1) {
+ $reportSchedule->isActive = 0;
+ $msg = sprintf(__('Paused %s'), $reportSchedule->name);
+ } else {
+ $reportSchedule->isActive = 1;
+ $msg = sprintf(__('Resumed %s'), $reportSchedule->name);
+ }
+
+ $reportSchedule->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => $msg
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Displays the Report Schedule Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayReportSchedulePage(Request $request, Response $response)
+ {
+ $reportsList = $this->reportService->listReports();
+ $availableReports = [];
+ foreach ($reportsList as $reports) {
+ foreach ($reports as $report) {
+ $availableReports[] = $report;
+ }
+ }
+
+ // Call to render the template
+ $this->getState()->template = 'report-schedule-page';
+ $this->getState()->setData([
+ 'availableReports' => $availableReports
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Displays an Add form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function addReportScheduleForm(Request $request, Response $response)
+ {
+
+ $reportName = $request->getParam('reportName', null);
+
+ // Populate form title and hidden fields
+ $formData = $this->reportService->getReportScheduleFormData($reportName, $request);
+
+ $template = $formData['template'];
+ $this->getState()->template = $template;
+ $this->getState()->setData($formData['data']);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editReportScheduleForm(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id, 0);
+
+ if ($reportSchedule->fromDt > 0) {
+ $reportSchedule->fromDt = Carbon::createFromTimestamp($reportSchedule->fromDt)->format(DateFormatHelper::getSystemFormat());
+ } else {
+ $reportSchedule->fromDt = '';
+ }
+
+ if ($reportSchedule->toDt > 0) {
+ $reportSchedule->toDt = Carbon::createFromTimestamp($reportSchedule->toDt)->format(DateFormatHelper::getSystemFormat());
+ } else {
+ $reportSchedule->toDt = '';
+ }
+
+ $this->getState()->template = 'reportschedule-form-edit';
+ $this->getState()->setData([
+ 'reportSchedule' => $reportSchedule
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Reset Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function resetReportScheduleForm(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id, 0);
+
+ // Only admin can reset it
+ if ($this->getUser()->userTypeId != 1) {
+ throw new AccessDeniedException(__('You do not have permissions to reset this report schedule'));
+ }
+
+ $data = [
+ 'reportSchedule' => $reportSchedule
+ ];
+
+ $this->getState()->template = 'reportschedule-form-reset';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteReportScheduleForm(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id, 0);
+
+ if (!$this->getUser()->checkDeleteable($reportSchedule)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete this report schedule'));
+ }
+
+ $data = [
+ 'reportSchedule' => $reportSchedule
+ ];
+
+ $this->getState()->template = 'reportschedule-form-delete';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Delete All Saved Report Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteAllSavedReportReportScheduleForm(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id, 0);
+
+ if (!$this->getUser()->checkDeleteable($reportSchedule)) {
+ throw new AccessDeniedException(__('You do not have permissions to delete saved reports of this report schedule'));
+ }
+
+ $data = [
+ 'reportSchedule' => $reportSchedule
+ ];
+
+ $this->getState()->template = 'reportschedule-form-deleteall';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Report Schedule Toggle Active Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function toggleActiveReportScheduleForm(Request $request, Response $response, $id)
+ {
+ $reportSchedule = $this->reportScheduleFactory->getById($id, 0);
+
+ if (!$this->getUser()->checkEditable($reportSchedule)) {
+ throw new AccessDeniedException(__('You do not have permissions to pause/resume this report schedule'));
+ }
+
+ $data = [
+ 'reportSchedule' => $reportSchedule
+ ];
+
+ $this->getState()->template = 'reportschedule-form-toggleactive';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ //
+}
diff --git a/lib/Controller/Sessions.php b/lib/Controller/Sessions.php
new file mode 100644
index 0000000..5e57c31
--- /dev/null
+++ b/lib/Controller/Sessions.php
@@ -0,0 +1,182 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\SessionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class Sessions
+ * @package Xibo\Controller
+ */
+class Sessions extends Base
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var SessionFactory
+ */
+ private $sessionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param SessionFactory $sessionFactory
+ */
+ public function __construct($store, $sessionFactory)
+ {
+ $this->store = $store;
+ $this->sessionFactory = $sessionFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'sessions-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response): Response|\Psr\Http\Message\ResponseInterface
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $sessions = $this->sessionFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter([
+ 'type' => $sanitizedQueryParams->getString('type'),
+ 'fromDt' => $sanitizedQueryParams->getString('fromDt')
+ ], $sanitizedQueryParams));
+
+ foreach ($sessions as $row) {
+ /* @var \Xibo\Entity\Session $row */
+
+ // Normalise the date
+ $row->lastAccessed =
+ Carbon::createFromTimeString($row->lastAccessed)?->format(DateFormatHelper::getSystemFormat());
+
+ if (!$this->isApi($request) && $this->getUser()->isSuperAdmin()) {
+ $row->includeProperty('buttons');
+
+ // No buttons on expired sessions
+ if ($row->isExpired == 1) {
+ continue;
+ }
+
+ // logout, current user/session
+ if ($row->userId === $this->getUser()->userId && session_id() === $row->sessionId) {
+ $url = $this->urlFor($request, 'logout');
+ } else {
+ // logout, different user/session
+ $url = $this->urlFor(
+ $request,
+ 'sessions.confirm.logout.form',
+ ['id' => $row->userId]
+ );
+ }
+
+ $row->buttons[] = [
+ 'id' => 'sessions_button_logout',
+ 'url' => $url,
+ 'text' => __('Logout')
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->sessionFactory->countLast();
+ $this->getState()->setData($sessions);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Confirm Logout Form
+ * @param Request $request
+ * @param Response $response
+ * @param int $id The UserID
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function confirmLogoutForm(Request $request, Response $response, $id)
+ {
+ if ($this->getUser()->userTypeId != 1) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'sessions-form-confirm-logout';
+ $this->getState()->setData([
+ 'userId' => $id,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Logout
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function logout(Request $request, Response $response, $id)
+ {
+ if ($this->getUser()->userTypeId != 1) {
+ throw new AccessDeniedException();
+ }
+
+ // We log out all of this user's sessions.
+ $this->sessionFactory->expireByUserId($id);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('User Logged Out.')
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Settings.php b/lib/Controller/Settings.php
new file mode 100644
index 0000000..f585a6f
--- /dev/null
+++ b/lib/Controller/Settings.php
@@ -0,0 +1,793 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\PlaylistMaxNumberChangedEvent;
+use Xibo\Event\SystemUserChangedEvent;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\TransitionFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Settings
+ * @package Xibo\Controller
+ */
+class Settings extends Base
+{
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var TransitionFactory */
+ private $transitionfactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /**
+ * Set common dependencies.
+ * @param LayoutFactory $layoutFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param TransitionFactory $transitionfactory
+ * @param UserFactory $userFactory
+ */
+ public function __construct($layoutFactory, $userGroupFactory, $transitionfactory, $userFactory)
+ {
+ $this->layoutFactory = $layoutFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->transitionfactory = $transitionfactory;
+ $this->userFactory = $userFactory;
+ }
+
+ /**
+ * Display Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ // Should we hide other themes?
+ $themes = [];
+ $hideThemes = $this->getConfig()->getThemeConfig('hide_others');
+
+ if (!$hideThemes) {
+ // Get all theme options
+ $directory = new \RecursiveDirectoryIterator(PROJECT_ROOT . '/web/theme', \FilesystemIterator::SKIP_DOTS);
+ $filter = new \RecursiveCallbackFilterIterator($directory, function($current, $key, $iterator) {
+
+ if ($current->isDir()) {
+ return true;
+ }
+
+ return strpos($current->getFilename(), 'config.php') === 0;
+ });
+
+ $iterator = new \RecursiveIteratorIterator($filter);
+
+ // Add options for all themes installed
+ foreach($iterator as $file) {
+ /* @var \SplFileInfo $file */
+ $this->getLog()->debug('Found ' . $file->getPath());
+
+ // Include the config file
+ include $file->getPath() . '/' . $file->getFilename();
+
+ $themes[] = ['id' => basename($file->getPath()), 'value' => $config['theme_name']];
+ }
+ }
+
+ // A list of timezones
+ $timeZones = [];
+ foreach (DateFormatHelper::timezoneList() as $key => $value) {
+ $timeZones[] = ['id' => $key, 'value' => $value];
+ }
+
+ // A list of languages
+ // Build an array of supported languages
+ $languages = [];
+ $localeDir = PROJECT_ROOT . '/locale';
+ foreach (array_map('basename', glob($localeDir . '/*.mo')) as $lang) {
+ // Trim the .mo off the end
+ $lang = str_replace('.mo', '', $lang);
+ $languages[] = ['id' => $lang, 'value' => $lang];
+ }
+
+ // The default layout
+ try {
+ $defaultLayout = $this->layoutFactory->getById($this->getConfig()->getSetting('DEFAULT_LAYOUT'));
+ } catch (NotFoundException $notFoundException) {
+ $defaultLayout = null;
+ }
+
+ // The system User
+ try {
+ $systemUser = $this->userFactory->getById($this->getConfig()->getSetting('SYSTEM_USER'));
+ } catch (NotFoundException $notFoundException) {
+ $systemUser = null;
+ }
+
+ // The default user group
+ try {
+ $defaultUserGroup = $this->userGroupFactory->getById($this->getConfig()->getSetting('DEFAULT_USERGROUP'));
+ } catch (NotFoundException $notFoundException) {
+ $defaultUserGroup = null;
+ }
+
+ // The default Transition In
+ try {
+ $defaultTransitionIn = $this->transitionfactory->getByCode($this->getConfig()->getSetting('DEFAULT_TRANSITION_IN'));
+ } catch (NotFoundException $notFoundException) {
+ $defaultTransitionIn = null;
+ }
+
+ // The default Transition Out
+ try {
+ $defaultTransitionOut = $this->transitionfactory->getByCode($this->getConfig()->getSetting('DEFAULT_TRANSITION_OUT'));
+ } catch (NotFoundException $notFoundException) {
+ $defaultTransitionOut = null;
+ }
+
+ // Work out whether we're in a valid elevate log period
+ $elevateLogUntil = $this->getConfig()->getSetting('ELEVATE_LOG_UNTIL');
+
+ if ($elevateLogUntil != null) {
+ $elevateLogUntil = intval($elevateLogUntil);
+
+ if ($elevateLogUntil <= Carbon::now()->format('U')) {
+ $elevateLogUntil = null;
+ } else {
+ $elevateLogUntil = Carbon::createFromTimestamp($elevateLogUntil)->format(DateFormatHelper::getSystemFormat());
+ }
+ }
+
+ // Render the Theme and output
+ $this->getState()->template = 'settings-page';
+ $this->getState()->setData([
+ 'hideThemes' => $hideThemes,
+ 'themes' => $themes,
+ 'languages' => $languages,
+ 'timeZones' => $timeZones,
+ 'defaultLayout' => $defaultLayout,
+ 'defaultUserGroup' => $defaultUserGroup,
+ 'elevateLogUntil' => $elevateLogUntil,
+ 'defaultTransitionIn' => $defaultTransitionIn,
+ 'defaultTransitionOut' => $defaultTransitionOut,
+ 'systemUser' => $systemUser
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Update settings
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @phpcs:disable Generic.Files.LineLength.TooLong
+ */
+ public function update(Request $request, Response $response)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $changedSettings = [];
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Pull in all of the settings we're expecting to be submitted with this form.
+
+ if ($this->getConfig()->isSettingEditable('LIBRARY_LOCATION')) {
+ $libraryLocation = $sanitizedParams->getString('LIBRARY_LOCATION');
+
+ // Validate library location
+ // Check for a trailing slash and add it if its not there
+ $libraryLocation = rtrim($libraryLocation, '/');
+ $libraryLocation = rtrim($libraryLocation, '\\') . DIRECTORY_SEPARATOR;
+
+ // Attempt to add the directory specified
+ if (!file_exists($libraryLocation . 'temp')) {
+ // Make the directory with broad permissions recursively (so will add the whole path)
+ mkdir($libraryLocation . 'temp', 0777, true);
+ }
+
+ if (!is_writable($libraryLocation . 'temp')) {
+ throw new InvalidArgumentException(__('The Library Location you have picked is not writeable'), 'LIBRARY_LOCATION');
+ }
+
+ $this->handleChangedSettings('LIBRARY_LOCATION', $this->getConfig()->getSetting('LIBRARY_LOCATION'), $libraryLocation, $changedSettings);
+ $this->getConfig()->changeSetting('LIBRARY_LOCATION', $libraryLocation);
+ }
+
+ if ($this->getConfig()->isSettingEditable('SERVER_KEY')) {
+ $this->handleChangedSettings('SERVER_KEY', $this->getConfig()->getSetting('SERVER_KEY'), $sanitizedParams->getString('SERVER_KEY'), $changedSettings);
+ $this->getConfig()->changeSetting('SERVER_KEY', $sanitizedParams->getString('SERVER_KEY'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('GLOBAL_THEME_NAME')) {
+ $this->handleChangedSettings('GLOBAL_THEME_NAME', $this->getConfig()->getSetting('GLOBAL_THEME_NAME'), $sanitizedParams->getString('GLOBAL_THEME_NAME'), $changedSettings);
+ $this->getConfig()->changeSetting('GLOBAL_THEME_NAME', $sanitizedParams->getString('GLOBAL_THEME_NAME'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('NAVIGATION_MENU_POSITION')) {
+ $this->handleChangedSettings('NAVIGATION_MENU_POSITION', $this->getConfig()->getSetting('NAVIGATION_MENU_POSITION'), $sanitizedParams->getString('NAVIGATION_MENU_POSITION'), $changedSettings);
+ $this->getConfig()->changeSetting('NAVIGATION_MENU_POSITION', $sanitizedParams->getString('NAVIGATION_MENU_POSITION'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('LIBRARY_MEDIA_UPDATEINALL_CHECKB')) {
+ $this->handleChangedSettings('LIBRARY_MEDIA_UPDATEINALL_CHECKB', $this->getConfig()->getSetting('LIBRARY_MEDIA_UPDATEINALL_CHECKB'), $sanitizedParams->getCheckbox('LIBRARY_MEDIA_UPDATEINALL_CHECKB'), $changedSettings);
+ $this->getConfig()->changeSetting('LIBRARY_MEDIA_UPDATEINALL_CHECKB', $sanitizedParams->getCheckbox('LIBRARY_MEDIA_UPDATEINALL_CHECKB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('LAYOUT_COPY_MEDIA_CHECKB')) {
+ $this->handleChangedSettings('LAYOUT_COPY_MEDIA_CHECKB', $this->getConfig()->getSetting('LAYOUT_COPY_MEDIA_CHECKB'), $sanitizedParams->getCheckbox('LAYOUT_COPY_MEDIA_CHECKB'), $changedSettings);
+ $this->getConfig()->changeSetting('LAYOUT_COPY_MEDIA_CHECKB', $sanitizedParams->getCheckbox('LAYOUT_COPY_MEDIA_CHECKB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('LIBRARY_MEDIA_DELETEOLDVER_CHECKB')) {
+ $this->handleChangedSettings('LIBRARY_MEDIA_DELETEOLDVER_CHECKB', $this->getConfig()->getSetting('LIBRARY_MEDIA_DELETEOLDVER_CHECKB'), $sanitizedParams->getCheckbox('LIBRARY_MEDIA_DELETEOLDVER_CHECKB'), $changedSettings);
+ $this->getConfig()->changeSetting('LIBRARY_MEDIA_DELETEOLDVER_CHECKB', $sanitizedParams->getCheckbox('LIBRARY_MEDIA_DELETEOLDVER_CHECKB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB')) {
+ $this->handleChangedSettings('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB', $this->getConfig()->getSetting('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB'), $sanitizedParams->getCheckbox('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB', $sanitizedParams->getCheckbox('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_TRANSITION_IN')) {
+ $this->handleChangedSettings('DEFAULT_TRANSITION_IN', $this->getConfig()->getSetting('DEFAULT_TRANSITION_IN'), $sanitizedParams->getString('DEFAULT_TRANSITION_IN'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_TRANSITION_IN', $sanitizedParams->getString('DEFAULT_TRANSITION_IN'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_TRANSITION_OUT')) {
+ $this->handleChangedSettings('DEFAULT_TRANSITION_OUT', $this->getConfig()->getSetting('DEFAULT_TRANSITION_OUT'), $sanitizedParams->getString('DEFAULT_TRANSITION_OUT'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_TRANSITION_OUT', $sanitizedParams->getString('DEFAULT_TRANSITION_OUT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_TRANSITION_DURATION')) {
+ $this->handleChangedSettings('DEFAULT_TRANSITION_DURATION', $this->getConfig()->getSetting('DEFAULT_TRANSITION_DURATION'), $sanitizedParams->getInt('DEFAULT_TRANSITION_DURATION'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_TRANSITION_DURATION', $sanitizedParams->getInt('DEFAULT_TRANSITION_DURATION'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_TRANSITION_AUTO_APPLY')) {
+ $this->handleChangedSettings('DEFAULT_TRANSITION_AUTO_APPLY', $this->getConfig()->getSetting('DEFAULT_TRANSITION_AUTO_APPLY'), $sanitizedParams->getCheckbox('DEFAULT_TRANSITION_AUTO_APPLY'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_TRANSITION_AUTO_APPLY', $sanitizedParams->getCheckbox('DEFAULT_TRANSITION_AUTO_APPLY'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_RESIZE_THRESHOLD')) {
+ $this->handleChangedSettings('DEFAULT_RESIZE_THRESHOLD', $this->getConfig()->getSetting('DEFAULT_RESIZE_THRESHOLD'), $sanitizedParams->getInt('DEFAULT_RESIZE_THRESHOLD'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_RESIZE_THRESHOLD', $sanitizedParams->getInt('DEFAULT_RESIZE_THRESHOLD'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_RESIZE_LIMIT')) {
+ $this->handleChangedSettings('DEFAULT_RESIZE_LIMIT', $this->getConfig()->getSetting('DEFAULT_RESIZE_LIMIT'), $sanitizedParams->getInt('DEFAULT_RESIZE_LIMIT'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_RESIZE_LIMIT', $sanitizedParams->getInt('DEFAULT_RESIZE_LIMIT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DATASET_HARD_ROW_LIMIT')) {
+ $this->handleChangedSettings('DATASET_HARD_ROW_LIMIT', $this->getConfig()->getSetting('DATASET_HARD_ROW_LIMIT'), $sanitizedParams->getInt('DATASET_HARD_ROW_LIMIT'), $changedSettings);
+ $this->getConfig()->changeSetting('DATASET_HARD_ROW_LIMIT', $sanitizedParams->getInt('DATASET_HARD_ROW_LIMIT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_PURGE_LIST_TTL')) {
+ $this->handleChangedSettings('DEFAULT_PURGE_LIST_TTL', $this->getConfig()->getSetting('DEFAULT_PURGE_LIST_TTL'), $sanitizedParams->getInt('DEFAULT_PURGE_LIST_TTL'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_PURGE_LIST_TTL', $sanitizedParams->getInt('DEFAULT_PURGE_LIST_TTL'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_LAYOUT')) {
+ $this->handleChangedSettings('DEFAULT_LAYOUT', $this->getConfig()->getSetting('DEFAULT_LAYOUT'), $sanitizedParams->getInt('DEFAULT_LAYOUT'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_LAYOUT', $sanitizedParams->getInt('DEFAULT_LAYOUT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('XMR_ADDRESS')) {
+ $this->handleChangedSettings('XMR_ADDRESS', $this->getConfig()->getSetting('XMR_ADDRESS'), $sanitizedParams->getString('XMR_ADDRESS'), $changedSettings);
+ $this->getConfig()->changeSetting('XMR_ADDRESS', $sanitizedParams->getString('XMR_ADDRESS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('XMR_PUB_ADDRESS')) {
+ $this->handleChangedSettings('XMR_PUB_ADDRESS', $this->getConfig()->getSetting('XMR_PUB_ADDRESS'), $sanitizedParams->getString('XMR_PUB_ADDRESS'), $changedSettings);
+ $this->getConfig()->changeSetting('XMR_PUB_ADDRESS', $sanitizedParams->getString('XMR_PUB_ADDRESS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('XMR_WS_ADDRESS')) {
+ $this->handleChangedSettings('XMR_WS_ADDRESS', $this->getConfig()->getSetting('XMR_WS_ADDRESS'), $sanitizedParams->getString('XMR_WS_ADDRESS'), $changedSettings);
+ $this->getConfig()->changeSetting('XMR_WS_ADDRESS', $sanitizedParams->getString('XMR_WS_ADDRESS'), 1);
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_LAT')) {
+ $value = $sanitizedParams->getString('DEFAULT_LAT');
+ $this->handleChangedSettings('DEFAULT_LAT', $this->getConfig()->getSetting('DEFAULT_LAT'), $value, $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_LAT', $value);
+
+ if (!v::latitude()->validate($value)) {
+ throw new InvalidArgumentException(__('The latitude entered is not valid.'), 'DEFAULT_LAT');
+ }
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_LONG')) {
+ $value = $sanitizedParams->getString('DEFAULT_LONG');
+ $this->handleChangedSettings('DEFAULT_LONG', $this->getConfig()->getSetting('DEFAULT_LONG'), $value, $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_LONG', $value);
+
+ if (!v::longitude()->validate($value)) {
+ throw new InvalidArgumentException(__('The longitude entered is not valid.'), 'DEFAULT_LONG');
+ }
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER')) {
+ $this->handleChangedSettings('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER', $this->getConfig()->getSetting('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER'), $sanitizedParams->getInt('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER', $sanitizedParams->getInt('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT')) {
+ $this->handleChangedSettings('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT', $this->getConfig()->getSetting('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT'), $sanitizedParams->getInt('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT', $sanitizedParams->getInt('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SHOW_DISPLAY_AS_VNCLINK')) {
+ $this->handleChangedSettings('SHOW_DISPLAY_AS_VNCLINK', $this->getConfig()->getSetting('SHOW_DISPLAY_AS_VNCLINK'), $sanitizedParams->getString('SHOW_DISPLAY_AS_VNCLINK'), $changedSettings);
+ $this->getConfig()->changeSetting('SHOW_DISPLAY_AS_VNCLINK', $sanitizedParams->getString('SHOW_DISPLAY_AS_VNCLINK'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SHOW_DISPLAY_AS_VNC_TGT')) {
+ $this->handleChangedSettings('SHOW_DISPLAY_AS_VNC_TGT', $this->getConfig()->getSetting('SHOW_DISPLAY_AS_VNC_TGT'), $sanitizedParams->getString('SHOW_DISPLAY_AS_VNC_TGT'), $changedSettings);
+ $this->getConfig()->changeSetting('SHOW_DISPLAY_AS_VNC_TGT', $sanitizedParams->getString('SHOW_DISPLAY_AS_VNC_TGT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAX_LICENSED_DISPLAYS')) {
+ $this->handleChangedSettings('MAX_LICENSED_DISPLAYS', $this->getConfig()->getSetting('MAX_LICENSED_DISPLAYS'), $sanitizedParams->getInt('MAX_LICENSED_DISPLAYS'), $changedSettings);
+ $this->getConfig()->changeSetting('MAX_LICENSED_DISPLAYS', $sanitizedParams->getInt('MAX_LICENSED_DISPLAYS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT')) {
+ $this->handleChangedSettings('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT', $this->getConfig()->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'), $sanitizedParams->getString('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT', $sanitizedParams->getString('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_PROFILE_STATS_DEFAULT')) {
+ $this->handleChangedSettings('DISPLAY_PROFILE_STATS_DEFAULT', $this->getConfig()->getSetting('DISPLAY_PROFILE_STATS_DEFAULT'), $sanitizedParams->getCheckbox('DISPLAY_PROFILE_STATS_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_PROFILE_STATS_DEFAULT', $sanitizedParams->getCheckbox('DISPLAY_PROFILE_STATS_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('LAYOUT_STATS_ENABLED_DEFAULT')) {
+ $this->handleChangedSettings('LAYOUT_STATS_ENABLED_DEFAULT', $this->getConfig()->getSetting('LAYOUT_STATS_ENABLED_DEFAULT'), $sanitizedParams->getCheckbox('LAYOUT_STATS_ENABLED_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('LAYOUT_STATS_ENABLED_DEFAULT', $sanitizedParams->getCheckbox('LAYOUT_STATS_ENABLED_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PLAYLIST_STATS_ENABLED_DEFAULT')) {
+ $this->handleChangedSettings('PLAYLIST_STATS_ENABLED_DEFAULT', $this->getConfig()->getSetting('PLAYLIST_STATS_ENABLED_DEFAULT'), $sanitizedParams->getString('PLAYLIST_STATS_ENABLED_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('PLAYLIST_STATS_ENABLED_DEFAULT', $sanitizedParams->getString('PLAYLIST_STATS_ENABLED_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MEDIA_STATS_ENABLED_DEFAULT')) {
+ $this->handleChangedSettings('MEDIA_STATS_ENABLED_DEFAULT', $this->getConfig()->getSetting('MEDIA_STATS_ENABLED_DEFAULT'), $sanitizedParams->getString('MEDIA_STATS_ENABLED_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('MEDIA_STATS_ENABLED_DEFAULT', $sanitizedParams->getString('MEDIA_STATS_ENABLED_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('WIDGET_STATS_ENABLED_DEFAULT')) {
+ $this->handleChangedSettings('WIDGET_STATS_ENABLED_DEFAULT', $this->getConfig()->getSetting('WIDGET_STATS_ENABLED_DEFAULT'), $sanitizedParams->getString('WIDGET_STATS_ENABLED_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', $sanitizedParams->getString('WIDGET_STATS_ENABLED_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED')) {
+ $this->handleChangedSettings('DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED', $this->getConfig()->getSetting('DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED'), $sanitizedParams->getCheckbox('DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED', $sanitizedParams->getCheckbox('DISPLAY_PROFILE_CURRENT_LAYOUT_STATUS_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_LOCK_NAME_TO_DEVICENAME')) {
+ $this->handleChangedSettings('DISPLAY_LOCK_NAME_TO_DEVICENAME', $this->getConfig()->getSetting('DISPLAY_LOCK_NAME_TO_DEVICENAME'), $sanitizedParams->getCheckbox('DISPLAY_LOCK_NAME_TO_DEVICENAME'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_LOCK_NAME_TO_DEVICENAME', $sanitizedParams->getCheckbox('DISPLAY_LOCK_NAME_TO_DEVICENAME'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED')) {
+ $this->handleChangedSettings('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED', $this->getConfig()->getSetting('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED'), $sanitizedParams->getCheckbox('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED', $sanitizedParams->getCheckbox('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT')) {
+ $this->handleChangedSettings('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', $this->getConfig()->getSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT'), $sanitizedParams->getInt('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', $sanitizedParams->getInt('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_SCREENSHOT_TTL')) {
+ $this->handleChangedSettings('DISPLAY_SCREENSHOT_TTL', $this->getConfig()->getSetting('DISPLAY_SCREENSHOT_TTL'), $sanitizedParams->getInt('DISPLAY_SCREENSHOT_TTL'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_SCREENSHOT_TTL', $sanitizedParams->getInt('DISPLAY_SCREENSHOT_TTL'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_AUTO_AUTH')) {
+ $this->handleChangedSettings('DISPLAY_AUTO_AUTH', $this->getConfig()->getSetting('DISPLAY_AUTO_AUTH'), $sanitizedParams->getCheckbox('DISPLAY_AUTO_AUTH'), $changedSettings);
+ $this->getConfig()->changeSetting('DISPLAY_AUTO_AUTH', $sanitizedParams->getCheckbox('DISPLAY_AUTO_AUTH'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DISPLAY_DEFAULT_FOLDER')) {
+ $this->handleChangedSettings(
+ 'DISPLAY_DEFAULT_FOLDER',
+ $this->getConfig()->getSetting('DISPLAY_DEFAULT_FOLDER'),
+ $sanitizedParams->getInt('DISPLAY_DEFAULT_FOLDER'),
+ $changedSettings
+ );
+ $this->getConfig()->changeSetting(
+ 'DISPLAY_DEFAULT_FOLDER',
+ $sanitizedParams->getInt('DISPLAY_DEFAULT_FOLDER'),
+ 1
+ );
+ }
+
+ if ($this->getConfig()->isSettingEditable('HELP_BASE')) {
+ $this->handleChangedSettings('HELP_BASE', $this->getConfig()->getSetting('HELP_BASE'), $sanitizedParams->getString('HELP_BASE'), $changedSettings);
+ $this->getConfig()->changeSetting('HELP_BASE', $sanitizedParams->getString('HELP_BASE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('QUICK_CHART_URL')) {
+ $this->handleChangedSettings('QUICK_CHART_URL', $this->getConfig()->getSetting('QUICK_CHART_URL'), $sanitizedParams->getString('QUICK_CHART_URL'), $changedSettings);
+ $this->getConfig()->changeSetting('QUICK_CHART_URL', $sanitizedParams->getString('QUICK_CHART_URL'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PHONE_HOME')) {
+ $this->handleChangedSettings('PHONE_HOME', $this->getConfig()->getSetting('PHONE_HOME'), $sanitizedParams->getCheckbox('PHONE_HOME'), $changedSettings);
+ $this->getConfig()->changeSetting('PHONE_HOME', $sanitizedParams->getCheckbox('PHONE_HOME'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PHONE_HOME_KEY')) {
+ $this->handleChangedSettings('PHONE_HOME_KEY', $this->getConfig()->getSetting('PHONE_HOME_KEY'), $sanitizedParams->getString('PHONE_HOME_KEY'), $changedSettings);
+ $this->getConfig()->changeSetting('PHONE_HOME_KEY', $sanitizedParams->getString('PHONE_HOME_KEY'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PHONE_HOME_DATE')) {
+ $this->handleChangedSettings('PHONE_HOME_DATE', $this->getConfig()->getSetting('PHONE_HOME_DATE'), $sanitizedParams->getInt('PHONE_HOME_DATE'), $changedSettings);
+ $this->getConfig()->changeSetting('PHONE_HOME_DATE', $sanitizedParams->getInt('PHONE_HOME_DATE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SCHEDULE_LOOKAHEAD')) {
+ $this->handleChangedSettings('SCHEDULE_LOOKAHEAD', $this->getConfig()->getSetting('SCHEDULE_LOOKAHEAD'), $sanitizedParams->getCheckbox('SCHEDULE_LOOKAHEAD'), $changedSettings);
+ $this->getConfig()->changeSetting('SCHEDULE_LOOKAHEAD', $sanitizedParams->getCheckbox('SCHEDULE_LOOKAHEAD'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('REQUIRED_FILES_LOOKAHEAD')) {
+ $this->handleChangedSettings('REQUIRED_FILES_LOOKAHEAD', $this->getConfig()->getSetting('REQUIRED_FILES_LOOKAHEAD'), $sanitizedParams->getInt('REQUIRED_FILES_LOOKAHEAD'), $changedSettings);
+ $this->getConfig()->changeSetting('REQUIRED_FILES_LOOKAHEAD', $sanitizedParams->getInt('REQUIRED_FILES_LOOKAHEAD'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SETTING_IMPORT_ENABLED')) {
+ $this->handleChangedSettings('SETTING_IMPORT_ENABLED', $this->getConfig()->getSetting('SETTING_IMPORT_ENABLED'), $sanitizedParams->getCheckbox('SETTING_IMPORT_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('SETTING_IMPORT_ENABLED', $sanitizedParams->getCheckbox('SETTING_IMPORT_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SETTING_LIBRARY_TIDY_ENABLED')) {
+ $this->handleChangedSettings('SETTING_LIBRARY_TIDY_ENABLED', $this->getConfig()->getSetting('SETTING_LIBRARY_TIDY_ENABLED'), $sanitizedParams->getCheckbox('SETTING_LIBRARY_TIDY_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('SETTING_LIBRARY_TIDY_ENABLED', $sanitizedParams->getCheckbox('SETTING_LIBRARY_TIDY_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('EMBEDDED_STATUS_WIDGET')) {
+ $this->handleChangedSettings('EMBEDDED_STATUS_WIDGET', $this->getConfig()->getSetting('EMBEDDED_STATUS_WIDGET'), $sanitizedParams->getString('EMBEDDED_STATUS_WIDGET'), $changedSettings);
+ $this->getConfig()->changeSetting('EMBEDDED_STATUS_WIDGET', $sanitizedParams->getString('EMBEDDED_STATUS_WIDGET'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULTS_IMPORTED')) {
+ $this->handleChangedSettings('DEFAULTS_IMPORTED', $this->getConfig()->getSetting('DEFAULTS_IMPORTED'), $sanitizedParams->getCheckbox('DEFAULTS_IMPORTED'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULTS_IMPORTED', $sanitizedParams->getCheckbox('DEFAULTS_IMPORTED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DASHBOARD_LATEST_NEWS_ENABLED')) {
+ $this->handleChangedSettings('DASHBOARD_LATEST_NEWS_ENABLED', $this->getConfig()->getSetting('DASHBOARD_LATEST_NEWS_ENABLED'), $sanitizedParams->getCheckbox('DASHBOARD_LATEST_NEWS_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('DASHBOARD_LATEST_NEWS_ENABLED', $sanitizedParams->getCheckbox('DASHBOARD_LATEST_NEWS_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('INSTANCE_SUSPENDED')) {
+ $this->handleChangedSettings('INSTANCE_SUSPENDED', $this->getConfig()->getSetting('INSTANCE_SUSPENDED'), $sanitizedParams->getString('INSTANCE_SUSPENDED'), $changedSettings);
+ $this->getConfig()->changeSetting('INSTANCE_SUSPENDED', $sanitizedParams->getString('INSTANCE_SUSPENDED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('LATEST_NEWS_URL')) {
+ $this->handleChangedSettings('LATEST_NEWS_URL', $this->getConfig()->getSetting('LATEST_NEWS_URL'), $sanitizedParams->getString('LATEST_NEWS_URL'), $changedSettings);
+ $this->getConfig()->changeSetting('LATEST_NEWS_URL', $sanitizedParams->getString('LATEST_NEWS_URL'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('REPORTS_EXPORT_SHOW_LOGO')) {
+ $this->handleChangedSettings(
+ 'REPORTS_EXPORT_SHOW_LOGO',
+ $this->getConfig()->getSetting('REPORTS_EXPORT_SHOW_LOGO'),
+ $sanitizedParams->getCheckbox('REPORTS_EXPORT_SHOW_LOGO'),
+ $changedSettings
+ );
+ $this->getConfig()->changeSetting(
+ 'REPORTS_EXPORT_SHOW_LOGO',
+ $sanitizedParams->getCheckbox('REPORTS_EXPORT_SHOW_LOGO')
+ );
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAINTENANCE_ENABLED')) {
+ $this->handleChangedSettings('MAINTENANCE_ENABLED', $this->getConfig()->getSetting('MAINTENANCE_ENABLED'), $sanitizedParams->getString('MAINTENANCE_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('MAINTENANCE_ENABLED', $sanitizedParams->getString('MAINTENANCE_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAINTENANCE_EMAIL_ALERTS')) {
+ $this->handleChangedSettings('MAINTENANCE_EMAIL_ALERTS', $this->getConfig()->getSetting('MAINTENANCE_EMAIL_ALERTS'), $sanitizedParams->getCheckbox('MAINTENANCE_EMAIL_ALERTS'), $changedSettings);
+ $this->getConfig()->changeSetting('MAINTENANCE_EMAIL_ALERTS', $sanitizedParams->getCheckbox('MAINTENANCE_EMAIL_ALERTS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAINTENANCE_LOG_MAXAGE')) {
+ $this->handleChangedSettings('MAINTENANCE_LOG_MAXAGE', $this->getConfig()->getSetting('MAINTENANCE_LOG_MAXAGE'), $sanitizedParams->getInt('MAINTENANCE_LOG_MAXAGE'), $changedSettings);
+ $this->getConfig()->changeSetting('MAINTENANCE_LOG_MAXAGE', $sanitizedParams->getInt('MAINTENANCE_LOG_MAXAGE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAINTENANCE_STAT_MAXAGE')) {
+ $this->handleChangedSettings('MAINTENANCE_STAT_MAXAGE', $this->getConfig()->getSetting('MAINTENANCE_STAT_MAXAGE'), $sanitizedParams->getInt('MAINTENANCE_STAT_MAXAGE'), $changedSettings);
+ $this->getConfig()->changeSetting('MAINTENANCE_STAT_MAXAGE', $sanitizedParams->getInt('MAINTENANCE_STAT_MAXAGE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAINTENANCE_ALERT_TOUT')) {
+ $this->handleChangedSettings('MAINTENANCE_ALERT_TOUT', $this->getConfig()->getSetting('MAINTENANCE_ALERT_TOUT'), $sanitizedParams->getInt('MAINTENANCE_ALERT_TOUT'), $changedSettings);
+ $this->getConfig()->changeSetting('MAINTENANCE_ALERT_TOUT', $sanitizedParams->getInt('MAINTENANCE_ALERT_TOUT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MAINTENANCE_ALWAYS_ALERT')) {
+ $this->handleChangedSettings('MAINTENANCE_ALWAYS_ALERT', $this->getConfig()->getSetting('MAINTENANCE_ALWAYS_ALERT'), $sanitizedParams->getCheckbox('MAINTENANCE_ALWAYS_ALERT'), $changedSettings);
+ $this->getConfig()->changeSetting('MAINTENANCE_ALWAYS_ALERT', $sanitizedParams->getCheckbox('MAINTENANCE_ALWAYS_ALERT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('mail_to')) {
+ $this->handleChangedSettings('mail_to', $this->getConfig()->getSetting('mail_to'), $sanitizedParams->getString('mail_to'), $changedSettings);
+ $this->getConfig()->changeSetting('mail_to', $sanitizedParams->getString('mail_to'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('mail_from')) {
+ $this->handleChangedSettings('mail_from', $this->getConfig()->getSetting('mail_from'), $sanitizedParams->getString('mail_from'), $changedSettings);
+ $this->getConfig()->changeSetting('mail_from', $sanitizedParams->getString('mail_from'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('mail_from_name')) {
+ $this->handleChangedSettings('mail_from_name', $this->getConfig()->getSetting('mail_from_name'), $sanitizedParams->getString('mail_from_name'), $changedSettings);
+ $this->getConfig()->changeSetting('mail_from_name', $sanitizedParams->getString('mail_from_name'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SENDFILE_MODE')) {
+ $this->handleChangedSettings('SENDFILE_MODE', $this->getConfig()->getSetting('SENDFILE_MODE'), $sanitizedParams->getString('SENDFILE_MODE'), $changedSettings);
+ $this->getConfig()->changeSetting('SENDFILE_MODE', $sanitizedParams->getString('SENDFILE_MODE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PROXY_HOST')) {
+ $this->handleChangedSettings('PROXY_HOST', $this->getConfig()->getSetting('PROXY_HOST'), $sanitizedParams->getString('PROXY_HOST'), $changedSettings);
+ $this->getConfig()->changeSetting('PROXY_HOST', $sanitizedParams->getString('PROXY_HOST'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PROXY_PORT')) {
+ $this->handleChangedSettings('PROXY_PORT', $this->getConfig()->getSetting('PROXY_PORT'), $sanitizedParams->getString('PROXY_PORT'), $changedSettings);
+ $this->getConfig()->changeSetting('PROXY_PORT', $sanitizedParams->getString('PROXY_PORT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PROXY_AUTH')) {
+ $this->handleChangedSettings('PROXY_AUTH', $this->getConfig()->getSetting('PROXY_AUTH'), $sanitizedParams->getString('PROXY_AUTH'), $changedSettings);
+ $this->getConfig()->changeSetting('PROXY_AUTH', $sanitizedParams->getString('PROXY_AUTH'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PROXY_EXCEPTIONS')) {
+ $this->handleChangedSettings('PROXY_EXCEPTIONS', $this->getConfig()->getSetting('PROXY_EXCEPTIONS'), $sanitizedParams->getString('PROXY_EXCEPTIONS'), $changedSettings);
+ $this->getConfig()->changeSetting('PROXY_EXCEPTIONS', $sanitizedParams->getString('PROXY_EXCEPTIONS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('CDN_URL')) {
+ $this->handleChangedSettings('CDN_URL', $this->getConfig()->getSetting('CDN_URL'), $sanitizedParams->getString('CDN_URL'), $changedSettings);
+ $this->getConfig()->changeSetting('CDN_URL', $sanitizedParams->getString('CDN_URL'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('MONTHLY_XMDS_TRANSFER_LIMIT_KB')) {
+ $this->handleChangedSettings('MONTHLY_XMDS_TRANSFER_LIMIT_KB', $this->getConfig()->getSetting('MONTHLY_XMDS_TRANSFER_LIMIT_KB'), $sanitizedParams->getInt('MONTHLY_XMDS_TRANSFER_LIMIT_KB'), $changedSettings);
+ $this->getConfig()->changeSetting('MONTHLY_XMDS_TRANSFER_LIMIT_KB', $sanitizedParams->getInt('MONTHLY_XMDS_TRANSFER_LIMIT_KB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('LIBRARY_SIZE_LIMIT_KB')) {
+ $this->handleChangedSettings('LIBRARY_SIZE_LIMIT_KB', $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB'), $sanitizedParams->getInt('LIBRARY_SIZE_LIMIT_KB'), $changedSettings);
+ $this->getConfig()->changeSetting('LIBRARY_SIZE_LIMIT_KB', $sanitizedParams->getInt('LIBRARY_SIZE_LIMIT_KB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('FORCE_HTTPS')) {
+ $this->handleChangedSettings('FORCE_HTTPS', $this->getConfig()->getSetting('FORCE_HTTPS'), $sanitizedParams->getCheckbox('FORCE_HTTPS'), $changedSettings);
+ $this->getConfig()->changeSetting('FORCE_HTTPS', $sanitizedParams->getCheckbox('FORCE_HTTPS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('ISSUE_STS')) {
+ $this->handleChangedSettings('ISSUE_STS', $this->getConfig()->getSetting('ISSUE_STS'), $sanitizedParams->getCheckbox('ISSUE_STS'), $changedSettings);
+ $this->getConfig()->changeSetting('ISSUE_STS', $sanitizedParams->getCheckbox('ISSUE_STS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('STS_TTL')) {
+ $this->handleChangedSettings('STS_TTL', $this->getConfig()->getSetting('STS_TTL'), $sanitizedParams->getInt('STS_TTL'), $changedSettings);
+ $this->getConfig()->changeSetting('STS_TTL', $sanitizedParams->getInt('STS_TTL'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('WHITELIST_LOAD_BALANCERS')) {
+ $this->handleChangedSettings('WHITELIST_LOAD_BALANCERS', $this->getConfig()->getSetting('WHITELIST_LOAD_BALANCERS'), $sanitizedParams->getString('WHITELIST_LOAD_BALANCERS'), $changedSettings);
+ $this->getConfig()->changeSetting('WHITELIST_LOAD_BALANCERS', $sanitizedParams->getString('WHITELIST_LOAD_BALANCERS'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('REGION_OPTIONS_COLOURING')) {
+ $this->handleChangedSettings('REGION_OPTIONS_COLOURING', $this->getConfig()->getSetting('REGION_OPTIONS_COLOURING'), $sanitizedParams->getString('REGION_OPTIONS_COLOURING'), $changedSettings);
+ $this->getConfig()->changeSetting('REGION_OPTIONS_COLOURING', $sanitizedParams->getString('REGION_OPTIONS_COLOURING'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SCHEDULE_WITH_VIEW_PERMISSION')) {
+ $this->handleChangedSettings('SCHEDULE_WITH_VIEW_PERMISSION', $this->getConfig()->getSetting('SCHEDULE_WITH_VIEW_PERMISSION'), $sanitizedParams->getCheckbox('SCHEDULE_WITH_VIEW_PERMISSION'), $changedSettings);
+ $this->getConfig()->changeSetting('SCHEDULE_WITH_VIEW_PERMISSION', $sanitizedParams->getCheckbox('SCHEDULE_WITH_VIEW_PERMISSION'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SCHEDULE_SHOW_LAYOUT_NAME')) {
+ $this->handleChangedSettings('SCHEDULE_SHOW_LAYOUT_NAME', $this->getConfig()->getSetting('SCHEDULE_SHOW_LAYOUT_NAME'), $sanitizedParams->getCheckbox('SCHEDULE_SHOW_LAYOUT_NAME'), $changedSettings);
+ $this->getConfig()->changeSetting('SCHEDULE_SHOW_LAYOUT_NAME', $sanitizedParams->getCheckbox('SCHEDULE_SHOW_LAYOUT_NAME'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('TASK_CONFIG_LOCKED_CHECKB')) {
+ $this->handleChangedSettings('TASK_CONFIG_LOCKED_CHECKB', $this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB'), $sanitizedParams->getCheckbox('TASK_CONFIG_LOCKED_CHECKB'), $changedSettings);
+ $this->getConfig()->changeSetting('TASK_CONFIG_LOCKED_CHECKB', $sanitizedParams->getCheckbox('TASK_CONFIG_LOCKED_CHECKB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('TRANSITION_CONFIG_LOCKED_CHECKB')) {
+ $this->handleChangedSettings('TRANSITION_CONFIG_LOCKED_CHECKB', $this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB'), $sanitizedParams->getCheckbox('TRANSITION_CONFIG_LOCKED_CHECKB'), $changedSettings);
+ $this->getConfig()->changeSetting('TRANSITION_CONFIG_LOCKED_CHECKB', $sanitizedParams->getCheckbox('TRANSITION_CONFIG_LOCKED_CHECKB'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('FOLDERS_ALLOW_SAVE_IN_ROOT')) {
+ $this->handleChangedSettings('FOLDERS_ALLOW_SAVE_IN_ROOT', $this->getConfig()->getSetting('FOLDERS_ALLOW_SAVE_IN_ROOT'), $sanitizedParams->getCheckbox('FOLDERS_ALLOW_SAVE_IN_ROOT'), $changedSettings);
+ $this->getConfig()->changeSetting('FOLDERS_ALLOW_SAVE_IN_ROOT', $sanitizedParams->getCheckbox('FOLDERS_ALLOW_SAVE_IN_ROOT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_LANGUAGE')) {
+ $this->handleChangedSettings('DEFAULT_LANGUAGE', $this->getConfig()->getSetting('DEFAULT_LANGUAGE'), $sanitizedParams->getString('DEFAULT_LANGUAGE'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_LANGUAGE', $sanitizedParams->getString('DEFAULT_LANGUAGE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('defaultTimezone')) {
+ $this->handleChangedSettings('defaultTimezone', $this->getConfig()->getSetting('defaultTimezone'), $sanitizedParams->getString('defaultTimezone'), $changedSettings);
+ $this->getConfig()->changeSetting('defaultTimezone', $sanitizedParams->getString('defaultTimezone'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DATE_FORMAT')) {
+ $this->handleChangedSettings('DATE_FORMAT', $this->getConfig()->getSetting('DATE_FORMAT'), $sanitizedParams->getString('DATE_FORMAT'), $changedSettings);
+ $this->getConfig()->changeSetting('DATE_FORMAT', $sanitizedParams->getString('DATE_FORMAT'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DETECT_LANGUAGE')) {
+ $this->handleChangedSettings('DETECT_LANGUAGE', $this->getConfig()->getSetting('DETECT_LANGUAGE'), $sanitizedParams->getCheckbox('DETECT_LANGUAGE'), $changedSettings);
+ $this->getConfig()->changeSetting('DETECT_LANGUAGE', $sanitizedParams->getCheckbox('DETECT_LANGUAGE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('CALENDAR_TYPE')) {
+ $this->handleChangedSettings('CALENDAR_TYPE', $this->getConfig()->getSetting('CALENDAR_TYPE'), $sanitizedParams->getString('CALENDAR_TYPE'), $changedSettings);
+ $this->getConfig()->changeSetting('CALENDAR_TYPE', $sanitizedParams->getString('CALENDAR_TYPE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('RESTING_LOG_LEVEL')) {
+ $this->handleChangedSettings('RESTING_LOG_LEVEL', $this->getConfig()->getSetting('RESTING_LOG_LEVEL'), $sanitizedParams->getString('RESTING_LOG_LEVEL'), $changedSettings);
+ $this->getConfig()->changeSetting('RESTING_LOG_LEVEL', $sanitizedParams->getString('RESTING_LOG_LEVEL'));
+ }
+
+ // Handle changes to log level
+ $newLogLevel = null;
+ $newElevateUntil = null;
+ $currentLogLevel = $this->getConfig()->getSetting('audit');
+
+ if ($this->getConfig()->isSettingEditable('audit')) {
+ $newLogLevel = $sanitizedParams->getString('audit');
+ $this->handleChangedSettings('audit', $this->getConfig()->getSetting('audit'), $newLogLevel, $changedSettings);
+ $this->getConfig()->changeSetting('audit', $newLogLevel);
+ }
+
+ if ($this->getConfig()->isSettingEditable('ELEVATE_LOG_UNTIL') && $sanitizedParams->getDate('ELEVATE_LOG_UNTIL') != null) {
+ $newElevateUntil = $sanitizedParams->getDate('ELEVATE_LOG_UNTIL')->format('U');
+ $this->handleChangedSettings('ELEVATE_LOG_UNTIL', $this->getConfig()->getSetting('ELEVATE_LOG_UNTIL'), $newElevateUntil, $changedSettings);
+ $this->getConfig()->changeSetting('ELEVATE_LOG_UNTIL', $newElevateUntil);
+ }
+
+ // Have we changed log level? If so, were we also provided the elevate until setting?
+ if ($newElevateUntil === null && $currentLogLevel != $newLogLevel) {
+ // We haven't provided an elevate until (meaning it is not visible)
+ $this->getConfig()->changeSetting('ELEVATE_LOG_UNTIL', Carbon::now()->addHour()->format('U'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SERVER_MODE')) {
+ $this->handleChangedSettings('SERVER_MODE', $this->getConfig()->getSetting('SERVER_MODE'), $sanitizedParams->getString('SERVER_MODE'), $changedSettings);
+ $this->getConfig()->changeSetting('SERVER_MODE', $sanitizedParams->getString('SERVER_MODE'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('SYSTEM_USER')) {
+ $this->handleChangedSettings('SYSTEM_USER', $this->getConfig()->getSetting('SYSTEM_USER'), $sanitizedParams->getInt('SYSTEM_USER'), $changedSettings);
+ $this->getConfig()->changeSetting('SYSTEM_USER', $sanitizedParams->getInt('SYSTEM_USER'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('DEFAULT_USERGROUP')) {
+ $this->handleChangedSettings('DEFAULT_USERGROUP', $this->getConfig()->getSetting('DEFAULT_USERGROUP'), $sanitizedParams->getInt('DEFAULT_USERGROUP'), $changedSettings);
+ $this->getConfig()->changeSetting('DEFAULT_USERGROUP', $sanitizedParams->getInt('DEFAULT_USERGROUP'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('defaultUsertype')) {
+ $this->handleChangedSettings('defaultUsertype', $this->getConfig()->getSetting('defaultUsertype'), $sanitizedParams->getString('defaultUsertype'), $changedSettings);
+ $this->getConfig()->changeSetting('defaultUsertype', $sanitizedParams->getString('defaultUsertype'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('USER_PASSWORD_POLICY')) {
+ $this->handleChangedSettings('USER_PASSWORD_POLICY', $this->getConfig()->getSetting('USER_PASSWORD_POLICY'), $sanitizedParams->getString('USER_PASSWORD_POLICY'), $changedSettings);
+ $this->getConfig()->changeSetting('USER_PASSWORD_POLICY', $sanitizedParams->getString('USER_PASSWORD_POLICY'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('USER_PASSWORD_ERROR')) {
+ $this->handleChangedSettings('USER_PASSWORD_ERROR', $this->getConfig()->getSetting('USER_PASSWORD_ERROR'), $sanitizedParams->getString('USER_PASSWORD_ERROR'), $changedSettings);
+ $this->getConfig()->changeSetting('USER_PASSWORD_ERROR', $sanitizedParams->getString('USER_PASSWORD_ERROR'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('PASSWORD_REMINDER_ENABLED')) {
+ $this->handleChangedSettings('PASSWORD_REMINDER_ENABLED', $this->getConfig()->getSetting('PASSWORD_REMINDER_ENABLED'), $sanitizedParams->getString('PASSWORD_REMINDER_ENABLED'), $changedSettings);
+ $this->getConfig()->changeSetting('PASSWORD_REMINDER_ENABLED', $sanitizedParams->getString('PASSWORD_REMINDER_ENABLED'));
+ }
+
+ if ($this->getConfig()->isSettingEditable('TWOFACTOR_ISSUER')) {
+ $this->handleChangedSettings('TWOFACTOR_ISSUER', $this->getConfig()->getSetting('TWOFACTOR_ISSUER'), $sanitizedParams->getString('TWOFACTOR_ISSUER'), $changedSettings);
+ $this->getConfig()->changeSetting('TWOFACTOR_ISSUER', $sanitizedParams->getString('TWOFACTOR_ISSUER'));
+ }
+
+ if ($changedSettings != []) {
+ $this->getLog()->audit('Settings', 0, 'Updated', $changedSettings);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Settings Updated')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ private function handleChangedSettings($setting, $oldValue, $newValue, &$changedSettings)
+ {
+ if ($oldValue != $newValue) {
+ if ($setting === 'SYSTEM_USER') {
+ $newSystemUser = $this->userFactory->getById($newValue);
+ $oldSystemUser = $this->userFactory->getById($oldValue);
+ $this->getDispatcher()->dispatch(SystemUserChangedEvent::$NAME, new SystemUserChangedEvent($oldSystemUser, $newSystemUser));
+ } elseif ($setting === 'DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT') {
+ $this->getDispatcher()->dispatch(PlaylistMaxNumberChangedEvent::$NAME, new PlaylistMaxNumberChangedEvent($newValue));
+ }
+ if ($setting === 'ELEVATE_LOG_UNTIL') {
+ $changedSettings[$setting] = Carbon::createFromTimestamp($oldValue)->format(DateFormatHelper::getSystemFormat()) . ' > ' . Carbon::createFromTimestamp($newValue)->format(DateFormatHelper::getSystemFormat());
+ } else {
+ $changedSettings[$setting] = $oldValue . ' > ' . $newValue;
+ }
+ }
+ }
+}
diff --git a/lib/Controller/Stats.php b/lib/Controller/Stats.php
new file mode 100644
index 0000000..e552d8c
--- /dev/null
+++ b/lib/Controller/Stats.php
@@ -0,0 +1,1085 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\ConnectorReportEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Helper\SendFile;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Stats
+ * @package Xibo\Controller
+ */
+class Stats extends Base
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var TimeSeriesStoreInterface
+ */
+ private $timeSeriesStore;
+
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param TimeSeriesStoreInterface $timeSeriesStore
+ * @param ReportServiceInterface $reportService
+ * @param DisplayFactory $displayFactory
+ */
+ public function __construct($store, $timeSeriesStore, $reportService, $displayFactory)
+ {
+ $this->store = $store;
+ $this->timeSeriesStore = $timeSeriesStore;
+ $this->reportService = $reportService;
+ $this->displayFactory = $displayFactory;
+ }
+
+ /**
+ * Report page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function displayReportPage(Request $request, Response $response)
+ {
+ // ------------
+ // Dispatch an event to get connector reports
+ $event = new ConnectorReportEvent();
+ $this->getDispatcher()->dispatch($event, ConnectorReportEvent::$NAME);
+
+ $data = [
+ // List of Displays this user has permission for
+ 'defaults' => [
+ 'fromDate' => Carbon::now()->subSeconds(86400 * 35)->format(DateFormatHelper::getSystemFormat()),
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'availableReports' => $this->reportService->listReports(),
+ 'connectorReports' => $event->getReports()
+ ]
+ ];
+
+ $this->getState()->template = 'report-page';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Definition(
+ * definition="StatisticsData",
+ * @SWG\Property(
+ * property="type",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="display",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="displayId",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="layout",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="layoutId",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="media",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="mediaId",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="widgetId",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="scheduleId",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="numberPlays",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="duration",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="start",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="end",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="statDate",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="tag",
+ * type="string"
+ * )
+ * )
+ *
+ *
+ * Stats API
+ *
+ * @SWG\Get(
+ * path="/stats",
+ * operationId="statsSearch",
+ * tags={"statistics"},
+ * @SWG\Parameter(
+ * name="type",
+ * in="query",
+ * description="The type of stat to return. Layout|Media|Widget",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="query",
+ * description="The start date for the filter. Default = 24 hours ago",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="query",
+ * description="The end date for the filter. Default = now.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="statDate",
+ * in="query",
+ * description="The statDate filter returns records that are greater than or equal a particular date",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="statId",
+ * in="query",
+ * description="The statId filter returns records that are greater than a particular statId",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="query",
+ * description="An optional display Id to filter",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayIds",
+ * description="An optional array of display Id to filter",
+ * in="query",
+ * required=false,
+ * type="array",
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="layoutId",
+ * description="An optional array of layout Id to filter",
+ * in="query",
+ * required=false,
+ * type="array",
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="parentCampaignId",
+ * description="An optional Parent Campaign ID to filter",
+ * in="query",
+ * required=false,
+ * type="integer",
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * description="An optional array of media Id to filter",
+ * in="query",
+ * required=false,
+ * type="array",
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="campaignId",
+ * in="query",
+ * description="An optional Campaign Id to filter",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="returnDisplayLocalTime",
+ * in="query",
+ * description="true/1/On if the results should be in display local time, otherwise CMS time",
+ * type="boolean",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="returnDateFormat",
+ * in="query",
+ * description="A PHP formatted date format for how the dates in this call should be returned.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="embed",
+ * in="query",
+ * description="Should the return embed additional data, options are layoutTags,displayTags and mediaTags",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(
+ * ref="#/definitions/StatisticsData"
+ * )
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $fromDt = $sanitizedQueryParams->getDate('fromDt', ['default' => Carbon::now()->subDay()]);
+ $toDt = $sanitizedQueryParams->getDate('toDt', ['default' => Carbon::now()]);
+ $type = strtolower($sanitizedQueryParams->getString('type'));
+
+ $displayId = $sanitizedQueryParams->getInt('displayId');
+ $displays = $sanitizedQueryParams->getIntArray('displayIds', ['default' => []]);
+ $layoutIds = $sanitizedQueryParams->getIntArray('layoutId', ['default' => []]);
+ $mediaIds = $sanitizedQueryParams->getIntArray('mediaId', ['default' => []]);
+ $statDate = $sanitizedQueryParams->getDate('statDate');
+ $statDateLessThan = $sanitizedQueryParams->getDate('statDateLessThan');
+ $statId = $sanitizedQueryParams->getString('statId');
+ $campaignId = $sanitizedQueryParams->getInt('campaignId');
+ $parentCampaignId = $sanitizedQueryParams->getInt('parentCampaignId');
+ $eventTag = $sanitizedQueryParams->getString('eventTag');
+
+ // Return formatting
+ $returnDisplayLocalTime = $sanitizedQueryParams->getCheckbox('returnDisplayLocalTime');
+ $returnDateFormat = $sanitizedQueryParams->getString('returnDateFormat', ['default' => DateFormatHelper::getSystemFormat()]);
+
+ // Embed Tags
+ $embed = explode(',', $sanitizedQueryParams->getString('embed', ['default' => '']));
+
+ // CMS timezone
+ $defaultTimezone = $this->getConfig()->getSetting('defaultTimezone');
+
+ // Paging
+ $start = $sanitizedQueryParams->getInt('start', ['default' => 0]);
+ $length = $sanitizedQueryParams->getInt('length', ['default' => 10]);
+
+ // Merge displayId and displayIds
+ if ($displayId != 0) {
+ $displays = array_unique(array_merge($displays, [$displayId]));
+ }
+
+ // Do not filter by display if super admin and no display is selected
+ // Super admin will be able to see stat records of deleted display, we will not filter by display later
+ $timeZoneCache = [];
+ $displayIds = $this->authoriseDisplayIds($displays, $timeZoneCache);
+
+ // Call the time series interface getStats
+ $resultSet = $this->timeSeriesStore->getStats(
+ [
+ 'fromDt'=> $fromDt,
+ 'toDt'=> $toDt,
+ 'type' => $type,
+ 'displayIds' => $displayIds,
+ 'layoutIds' => $layoutIds,
+ 'mediaIds' => $mediaIds,
+ 'statDate' => $statDate,
+ 'statDateLessThan' => $statDateLessThan,
+ 'statId' => $statId,
+ 'campaignId' => $campaignId,
+ 'parentCampaignId' => $parentCampaignId,
+ 'eventTag' => $eventTag,
+ 'displayTags' => in_array('displayTags', $embed),
+ 'layoutTags' => in_array('layoutTags', $embed),
+ 'mediaTags' => in_array('mediaTags', $embed),
+ 'start' => $start,
+ 'length' => $length,
+ ]);
+
+ $rows = [];
+ foreach ($resultSet->getArray() as $row) {
+ $entry = [];
+
+ // Load my row into the sanitizer
+ $sanitizedRow = $this->getSanitizer($row);
+
+ // Core details
+ $entry['id'] = $resultSet->getIdFromRow($row);
+ $entry['type'] = strtolower($sanitizedRow->getString('type'));
+ $entry['displayId'] = $sanitizedRow->getInt(('displayId'));
+
+ // Get the start/end date
+ $start = $resultSet->getDateFromValue($row['start']);
+ $end = $resultSet->getDateFromValue($row['end']);
+
+ if ($returnDisplayLocalTime) {
+ // Convert the dates to the display timezone.
+ if (!array_key_exists($entry['displayId'], $timeZoneCache)) {
+ try {
+ $display = $this->displayFactory->getById($entry['displayId']);
+ $timeZoneCache[$entry['displayId']] = (empty($display->timeZone)) ? $defaultTimezone : $display->timeZone;
+ } catch (\Xibo\Support\Exception\NotFoundException $e) {
+ $timeZoneCache[$entry['displayId']] = $defaultTimezone;
+ }
+ }
+ $start = $start->tz($timeZoneCache[$entry['displayId']]);
+ $end = $end->tz($timeZoneCache[$entry['displayId']]);
+ }
+
+ $widgetId = $sanitizedRow->getInt('widgetId', ['default' => 0]);
+ $widgetName = $sanitizedRow->getString('media');
+ $widgetName = ($widgetName == '' && $widgetId != 0) ? __('Deleted from Layout') : $widgetName;
+
+ $entry['display'] = $sanitizedRow->getString('display', ['default' => __('Not Found')]);
+ $entry['layout'] = $sanitizedRow->getString('layout', ['default' => __('Not Found')]);
+ $entry['media'] = $widgetName;
+ $entry['numberPlays'] = $sanitizedRow->getInt('count');
+ $entry['duration'] = $sanitizedRow->getInt('duration');
+ $entry['start'] = $start->format($returnDateFormat);
+ $entry['end'] = $end->format($returnDateFormat);
+ $entry['layoutId'] = $sanitizedRow->getInt('layoutId', ['default' => 0]);
+ $entry['campaignId'] = $sanitizedRow->getInt('campaignId', ['default' => 0]);
+ $entry['widgetId'] = $widgetId;
+ $entry['mediaId'] = $sanitizedRow->getInt('mediaId', ['default' => 0]);
+ $entry['scheduleId'] = $sanitizedRow->getInt('scheduleId', ['default' => 0]);
+ $entry['tag'] = $sanitizedRow->getString('tag');
+ $entry['statDate'] = isset($row['statDate']) ? $resultSet->getDateFromValue($row['statDate'])->format(DateFormatHelper::getSystemFormat()) : '';
+ $entry['engagements'] = $resultSet->getEngagementsFromRow($row);
+
+ // Tags
+ // ----
+ // Display tags
+ $tagFilter = $resultSet->getTagFilterFromRow($row);
+ if (in_array('displayTags', $embed)) {
+ $entry['displayTags'] = $tagFilter['dg'] ?? [];
+ }
+
+ // Layout tags
+ if (in_array('layoutTags', $embed)) {
+ $entry['layoutTags'] = $tagFilter['layout'] ?? [];
+ }
+
+ // Media tags
+ if (in_array('mediaTags', $embed)) {
+ $entry['mediaTags'] = $tagFilter['media'] ?? [];
+ }
+
+ $rows[] = $entry;
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $resultSet->getTotalCount();
+ $this->getState()->setData($rows);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Bandwidth Data
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function bandwidthData(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $fromDt = $sanitizedParams->getDate('fromDt', ['default' => $sanitizedParams->getDate('bandwidthFromDt')]);
+ $toDt = $sanitizedParams->getDate('toDt', ['default' => $sanitizedParams->getDate('bandwidthToDt')]);
+
+ // Get an array of display id this user has access to.
+ $displayIds = [];
+
+ foreach ($this->displayFactory->query(null, []) as $display) {
+ $displayIds[] = $display->displayId;
+ }
+
+ if (count($displayIds) <= 0) {
+ throw new InvalidArgumentException(__('No displays with View permissions'), 'displays');
+ }
+
+ // Get some data for a bandwidth chart
+ $dbh = $this->store->getConnection();
+
+ $displayId = $sanitizedParams->getInt('displayId');
+ $params = array(
+ 'month' => $fromDt->setDateTime($fromDt->year, $fromDt->month, 1, 0, 0)->format('U'),
+ 'month2' => $toDt->addMonth()->setDateTime($toDt->year, $toDt->month, 1, 0, 0)->format('U')
+ );
+
+ $SQL = 'SELECT display.display, IFNULL(SUM(Size), 0) AS size ';
+
+ if ($displayId != 0)
+ $SQL .= ', bandwidthtype.name AS type ';
+
+ // For user with limited access, return only data for displays this user has permissions to.
+ $joinType = ($this->getUser()->isSuperAdmin()) ? 'LEFT OUTER JOIN' : 'INNER JOIN';
+
+ $SQL .= ' FROM `bandwidth` ' .
+ $joinType . ' `display`
+ ON display.displayid = bandwidth.displayid AND display.displayId IN (' . implode(',', $displayIds) . ') ';
+
+ if ($displayId != 0)
+ $SQL .= '
+ INNER JOIN bandwidthtype
+ ON bandwidthtype.bandwidthtypeid = bandwidth.type
+ ';
+
+ $SQL .= ' WHERE month > :month
+ AND month < :month2 ';
+
+ if ($displayId != 0) {
+ $SQL .= ' AND display.displayid = :displayid ';
+ $params['displayid'] = $displayId;
+ }
+
+ $SQL .= 'GROUP BY display.display ';
+
+ if ($displayId != 0)
+ $SQL .= ' , bandwidthtype.name ';
+
+ $SQL .= 'ORDER BY display.display';
+
+ $sth = $dbh->prepare($SQL);
+
+ $sth->execute($params);
+
+ // Get the results
+ $results = $sth->fetchAll();
+
+ $maxSize = 0;
+ foreach ($results as $library) {
+ $maxSize = ($library['size'] > $maxSize) ? $library['size'] : $maxSize;
+ }
+
+ // Decide what our units are going to be, based on the size
+ $base = floor(log($maxSize) / log(1024));
+
+ $labels = [];
+ $data = [];
+ $backgroundColor = [];
+
+ foreach ($results as $row) {
+
+ // label depends whether we are filtered by display
+ if ($displayId != 0) {
+ $labels[] = $row['type'];
+ } else {
+ $labels[] = $row['display'] === null ? __('Deleted Displays') : $row['display'];
+ }
+ $backgroundColor[] = ($row['display'] === null) ? 'rgb(255,0,0)' : 'rgb(11, 98, 164)';
+ $data[] = round((double)$row['size'] / (pow(1024, $base)), 2);
+ }
+
+ // Set up some suffixes
+ $suffixes = array('bytes', 'k', 'M', 'G', 'T');
+
+ $this->getState()->extra = [
+ 'labels' => $labels,
+ 'data' => $data,
+ 'backgroundColor' => $backgroundColor,
+ 'postUnits' => (isset($suffixes[$base]) ? $suffixes[$base] : '')
+ ];
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Output CSV Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function exportForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'statistics-form-export';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Total count of stats
+ *
+ * @SWG\Get(
+ * path="/stats/getExportStatsCount",
+ * operationId="getExportStatsCount",
+ * tags={"statistics"},
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="query",
+ * description="The start date for the filter. Default = 24 hours ago",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="query",
+ * description="The end date for the filter. Default = now.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="query",
+ * description="An optional display Id to filter",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getExportStatsCount(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // We are expecting some parameters
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+ $displayId = $sanitizedParams->getInt('displayId');
+
+ if ($fromDt != null) {
+ $fromDt->startOfDay();
+ }
+
+ if ($toDt != null) {
+ $toDt->addDay()->startOfDay();
+ }
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt != null && $toDt != null && $fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ // Do not filter by display if super admin and no display is selected
+ // Super admin will be able to see stat records of deleted display, we will not filter by display later
+ $displayIds = [];
+ if (!$this->getUser()->isSuperAdmin()) {
+ // Get an array of display id this user has access to.
+ foreach ($this->displayFactory->query() as $display) {
+ $displayIds[] = $display->displayId;
+ }
+
+ if (count($displayIds) <= 0)
+ throw new InvalidArgumentException(__('No displays with View permissions'), 'displays');
+
+ // Set displayIds as [-1] if the user selected a display for which they don't have permission
+ if ($displayId != 0) {
+ if (!in_array($displayId, $displayIds)) {
+ $displayIds = [-1];
+ } else {
+ $displayIds = [$displayId];
+ }
+ }
+ } else {
+ if ($displayId != 0) {
+ $displayIds = [$displayId];
+ }
+ }
+
+ // Call the time series interface getStats
+ $resultSet = $this->timeSeriesStore->getExportStatsCount(
+ [
+ 'fromDt'=> $fromDt,
+ 'toDt'=> $toDt,
+ 'displayIds' => $displayIds
+ ]);
+
+ $data = [
+ 'total' => $resultSet
+ ];
+
+ $this->getState()->template = 'statistics-form-export';
+ $this->getState()->recordsTotal = $resultSet;
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+
+ }
+
+ /**
+ * Outputs a CSV of stats
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function export(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // We are expecting some parameters
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+ $displayId = $sanitizedParams->getInt('displayId');
+ $tempFileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/stats_' . Random::generateString();
+ $isOutputUtc = $sanitizedParams->getCheckbox('isOutputUtc');
+
+ // Do not filter by display if super admin and no display is selected
+ // Super admin will be able to see stat records of deleted display, we will not filter by display later
+ $displayIds = [];
+ if (!$this->getUser()->isSuperAdmin()) {
+ // Get an array of display id this user has access to.
+ foreach ($this->displayFactory->query() as $display) {
+ $displayIds[] = $display->displayId;
+ }
+
+ if (count($displayIds) <= 0) {
+ throw new InvalidArgumentException(__('No displays with View permissions'), 'displays');
+ }
+
+ // Set displayIds as [-1] if the user selected a display for which they don't have permission
+ if ($displayId != 0) {
+ if (!in_array($displayId, $displayIds)) {
+ $displayIds = [-1];
+ } else {
+ $displayIds = [$displayId];
+ }
+ }
+ } else {
+ if ($displayId != 0) {
+ $displayIds = [$displayId];
+ }
+ }
+
+ if ($fromDt == null || $toDt == null) {
+ throw new InvalidArgumentException(__('Both fromDt/toDt should be provided'), 'fromDt/toDt');
+ }
+
+ $fromDt->startOfDay();
+ $toDt->addDay()->startOfDay();
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ // Get result set
+ $resultSet = $this->timeSeriesStore->getStats([
+ 'fromDt'=> $fromDt,
+ 'toDt'=> $toDt,
+ 'displayIds' => $displayIds,
+ ]);
+
+ $out = fopen($tempFileName, 'w');
+ fputcsv($out, ['Stat Date', 'Type', 'FromDT', 'ToDT', 'Layout', 'Campaign', 'Display', 'Media', 'Tag', 'Duration', 'Count', 'Engagements']);
+
+ $defaultTimezone = $this->getConfig()->getSetting('defaultTimezone');
+
+ while ($row = $resultSet->getNextRow() ) {
+ $sanitizedRow = $this->getSanitizer($row);
+ $sanitizedRow->setDefaultOptions(['defaultIfNotExists' => true]);
+
+ // Read the columns
+ $type = strtolower($sanitizedRow->getString('type'));
+ $statDate = isset($row['statDate']) ? $resultSet->getDateFromValue($row['statDate']) : null;
+ $fromDt = $resultSet->getDateFromValue($row['start']);
+ $toDt = $resultSet->getDateFromValue($row['end']);
+ // MySQL stores dates in the timezone of the CMS,
+ // while MongoDB converts those dates to UTC before storing them.
+
+ // If we choose to retrieve the dates in UTC:
+ // MongoDB: We don't need to convert the dates as they are "already" in UTC
+ // MySQL: We need to convert the dates to UTC as they are in CMS Local Time
+
+ // If we choose to retrieve the dates in CMS Local Time:
+ // MongoDB: We need to convert the dates to CMS Local Time
+ // MySQL: We don't need to convert the dates, as they are "already" in CMS Local Time
+
+ // For MySQL, dates are already in CMS Local Time
+ // For MongoDB, dates are in UTC
+ if ($isOutputUtc) {
+ if ($this->timeSeriesStore->getEngine() == 'mysql') {
+ $fromDt = $fromDt->setTimezone('UTC');
+ $toDt = $toDt->setTimezone('UTC');
+ $statDate = isset($statDate) ? $statDate->setTimezone('UTC') : null;
+ }
+ } else {
+ if ($this->timeSeriesStore->getEngine() == 'mongodb') {
+ $fromDt = $fromDt->setTimezone($defaultTimezone);
+ $toDt = $toDt->setTimezone($defaultTimezone);
+ $statDate = isset($statDate) ? $statDate->setTimezone($defaultTimezone) : null;
+ }
+ }
+
+ $statDate = isset($statDate) ? $statDate->format(DateFormatHelper::getSystemFormat()) : null;
+ $fromDt = $fromDt->format(DateFormatHelper::getSystemFormat());
+ $toDt = $toDt->format(DateFormatHelper::getSystemFormat());
+
+ $engagements = $resultSet->getEngagementsFromRow($row, false);
+ $layout = $sanitizedRow->getString('layout', ['default' => __('Not Found')]);
+ $parentCampaign = $sanitizedRow->getString('parentCampaign', ['default' => '']);
+ $display = $sanitizedRow->getString('display', ['default' => __('Not Found')]);
+ $media = $sanitizedRow->getString('media', ['default' => '']);
+ $tag = $sanitizedRow->getString('tag', ['default' => '']);
+ $duration = $sanitizedRow->getInt('duration', ['default' => 0]);
+ $count = $sanitizedRow->getInt('count', ['default' => 0]);
+
+ fputcsv($out, [$statDate, $type, $fromDt, $toDt, $layout, $parentCampaign, $display, $media, $tag, $duration, $count, $engagements]);
+ }
+
+ fclose($out);
+
+ $this->setNoOutput(true);
+
+ return $this->render($request, SendFile::decorateResponse(
+ $response,
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $tempFileName,
+ 'stats.csv'
+ )->withHeader('Content-Type', 'text/csv'));
+ }
+
+ /**
+ * @SWG\Definition(
+ * definition="TimeDisconnectedData",
+ * @SWG\Property(
+ * property="display",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="displayId",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="duration",
+ * type="integer"
+ * ),
+ * @SWG\Property(
+ * property="start",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="end",
+ * type="string"
+ * ),
+ * @SWG\Property(
+ * property="isFinished",
+ * type="boolean"
+ * )
+ * )
+ *
+ * @SWG\Get(
+ * path="/stats/timeDisconnected",
+ * operationId="timeDisconnectedSearch",
+ * tags={"statistics"},
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="query",
+ * description="The start date for the filter.",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="query",
+ * description="The end date for the filter.",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * in="query",
+ * description="An optional display Id to filter",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="displayIds",
+ * description="An optional array of display Id to filter",
+ * in="query",
+ * required=false,
+ * type="array",
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="returnDisplayLocalTime",
+ * in="query",
+ * description="true/1/On if the results should be in display local time, otherwise CMS time",
+ * type="boolean",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="returnDateFormat",
+ * in="query",
+ * description="A PHP formatted date format for how the dates in this call should be returned.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(
+ * ref="#/definitions/TimeDisconnectedData"
+ * )
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function gridTimeDisconnected(Request $request, Response $response): Response
+ {
+ // CMS timezone
+ $defaultTimezone = $this->getConfig()->getSetting('defaultTimezone');
+
+ $params = $this->getSanitizer($request->getParams());
+ $fromDt = $params->getDate('fromDt');
+ $toDt = $params->getDate('toDt');
+ $displayId = $params->getInt('displayId');
+ $displays = $params->getIntArray('displayIds');
+ $returnDisplayLocalTime = $params->getCheckbox('returnDisplayLocalTime');
+ $returnDateFormat = $params->getString('returnDateFormat', 'Y-m-d H:i:s');
+
+ // Merge displayId and displayIds
+ if ($displayId != 0) {
+ $displays = array_unique(array_merge($displays, [$displayId]));
+ }
+
+ $timeZoneCache = [];
+ $displayIds = $this->authoriseDisplayIds($displays, $timeZoneCache);
+
+ $params = [];
+ $select = '
+ SELECT displayevent.eventDate,
+ display.displayId,
+ display.display,
+ displayevent.start,
+ displayevent.end
+ ';
+ $body = '
+ FROM displayevent
+ INNER JOIN display
+ ON displayevent.displayId = display.displayId
+ WHERE 1 = 1
+ ';
+
+ if (count($displays) > 0) {
+ $body .= ' AND display.displayId IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ if ($fromDt != null) {
+ $body .= ' AND displayevent.start >= :start ';
+ $params['start'] = $fromDt->format('U');
+ }
+
+ if ($toDt != null) {
+ $body .= ' AND displayevent.end < :end ';
+ $params['end'] = $toDt->format('U');
+ }
+
+ // Sorting?
+ $filterBy = $this->gridRenderFilter([], $params);
+ $sortOrder = $this->gridRenderSort($params);
+
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+
+ // Paging
+ $filterBy = $this->getSanitizer($filterBy);
+ if ($filterBy !== null && $filterBy->hasParam('start') && $filterBy->hasParam('length')) {
+ $limit = ' LIMIT ' . intval($filterBy->getInt('start', ['default' => 0])) . ', '
+ . $filterBy->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ // Run the main query
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ // Load my row into the sanitizer
+ $sanitizedRow = $this->getSanitizer($row);
+
+ $entry = [];
+ $entry['displayId'] = $sanitizedRow->getInt('displayId');
+ $entry['display'] = $sanitizedRow->getString('display');
+ $entry['isFinished'] = $row['end'] !== null;
+
+ // Get the start/end date
+ $start = Carbon::createFromTimestamp($row['start']);
+ $end = Carbon::createFromTimestamp($row['end']);
+
+ if ($returnDisplayLocalTime) {
+ // Convert the dates to the display timezone.
+ if (!array_key_exists($entry['displayId'], $timeZoneCache)) {
+ try {
+ $display = $this->displayFactory->getById($entry['displayId']);
+ $timeZoneCache[$entry['displayId']] = (empty($display->timeZone)) ? $defaultTimezone : $display->timeZone;
+ } catch (NotFoundException $e) {
+ $timeZoneCache[$entry['displayId']] = $defaultTimezone;
+ }
+ }
+ $start = $start->tz($timeZoneCache[$entry['displayId']]);
+ $end = $end->tz($timeZoneCache[$entry['displayId']]);
+ }
+ $entry['start'] = $start->format($returnDateFormat);
+ $entry['end'] = $end->format($returnDateFormat);
+ $entry['duration'] = $end->diffInSeconds($start);
+ $rows[] = $entry;
+ }
+
+ // Paging
+ if ($limit != '' && count($rows) > 0) {
+ $results = $this->store->select($select . $body, $params);
+ $this->getState()->recordsTotal = count($results);
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($rows);
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param $displays
+ * @param $timeZoneCache
+ * @return array|int[]
+ * @throws \Xibo\Support\Exception\InvalidArgumentException|\Xibo\Support\Exception\NotFoundException
+ */
+ private function authoriseDisplayIds($displays, &$timeZoneCache)
+ {
+ $displayIds = [];
+ $displaysAccessible = [];
+
+ if (!$this->getUser()->isSuperAdmin()) {
+ // Get an array of display id this user has access to.
+ foreach ($this->displayFactory->query() as $display) {
+ $displaysAccessible[] = $display->displayId;
+
+ // Cache the display timezone.
+ $timeZoneCache[$display->displayId] = $display->timeZone;
+ }
+
+ if (count($displaysAccessible) <= 0)
+ throw new InvalidArgumentException(__('No displays with View permissions'), 'displays');
+
+ // Set displayIds as [-1] if the user selected a display for which they don't have permission
+ if (count($displays) <= 0) {
+ $displayIds = $displaysAccessible;
+ } else {
+ foreach ($displays as $key => $id) {
+ if (!in_array($id, $displaysAccessible)) {
+ unset($displays[$key]);
+ } else {
+ $displayIds[] = $id;
+ }
+ }
+
+ if (count($displays) <= 0 ) {
+ $displayIds = [-1];
+ }
+ }
+ } else {
+ $displayIds = $displays;
+ }
+
+ return $displayIds;
+ }
+}
diff --git a/lib/Controller/StatusDashboard.php b/lib/Controller/StatusDashboard.php
new file mode 100644
index 0000000..3a6f7c5
--- /dev/null
+++ b/lib/Controller/StatusDashboard.php
@@ -0,0 +1,550 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Exception;
+use GuzzleHttp\Client;
+use PicoFeed\PicoFeedException;
+use PicoFeed\Reader\Reader;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\MediaService;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class StatusDashboard
+ * @package Xibo\Controller
+ */
+class StatusDashboard extends Base
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var PoolInterface
+ */
+ private $pool;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param PoolInterface $pool
+ * @param UserFactory $userFactory
+ * @param DisplayFactory $displayFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param MediaFactory $mediaFactory
+ */
+ public function __construct($store, $pool, $userFactory, $displayFactory, $displayGroupFactory, $mediaFactory)
+ {
+ $this->store = $store;
+ $this->pool = $pool;
+ $this->userFactory = $userFactory;
+ $this->displayFactory = $displayFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->mediaFactory = $mediaFactory;
+ }
+
+ /**
+ * Displays
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function displays(Request $request, Response $response)
+ {
+ $parsedRequestParams = $this->getSanitizer($request->getParams());
+ // Get a list of displays
+ $displays = $this->displayFactory->query($this->gridRenderSort($parsedRequestParams), $this->gridRenderFilter([], $parsedRequestParams));
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->displayFactory->countLast();
+ $this->getState()->setData($displays);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * View
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $data = [];
+ // Set up some suffixes
+ $suffixes = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB');
+
+ try {
+ // Get some data for a bandwidth chart
+ $dbh = $this->store->getConnection();
+ $params = ['month' => Carbon::now()->subSeconds(86400 * 365)->format('U')];
+
+ $sql = '
+ SELECT month,
+ SUM(size) AS size
+ FROM (
+ SELECT MAX(FROM_UNIXTIME(month)) AS month,
+ IFNULL(SUM(Size), 0) AS size,
+ MIN(month) AS month_order
+ FROM `bandwidth`
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.displayID = bandwidth.displayId
+ INNER JOIN `displaygroup`
+ ON displaygroup.DisplayGroupID = lkdisplaydg.DisplayGroupID
+ AND displaygroup.isDisplaySpecific = 1
+ WHERE month > :month
+ ';
+
+ // Permissions
+ $this->displayFactory->viewPermissionSql('Xibo\Entity\DisplayGroup', $sql, $params, '`lkdisplaydg`.displayGroupId');
+
+ $sql .= ' GROUP BY MONTH(FROM_UNIXTIME(month)) ';
+
+ // Include deleted displays?
+ if ($this->getUser()->isSuperAdmin()) {
+ $sql .= '
+ UNION ALL
+ SELECT MAX(FROM_UNIXTIME(month)) AS month,
+ IFNULL(SUM(Size), 0) AS size,
+ MIN(month) AS month_order
+ FROM `bandwidth`
+ WHERE bandwidth.displayId NOT IN (SELECT displayId FROM `display`)
+ AND month > :month
+ GROUP BY MONTH(FROM_UNIXTIME(month))
+ ';
+ }
+
+ $sql .= '
+ ) grp
+ GROUP BY month
+ ORDER BY MIN(month_order)
+ ';
+
+ // Run the SQL
+ $results = $this->store->select($sql, $params);
+
+ // Monthly bandwidth - optionally tested against limits
+ $xmdsLimit = $this->getConfig()->getSetting('MONTHLY_XMDS_TRANSFER_LIMIT_KB');
+
+ $maxSize = 0;
+ foreach ($results as $row) {
+ $maxSize = ($row['size'] > $maxSize) ? $row['size'] : $maxSize;
+ }
+
+ // Decide what our units are going to be, based on the size
+ $base = ($maxSize == 0) ? 0 : floor(log($maxSize) / log(1024));
+
+ if ($xmdsLimit > 0) {
+ // Convert to appropriate size (xmds limit is in KB)
+ $xmdsLimit = ($xmdsLimit * 1024) / (pow(1024, $base));
+ $data['xmdsLimit'] = round($xmdsLimit, 2) . ' ' . $suffixes[$base];
+ }
+
+ $labels = [];
+ $usage = [];
+ $limit = [];
+
+ foreach ($results as $row) {
+ $sanitizedRow = $this->getSanitizer($row);
+ $labels[] = Carbon::createFromTimeString($sanitizedRow->getString('month'))->format('F');
+
+ $size = ((double)$row['size']) / (pow(1024, $base));
+ $usage[] = round($size, 2);
+
+ $limit[] = round($xmdsLimit - $size, 2);
+ }
+
+ // What if we are empty?
+ if (count($results) == 0) {
+ $labels[] = Carbon::now()->format('F');
+ $usage[] = 0;
+ $limit[] = 0;
+ }
+
+ // Organise our datasets
+ $dataSets = [
+ [
+ 'label' => __('Used'),
+ 'backgroundColor' => ($xmdsLimit > 0) ? 'rgb(255, 0, 0)' : 'rgb(11, 98, 164)',
+ 'data' => $usage
+ ]
+ ];
+
+ if ($xmdsLimit > 0) {
+ $dataSets[] = [
+ 'label' => __('Available'),
+ 'backgroundColor' => 'rgb(0, 204, 0)',
+ 'data' => $limit
+ ];
+ }
+
+ // Set the data
+ $data['xmdsLimitSet'] = ($xmdsLimit > 0);
+ $data['bandwidthSuffix'] = $suffixes[$base];
+ $data['bandwidthWidget'] = json_encode([
+ 'labels' => $labels,
+ 'datasets' => $dataSets
+ ]);
+
+ // We would also like a library usage pie chart!
+ if ($this->getUser()->libraryQuota != 0) {
+ $libraryLimit = $this->getUser()->libraryQuota * 1024;
+ } else {
+ $libraryLimit = $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+ }
+
+ // Library Size in Bytes
+ $params = [];
+ $sql = 'SELECT IFNULL(SUM(FileSize), 0) AS SumSize, type FROM `media` WHERE 1 = 1 ';
+ $this->mediaFactory->viewPermissionSql('Xibo\Entity\Media', $sql, $params, '`media`.mediaId', '`media`.userId', [], 'media.permissionsFolderId');
+ $sql .= ' GROUP BY type ';
+
+ $sth = $dbh->prepare($sql);
+ $sth->execute($params);
+
+ $results = $sth->fetchAll();
+ // add any dependencies fonts, player software etc to the results
+ $event = new \Xibo\Event\DependencyFileSizeEvent($results);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+ $results = $event->getResults();
+
+ // Do we base the units on the maximum size or the library limit
+ $maxSize = 0;
+ if ($libraryLimit > 0) {
+ $maxSize = $libraryLimit;
+ } else {
+ // Find the maximum sized chunk of the items in the library
+ foreach ($results as $library) {
+ $maxSize = ($library['SumSize'] > $maxSize) ? $library['SumSize'] : $maxSize;
+ }
+ }
+
+ // Decide what our units are going to be, based on the size
+ $base = ($maxSize == 0) ? 0 : floor(log($maxSize) / log(1024));
+
+ $libraryUsage = [];
+ $libraryLabels = [];
+ $totalSize = 0;
+ foreach ($results as $library) {
+ $libraryUsage[] = round((double)$library['SumSize'] / (pow(1024, $base)), 2);
+ $libraryLabels[] = ucfirst($library['type']) . ' ' . $suffixes[$base];
+
+ $totalSize = $totalSize + $library['SumSize'];
+ }
+
+ // Do we need to add the library remaining?
+ if ($libraryLimit > 0) {
+ $remaining = round(($libraryLimit - $totalSize) / (pow(1024, $base)), 2);
+
+ $libraryUsage[] = $remaining;
+ $libraryLabels[] = __('Free') . ' ' . $suffixes[$base];
+ }
+
+ // What if we are empty?
+ if (count($results) == 0 && $libraryLimit <= 0) {
+ $libraryUsage[] = 0;
+ $libraryLabels[] = __('Empty');
+ }
+
+ $data['libraryLimitSet'] = ($libraryLimit > 0);
+ $data['libraryLimit'] = (round((double)$libraryLimit / (pow(1024, $base)), 2)) . ' ' . $suffixes[$base];
+ $data['librarySize'] = ByteFormatter::format($totalSize, 1);
+ $data['librarySuffix'] = $suffixes[$base];
+ $data['libraryWidgetLabels'] = json_encode($libraryLabels);
+ $data['libraryWidgetData'] = json_encode($libraryUsage);
+
+ // Get a count of users
+ $data['countUsers'] = $this->userFactory->count();
+
+ // Get a count of active layouts, only for display groups we have permission for
+ $params = ['now' => Carbon::now()->format('U')];
+
+ $sql = '
+ SELECT IFNULL(COUNT(*), 0) AS count_scheduled
+ FROM `schedule`
+ WHERE (
+ :now BETWEEN FromDT AND ToDT
+ OR `schedule`.recurrence_range >= :now
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ AND eventId IN (
+ SELECT eventId
+ FROM `lkscheduledisplaygroup`
+ WHERE 1 = 1
+ ';
+
+ $this->displayFactory->viewPermissionSql('Xibo\Entity\DisplayGroup', $sql, $params, '`lkscheduledisplaygroup`.displayGroupId');
+
+ $sql .= ' ) ';
+
+ $sth = $dbh->prepare($sql);
+ $sth->execute($params);
+
+ $data['nowShowing'] = $sth->fetchColumn(0);
+
+ // Latest news
+ if ($this->getConfig()->getSetting('DASHBOARD_LATEST_NEWS_ENABLED') == 1
+ && !empty($this->getConfig()->getSetting('LATEST_NEWS_URL'))
+ ) {
+ // Make sure we have the cache location configured
+ MediaService::ensureLibraryExists($this->getConfig()->getSetting('LIBRARY_LOCATION'));
+
+ try {
+ $feedUrl = $this->getConfig()->getSetting('LATEST_NEWS_URL');
+ $cache = $this->pool->getItem('rss/' . md5($feedUrl));
+
+ $latestNews = $cache->get();
+
+ // Check the cache
+ if ($cache->isMiss()) {
+ // Create a Guzzle Client to get the Feed XML
+ $client = new Client();
+ $responseGuzzle = $client->get($feedUrl, $this->getConfig()->getGuzzleProxy());
+
+ // Pull out the content type and body
+ $result = explode('charset=', $responseGuzzle->getHeaderLine('Content-Type'));
+ $document['encoding'] = $result[1] ?? '';
+ $document['xml'] = $responseGuzzle->getBody();
+
+ $this->getLog()->debug($document['xml']);
+
+ // Get the feed parser
+ $reader = new Reader();
+ $parser = $reader->getParser($feedUrl, $document['xml'], $document['encoding']);
+
+ // Get a feed object
+ $feed = $parser->execute();
+
+ // Parse the items in the feed
+ $latestNews = [];
+
+ foreach ($feed->getItems() as $item) {
+ // Try to get the description tag
+ $desc = $item->getTag('description');
+ if (!$desc) {
+ // use content with tags stripped
+ $content = strip_tags($item->getContent());
+ } else {
+ // use description
+ $content = ($desc[0] ?? strip_tags($item->getContent()));
+ }
+
+ $latestNews[] = [
+ 'title' => $item->getTitle(),
+ 'description' => $content,
+ 'link' => $item->getUrl(),
+ 'date' => Carbon::instance($item->getDate())->format(DateFormatHelper::getSystemFormat()),
+ ];
+ }
+
+ // Store in the cache for 1 day
+ $cache->set($latestNews);
+ $cache->expiresAfter(86400);
+
+ $this->pool->saveDeferred($cache);
+ }
+
+ $data['latestNews'] = $latestNews;
+ } catch (PicoFeedException $e) {
+ $this->getLog()->error('Unable to get feed: %s', $e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+
+ $data['latestNews'] = array(array('title' => __('Latest news not available.'), 'description' => '', 'link' => ''));
+ }
+ } else {
+ $data['latestNews'] = array(array('title' => __('Latest news not enabled.'), 'description' => '', 'link' => ''));
+ }
+
+ // Display Status and Media Inventory data - Level one
+ $displays = $this->displayFactory->query();
+ $displayLoggedIn = [];
+ $displayMediaStatus = [];
+ $displaysOnline = 0;
+ $displaysOffline = 0;
+ $displaysMediaUpToDate = 0;
+ $displaysMediaNotUpToDate = 0;
+
+ foreach ($displays as $display) {
+ $displayLoggedIn[] = $display->loggedIn;
+ $displayMediaStatus[] = $display->mediaInventoryStatus;
+ }
+
+ foreach ($displayLoggedIn as $status) {
+ if ($status == 1) {
+ $displaysOnline++;
+ } else {
+ $displaysOffline++;
+ }
+ }
+
+ foreach ($displayMediaStatus as $statusMedia) {
+ if ($statusMedia == 1) {
+ $displaysMediaUpToDate++;
+ } else {
+ $displaysMediaNotUpToDate++;
+ }
+ }
+
+ $data['displayStatus'] = json_encode([$displaysOnline, $displaysOffline]);
+ $data['displayMediaStatus'] = json_encode([$displaysMediaUpToDate, $displaysMediaNotUpToDate]);
+ } catch (Exception $e) {
+ $this->getLog()->error($e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+
+ // Show the error in place of the bandwidth chart
+ $data['widget-error'] = 'Unable to get widget details';
+ }
+
+ // Do we have an embedded widget?
+ $data['embeddedWidget'] = html_entity_decode($this->getConfig()->getSetting('EMBEDDED_STATUS_WIDGET'));
+
+ // Render the Theme and output
+ $this->getState()->template = 'dashboard-status-page';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayGroups(Request $request, Response $response)
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+ $status = null;
+ $inventoryStatus = null;
+ $params = [];
+ $label = $parsedQueryParams->getString('status');
+ $labelContent = $parsedQueryParams->getString('inventoryStatus');
+
+ $displayGroupIds = [];
+ $displayGroupNames = [];
+ $displaysAssigned = [];
+ $data = [];
+
+ if (isset($label)) {
+ if ($label == 'Online') {
+ $status = 1;
+ } else {
+ $status = 0;
+ }
+ }
+
+ if (isset($labelContent)) {
+ if ($labelContent == 'Up to Date') {
+ $inventoryStatus = 1;
+ } else {
+ $inventoryStatus = -1;
+ }
+ }
+
+ try {
+ $sql = 'SELECT DISTINCT displaygroup.DisplayGroupID, displaygroup.displayGroup
+ FROM displaygroup
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = displaygroup.DisplayGroupID
+ INNER JOIN `display`
+ ON display.displayid = lkdisplaydg.DisplayID
+ WHERE
+ displaygroup.IsDisplaySpecific = 0 ';
+
+ if ($status !== null) {
+ $sql .= ' AND display.loggedIn = :status ';
+ $params = ['status' => $status];
+ }
+
+ if ($inventoryStatus != null) {
+ if ($inventoryStatus === -1) {
+ $sql .= ' AND display.MediaInventoryStatus <> 1';
+ } else {
+ $sql .= ' AND display.MediaInventoryStatus = :inventoryStatus';
+ $params = ['inventoryStatus' => $inventoryStatus];
+ }
+ }
+
+ $this->displayFactory->viewPermissionSql('Xibo\Entity\DisplayGroup', $sql, $params, '`lkdisplaydg`.displayGroupId', null, [], 'permissionsFolderId');
+
+ $sql .= ' ORDER BY displaygroup.DisplayGroup ';
+
+ $results = $this->store->select($sql, $params);
+
+ foreach ($results as $row) {
+ $displayGroupNames[] = $row['displayGroup'];
+ $displayGroupIds[] = $row['DisplayGroupID'];
+ $displaysAssigned[] = count($this->displayFactory->query(['displayGroup'], ['displayGroupId' => $row['DisplayGroupID'], 'mediaInventoryStatus' => $inventoryStatus, 'loggedIn' => $status]));
+ }
+
+ $data['displayGroupNames'] = json_encode($displayGroupNames);
+ $data['displayGroupIds'] = json_encode($displayGroupIds);
+ $data['displayGroupMembers'] = json_encode($displaysAssigned);
+
+ $this->getState()->setData($data);
+ } catch (Exception $e) {
+ $this->getLog()->error($e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+ }
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/SyncGroup.php b/lib/Controller/SyncGroup.php
new file mode 100755
index 0000000..da76ce9
--- /dev/null
+++ b/lib/Controller/SyncGroup.php
@@ -0,0 +1,722 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\SyncGroupFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class SyncGroup
+ * @package Xibo\Controller
+ */
+class SyncGroup extends Base
+{
+ private SyncGroupFactory $syncGroupFactory;
+ private FolderFactory $folderFactory;
+
+ public function __construct(
+ SyncGroupFactory $syncGroupFactory,
+ FolderFactory $folderFactory
+ ) {
+ $this->syncGroupFactory = $syncGroupFactory;
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * Sync Group Page Render
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'syncgroup-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/syncgroups",
+ * summary="Get Sync Groups",
+ * tags={"syncGroup"},
+ * operationId="syncGroupSearch",
+ * @SWG\Parameter(
+ * name="syncGroupId",
+ * in="query",
+ * description="Filter by syncGroup Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="query",
+ * description="Filter by syncGroup Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ownerId",
+ * in="query",
+ * description="Filter by Owner ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="query",
+ * description="Filter by Folder ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="a successful response",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/SyncGroup")
+ * ),
+ * @SWG\Header(
+ * header="X-Total-Count",
+ * description="The total number of records",
+ * type="integer"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws ControllerNotImplemented
+ * @throws InvalidArgumentException
+ */
+ public function grid(Request $request, Response $response): Response|\Psr\Http\Message\ResponseInterface
+ {
+ $parsedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'syncGroupId' => $parsedQueryParams->getInt('syncGroupId'),
+ 'name' => $parsedQueryParams->getString('name'),
+ 'folderId' => $parsedQueryParams->getInt('folderId'),
+ 'ownerId' => $parsedQueryParams->getInt('ownerId'),
+ 'leadDisplayId' => $parsedQueryParams->getInt('leadDisplayId')
+ ];
+
+ $syncGroups = $this->syncGroupFactory->query(
+ $this->gridRenderSort($parsedQueryParams),
+ $this->gridRenderFilter($filter, $parsedQueryParams)
+ );
+
+ foreach ($syncGroups as $syncGroup) {
+ if (!empty($syncGroup->leadDisplayId)) {
+ try {
+ $display = $this->syncGroupFactory->getLeadDisplay($syncGroup->leadDisplayId);
+ $syncGroup->leadDisplay = $display->display;
+ } catch (NotFoundException $exception) {
+ $this->getLog()->error(
+ sprintf(
+ 'Lead Display %d not found for %s',
+ $syncGroup->leadDisplayId,
+ $syncGroup->name
+ )
+ );
+ }
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $syncGroup->includeProperty('buttons');
+
+ if ($this->getUser()->featureEnabled('display.syncModify')
+ && $this->getUser()->checkEditable($syncGroup)
+ ) {
+ // Edit
+ $syncGroup->buttons[] = [
+ 'id' => 'syncgroup_button_group_edit',
+ 'url' => $this->urlFor($request, 'syncgroup.form.edit', ['id' => $syncGroup->syncGroupId]),
+ 'text' => __('Edit')
+ ];
+ // Group Members
+ $syncGroup->buttons[] = [
+ 'id' => 'syncgroup_button_group_members',
+ 'url' => $this->urlFor($request, 'syncgroup.form.members', ['id' => $syncGroup->syncGroupId]),
+ 'text' => __('Members')
+ ];
+ $syncGroup->buttons[] = ['divider' => true];
+
+ // Delete
+ $syncGroup->buttons[] = [
+ 'id' => 'syncgroup_button_group_delete',
+ 'url' => $this->urlFor($request, 'syncgroup.form.delete', ['id' => $syncGroup->syncGroupId]),
+ 'text' => __('Delete')
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->syncGroupFactory->countLast();
+ $this->getState()->setData($syncGroups);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return Response|ResponseInterface
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ */
+ public function addForm(Request $request, Response $response): Response|ResponseInterface
+ {
+ $this->getState()->template = 'syncgroup-form-add';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Adds a Sync Group
+ * @SWG\Post(
+ * path="/syncgroup/add",
+ * operationId="syncGroupAdd",
+ * tags={"syncGroup"},
+ * summary="Add a Sync Group",
+ * description="Add a new Sync Group to the CMS",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Sync Group Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="syncPublisherPort",
+ * in="formData",
+ * description="The publisher port number on which sync group members will communicate - default 9590",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayGroup"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new DisplayGroup",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function add(Request $request, Response $response): Response|ResponseInterface
+ {
+ if (!$this->getUser()->featureEnabled('display.syncAdd')) {
+ throw new AccessDeniedException();
+ }
+
+ $params = $this->getSanitizer($request->getParams());
+
+ // Folders
+ $folderId = $params->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ $syncGroup = $this->syncGroupFactory->createEmpty();
+ $syncGroup->name = $params->getString('name');
+ $syncGroup->ownerId = $this->getUser()->userId;
+ $syncGroup->syncPublisherPort = $params->getInt('syncPublisherPort');
+ $syncGroup->syncSwitchDelay = $params->getInt('syncSwitchDelay');
+ $syncGroup->syncVideoPauseDelay = $params->getInt('syncVideoPauseDelay');
+ $syncGroup->folderId = $folder->getId();
+ $syncGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ $syncGroup->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $syncGroup->name),
+ 'id' => $syncGroup->syncGroupId,
+ 'data' => $syncGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function membersForm(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($syncGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Displays in Group
+ $displaysAssigned = $syncGroup->getSyncGroupMembers();
+
+ $this->getState()->template = 'syncgroup-form-members';
+ $this->getState()->setData([
+ 'syncGroup' => $syncGroup,
+ 'extra' => [
+ 'displaysAssigned' => $displaysAssigned,
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/syncgroup/{syncGroupId}/members",
+ * operationId="syncGroupMembers",
+ * tags={"syncGroup"},
+ * summary="Assign one or more Displays to a Sync Group",
+ * description="Adds the provided Displays to the Sync Group",
+ * @SWG\Parameter(
+ * name="syncGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Sync Group to assign to",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayId",
+ * type="array",
+ * in="formData",
+ * description="The Display Ids to assign",
+ * required=true,
+ * @SWG\Items(
+ * type="integer"
+ * )
+ * ),
+ * @SWG\Parameter(
+ * name="unassignDisplayId",
+ * in="formData",
+ * description="An optional array of Display IDs to unassign",
+ * type="array",
+ * required=false,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function members(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($syncGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Support both an array and a single int.
+ $displays = $sanitizedParams->getParam('displayId');
+ if (is_numeric($displays)) {
+ $displays = [$sanitizedParams->getInt('displayId')];
+ } else {
+ $displays = $sanitizedParams->getIntArray('displayId', ['default' => []]);
+ }
+
+ $syncGroup->setMembers($displays);
+
+ // Have we been provided with unassign id's as well?
+ $unSetDisplays = $sanitizedParams->getParam('unassignDisplayId');
+ if (is_numeric($unSetDisplays)) {
+ $unSetDisplays = [$sanitizedParams->getInt('unassignDisplayId')];
+ } else {
+ $unSetDisplays = $sanitizedParams->getIntArray('unassignDisplayId', ['default' => []]);
+ }
+
+ $syncGroup->unSetMembers($unSetDisplays);
+ $syncGroup->modifiedBy = $this->getUser()->userId;
+
+ if (empty($syncGroup->getSyncGroupMembers()) ||
+ in_array($syncGroup->leadDisplayId, $unSetDisplays)
+ ) {
+ $syncGroup->leadDisplayId = null;
+ }
+
+ $syncGroup->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Displays assigned to %s'), $syncGroup->name),
+ 'id' => $syncGroup->syncGroupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($syncGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $leadDisplay = null;
+
+ if (!empty($syncGroup->leadDisplayId)) {
+ $leadDisplay = $this->syncGroupFactory->getLeadDisplay($syncGroup->leadDisplayId);
+ }
+
+ $this->getState()->template = 'syncgroup-form-edit';
+ $this->getState()->setData([
+ 'syncGroup' => $syncGroup,
+ 'leadDisplay' => $leadDisplay,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edits a Sync Group
+ * @SWG\Post(
+ * path="/syncgroup/{syncGroupId}/edit",
+ * operationId="syncGroupEdit",
+ * tags={"syncGroup"},
+ * summary="Edit a Sync Group",
+ * description="Edit an existing Sync Group",
+ * @SWG\Parameter(
+ * name="syncGroupId",
+ * type="integer",
+ * in="path",
+ * description="The Sync Group to assign to",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Sync Group Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="syncPublisherPort",
+ * in="formData",
+ * description="The publisher port number on which sync group members will communicate - default 9590",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="syncSwitchDelay",
+ * in="formData",
+ * description="The delay (in ms) when displaying the changes in content - default 750",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="syncVideoPauseDelay",
+ * in="formData",
+ * description="The delay (in ms) before unpausing the video on start - default 100",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="leadDisplayId",
+ * in="formData",
+ * description="The ID of the Display that belongs to this Sync Group and should act as a Lead Display",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="folderId",
+ * in="formData",
+ * description="Folder ID to which this object should be assigned to",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/DisplayGroup"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new DisplayGroup",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+ $params = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($syncGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Folders
+ $folderId = $params->getInt('folderId');
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ $syncGroup->name = $params->getString('name');
+ $syncGroup->syncPublisherPort = $params->getInt('syncPublisherPort');
+ $syncGroup->syncSwitchDelay = $params->getInt('syncSwitchDelay');
+ $syncGroup->syncVideoPauseDelay = $params->getInt('syncVideoPauseDelay');
+ $syncGroup->leadDisplayId = $params->getInt('leadDisplayId');
+ $syncGroup->modifiedBy = $this->getUser()->userId;
+ $syncGroup->folderId = $folder->getId();
+ $syncGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ $syncGroup->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $syncGroup->name),
+ 'id' => $syncGroup->syncGroupId,
+ 'data' => $syncGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($syncGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ // Set the form
+ $this->getState()->template = 'syncgroup-form-delete';
+ $this->getState()->setData([
+ 'syncGroup' => $syncGroup,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Delete(
+ * path="/syncgroup/{syncGroupId}/delete",
+ * operationId="syncGroupDelete",
+ * tags={"syncGroup"},
+ * summary="Delete a Sync Group",
+ * description="Delete an existing Sync Group identified by its Id",
+ * @SWG\Parameter(
+ * name="syncGroupId",
+ * type="integer",
+ * in="path",
+ * description="The syncGroupId to delete",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws AccessDeniedException
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function delete(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($syncGroup)) {
+ throw new AccessDeniedException();
+ }
+
+ $syncGroup->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $syncGroup->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/syncgroup/{syncGroupId}/displays",
+ * summary="Get members of this sync group",
+ * tags={"syncGroup"},
+ * operationId="syncGroupDisplays",
+ * @SWG\Parameter(
+ * name="syncGroupId",
+ * type="integer",
+ * in="path",
+ * description="The syncGroupId to delete",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="eventId",
+ * in="query",
+ * description="Filter by event ID - return will include Layouts Ids scheduled against each group member",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="a successful response",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/SyncGroup")
+ * ),
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return Response|ResponseInterface
+ * @throws ControllerNotImplemented
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function fetchDisplays(Request $request, Response $response, $id): Response|ResponseInterface
+ {
+ $syncGroup = $this->syncGroupFactory->getById($id);
+ $params = $this->getSanitizer($request->getParams());
+ $displays = [];
+
+ if (!empty($params->getInt('eventId'))) {
+ $syncGroupMembers = $syncGroup->getGroupMembersForForm();
+ foreach ($syncGroupMembers as $display) {
+ $layoutId = $syncGroup->getLayoutIdForDisplay(
+ $params->getInt('eventId'),
+ $display['displayId']
+ );
+ $display['layoutId'] = $layoutId;
+ $displays[] = $display;
+ }
+ } else {
+ $displays = $syncGroup->getGroupMembersForForm();
+ }
+
+ $this->getState()->setData([
+ 'displays' => $displays
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
\ No newline at end of file
diff --git a/lib/Controller/Tag.php b/lib/Controller/Tag.php
new file mode 100644
index 0000000..de5f902
--- /dev/null
+++ b/lib/Controller/Tag.php
@@ -0,0 +1,766 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\TagAddEvent;
+use Xibo\Event\TagDeleteEvent;
+use Xibo\Event\TagEditEvent;
+use Xibo\Event\TriggerTaskEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Tag
+ * @package Xibo\Controller
+ */
+class Tag extends Base
+{
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /**
+ * Set common dependencies.
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param LayoutFactory $layoutFactory
+ * @param TagFactory $tagFactory
+ * @param UserFactory $userFactory
+ * @param DisplayFactory $displayFactory
+ * @param MediaFactory $mediaFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param CampaignFactory $campaignFactory
+ * @param PlaylistFactory $playlistFactory
+ */
+ public function __construct($displayGroupFactory, $layoutFactory, $tagFactory, $userFactory, $displayFactory, $mediaFactory, $scheduleFactory, $campaignFactory, $playlistFactory)
+ {
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->tagFactory = $tagFactory;
+ $this->userFactory = $userFactory;
+ $this->displayFactory = $displayFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->playlistFactory = $playlistFactory;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'tag-page';
+ $this->getState()->setData([
+ 'users' => $this->userFactory->query()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Tag Search
+ *
+ * @SWG\Get(
+ * path="/tag",
+ * operationId="tagSearch",
+ * tags={"tags"},
+ * summary="Search Tags",
+ * description="Search for Tags viewable by this user",
+ * @SWG\Parameter(
+ * name="tagId",
+ * in="query",
+ * description="Filter by Tag Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="tag",
+ * in="query",
+ * description="Filter by partial Tag",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="exactTag",
+ * in="query",
+ * description="Filter by exact Tag",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isSystem",
+ * in="query",
+ * description="Filter by isSystem flag",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRequired",
+ * in="query",
+ * description="Filter by isRequired flag",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="haveOptions",
+ * in="query",
+ * description="Set to 1 to show only results that have options set",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Tag")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'tagId' => $sanitizedQueryParams->getInt('tagId'),
+ 'tag' => $sanitizedQueryParams->getString('tag'),
+ 'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
+ 'isSystem' => $sanitizedQueryParams->getCheckbox('isSystem'),
+ 'isRequired' => $sanitizedQueryParams->getCheckbox('isRequired'),
+ 'haveOptions' => $sanitizedQueryParams->getCheckbox('haveOptions'),
+ 'allTags' => $sanitizedQueryParams->getInt('allTags'),
+ 'logicalOperatorName' => $sanitizedQueryParams->getString('logicalOperatorName'),
+ ];
+
+ $tags = $this->tagFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter($filter, $sanitizedQueryParams));
+
+ foreach ($tags as $tag) {
+ /* @var \Xibo\Entity\Tag $tag */
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $tag->includeProperty('buttons');
+ $tag->buttons = [];
+
+
+ //Show buttons for non system tags
+ if ($tag->isSystem === 0) {
+ // Edit the Tag
+ $tag->buttons[] = [
+ 'id' => 'tag_button_edit',
+ 'url' => $this->urlFor($request,'tag.edit.form', ['id' => $tag->tagId]),
+ 'text' => __('Edit')
+ ];
+
+ // Delete Tag
+ $tag->buttons[] = [
+ 'id' => 'tag_button_delete',
+ 'url' => $this->urlFor($request,'tag.delete.form', ['id' => $tag->tagId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request,'tag.delete', ['id' => $tag->tagId])],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'tag_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $tag->tag]
+ ]
+ ];
+ }
+
+ $tag->buttons[] = [
+ 'id' => 'tag_button_usage',
+ 'url' => $this->urlFor($request, 'tag.usage.form', ['id' => $tag->tagId]),
+ 'text' => __('Usage')
+ ];
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->tagFactory->countLast();
+ $this->getState()->setData($tags);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Tag Add Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'tag-form-add';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add a Tag
+ *
+ * @SWG\Post(
+ * path="/tag",
+ * operationId="tagAdd",
+ * tags={"tags"},
+ * summary="Add a new Tag",
+ * description="Add a new Tag",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Tag name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRequired",
+ * in="formData",
+ * description="A flag indicating whether value selection on assignment is required",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="options",
+ * in="formData",
+ * description="A comma separated string of Tag options",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Tag")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $values = [];
+ $tag = $this->tagFactory->create($sanitizedParams->getString('name'));
+ $tag->options = [];
+ $tag->isRequired = $sanitizedParams->getCheckbox('isRequired');
+ $optionValues = $sanitizedParams->getString('options');
+
+ if ($optionValues != '') {
+ $optionValuesArray = explode(',', $optionValues);
+ foreach ($optionValuesArray as $options) {
+ $values[] = $options;
+ }
+ $tag->options = json_encode($values);
+ } else {
+ $tag->options = null;
+ }
+
+ $tag->save();
+
+ // dispatch Tag add event
+ $event = new TagAddEvent($tag->tagId);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $tag->tag),
+ 'id' => $tag->tagId,
+ 'data' => $tag
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit a Tag
+ *
+ * @SWG\Put(
+ * path="/tag/{tagId}",
+ * operationId="tagEdit",
+ * tags={"tags"},
+ * summary="Edit existing Tag",
+ * description="Edit existing Tag",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="Tag name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRequired",
+ * in="formData",
+ * description="A flag indicating whether value selection on assignment is required",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="options",
+ * in="formData",
+ * description="A comma separated string of Tag options",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Tag")
+ * )
+ * )
+ * )
+ *
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $tag = $this->tagFactory->getById($id);
+ $tagOptions = '';
+
+ if (isset($tag->options)) {
+ $tagOptions = implode(',', json_decode($tag->options));
+ }
+
+ $this->getState()->template = 'tag-form-edit';
+ $this->getState()->setData([
+ 'tag' => $tag,
+ 'options' => $tagOptions,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ public function usageForm(Request $request, Response $response, $id)
+ {
+ $tag = $this->tagFactory->getById($id);
+
+ $this->getState()->template = 'tag-usage-form';
+ $this->getState()->setData([
+ 'tag' => $tag
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ public function usage(Request $request, Response $response, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'tagId' => $id,
+ ];
+
+ $entries = $this->tagFactory->getAllLinks(
+ $this->gridRenderSort($sanitizedParams),
+ $this->gridRenderFilter($filter, $sanitizedQueryParams)
+ );
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->tagFactory->countLast();
+ $this->getState()->setData($entries);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit a Tag
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $tag = $this->tagFactory->getById($id);
+
+ if ($tag->isSystem === 1) {
+ throw new AccessDeniedException(__('Access denied System tags cannot be edited'));
+ }
+
+ if(isset($tag->options)) {
+ $tagOptionsCurrent = implode(',', json_decode($tag->options));
+ $tagOptionsArrayCurrent = explode(',', $tagOptionsCurrent);
+ }
+
+ $values = [];
+
+ $oldTag = $tag->tag;
+ $tag->tag = $sanitizedParams->getString('name');
+ $tag->isRequired = $sanitizedParams->getCheckbox('isRequired');
+ $optionValues = $sanitizedParams->getString('options');
+
+ if ($optionValues != '') {
+ $optionValuesArray = explode(',', $optionValues);
+ foreach ($optionValuesArray as $option) {
+ $values[] = trim($option);
+ }
+ $tag->options = json_encode($values);
+ } else {
+ $tag->options = null;
+ }
+
+ // if option were changed, we need to compare the array of options before and after edit
+ if($tag->hasPropertyChanged('options')) {
+
+ if (isset($tagOptionsArrayCurrent)) {
+
+ if(isset($tag->options)) {
+ $tagOptions = implode(',', json_decode($tag->options));
+ $tagOptionsArray = explode(',', $tagOptions);
+ } else {
+ $tagOptionsArray = [];
+ }
+
+ // compare array of options before and after the Tag edit was made
+ $tagValuesToRemove = array_diff($tagOptionsArrayCurrent, $tagOptionsArray);
+
+ // go through every element of the new array and set the value to null if removed value was assigned to one of the lktag tables
+ $tag->updateTagValues($tagValuesToRemove);
+ }
+ }
+
+ $tag->save();
+
+ // dispatch Tag edit event
+ $event = new TagEditEvent($tag->tagId, $oldTag, $tag->tag);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Edited %s'), $tag->tag),
+ 'id' => $tag->tagId,
+ 'data' => $tag
+ ]);
+
+ return $this->render($request,$response);
+ }
+
+ /**
+ * Shows the Delete Group Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $tag = $this->tagFactory->getById($id);
+
+ $this->getState()->template = 'tag-form-delete';
+ $this->getState()->setData([
+ 'tag' => $tag,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Tag
+ *
+ * @SWG\Delete(
+ * path="/tag/{tagId}",
+ * operationId="tagDelete",
+ * tags={"tags"},
+ * summary="Delete Tag",
+ * description="Delete a Tag",
+ * @SWG\Parameter(
+ * name="tagId",
+ * in="path",
+ * description="The Tag ID to delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $tag = $this->tagFactory->getById($id);
+
+ if ($tag->isSystem === 1) {
+ throw new AccessDeniedException(__('Access denied System tags cannot be deleted'));
+ }
+
+ // Dispatch delete event, remove this tag links in all lktag tables.
+ $event = new TagDeleteEvent($tag->tagId);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+ // tag delete, remove the record from tag table
+ $tag->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $tag->tag)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function loadTagOptions(Request $request, Response $response)
+ {
+ $tagName = $this->getSanitizer($request->getParams())->getString('name');
+
+ try {
+ $tag = $this->tagFactory->getByTag($tagName);
+ } catch (NotFoundException $e) {
+ // User provided new tag, which is fine
+ $tag = null;
+ }
+
+ $this->getState()->setData([
+ 'tag' => ($tag === null) ? null : $tag
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function editMultiple(Request $request, Response $response)
+ {
+ // Handle permissions
+ if (!$this->getUser()->featureEnabled('tag.tagging')) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $targetType = $sanitizedParams->getString('targetType');
+ $targetIds = $sanitizedParams->getString('targetIds');
+ $tagsToAdd = $sanitizedParams->getString('addTags');
+ $tagsToRemove = $sanitizedParams->getString('removeTags');
+
+ // check if we need to do anything first
+ if ($tagsToAdd != '' || $tagsToRemove != '') {
+
+ // covert comma separated string of ids into array
+ $targetIdsArray = explode(',', $targetIds);
+
+ // get tags to assign and unassign
+ $tags = $this->tagFactory->tagsFromString($tagsToAdd);
+ $untags = $this->tagFactory->tagsFromString($tagsToRemove);
+
+ // depending on the type we need different factory
+ switch ($targetType){
+ case 'layout':
+ $entityFactory = $this->layoutFactory;
+ break;
+ case 'playlist':
+ $entityFactory = $this->playlistFactory;
+ break;
+ case 'media':
+ $entityFactory = $this->mediaFactory;
+ break;
+ case 'campaign':
+ $entityFactory = $this->campaignFactory;
+ break;
+ case 'displayGroup':
+ case 'display':
+ $entityFactory = $this->displayGroupFactory;
+ break;
+ default:
+ throw new InvalidArgumentException(__('Edit multiple tags is not supported on this item'), 'targetType');
+ }
+
+ foreach ($targetIdsArray as $id) {
+ // get the entity by provided id, for display we need different function
+ $this->getLog()->debug('editMultiple: lookup using id: ' . $id . ' for type: ' . $targetType);
+ if ($targetType === 'display') {
+ $entity = $entityFactory->getDisplaySpecificByDisplayId($id);
+ } else {
+ $entity = $entityFactory->getById($id);
+ }
+
+ if ($targetType === 'display' || $targetType === 'displaygroup') {
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($entity), DisplayGroupLoadEvent::$NAME);
+ }
+
+ foreach ($untags as $untag) {
+ $entity->unassignTag($untag);
+ }
+
+ // go through tags and adjust assignments.
+ foreach ($tags as $tag) {
+ $entity->assignTag($tag);
+ }
+
+ $entity->save(['isTagEdit' => true]);
+ }
+
+ // Once we're done, and if we're a Display entity, we need to calculate the dynamic display groups
+ if ($targetType === 'display') {
+ // Background update.
+ $this->getDispatcher()->dispatch(
+ new TriggerTaskEvent('\Xibo\XTR\MaintenanceRegularTask', 'DYNAMIC_DISPLAY_GROUP_ASSESSED'),
+ TriggerTaskEvent::$NAME
+ );
+ }
+ } else {
+ $this->getLog()->debug('Tags were not changed');
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Tags Edited')
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Task.php b/lib/Controller/Task.php
new file mode 100644
index 0000000..c16307f
--- /dev/null
+++ b/lib/Controller/Task.php
@@ -0,0 +1,621 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Cron\CronExpression;
+use Illuminate\Support\Str;
+use Psr\Container\ContainerInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\TaskFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\XTR\TaskInterface;
+
+/**
+ * Class Task
+ * @package Xibo\Controller
+ */
+class Task extends Base
+{
+ /** @var TaskFactory */
+ private $taskFactory;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var TimeSeriesStoreInterface */
+ private $timeSeriesStore;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** ContainerInterface */
+ private $container;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param TimeSeriesStoreInterface $timeSeriesStore
+ * @param PoolInterface $pool
+ * @param TaskFactory $taskFactory
+ * @param ContainerInterface $container
+ */
+ public function __construct($store, $timeSeriesStore, $pool, $taskFactory, ContainerInterface $container)
+ {
+ $this->taskFactory = $taskFactory;
+ $this->store = $store;
+ $this->timeSeriesStore = $timeSeriesStore;
+ $this->pool = $pool;
+ $this->container = $container;
+ }
+
+ /**
+ * Display Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'task-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Grid
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $tasks = $this->taskFactory->query(
+ $this->gridRenderSort($sanitizedParams),
+ $this->gridRenderFilter([], $sanitizedParams)
+ );
+
+ foreach ($tasks as $task) {
+ /** @var \Xibo\Entity\Task $task */
+
+ $task->setUnmatchedProperty('nextRunDt', $task->nextRunDate());
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $task->includeProperty('buttons');
+
+ $task->buttons[] = array(
+ 'id' => 'task_button_run.now',
+ 'url' => $this->urlFor($request, 'task.runNow.form', ['id' => $task->taskId]),
+ 'text' => __('Run Now'),
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor($request, 'task.runNow', ['id' => $task->taskId]),
+ ],
+ ['name' => 'commit-method', 'value' => 'POST']
+ ]
+ );
+
+ // Don't show any edit buttons if the config is locked.
+ if ($this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') == 1
+ || $this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') == 'Checked'
+ ) {
+ continue;
+ }
+
+ // Edit Button
+ $task->buttons[] = array(
+ 'id' => 'task_button_edit',
+ 'url' => $this->urlFor($request, 'task.edit.form', ['id' => $task->taskId]),
+ 'text' => __('Edit')
+ );
+
+ // Delete Button
+ $task->buttons[] = array(
+ 'id' => 'task_button_delete',
+ 'url' => $this->urlFor($request, 'task.delete.form', ['id' => $task->taskId]),
+ 'text' => __('Delete')
+ );
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->taskFactory->countLast();
+ $this->getState()->setData($tasks);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ // Provide a list of possible task classes by searching for .task file in /tasks and /custom
+ $data = ['tasksAvailable' => []];
+
+ // Do we have any modules to install?!
+ if ($this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') != 1 && $this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') != 'Checked') {
+ // Get a list of matching files in the modules folder
+ $files = array_merge(glob(PROJECT_ROOT . '/tasks/*.task'), glob(PROJECT_ROOT . '/custom/*.task'));
+
+ // Add to the list of available tasks
+ foreach ($files as $file) {
+ $config = json_decode(file_get_contents($file));
+ $config->file = Str::replaceFirst(PROJECT_ROOT, '', $file);
+
+ $data['tasksAvailable'][] = $config;
+ }
+ }
+
+ $this->getState()->template = 'task-form-add';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $task = $this->taskFactory->create();
+ $task->name = $sanitizedParams->getString('name');
+ $task->configFile = $sanitizedParams->getString('file');
+ $task->schedule = $sanitizedParams->getString('schedule');
+ $task->status = \Xibo\Entity\Task::$STATUS_IDLE;
+ $task->lastRunStatus = 0;
+ $task->isActive = 0;
+ $task->runNow = 0;
+ $task->setClassAndOptions();
+ $task->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $task->name),
+ 'id' => $task->taskId,
+ 'data' => $task
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $task = $this->taskFactory->getById($id);
+ $task->setClassAndOptions();
+
+ $this->getState()->template = 'task-form-edit';
+ $this->getState()->setData([
+ 'task' => $task
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $task = $this->taskFactory->getById($id);
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $task->setClassAndOptions();
+ $task->name = $sanitizedParams->getString('name');
+ $task->schedule = $sanitizedParams->getString('schedule');
+ $task->isActive = $sanitizedParams->getCheckbox('isActive');
+
+ // Loop through each option and see if a new value is provided
+ foreach ($task->options as $option => $value) {
+ $provided = $sanitizedParams->getString($option);
+
+ if ($provided !== null) {
+ $this->getLog()->debug('Setting ' . $option . ' to ' . $provided);
+ $task->options[$option] = $provided;
+ }
+ }
+
+ $this->getLog()->debug('New options = ' . var_export($task->options, true));
+
+ $task->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'message' => sprintf(__('Edited %s'), $task->name),
+ 'id' => $task->taskId,
+ 'data' => $task
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $task = $this->taskFactory->getById($id);
+
+ $this->getState()->template = 'task-form-delete';
+ $this->getState()->setData([
+ 'task' => $task
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $task = $this->taskFactory->getById($id);
+ $task->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Deleted %s'), $task->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function runNowForm(Request $request, Response $response, $id)
+ {
+ $task = $this->taskFactory->getById($id);
+
+ $this->getState()->template = 'task-form-run-now';
+ $this->getState()->autoSubmit = $this->getAutoSubmit('taskRunNowForm');
+ $this->getState()->setData([
+ 'task' => $task
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function runNow(Request $request, Response $response, $id)
+ {
+ $task = $this->taskFactory->getById($id);
+ $task->runNow = 1;
+ $task->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('Run Now set on %s'), $task->name)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function run(Request $request, Response $response, $id)
+ {
+ // Get this task
+ if (is_numeric($id)) {
+ $task = $this->taskFactory->getById($id);
+ } else {
+ $task = $this->taskFactory->getByName($id);
+ }
+
+ // Set to running
+ $this->getLog()->debug('run: Running Task ' . $task->name
+ . ' [' . $task->taskId . '], Class = ' . $task->class);
+
+ // Run
+ $task->setStarted();
+
+ try {
+ // Instantiate
+ if (!class_exists($task->class)) {
+ throw new NotFoundException(sprintf(__('Task with class name %s not found'), $task->class));
+ }
+
+ /** @var TaskInterface $taskClass */
+ $taskClass = new $task->class();
+
+ // Record the start time
+ $start = Carbon::now()->format('U');
+
+ $taskClass
+ ->setSanitizer($this->getSanitizer($request->getParams()))
+ ->setUser($this->getUser())
+ ->setConfig($this->getConfig())
+ ->setLogger($this->getLog())
+ ->setPool($this->pool)
+ ->setStore($this->store)
+ ->setTimeSeriesStore($this->timeSeriesStore)
+ ->setDispatcher($this->getDispatcher())
+ ->setFactories($this->container)
+ ->setTask($task)
+ ->run();
+
+ // We should commit anything this task has done
+ $this->store->commitIfNecessary();
+
+ // Collect results
+ $task->lastRunDuration = Carbon::now()->format('U') - $start;
+ $task->lastRunMessage = $taskClass->getRunMessage();
+ $task->lastRunStatus = \Xibo\Entity\Task::$STATUS_SUCCESS;
+ $task->lastRunExitCode = 0;
+ } catch (\Exception $e) {
+ $this->getLog()->error('run: ' . $e->getMessage() . ' Exception Type: ' . get_class($e));
+ $this->getLog()->debug($e->getTraceAsString());
+
+ // We should roll back anything we've done so far
+ if ($this->store->getConnection()->inTransaction()) {
+ $this->store->getConnection()->rollBack();
+ }
+
+ // Set the results to error
+ $task->lastRunMessage = $e->getMessage();
+ $task->lastRunStatus = \Xibo\Entity\Task::$STATUS_ERROR;
+ $task->lastRunExitCode = 1;
+ }
+
+ $task->lastRunDt = Carbon::now()->format('U');
+ $task->runNow = 0;
+ $task->status = \Xibo\Entity\Task::$STATUS_IDLE;
+
+ // lastRunMessage columns has a limit of 254 characters, if the message is longer, we need to truncate it.
+ if (strlen($task->lastRunMessage) >= 255) {
+ $task->lastRunMessage = substr($task->lastRunMessage, 0, 249) . '(...)';
+ }
+
+ // Finished
+ $task->setFinished();
+
+ $this->getLog()->debug('run: Finished Task ' . $task->name . ' [' . $task->taskId . '] Run Dt: '
+ . Carbon::now()->format(DateFormatHelper::getSystemFormat()));
+
+ $this->setNoOutput();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Poll for tasks to run
+ * continue polling until there aren't anymore to run
+ * allow for multiple polls to run at the same time
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function poll(Request $request, Response $response)
+ {
+ $this->getLog()->debug('poll: XTR poll started');
+
+ // Process timeouts
+ $this->pollProcessTimeouts();
+
+ // Keep track of tasks we've run during this poll period
+ // we will use this as a catch-all so that we do not run a task more than once.
+ $tasksRun = [];
+
+ // We loop until we have gone through without running a task
+ // each loop we are expecting to run ONE task only, to allow for multiple runs of XTR at the
+ // same time.
+ while (true) {
+ // Get tasks that aren't running currently
+ // we have to get them all here because we can't calculate the CRON schedule with SQL,
+ // therefore we return them all and process one and a time.
+ $tasks = $this->store->select('
+ SELECT taskId, `schedule`, runNow, lastRunDt
+ FROM `task`
+ WHERE isActive = 1
+ AND `status` <> :status
+ ORDER BY lastRunDuration
+ ', ['status' => \Xibo\Entity\Task::$STATUS_RUNNING], 'xtr', true);
+
+ // Assume we won't run anything
+ $taskRun = false;
+
+ foreach ($tasks as $task) {
+ /** @var \Xibo\Entity\Task $task */
+ $taskId = $task['taskId'];
+
+ // Skip tasks that have already been run
+ if (in_array($taskId, $tasksRun)) {
+ continue;
+ }
+
+ try {
+ $cron = new CronExpression($task['schedule']);
+ } catch (\Exception $e) {
+ $this->getLog()->info('run: CRON syntax error for taskId ' . $taskId
+ . ', e: ' . $e->getMessage());
+
+ // Try and take the first X characters instead.
+ try {
+ $cron = new CronExpression(substr($task['schedule'], 0, strlen($task['schedule']) - 2));
+ } catch (\Exception) {
+ $this->getLog()->error('run: cannot fix CRON syntax error ' . $taskId);
+ continue;
+ }
+ }
+
+ // Is the next run date of this event earlier than now, or is the task set to runNow
+ $nextRunDt = $cron->getNextRunDate(\DateTime::createFromFormat('U', $task['lastRunDt']))
+ ->format('U');
+
+ if ($task['runNow'] == 1 || $nextRunDt <= Carbon::now()->format('U')) {
+ $this->getLog()->info('poll: Running Task ' . $taskId);
+
+ try {
+ // Pass to run.
+ $this->run($request, $response, $taskId);
+ } catch (\Exception $exception) {
+ // The only thing which can fail inside run is core code,
+ // so it is reasonable here to disable the task.
+ $this->getLog()->error('poll: Task run error for taskId ' . $taskId
+ . '. E = ' . $exception->getMessage());
+ $this->getLog()->debug($exception->getTraceAsString());
+
+ // Set to error and disable.
+ $this->store->update('
+ UPDATE `task` SET status = :status, isActive = :isActive, lastRunMessage = :lastRunMessage
+ WHERE taskId = :taskId
+ ', [
+ 'taskId' => $taskId,
+ 'status' => \Xibo\Entity\Task::$STATUS_ERROR,
+ 'isActive' => 0,
+ 'lastRunMessage' => 'Fatal Error: ' . $exception->getMessage()
+ ], 'xtr', true, false);
+ }
+
+ // We have run a task
+ $taskRun = true;
+
+ // We've run this task during this polling period
+ $tasksRun[] = $taskId;
+
+ // As mentioned above, we only run 1 task at a time to allow for concurrent runs of XTR.
+ break;
+ }
+ }
+
+ // If we haven't run a task, then stop
+ if (!$taskRun) {
+ break;
+ }
+ }
+
+ $this->getLog()->debug('XTR poll stopped');
+ $this->setNoOutput();
+
+ return $this->render($request, $response);
+ }
+
+ private function pollProcessTimeouts()
+ {
+ $count = $this->store->update('
+ UPDATE `task` SET `status` = :newStatus
+ WHERE `isActive` = 1
+ AND `status` = :currentStatus
+ AND `lastRunStartDt` < :timeout
+ ', [
+ 'timeout' => Carbon::now()->subHours(12)->format('U'),
+ 'currentStatus' => \Xibo\Entity\Task::$STATUS_RUNNING,
+ 'newStatus' => \Xibo\Entity\Task::$STATUS_TIMEOUT,
+ ], 'xtr', false, false);
+
+ if ($count > 0) {
+ $this->getLog()->error($count . ' timed out tasks.');
+ } else {
+ $this->getLog()->debug('No timed out tasks.');
+ }
+ }
+}
diff --git a/lib/Controller/Template.php b/lib/Controller/Template.php
new file mode 100644
index 0000000..6ac528b
--- /dev/null
+++ b/lib/Controller/Template.php
@@ -0,0 +1,806 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Parsedown;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\SearchResult;
+use Xibo\Entity\SearchResults;
+use Xibo\Event\TemplateProviderEvent;
+use Xibo\Event\TemplateProviderListEvent;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Template
+ * @package Xibo\Controller
+ */
+class Template extends Base
+{
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var \Xibo\Factory\ResolutionFactory
+ */
+ private $resolutionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param LayoutFactory $layoutFactory
+ * @param TagFactory $tagFactory
+ * @param \Xibo\Factory\ResolutionFactory $resolutionFactory
+ */
+ public function __construct($layoutFactory, $tagFactory, $resolutionFactory)
+ {
+ $this->layoutFactory = $layoutFactory;
+ $this->tagFactory = $tagFactory;
+ $this->resolutionFactory = $resolutionFactory;
+ }
+
+ /**
+ * Display page logic
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ // Call to render the template
+ $this->getState()->template = 'template-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Data grid
+ *
+ * @SWG\Get(
+ * path="/template",
+ * operationId="templateSearch",
+ * tags={"template"},
+ * summary="Template Search",
+ * description="Search templates this user has access to",
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Layout")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+ // Embed?
+ $embed = ($sanitizedQueryParams->getString('embed') != null)
+ ? explode(',', $sanitizedQueryParams->getString('embed'))
+ : [];
+
+ $templates = $this->layoutFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter([
+ 'excludeTemplates' => 0,
+ 'tags' => $sanitizedQueryParams->getString('tags'),
+ 'layoutId' => $sanitizedQueryParams->getInt('templateId'),
+ 'layout' => $sanitizedQueryParams->getString('template'),
+ 'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
+ 'folderId' => $sanitizedQueryParams->getInt('folderId'),
+ 'logicalOperator' => $sanitizedQueryParams->getString('logicalOperator'),
+ 'logicalOperatorName' => $sanitizedQueryParams->getString('logicalOperatorName'),
+ ], $sanitizedQueryParams));
+
+ foreach ($templates as $template) {
+ /* @var \Xibo\Entity\Layout $template */
+
+ if (in_array('regions', $embed)) {
+ $template->load([
+ 'loadPlaylists' => in_array('playlists', $embed),
+ 'loadCampaigns' => in_array('campaigns', $embed),
+ 'loadPermissions' => in_array('permissions', $embed),
+ 'loadTags' => in_array('tags', $embed),
+ 'loadWidgets' => in_array('widgets', $embed)
+ ]);
+ }
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $template->includeProperty('buttons');
+
+ // Thumbnail
+ $template->setUnmatchedProperty('thumbnail', '');
+ if (file_exists($template->getThumbnailUri())) {
+ $template->setUnmatchedProperty(
+ 'thumbnail',
+ $this->urlFor($request, 'layout.download.thumbnail', ['id' => $template->layoutId])
+ );
+ }
+
+ // Parse down for description
+ $template->setUnmatchedProperty(
+ 'descriptionWithMarkup',
+ Parsedown::instance()->setSafeMode(true)->text($template->description),
+ );
+
+ if ($this->getUser()->featureEnabled('template.modify')
+ && $this->getUser()->checkEditable($template)
+ ) {
+ // Design Button
+ $template->buttons[] = [
+ 'id' => 'layout_button_design',
+ 'linkType' => '_self', 'external' => true,
+ 'url' => $this->urlFor(
+ $request,
+ 'layout.designer',
+ ['id' => $template->layoutId]
+ ) . '?isTemplateEditor=1',
+ 'text' => __('Alter Template')
+ ];
+
+ if ($template->isEditable()) {
+ $template->buttons[] = ['divider' => true];
+
+ $template->buttons[] = array(
+ 'id' => 'layout_button_publish',
+ 'url' => $this->urlFor($request, 'layout.publish.form', ['id' => $template->layoutId]),
+ 'text' => __('Publish')
+ );
+
+ $template->buttons[] = array(
+ 'id' => 'layout_button_discard',
+ 'url' => $this->urlFor($request, 'layout.discard.form', ['id' => $template->layoutId]),
+ 'text' => __('Discard')
+ );
+
+ $template->buttons[] = ['divider' => true];
+ } else {
+ $template->buttons[] = ['divider' => true];
+
+ // Checkout Button
+ $template->buttons[] = array(
+ 'id' => 'layout_button_checkout',
+ 'url' => $this->urlFor($request, 'layout.checkout.form', ['id' => $template->layoutId]),
+ 'text' => __('Checkout'),
+ 'dataAttributes' => [
+ ['name' => 'auto-submit', 'value' => true],
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'layout.checkout',
+ ['id' => $template->layoutId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'PUT']
+ ]
+ );
+
+ $template->buttons[] = ['divider' => true];
+ }
+
+ // Edit Button
+ $template->buttons[] = array(
+ 'id' => 'layout_button_edit',
+ 'url' => $this->urlFor($request, 'template.edit.form', ['id' => $template->layoutId]),
+ 'text' => __('Edit')
+ );
+
+ // Select Folder
+ if ($this->getUser()->featureEnabled('folder.view')) {
+ $template->buttons[] = [
+ 'id' => 'campaign_button_selectfolder',
+ 'url' => $this->urlFor($request, 'campaign.selectfolder.form', ['id' => $template->campaignId]),
+ 'text' => __('Select Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'campaign.selectfolder',
+ ['id' => $template->campaignId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'put'],
+ ['name' => 'id', 'value' => 'campaign_button_selectfolder'],
+ ['name' => 'text', 'value' => __('Move to Folder')],
+ ['name' => 'rowtitle', 'value' => $template->layout],
+ ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen']
+ ]
+ ];
+ }
+
+ // Copy Button
+ $template->buttons[] = array(
+ 'id' => 'layout_button_copy',
+ 'url' => $this->urlFor($request, 'layout.copy.form', ['id' => $template->layoutId]),
+ 'text' => __('Copy')
+ );
+ }
+
+ // Extra buttons if have delete permissions
+ if ($this->getUser()->featureEnabled('template.modify')
+ && $this->getUser()->checkDeleteable($template)) {
+ // Delete Button
+ $template->buttons[] = [
+ 'id' => 'layout_button_delete',
+ 'url' => $this->urlFor($request, 'layout.delete.form', ['id' => $template->layoutId]),
+ 'text' => __('Delete'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'layout.delete',
+ ['id' => $template->layoutId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'delete'],
+ ['name' => 'id', 'value' => 'layout_button_delete'],
+ ['name' => 'text', 'value' => __('Delete')],
+ ['name' => 'sort-group', 'value' => 1],
+ ['name' => 'rowtitle', 'value' => $template->layout]
+ ]
+ ];
+ }
+
+ $template->buttons[] = ['divider' => true];
+
+ // Extra buttons if we have modify permissions
+ if ($this->getUser()->featureEnabled('template.modify')
+ && $this->getUser()->checkPermissionsModifyable($template)) {
+ // Permissions button
+ $template->buttons[] = [
+ 'id' => 'layout_button_permissions',
+ 'url' => $this->urlFor(
+ $request,
+ 'user.permissions.form',
+ ['entity' => 'Campaign', 'id' => $template->campaignId]
+ ) . '?nameOverride=' . __('Template'),
+ 'text' => __('Share'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ [
+ 'name' => 'commit-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi',
+ ['entity' => 'Campaign', 'id' => $template->campaignId]
+ )
+ ],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'layout_button_permissions'],
+ ['name' => 'text', 'value' => __('Share')],
+ ['name' => 'rowtitle', 'value' => $template->layout],
+ ['name' => 'sort-group', 'value' => 2],
+ ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'],
+ [
+ 'name' => 'custom-handler-url',
+ 'value' => $this->urlFor(
+ $request,
+ 'user.permissions.multi.form',
+ ['entity' => 'Campaign']
+ )
+ ],
+ ['name' => 'content-id-name', 'value' => 'campaignId']
+ ]
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('layout.export')) {
+ $template->buttons[] = ['divider' => true];
+
+ // Export Button
+ $template->buttons[] = array(
+ 'id' => 'layout_button_export',
+ 'linkType' => '_self',
+ 'external' => true,
+ 'url' => $this->urlFor($request, 'layout.export', ['id' => $template->layoutId]),
+ 'text' => __('Export')
+ );
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->layoutFactory->countLast();
+ $this->getState()->setData($templates);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Data grid
+ *
+ * @SWG\Get(
+ * path="/template/search",
+ * operationId="templateSearchAll",
+ * tags={"template"},
+ * summary="Template Search All",
+ * description="Search all templates from local and connectors",
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/SearchResult")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function search(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+ $provider = $sanitizedQueryParams->getString('provider', ['default' => 'both']);
+
+ $searchResults = new SearchResults();
+ if ($provider === 'both' || $provider === 'local') {
+ $templates = $this->layoutFactory->query(['layout'], $this->gridRenderFilter([
+ 'excludeTemplates' => 0,
+ 'layout' => $sanitizedQueryParams->getString('template'),
+ 'folderId' => $sanitizedQueryParams->getInt('folderId'),
+ 'orientation' => $sanitizedQueryParams->getString('orientation', ['defaultOnEmptyString' => true]),
+ 'publishedStatusId' => 1
+ ], $sanitizedQueryParams));
+
+ foreach ($templates as $template) {
+ $searchResult = new SearchResult();
+ $searchResult->id = $template->layoutId;
+ $searchResult->source = 'local';
+ $searchResult->title = $template->layout;
+
+ // Handle the description
+ $searchResult->description = '';
+ if (!empty($template->description)) {
+ $searchResult->description = Parsedown::instance()->setSafeMode(true)->line($template->description);
+ }
+ $searchResult->orientation = $template->orientation;
+ $searchResult->width = $template->width;
+ $searchResult->height = $template->height;
+
+ if (!empty($template->tags)) {
+ foreach ($template->getTags() as $tag) {
+ if ($tag->tag === 'template') {
+ continue;
+ }
+ $searchResult->tags[] = $tag->tag;
+ }
+ }
+
+ // Thumbnail
+ $searchResult->thumbnail = '';
+ if (file_exists($template->getThumbnailUri())) {
+ $searchResult->thumbnail = $this->urlFor(
+ $request,
+ 'layout.download.thumbnail',
+ ['id' => $template->layoutId]
+ );
+ }
+
+ $searchResults->data[] = $searchResult;
+ }
+ }
+
+ if ($provider === 'both' || $provider === 'remote') {
+ // Hand off to any other providers that may want to provide results.
+ $event = new TemplateProviderEvent(
+ $searchResults,
+ $sanitizedQueryParams->getInt('start', ['default' => 0]),
+ $sanitizedQueryParams->getInt('length', ['default' => 15]),
+ $sanitizedQueryParams->getString('template'),
+ $sanitizedQueryParams->getString('orientation'),
+ );
+
+ $this->getLog()->debug('Dispatching event. ' . $event->getName());
+ try {
+ $this->getDispatcher()->dispatch($event, $event->getName());
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Template search: Exception in dispatched event: ' . $exception->getMessage());
+ $this->getLog()->debug($exception->getTraceAsString());
+ }
+ }
+ return $response->withJson($searchResults);
+ }
+
+ /**
+ * Template Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function addTemplateForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to view this layout'));
+ }
+
+ $this->getState()->template = 'template-form-add-from-layout';
+ $this->getState()->setData([
+ 'layout' => $layout,
+ ]);
+
+ return $this->render($request, $response);
+ }
+ /**
+ * Add a Template
+ * @SWG\Post(
+ * path="/template",
+ * operationId="templateAdd",
+ * tags={"template"},
+ * summary="Add a Template",
+ * description="Add a new Template to the CMS",
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The layout name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="The layout description",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="resolutionId",
+ * in="formData",
+ * description="If a Template is not provided, provide the resolutionId for this Layout.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="returnDraft",
+ * in="formData",
+ * description="Should we return the Draft Layout or the Published Layout on Success?",
+ * type="boolean",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $name = $sanitizedParams->getString('name');
+ $description = $sanitizedParams->getString('description');
+ $resolutionId = $sanitizedParams->getInt('resolutionId');
+ $enableStat = $sanitizedParams->getCheckbox('enableStat');
+ $autoApplyTransitions = $sanitizedParams->getCheckbox('autoApplyTransitions');
+ $folderId = $sanitizedParams->getInt('folderId');
+
+ if ($folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) {
+ $folderId = $this->getUser()->homeFolderId;
+ }
+
+ // Tags
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags'));
+ } else {
+ $tags = [];
+ }
+ $tags[] = $this->tagFactory->tagFromString('template');
+
+ $layout = $this->layoutFactory->createFromResolution($resolutionId,
+ $this->getUser()->userId,
+ $name,
+ $description,
+ $tags,
+ null
+ );
+
+ // Set layout enableStat flag
+ $layout->enableStat = $enableStat;
+
+ // Set auto apply transitions flag
+ $layout->autoApplyTransitions = $autoApplyTransitions;
+
+ // Set folderId
+ $layout->folderId = $folderId;
+
+ // Save
+ $layout->save();
+
+ // Automatically checkout the new layout for edit
+ $layout = $this->layoutFactory->checkoutLayout($layout, $sanitizedParams->getCheckbox('returnDraft'));
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add template
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Post(
+ * path="/template/{layoutId}",
+ * operationId="template.add.from.layout",
+ * tags={"template"},
+ * summary="Add a template from a Layout",
+ * description="Use the provided layout as a base for a new template",
+ * @SWG\Parameter(
+ * name="layoutId",
+ * in="path",
+ * description="The Layout ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="includeWidgets",
+ * in="formData",
+ * description="Flag indicating whether to include the widgets in the Template",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="The Template Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="tags",
+ * in="formData",
+ * description="Comma separated list of Tags for the template",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="description",
+ * in="formData",
+ * description="A description of the Template",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Layout"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ */
+ public function addFromLayout(Request $request, Response $response, $id): Response
+ {
+ // Get the layout
+ $layout = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkViewable($layout)) {
+ throw new AccessDeniedException(__('You do not have permissions to view this layout'));
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Should the copy include the widgets
+ $includeWidgets = ($sanitizedParams->getCheckbox('includeWidgets') == 1);
+
+ // Load without anything
+ $layout->load([
+ 'loadPlaylists' => true,
+ 'loadWidgets' => $includeWidgets,
+ 'playlistIncludeRegionAssignments' => false,
+ 'loadTags' => false,
+ 'loadPermissions' => false,
+ 'loadCampaigns' => false
+ ]);
+ $originalLayout = $layout;
+
+ $layout = clone $layout;
+
+ $layout->layout = $sanitizedParams->getString('name');
+ if ($this->getUser()->featureEnabled('tag.tagging')) {
+ $layout->updateTagLinks($this->tagFactory->tagsFromString($sanitizedParams->getString('tags')));
+ } else {
+ $layout->tags = [];
+ }
+ $layout->assignTag($this->tagFactory->tagFromString('template'));
+
+ $layout->description = $sanitizedParams->getString('description');
+ $layout->folderId = $sanitizedParams->getInt('folderId');
+
+ if ($layout->folderId === 1) {
+ $this->checkRootFolderAllowSave();
+ }
+
+ // When saving a layout as a template, we should not include the empty canva region as that requires
+ // a widget to be inside it.
+ // https://github.com/xibosignage/xibo/issues/3574
+ if (!$includeWidgets) {
+ $this->getLog()->debug('addFromLayout: widgets have not been included, checking for empty regions');
+
+ $regionsWithWidgets = [];
+ foreach ($layout->regions as $region) {
+ if ($region->type === 'canvas') {
+ $this->getLog()->debug('addFromLayout: Canvas region excluded from export');
+ } else {
+ $regionsWithWidgets[] = $region;
+ }
+ }
+ $layout->regions = $regionsWithWidgets;
+ }
+
+ $layout->setOwner($this->getUser()->userId, true);
+ $layout->save();
+
+ if ($includeWidgets) {
+ // Sub-Playlist
+ foreach ($layout->regions as $region) {
+ // Match our original region id to the id in the parent layout
+ $original = $originalLayout->getRegion($region->getOriginalValue('regionId'));
+
+ // Make sure Playlist closure table from the published one are copied over
+ $original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId);
+ }
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Saved %s'), $layout->layout),
+ 'id' => $layout->layoutId,
+ 'data' => $layout
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Displays an Add/Edit form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'template-form-add';
+ $this->getState()->setData([
+ 'resolutions' => $this->resolutionFactory->query(['resolution']),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ // Get the layout
+ $template = $this->layoutFactory->getById($id);
+
+ // Check Permissions
+ if (!$this->getUser()->checkEditable($template)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'template-form-edit';
+ $this->getState()->setData([
+ 'layout' => $template,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Get list of Template providers with their details.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @return Response|ResponseInterface
+ */
+ public function providersList(Request $request, Response $response): Response|\Psr\Http\Message\ResponseInterface
+ {
+ $event = new TemplateProviderListEvent();
+ $this->getDispatcher()->dispatch($event, $event->getName());
+
+ $providers = $event->getProviders();
+
+ return $response->withJson($providers);
+ }
+}
diff --git a/lib/Controller/Transition.php b/lib/Controller/Transition.php
new file mode 100644
index 0000000..f7fa433
--- /dev/null
+++ b/lib/Controller/Transition.php
@@ -0,0 +1,165 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\TransitionFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class Transition
+ * @package Xibo\Controller
+ */
+class Transition extends Base
+{
+ /**
+ * @var TransitionFactory
+ */
+ private $transitionFactory;
+
+ /**
+ * Set common dependencies.
+ * @param TransitionFactory $transitionFactory
+ */
+ public function __construct($transitionFactory)
+ {
+ $this->transitionFactory = $transitionFactory;
+ }
+
+ /**
+ * No display page functionaility
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'transition-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+
+ $filter = [
+ 'transition' => $sanitizedQueryParams->getString('transition'),
+ 'code' => $sanitizedQueryParams->getString('code'),
+ 'availableAsIn' => $sanitizedQueryParams->getInt('availableAsIn'),
+ 'availableAsOut' => $sanitizedQueryParams->getInt('availableAsOut')
+ ];
+
+ $transitions = $this->transitionFactory->query($this->gridRenderSort($sanitizedQueryParams), $this->gridRenderFilter($filter, $sanitizedQueryParams));
+
+ foreach ($transitions as $transition) {
+ /* @var \Xibo\Entity\Transition $transition */
+
+ // If the module config is not locked, present some buttons
+ if ($this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB') != 1 && $this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB') != 'Checked' ) {
+
+ // Edit button
+ $transition->buttons[] = array(
+ 'id' => 'transition_button_edit',
+ 'url' => $this->urlFor($request,'transition.edit.form', ['id' => $transition->transitionId]),
+ 'text' => __('Edit')
+ );
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->transitionFactory->countLast();
+ $this->getState()->setData($transitions);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Transition Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ if ($this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB') == 1 || $this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB') == 'Checked') {
+ throw new AccessDeniedException(__('Transition Config Locked'));
+ }
+
+ $transition = $this->transitionFactory->getById($id);
+
+ $this->getState()->template = 'transition-form-edit';
+ $this->getState()->setData([
+ 'transition' => $transition,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Transition
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ if ($this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB') == 1 || $this->getConfig()->getSetting('TRANSITION_CONFIG_LOCKED_CHECKB') == 'Checked') {
+ throw new AccessDeniedException(__('Transition Config Locked'));
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $transition = $this->transitionFactory->getById($id);
+ $transition->availableAsIn = $sanitizedParams->getCheckbox('availableAsIn');
+ $transition->availableAsOut = $sanitizedParams->getCheckbox('availableAsOut');
+ $transition->save();
+
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $transition->transition),
+ 'id' => $transition->transitionId,
+ 'data' => $transition
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/User.php b/lib/Controller/User.php
new file mode 100644
index 0000000..03f7e5c
--- /dev/null
+++ b/lib/Controller/User.php
@@ -0,0 +1,2567 @@
+.
+ */
+namespace Xibo\Controller;
+
+use RobThree\Auth\TwoFactorAuth;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\Permission;
+use Xibo\Event\LayoutOwnerChangeEvent;
+use Xibo\Event\LayoutSharingChangeEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\ApplicationFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\SessionFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\UserTypeFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\QuickChartQRProvider;
+use Xibo\Helper\Random;
+use Xibo\Service\MediaService;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class User
+ * @package Xibo\Controller
+ */
+class User extends Base
+{
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var UserTypeFactory
+ */
+ private $userTypeFactory;
+
+ /**
+ * @var UserGroupFactory
+ */
+ private $userGroupFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var ApplicationFactory
+ */
+ private $applicationFactory;
+
+ /** @var SessionFactory */
+ private $sessionFactory;
+
+ /** @var MediaService */
+ private $mediaService;
+
+ /**
+ * Set common dependencies.
+ * @param UserFactory $userFactory
+ * @param UserTypeFactory $userTypeFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param PermissionFactory $permissionFactory
+ * @param ApplicationFactory $applicationFactory
+ * @param SessionFactory $sessionFactory
+ * @param MediaService $mediaService
+ */
+ public function __construct(
+ $userFactory,
+ $userTypeFactory,
+ $userGroupFactory,
+ $permissionFactory,
+ $applicationFactory,
+ $sessionFactory,
+ MediaService $mediaService
+ ) {
+ $this->userFactory = $userFactory;
+ $this->userTypeFactory = $userTypeFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->applicationFactory = $applicationFactory;
+ $this->sessionFactory = $sessionFactory;
+ $this->mediaService = $mediaService;
+ }
+
+ private function getMediaService(\Xibo\Entity\User $user): MediaService
+ {
+ $this->mediaService->setUser($user);
+ return $this->mediaService;
+ }
+
+ /**
+ * Home Page
+ * this redirects to the appropriate page for this user.
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function home(Request $request, Response $response)
+ {
+ // Should we show this user the welcome page?
+ if ($this->getUser()->newUserWizard == 0) {
+ return $response->withRedirect($this->urlFor($request, 'welcome.view'));
+ }
+
+ // User wizard seen, go to home page
+ $this->getLog()->debug('Showing the homepage: ' . $this->getUser()->homePageId);
+
+ try {
+ $homepage = $this->userGroupFactory->getHomepageByName($this->getUser()->homePageId);
+ } catch (NotFoundException $exception) {
+ return $response->withRedirect($this->urlFor($request, 'icondashboard.view'));
+ }
+
+ if (!$this->getUser()->featureEnabled($homepage->feature)) {
+ return $response->withRedirect($this->urlFor($request, 'icondashboard.view'));
+ } else {
+ return $response->withRedirect($this->urlFor($request, $homepage->homepage));
+ }
+ }
+
+ /**
+ * Welcome Page
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function welcome(Request $request, Response $response)
+ {
+ $this->getState()->template = 'welcome-page';
+
+ // Mark the page as seen
+ if ($this->getUser()->newUserWizard == 0) {
+ $this->getUser()->newUserWizard = 1;
+ $this->getUser()->save(['validate' => false]);
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Controls which pages are to be displayed
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'user-page';
+ $this->getState()->setData([
+ 'userTypes' => $this->userTypeFactory->query()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Me
+ *
+ * @SWG\Get(
+ * path="/user/me",
+ * operationId="userMe",
+ * tags={"user"},
+ * summary="Get Me",
+ * description="Get my details",
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/User")
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function myDetails(Request $request, Response $response)
+ {
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 200,
+ 'data' => $this->getUser()
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Prints the user information in a table based on a check box selection
+ *
+ * @SWG\Get(
+ * path="/user",
+ * operationId="userSearch",
+ * tags={"user"},
+ * summary="User Search",
+ * description="Search users",
+ * @SWG\Parameter(
+ * name="userId",
+ * in="query",
+ * description="Filter by User Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userName",
+ * in="query",
+ * description="Filter by User Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userTypeId",
+ * in="query",
+ * description="Filter by UserType Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="query",
+ * description="Filter by Retired",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/User")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ function grid(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getQueryParams());
+
+ // Filter our users?
+ $filterBy = [
+ 'userId' => $sanitizedParams->getInt('userId'),
+ 'userTypeId' => $sanitizedParams->getInt('userTypeId'),
+ 'userName' => $sanitizedParams->getString('userName'),
+ 'firstName' => $sanitizedParams->getString('firstName'),
+ 'lastName' => $sanitizedParams->getString('lastName'),
+ 'useRegexForName' => $sanitizedParams->getCheckbox('useRegexForName'),
+ 'retired' => $sanitizedParams->getInt('retired'),
+ 'logicalOperatorName' => $sanitizedParams->getString('logicalOperatorName'),
+ 'userGroupIdMembers' => $sanitizedParams->getInt('userGroupIdMembers'),
+ ];
+
+ // Load results into an array
+ $users = $this->userFactory->query($this->gridRenderSort($sanitizedParams), $this->gridRenderFilter($filterBy, $sanitizedParams));
+
+ foreach ($users as $user) {
+ /* @var \Xibo\Entity\User $user */
+
+ $user->setUnmatchedProperty('libraryQuotaFormatted', ByteFormatter::format($user->libraryQuota * 1024));
+
+ $user->loggedIn = $this->sessionFactory->getActiveSessionsForUser($user->userId);
+ $this->getLog()->debug('Logged in status for user ID ' . $user->userId . ' with name ' . $user->userName . ' is ' . $user->loggedIn);
+
+ // Set some text for the display status
+ $user->setUnmatchedProperty('twoFactorDescription', match ($user->twoFactorTypeId) {
+ 1 => __('Email'),
+ 2 => __('Google Authenticator'),
+ default => __('Disabled'),
+ });
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ $user->includeProperty('buttons');
+
+ // Deal with the home page
+ try {
+ $user->setUnmatchedProperty(
+ 'homePage',
+ $this->userGroupFactory->getHomepageByName($user->homePageId)->title
+ );
+ } catch (NotFoundException $exception) {
+ $this->getLog()->error('User has homepage which does not exist. userId: ' . $user->userId . ', homepage: ' . $user->homePageId);
+ $user->setUnmatchedProperty('homePage', __('Unknown homepage, please edit to update.'));
+ }
+
+ // Set the home folder
+ $user->setUnmatchedProperty('homeFolder', $user->getUnmatchedProperty('homeFolder', '/'));
+
+ // Super admins have some buttons
+ if ($this->getUser()->featureEnabled('users.modify')
+ && $this->getUser()->checkEditable($user)
+ ) {
+ // Edit
+ $user->buttons[] = [
+ 'id' => 'user_button_edit',
+ 'url' => $this->urlFor($request,'user.edit.form', ['id' => $user->userId]),
+ 'text' => __('Edit')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('users.modify')
+ && $this->getUser()->checkDeleteable($user)
+ && $user->userId != $this->getConfig()->getSetting('SYSTEM_USER')
+ && $this->getUser()->userId !== $user->userId
+ && ( ($this->getUser()->isGroupAdmin() && $user->userTypeId == 3) || $this->getUser()->isSuperAdmin() )
+ ) {
+ // Delete
+ $user->buttons[] = [
+ 'id' => 'user_button_delete',
+ 'url' => $this->urlFor($request,'user.delete.form', ['id' => $user->userId]),
+ 'text' => __('Delete')
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('folder.userHome')) {
+ $user->buttons[] = [
+ 'id' => 'user_button_set_home',
+ 'url' => $this->urlFor($request, 'user.homeFolder.form', ['id' => $user->userId]),
+ 'text' => __('Set Home Folder'),
+ 'multi-select' => true,
+ 'dataAttributes' => [
+ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'user.homeFolder', ['id' => $user->userId])],
+ ['name' => 'commit-method', 'value' => 'post'],
+ ['name' => 'id', 'value' => 'user_button_set_home'],
+ ['name' => 'text', 'value' => __('Set home folder')],
+ ['name' => 'rowtitle', 'value' => $user->userName],
+ ['name' => 'form-callback', 'value' => 'userHomeFolderMultiselectFormOpen']
+ ],
+ ];
+ }
+
+ if ($this->getUser()->featureEnabled('users.modify')
+ && $this->getUser()->checkPermissionsModifyable($user)
+ ) {
+ $user->buttons[] = ['divider' => true];
+
+ // User Groups
+ $user->buttons[] = array(
+ 'id' => 'user_button_group_membership',
+ 'url' => $this->urlFor($request,'user.membership.form', ['id' => $user->userId]),
+ 'text' => __('User Groups')
+ );
+ }
+
+ if ($this->getUser()->isSuperAdmin()) {
+ $user->buttons[] = ['divider' => true];
+
+ // Features
+ $user->buttons[] = [
+ 'id' => 'user_button_page_security',
+ 'url' => $this->urlFor($request,'group.acl.form', ['id' => $user->groupId, 'userId' => $user->userId]),
+ 'text' => __('Features'),
+ 'title' => __('Turn Features on/off for this User')
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->userFactory->countLast();
+ $this->getState()->setData($users);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Adds a user
+ *
+ * @SWG\Post(
+ * path="/user",
+ * operationId="userAdd",
+ * tags={"user"},
+ * summary="Add User",
+ * description="Add a new User",
+ * @SWG\Parameter(
+ * name="userName",
+ * in="formData",
+ * description="The User Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="email",
+ * in="formData",
+ * description="The user email address",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userTypeId",
+ * in="formData",
+ * description="The user type ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="homePageId",
+ * in="formData",
+ * description="The homepage to use for this User",
+ * type="string",
+ * enum={"statusdashboard.view", "icondashboard.view", "mediamanager.view", "playlistdashboard.view"},
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="libraryQuota",
+ * in="formData",
+ * description="The users library quota in kilobytes",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="password",
+ * in="formData",
+ * description="The users password",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="groupId",
+ * in="formData",
+ * description="The inital user group for this User",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="firstName",
+ * in="formData",
+ * description="The users first name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="lastName",
+ * in="formData",
+ * description="The users last name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="phone",
+ * in="formData",
+ * description="The users phone number",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref1",
+ * in="formData",
+ * description="Reference 1",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref2",
+ * in="formData",
+ * description="Reference 2",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref3",
+ * in="formData",
+ * description="Reference 3",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref4",
+ * in="formData",
+ * description="Reference 4",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref5",
+ * in="formData",
+ * description="Reference 5",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="newUserWizard",
+ * in="formData",
+ * description="Flag indicating whether to show the new user guide",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="hideNavigation",
+ * in="formData",
+ * description="Flag indicating whether to hide the navigation",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isPasswordChangeRequired",
+ * in="formData",
+ * description="A flag indicating whether password change should be forced for this user",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/User"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function add(Request $request, Response $response)
+ {
+ // Only group admins or super admins can create Users.
+ if (!$this->getUser()->isSuperAdmin() && !$this->getUser()->isGroupAdmin()) {
+ throw new AccessDeniedException(__('Only super and group admins can create users'));
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Build a user entity and save it
+ $user = $this->userFactory->create();
+ $user->setChildAclDependencies($this->userGroupFactory);
+
+ $user->userName = $sanitizedParams->getString('userName');
+ $user->email = $sanitizedParams->getString('email');
+ $user->homePageId = $sanitizedParams->getString('homePageId');
+ $user->libraryQuota = $sanitizedParams->getInt('libraryQuota', ['default' => 0]);
+ $user->setNewPassword($sanitizedParams->getString('password'));
+
+ // Are user home folders enabled? If not, use the default.
+ if ($this->getUser()->featureEnabled('folder.userHome')) {
+ $user->homeFolderId = $sanitizedParams->getInt('homeFolderId', ['default' => 1]);
+ } else {
+ $user->homeFolderId = 1;
+ }
+
+ if ($this->getUser()->isSuperAdmin()) {
+ $user->userTypeId = $sanitizedParams->getInt('userTypeId');
+ $user->isSystemNotification = $sanitizedParams->getCheckbox('isSystemNotification');
+ $user->isDisplayNotification = $sanitizedParams->getCheckbox('isDisplayNotification');
+ } else {
+ $user->userTypeId = 3;
+ $user->isSystemNotification = 0;
+ $user->isDisplayNotification = 0;
+ }
+
+ $user->firstName = $sanitizedParams->getString('firstName');
+ $user->lastName = $sanitizedParams->getString('lastName');
+ $user->phone = $sanitizedParams->getString('phone');
+ $user->ref1 = $sanitizedParams->getString('ref1');
+ $user->ref2 = $sanitizedParams->getString('ref2');
+ $user->ref3 = $sanitizedParams->getString('ref3');
+ $user->ref4 = $sanitizedParams->getString('ref4');
+ $user->ref5 = $sanitizedParams->getString('ref5');
+
+ // Options
+ $user->newUserWizard = $sanitizedParams->getCheckbox('newUserWizard');
+ $user->setOptionValue('hideNavigation', $sanitizedParams->getCheckbox('hideNavigation'));
+ $user->isPasswordChangeRequired = $sanitizedParams->getCheckbox('isPasswordChangeRequired');
+
+ // Initial user group
+ $group = $this->userGroupFactory->getById($sanitizedParams->getInt('groupId'));
+
+ if ($group->isUserSpecific == 1) {
+ throw new InvalidArgumentException(__('Invalid user group selected'), 'groupId');
+ }
+
+ // Save the user
+ $user->save();
+
+ // Assign the initial group
+ $group->assignUser($user);
+ $group->save(['validate' => false]);
+
+ // Handle enabled features for the homepage.
+ if (!empty($user->homePageId)) {
+ $homepage = $this->userGroupFactory->getHomepageByName($user->homePageId);
+ if (!empty($homepage->feature) && !$user->featureEnabled($homepage->feature)) {
+ throw new InvalidArgumentException(__('User does not have the enabled Feature for this Dashboard'), 'homePageId');
+ }
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $user->userName),
+ 'id' => $user->userId,
+ 'data' => $user
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit a user
+ *
+ * @SWG\Put(
+ * path="/user/{userId}",
+ * operationId="userEdit",
+ * tags={"user"},
+ * summary="Edit User",
+ * description="Edit existing User",
+ * @SWG\Parameter(
+ * name="userId",
+ * in="path",
+ * description="The user ID to edit",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="userName",
+ * in="formData",
+ * description="The User Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="email",
+ * in="formData",
+ * description="The user email address",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userTypeId",
+ * in="formData",
+ * description="The user type ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="homePageId",
+ * in="formData",
+ * description="The homepage to use for this User",
+ * type="string",
+ * enum={"statusdashboard.view", "icondashboard.view", "mediamanager.view", "playlistdashboard.view"},
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="libraryQuota",
+ * in="formData",
+ * description="The users library quota in kilobytes",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="newPassword",
+ * in="formData",
+ * description="New User password",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retypeNewPassword",
+ * in="formData",
+ * description="Repeat the new User password",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="retired",
+ * in="formData",
+ * description="Flag indicating whether to retire this user",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="firstName",
+ * in="formData",
+ * description="The users first name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="lastName",
+ * in="formData",
+ * description="The users last name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="phone",
+ * in="formData",
+ * description="The users phone number",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref1",
+ * in="formData",
+ * description="Reference 1",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref2",
+ * in="formData",
+ * description="Reference 2",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref3",
+ * in="formData",
+ * description="Reference 3",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref4",
+ * in="formData",
+ * description="Reference 4",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="ref5",
+ * in="formData",
+ * description="Reference 5",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="newUserWizard",
+ * in="formData",
+ * description="Flag indicating whether to show the new user guide",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="hideNavigation",
+ * in="formData",
+ * description="Flag indicating whether to hide the navigation",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="isPasswordChangeRequired",
+ * in="formData",
+ * description="A flag indicating whether password change should be forced for this user",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/User"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getLog()->debug('User Edit process started.');
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Build a user entity and save it
+ $user->setChildAclDependencies($this->userGroupFactory);
+ $user->load();
+ $user->userName = $sanitizedParams->getString('userName');
+ $user->email = $sanitizedParams->getString('email');
+ $user->homePageId = $sanitizedParams->getString('homePageId');
+ $user->libraryQuota = $sanitizedParams->getInt('libraryQuota');
+ $user->retired = $sanitizedParams->getCheckbox('retired');
+
+ // Are user home folders enabled? Don't change unless they are.
+ if ($this->getUser()->featureEnabled('folder.userHome')) {
+ $user->homeFolderId = $sanitizedParams->getInt('homeFolderId', ['default' => 1]);
+ }
+
+ // Some configuration is only avaialble to super admins.
+ if ($this->getUser()->isSuperAdmin()) {
+ $user->userTypeId = $sanitizedParams->getInt('userTypeId');
+ if ($user->retired === 1) {
+ $user->isSystemNotification = 0;
+ $user->isDisplayNotification = 0;
+ $user->isDataSetNotification = 0;
+ $user->isCustomNotification = 0;
+ $user->isLayoutNotification = 0;
+ $user->isLibraryNotification = 0;
+ $user->isReportNotification = 0;
+ $user->isScheduleNotification = 0;
+ } else {
+ $user->isSystemNotification = $sanitizedParams->getCheckbox('isSystemNotification');
+ $user->isDisplayNotification = $sanitizedParams->getCheckbox('isDisplayNotification');
+ $user->isDataSetNotification = $sanitizedParams->getCheckbox('isDataSetNotification');
+ $user->isCustomNotification = $sanitizedParams->getCheckbox('isCustomNotification');
+ $user->isLayoutNotification = $sanitizedParams->getCheckbox('isLayoutNotification');
+ $user->isLibraryNotification = $sanitizedParams->getCheckbox('isLibraryNotification');
+ $user->isReportNotification = $sanitizedParams->getCheckbox('isReportNotification');
+ $user->isScheduleNotification = $sanitizedParams->getCheckbox('isScheduleNotification');
+ }
+ }
+
+ $user->firstName = $sanitizedParams->getString('firstName');
+ $user->lastName = $sanitizedParams->getString('lastName');
+ $user->phone = $sanitizedParams->getString('phone');
+ $user->ref1 = $sanitizedParams->getString('ref1');
+ $user->ref2 = $sanitizedParams->getString('ref2');
+ $user->ref3 = $sanitizedParams->getString('ref3');
+ $user->ref4 = $sanitizedParams->getString('ref4');
+ $user->ref5 = $sanitizedParams->getString('ref5');
+
+ // Options
+ $user->newUserWizard = $sanitizedParams->getCheckbox('newUserWizard');
+ $user->setOptionValue('hideNavigation', $sanitizedParams->getCheckbox('hideNavigation'));
+ $user->isPasswordChangeRequired = $sanitizedParams->getCheckbox('isPasswordChangeRequired');
+
+ $this->getLog()->debug('Params read');
+
+ // Handle enabled features for the homepage.
+ $homepage = $this->userGroupFactory->getHomepageByName($user->homePageId);
+ if (!empty($homepage->feature) && !$user->featureEnabled($homepage->feature)) {
+ throw new InvalidArgumentException(
+ __('User does not have the enabled Feature for this Dashboard'),
+ 'homePageId'
+ );
+ }
+
+ $this->getLog()->debug('Homepage validated.');
+
+ // If we are a super admin
+ if ($this->getUser()->userTypeId == 1) {
+ $newPassword = $sanitizedParams->getString('newPassword');
+ $retypeNewPassword = $sanitizedParams->getString('retypeNewPassword');
+ $disableTwoFactor = $sanitizedParams->getCheckbox('disableTwoFactor');
+
+ if ($newPassword != null && $newPassword != '') {
+ $this->getLog()->debug('New password provided, checking.');
+
+ // Make sure they are the same
+ if ($newPassword != $retypeNewPassword) {
+ throw new InvalidArgumentException(__('Passwords do not match'));
+ }
+
+ // Set the new password
+ $user->setNewPassword($newPassword);
+ }
+
+ // super admin can clear the twoFactorTypeId and secret for the user.
+ if ($disableTwoFactor) {
+ $user->clearTwoFactor();
+ }
+ }
+
+ $this->getLog()->debug('About to save.');
+
+ // Save the user
+ $user->save();
+
+ $this->getLog()->debug('User saved, about to return.');
+
+ // Re-fetch the user before returning to ensure all fields are populated,
+ // especially those omitted in the edit request.
+ $user = $this->userFactory->getById($id);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $user->userName),
+ 'id' => $user->userId,
+ 'data' => $user
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Deletes a User
+ *
+ * @SWG\Delete(
+ * path="/user/{userId}",
+ * operationId="userDelete",
+ * tags={"user"},
+ * summary="User Delete",
+ * description="Delete user",
+ * @SWG\Parameter(
+ * name="userId",
+ * in="path",
+ * description="Id of the user to delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="deleteAllItems",
+ * in="formData",
+ * description="Flag indicating whether to delete all items owned by that user",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="reassignUserId",
+ * in="formData",
+ * description="Reassign all items owned by this user to the specified user ID",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/User")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // System User
+ if ($user->userId == $this->getConfig()->getSetting('SYSTEM_USER')) {
+ throw new InvalidArgumentException(__('This User is set as System User and cannot be deleted.'), 'userId');
+ }
+
+ if (!$this->getUser()->checkDeleteable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ if ($this->getUser()->userId === $user->userId) {
+ throw new InvalidArgumentException(__('Cannot delete your own User from the CMS.'));
+ }
+
+ if ($this->getUser()->isGroupAdmin() && $user->userTypeId !== 3) {
+ throw new InvalidArgumentException(__('Group Admin cannot remove Super Admins or other Group Admins.'));
+ }
+
+ if ($sanitizedParams->getCheckbox('deleteAllItems') && $user->isSuperAdmin()) {
+ throw new InvalidArgumentException(__('Cannot delete all items owned by a Super Admin, please reassign to a different User.'));
+ }
+
+ $user->setChildAclDependencies($this->userGroupFactory);
+
+ if ($sanitizedParams->getCheckbox('deleteAllItems') != 1) {
+ // Do we have a userId to reassign content to?
+ if ($sanitizedParams->getInt('reassignUserId') != null) {
+ // Reassign all content owned by this user to the provided user
+ $this->getLog()->debug(sprintf('Reassigning content to new userId: %d', $sanitizedParams->getInt('reassignUserId')));
+ $this->getDispatcher()->dispatch(
+ UserDeleteEvent::$NAME,
+ new UserDeleteEvent(
+ $user,
+ 'reassignAll',
+ $this->userFactory->getSystemUser(),
+ $this->userFactory->getById($sanitizedParams->getInt('reassignUserId'))
+ )
+ );
+ } else {
+ // Check to see if we have any child data that would prevent us from deleting
+ /** @var UserDeleteEvent $countChildren */
+ $countChildren = $this->getDispatcher()->dispatch(UserDeleteEvent::$NAME, new UserDeleteEvent($user, 'countChildren', $this->userFactory->getSystemUser()));
+
+ if ($countChildren->getReturnValue() > 0) {
+ throw new InvalidArgumentException(sprintf(__('This user cannot be deleted as it has %d child items'), $countChildren->getReturnValue()));
+ }
+ }
+ }
+
+ $this->getDispatcher()->dispatch(UserDeleteEvent::$NAME, new UserDeleteEvent($user, 'delete', $this->userFactory->getSystemUser()));
+ // Delete the user
+ $user->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Deleted %s'), $user->userName),
+ 'id' => $user->userId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function homepages(Request $request, Response $response)
+ {
+ // Only group admins or super admins can create Users.
+ if (!$this->getUser()->isSuperAdmin() && !$this->getUser()->isGroupAdmin()) {
+ throw new AccessDeniedException(__('Only super and group admins can create users'));
+ }
+
+ // Get all homepages accessible for a user group
+ $params = $this->getSanitizer($request->getParams());
+ $userId = $params->getInt('userId');
+
+ if ($userId !== null) {
+ $homepages = [];
+ $user = $this->userFactory->getById($userId)
+ ->setChildAclDependencies($this->userGroupFactory);
+
+ foreach ($this->userGroupFactory->getHomepages() as $homepage) {
+ if (empty($homepage->feature) || $user->featureEnabled($homepage->feature)) {
+ $homepages[] = $homepage;
+ }
+ }
+ } else {
+ $userTypeId = $params->getInt('userTypeId', [
+ 'throw' => function () {
+ throw new NotFoundException();
+ }
+ ]);
+
+ if ($userTypeId == 1) {
+ $homepages = $this->userGroupFactory->getHomepages();
+ } else {
+ $groupId = $params->getInt('groupId', [
+ 'throw' => function () {
+ throw new NotFoundException();
+ }
+ ]);
+ $group = $this->userGroupFactory->getById($groupId);
+
+ $homepages = [];
+ foreach ($this->userGroupFactory->getHomepages() as $homepage) {
+ if (empty($homepage->feature) || in_array($homepage->feature, $group->features)) {
+ $homepages[] = $homepage;
+ }
+ }
+ }
+ }
+
+ // Prepare output
+ $this->getState()->template = 'grid';
+
+ // Have we asked for a specific homepage?
+ $homepageFilter = $params->getString('homepage');
+ if ($homepageFilter !== null) {
+ if (array_key_exists($homepageFilter, $homepages)) {
+ $this->getState()->recordsTotal = 1;
+ $this->getState()->setData([$homepages[$homepageFilter]]);
+ return $this->render($request, $response);
+ } else {
+ throw new NotFoundException(__('Homepage not found'));
+ }
+ }
+
+ $this->getState()->recordsTotal = count($homepages);
+ $this->getState()->setData(array_values($homepages));
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * User Add Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function addForm(Request $request, Response $response)
+ {
+ // Only group admins or super admins can create Users.
+ if (!$this->getUser()->isSuperAdmin() && !$this->getUser()->isGroupAdmin()) {
+ throw new AccessDeniedException(__('Only super and group admins can create users'));
+ }
+
+ $defaultUserTypeId = 3;
+ foreach ($this->userTypeFactory->query(null, [
+ 'userType' => $this->getConfig()->getSetting('defaultUsertype')
+ ]) as $defaultUserType) {
+ $defaultUserTypeId = $defaultUserType->userTypeId;
+ }
+
+ $this->getState()->template = 'user-form-add';
+ $this->getState()->setData([
+ 'options' => [
+ 'userTypes' => ($this->getUser()->isSuperAdmin()) ? $this->userTypeFactory->getAllRoles() : $this->userTypeFactory->getNonAdminRoles(),
+ 'defaultGroupId' => $this->getConfig()->getSetting('DEFAULT_USERGROUP'),
+ 'defaultUserType' => $defaultUserTypeId
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * User Edit Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function editForm(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+ $user->setChildAclDependencies($this->userGroupFactory);
+
+ if (!$this->getUser()->checkEditable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ $homepage = [];
+ try {
+ $homepage = $this->userGroupFactory->getHomepageByName($user->homePageId);
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error(sprintf('User %d has non existing homepage %s', $user->userId, $user->homePageId));
+ }
+
+ $this->getState()->template = 'user-form-edit';
+ $this->getState()->setData([
+ 'user' => $user,
+ 'options' => [
+ 'homepage' => $homepage,
+ 'userTypes' => ($this->getUser()->isSuperAdmin()) ? $this->userTypeFactory->getAllRoles() : $this->userTypeFactory->getNonAdminRoles()
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * User Delete Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function deleteForm(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+
+ if (!$this->getUser()->checkDeleteable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'user-form-delete';
+ $this->getState()->setData([
+ 'user' => $user,
+ 'users' => $this->userFactory->query(null, ['notUserId' => $id]),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Change my password form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editProfileForm(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+
+ $this->getState()->template = 'user-form-edit-profile';
+ $this->getState()->setData([
+ 'user' => $user,
+ 'data' => [
+ 'setup' => $this->urlFor($request,'user.setup.profile'),
+ 'generate' => $this->urlFor($request,'user.recovery.generate.profile'),
+ 'show' => $this->urlFor($request,'user.recovery.show.profile'),
+ ]
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Change my Password
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \QRException
+ * @throws \RobThree\Auth\TwoFactorAuthException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function editProfile(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+
+ // get all other values from the form
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $oldPassword = $sanitizedParams->getString('password');
+ $newPassword = $sanitizedParams->getString('newPassword');
+ $retypeNewPassword = $sanitizedParams->getString('retypeNewPassword');
+ $user->email = $sanitizedParams->getString('email');
+ $user->twoFactorTypeId = $sanitizedParams->getInt('twoFactorTypeId');
+ $code = $sanitizedParams->getString('code');
+ $recoveryCodes = $sanitizedParams->getString('twoFactorRecoveryCodes');
+
+ if ($recoveryCodes != null) {
+ $user->twoFactorRecoveryCodes = json_decode($recoveryCodes);
+ }
+
+ // What situations do we need to check the old password is correct?
+ if ($user->hasPropertyChanged('twoFactorTypeId')
+ || ($user->hasPropertyChanged('email') && $user->twoFactorTypeId === 1)
+ || ($user->hasPropertyChanged('email') && $user->getOriginalValue('twoFactorTypeId') === 1)
+ || $newPassword != null
+ ) {
+ try {
+ $user->checkPassword($oldPassword);
+ } catch (AccessDeniedException $exception) {
+ throw new InvalidArgumentException(__('Please enter your password'), 'password');
+ }
+ }
+
+ // check if we have a new password provided, if so check if it was correctly entered
+ if ($newPassword != $retypeNewPassword) {
+ throw new InvalidArgumentException(__('Passwords do not match'), 'newPassword');
+ }
+
+ // check if we have saved secret, for google auth that is done on jQuery side
+ if (!isset($user->twoFactorSecret) && $user->twoFactorTypeId === 1) {
+ $this->tfaSetup($request, $response);
+ $user->twoFactorSecret = $_SESSION['tfaSecret'];
+ unset($_SESSION['tfaSecret']);
+ }
+
+ // if we are setting up email two factor auth, check if the email is entered on the form as well
+ if ($user->twoFactorTypeId === 1 && $user->email == '') {
+ throw new InvalidArgumentException(__('Please provide valid email address'), 'email');
+ }
+
+ // if we are setting up email two factor auth, check if the sending email address is entered in CMS Settings.
+ if ($user->twoFactorTypeId === 1 && $this->getConfig()->getSetting('mail_from') == '') {
+ throw new InvalidArgumentException(__('Please provide valid sending email address in CMS Settings on Network tab'), 'mail_from');
+ }
+
+ // if we have a new password provided, update the user record
+ if ($newPassword != null && $newPassword == $retypeNewPassword) {
+ $user->setNewPassword($newPassword, $oldPassword);
+ $user->isPasswordChangeRequired = 0;
+ $user->save([
+ 'passwordUpdate' => true
+ ]);
+ }
+
+ // if we are setting up Google auth, we are expecting a code from the form, validate the code here
+ // we want to show QR code and validate the access code also with the previous auth method was set to email
+ if ($user->twoFactorTypeId === 2
+ && ($user->twoFactorSecret === null || $user->getOriginalValue('twoFactorTypeId') === 1)
+ ) {
+ if (!isset($code)) {
+ throw new InvalidArgumentException(__('Access Code is empty'), 'code');
+ }
+
+ $validation = $this->tfaValidate($code, $user);
+
+ if (!$validation) {
+ unset($_SESSION['tfaSecret']);
+ throw new InvalidArgumentException(__('Access Code is incorrect'), 'code');
+ }
+
+ if ($validation) {
+ // if access code is correct, we want to set the secret to our user - either from session for new 2FA setup or leave it as it is for user changing from email to google auth
+ if (!isset($user->twoFactorSecret)) {
+ $secret = $_SESSION['tfaSecret'];
+ } else {
+ $secret = $user->twoFactorSecret;
+ }
+
+ $user->twoFactorSecret = $secret;
+ unset($_SESSION['tfaSecret']);
+ }
+ }
+
+ // if the two factor type is set to Off, clear any saved secrets and set the twoFactorTypeId to 0 in database.
+ if ($user->twoFactorTypeId == 0) {
+ $user->clearTwoFactor();
+ }
+
+ $user->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('User Profile Saved'),
+ 'id' => $user->userId,
+ 'data' => $user
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \QRException
+ * @throws \RobThree\Auth\TwoFactorAuthException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function tfaSetup(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+
+ $issuerSettings = $this->getConfig()->getSetting('TWOFACTOR_ISSUER');
+ $appName = $this->getConfig()->getThemeConfig('app_name');
+ $quickChartUrl = $this->getConfig()->getSetting('QUICK_CHART_URL', 'https://quickchart.io');
+
+ if ($issuerSettings !== '') {
+ $issuer = $issuerSettings;
+ } else {
+ $issuer = $appName;
+ }
+
+ $tfa = new TwoFactorAuth($issuer, 6, 30, 'sha1', new QuickChartQRProvider($quickChartUrl));
+
+ // create two factor secret and store it in user record
+ if (!isset($user->twoFactorSecret)) {
+ $secret = $tfa->createSecret();
+ $_SESSION['tfaSecret'] = $secret;
+ } else {
+ $secret = $user->twoFactorSecret;
+ }
+
+ // generate the QR code to scan, we only show it at first set up and only for Google auth
+ $qRUrl = $tfa->getQRCodeImageAsDataUri($user->userName, $secret, 150);
+
+ $this->getState()->setData([
+ 'qRUrl' => $qRUrl
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param string $code The Code to validate
+ * @param $user
+ * @return bool
+ * @throws \RobThree\Auth\TwoFactorAuthException
+ */
+ public function tfaValidate($code, $user)
+ {
+ $issuerSettings = $this->getConfig()->getSetting('TWOFACTOR_ISSUER');
+ $appName = $this->getConfig()->getThemeConfig('app_name');
+
+ if ($issuerSettings !== '') {
+ $issuer = $issuerSettings;
+ } else {
+ $issuer = $appName;
+ }
+
+ $tfa = new TwoFactorAuth($issuer);
+
+ if (isset($_SESSION['tfaSecret'])) {
+ // validate the provided two factor code with secret for this user
+ $result = $tfa->verifyCode($_SESSION['tfaSecret'], $code, 3);
+ } elseif (isset($user->twoFactorSecret)) {
+ $result = $tfa->verifyCode($user->twoFactorSecret, $code, 3);
+ } else {
+ $result = false;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function tfaRecoveryGenerate(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+
+ // clear any existing codes when we generate new ones
+ $user->twoFactorRecoveryCodes = [];
+
+ $count = 4;
+ $codes = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $codes[] = Random::generateString(50);
+ }
+
+ $user->twoFactorRecoveryCodes = $codes;
+
+ $this->getState()->setData([
+ 'codes' => json_encode($codes, JSON_PRETTY_PRINT)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function tfaRecoveryShow(Request $request, Response $response)
+ {
+ $user = $this->getUser();
+
+ $user->twoFactorRecoveryCodes = json_decode($user->twoFactorRecoveryCodes);
+
+ if (isset($_GET["generatedCodes"]) && !empty($_GET["generatedCodes"])) {
+ $generatedCodes = $_GET["generatedCodes"];
+ $user->twoFactorRecoveryCodes = json_encode($generatedCodes);
+ }
+
+ $this->getState()->setData([
+ 'codes' => $user->twoFactorRecoveryCodes
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Force User Password Change
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function forceChangePasswordPage(Request $request, Response $response): Response
+ {
+ $user = $this->getUser();
+
+ // if the flag to force change password is not set to 1 then redirect to the Homepage
+ if ($user->isPasswordChangeRequired != 1) {
+ return $response->withRedirect($this->urlFor($request, 'home'));
+ }
+
+ $this->getState()->template = 'user-force-change-password-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Force change my Password
+ * @param Request $request
+ * @param Response $response
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function forceChangePassword(Request $request, Response $response): Response
+ {
+ $user = $this->getUser();
+
+ // This is only valid if the user has that option set on their account
+ if ($user->isPasswordChangeRequired != 1) {
+ throw new AccessDeniedException();
+ }
+
+ // Save the user
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $newPassword = $sanitizedParams->getString('newPassword');
+ $retypeNewPassword = $sanitizedParams->getString('retypeNewPassword');
+
+ if ($newPassword == null || $retypeNewPassword == '') {
+ throw new InvalidArgumentException(__('Please enter the password'), 'password');
+ }
+
+ if ($newPassword != $retypeNewPassword) {
+ throw new InvalidArgumentException(__('Passwords do not match'), 'password');
+ }
+
+ // Make sure that the new password doesn't verify against the existing hash
+ try {
+ $user->checkPassword($newPassword);
+ throw new InvalidArgumentException(__('Please choose a new password'), 'password');
+ } catch (AccessDeniedException) {
+ // This is good, they don't match.
+ }
+
+ $user->setNewPassword($newPassword);
+ $user->save([
+ 'passwordUpdate' => true
+ ]);
+
+ $user->isPasswordChangeRequired = 0;
+ $user->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => __('Password Changed'),
+ 'id' => $user->userId,
+ 'data' => $user
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/user/permissions/{entity}/{objectId}",
+ * operationId="userPermissionsSearch",
+ * tags={"user"},
+ * summary="Permission Data",
+ * description="Permission data for the Entity and Object Provided.",
+ * @SWG\Parameter(
+ * name="entity",
+ * in="path",
+ * description="The Entity",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="objectId",
+ * in="path",
+ * description="The ID of the Object to return permissions for",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Permission")
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param string $entity
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function permissionsGrid(Request $request, Response $response, $entity, $id)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Load our object
+ $object = $this->parsePermissionsEntity($entity, $id);
+
+ // Does this user have permission to edit the permissions?!
+ if (!$this->getUser()->checkPermissionsModifyable($object)) {
+ throw new AccessDeniedException(__('You do not have permission to edit these permissions.'));
+ }
+
+ // List of all Groups with a view / edit / delete check box
+ $permissions = $this->permissionFactory->getAllByObjectId($this->getUser(), $object->permissionsClass(), $id, $this->gridRenderSort($sanitizedParams), $this->gridRenderFilter(['name' => $sanitizedParams->getString('name')], $sanitizedParams));
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($permissions);
+ $this->getState()->recordsTotal = $this->permissionFactory->countLast();
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * @SWG\Get(
+ * path="/user/permissions/{entity}",
+ * operationId="userPermissionsMultiSearch",
+ * tags={"user"},
+ * summary="Permission Data",
+ * description="Permission data for the multiple Entities and Objects Provided.",
+ * @SWG\Parameter(
+ * name="entity",
+ * in="path",
+ * description="The Entity",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="ids",
+ * in="query",
+ * description="The IDs of the Objects to return permissions for",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/Permission")
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param string $entities
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function permissionsMultiGrid(Request $request, Response $response, $entity)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check if the array of ids is passed
+ if($sanitizedParams->getString('ids') == '') {
+ throw new InvalidArgumentException(__('The array of ids is empty!'));
+ }
+
+ // Get array of ids
+ $ids = explode(',', $sanitizedParams->getString('ids'));
+
+ // Array of all the permissions
+ $permissions = [];
+ $newPermissions = [];
+ $objects = [];
+
+ // Load our objects
+ for ($i=0; $i < count($ids); $i++) {
+ $objectId = $ids[$i];
+
+ $objects[$i] = $this->parsePermissionsEntity($entity, $objectId);
+
+ // Does this user have permission to edit the permissions?!
+ if (!$this->getUser()->checkPermissionsModifyable($objects[$i])) {
+ throw new AccessDeniedException(__('You do not have permission to edit all the entities permissions.'));
+ }
+
+ // List of all Groups with a view / edit / delete check box
+ $permissions = array_merge_recursive($permissions, $this->permissionFactory->getAllByObjectId($this->getUser(), $objects[$i]->permissionsClass(), $objectId, $this->gridRenderSort($sanitizedParams), $this->gridRenderFilter(['name' => $sanitizedParams->getString('name')], $sanitizedParams)));
+ }
+
+ // Change permissions structure to be grouped by user group
+ foreach ($permissions as $permission) {
+
+ if(!array_key_exists($permission->groupId, $newPermissions)) {
+ $newPermissions[$permission->groupId] = [
+ "groupId" => $permission->groupId,
+ "group" => $permission->group,
+ "isUser" => $permission->isUser,
+ "entity" => $permission->entity,
+ "permissions" => [
+ $permission->objectId => [
+ "permissionId" => $permission->permissionId,
+ "view" => $permission->view,
+ "edit" => $permission->edit,
+ "delete" => $permission->delete
+ ]
+ ]
+ ];
+ } else {
+ $newPermissions[$permission->groupId]["permissions"][] = [
+ "permissionId" => $permission->permissionId,
+ "view" => $permission->view,
+ "edit" => $permission->edit,
+ "delete" => $permission->delete
+ ];
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->setData($newPermissions);
+ $this->getState()->recordsTotal = $this->permissionFactory->countLast();
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Permissions to users for the provided entity
+ * @param Request $request
+ * @param Response $response
+ * @param $entity
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function permissionsForm(Request $request, Response $response, $entity, $id)
+ {
+ $requestEntity = $entity;
+
+ // Load our object
+ $object = $this->parsePermissionsEntity($entity, $id);
+
+ // Does this user have permission to edit the permissions?!
+ if (!$this->getUser()->checkPermissionsModifyable($object)) {
+ throw new AccessDeniedException(__('You do not have permission to edit these permissions.'));
+ }
+
+ $currentPermissions = [];
+ foreach ($this->permissionFactory->getAllByObjectId($this->getUser(), $object->permissionsClass(), $id, ['groupId'], ['setOnly' => 1]) as $permission) {
+ /* @var Permission $permission */
+ $currentPermissions[$permission->groupId] = [
+ 'view' => ($permission->view == null) ? 0 : $permission->view,
+ 'edit' => ($permission->edit == null) ? 0 : $permission->edit,
+ 'delete' => ($permission->delete == null) ? 0 : $permission->delete
+ ];
+ }
+
+ $data = [
+ 'entity' => $requestEntity,
+ 'objectId' => $id,
+ 'permissions' => $currentPermissions,
+ 'canSetOwner' => $object->canChangeOwner(),
+ 'object' => $object,
+ 'objectNameOverride' => $this->getSanitizer($request->getParams())->getString('nameOverride'),
+ ];
+
+ $this->getState()->template = 'user-form-permissions';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * Permissions to users for the provided entity
+ * @param Request $request
+ * @param Response $response
+ * @param $entity
+ * @param $ids
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function permissionsMultiForm(Request $request, Response $response, $entity)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check if the array of ids is passed
+ if($sanitizedParams->getString('ids') == '') {
+ throw new InvalidArgumentException(__('The array of ids is empty!'));
+ }
+
+ // Get array of ids
+ $ids = $sanitizedParams->getString('ids');
+
+ $data = [
+ 'entity' => $entity,
+ 'objectIds' => $ids,
+ ];
+
+ $this->getState()->template = 'user-form-multiple-permissions';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/user/permissions/{entity}/{objectId}",
+ * operationId="userPermissionsSet",
+ * tags={"user"},
+ * summary="Permission Set",
+ * description="Set Permissions to users/groups for the provided entity.",
+ * @SWG\Parameter(
+ * name="entity",
+ * in="path",
+ * description="The Entity",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="objectId",
+ * in="path",
+ * description="The ID of the Object to set permissions on",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="groupIds",
+ * in="formData",
+ * description="Array of permissions with groupId as the key",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="ownerId",
+ * in="formData",
+ * description="Change the owner of this item. Leave empty to keep the current owner",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param string $entity
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function permissions(Request $request, Response $response, $entity, $id)
+ {
+ // Load our object
+ $object = $this->parsePermissionsEntity($entity, $id);
+
+ // Does this user have permission to edit the permissions?!
+ if (!$this->getUser()->checkPermissionsModifyable($object)) {
+ throw new AccessDeniedException(__('This object is not shared with you with edit permission'));
+ }
+
+ if ($object->permissionsClass() === 'Xibo\Entity\Folder' && $object->getId() === 1) {
+ throw new InvalidArgumentException(__('You cannot share the root folder'), 'id');
+ }
+
+ if ($object->permissionsClass() === 'Xibo\Entity\Region' && $object->type === 'canvas') {
+ throw new InvalidArgumentException(
+ __('You cannot share the Canvas on a Layout, share the layout instead.'),
+ 'type',
+ );
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get all current permissions
+ $permissions = $this->permissionFactory->getAllByObjectId($this->getUser(), $object->permissionsClass(), $id);
+
+ // Get the provided permissions
+ $groupIds = $sanitizedParams->getArray('groupIds');
+
+ // Run the update
+ $this->updatePermissions($permissions, $groupIds);
+
+ // Should we update the owner?
+ if ($sanitizedParams->getInt('ownerId') != 0) {
+ $ownerId = $sanitizedParams->getInt('ownerId');
+
+ $this->getLog()->debug('Requesting update to a new Owner - id = ' . $ownerId);
+
+ if ($object->canChangeOwner()) {
+ $object->setOwner($ownerId);
+ $object->save([
+ 'notify' => false,
+ 'manageDynamicDisplayLinks' => false,
+ 'validate' => false,
+ 'recalculateHash' => false
+ ]);
+ } else {
+ throw new ConfigurationException(__('Cannot change owner on this Object'));
+ }
+
+ // Nasty handling for ownerId on the Layout
+ // ideally we'd remove that column and rely on the campaign ownerId in 1.9 onward
+ if ($object->permissionsClass() == 'Xibo\Entity\Campaign') {
+ $this->getLog()->debug('Changing owner on child Layout');
+
+ $this->getDispatcher()->dispatch(
+ new LayoutOwnerChangeEvent($object->getId(), $ownerId),
+ LayoutOwnerChangeEvent::$NAME,
+ );
+ }
+ }
+
+ if ($object->permissionsClass() === 'Xibo\Entity\Folder') {
+ /** @var $object \Xibo\Entity\Folder */
+ $object->managePermissions();
+ } else if ($object->permissionsClass() === 'Xibo\Entity\Campaign') {
+ // Update any Canvas Regions to have the same permissions.
+ $event = new LayoutSharingChangeEvent($object->getId());
+ $this->getDispatcher()->dispatch($event, LayoutSharingChangeEvent::$NAME);
+
+ foreach ($event->getCanvasRegionIds() as $canvasRegionId) {
+ $this->getLog()->debug('permissions: canvas region detected, cascading permissions');
+ $permissions = $this->permissionFactory->getAllByObjectId(
+ $this->getUser(),
+ 'Xibo\Entity\Region',
+ $canvasRegionId,
+ );
+ $this->updatePermissions($permissions, $groupIds);
+ }
+ } else if ($object->permissionsClass() === 'Xibo\Entity\Region') {
+ /** @var $object \Xibo\Entity\Region */
+ // The regions own playlist should always have the same permissions.
+ $permissions = $this->permissionFactory->getAllByObjectId(
+ $this->getUser(),
+ 'Xibo\Entity\Playlist',
+ $object->getPlaylist()->playlistId
+ );
+
+ $this->updatePermissions($permissions, $groupIds);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpCode' => 204,
+ 'message' => __('Share option Updated')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+
+ /**
+ * @SWG\Post(
+ * path="/user/permissions/{entity}/multiple",
+ * operationId="userPermissionsMultiSet",
+ * tags={"user"},
+ * summary="Multiple Permission Set",
+ * description="Set Permissions to users/groups for multiple provided entities.",
+ * @SWG\Parameter(
+ * name="entity",
+ * in="path",
+ * description="The Entity type",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="ids",
+ * in="formData",
+ * description="Array of object IDs",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="groupIds",
+ * in="formData",
+ * description="Array of permissions with groupId as the key",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="string")
+ * ),
+ * @SWG\Parameter(
+ * name="ownerId",
+ * in="formData",
+ * description="Change the owner of this item. Leave empty to keep the current owner",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param string $entity
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function permissionsMulti(Request $request, Response $response, $entity)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Get array of ids
+ $ids = ($sanitizedParams->getString('ids') != '') ? explode(',', $sanitizedParams->getString('ids')) : [];
+
+ // Check if the array of ids is passed
+ if (count($ids) == 0) {
+ throw new InvalidArgumentException(__('The array of ids is empty!'));
+ }
+
+ // Set permissions for all the object ids, one by one
+ foreach ($ids as $id) {
+ $this->permissions($request, $response, $entity, $id);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpCode' => 204,
+ 'message' => __('Share option Updated')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Parse the Permissions Entity
+ * @param string $entity
+ * @param int $objectId
+ * @throws InvalidArgumentException
+ */
+ private function parsePermissionsEntity($entity, $objectId)
+ {
+ if ($entity == '') {
+ throw new InvalidArgumentException(__('Sharing requested without an entity'));
+ }
+
+ if ($objectId == 0) {
+ throw new InvalidArgumentException(__('Sharing form requested without an object'));
+ }
+
+ /** @var ParsePermissionEntityEvent $event */
+ $event = $this->getDispatcher()->dispatch(
+ new ParsePermissionEntityEvent($entity, $objectId),
+ ParsePermissionEntityEvent::$NAME . lcfirst($entity)
+ );
+
+ return $event->getObject();
+ }
+
+ /**
+ * Updates a set of permissions from a set of groupIds
+ * @param Permission[] $permissions
+ * @param array $groupIds
+ */
+ private function updatePermissions($permissions, $groupIds)
+ {
+ $this->getLog()->debug(sprintf('Received Permissions Array to update: %s', var_export($groupIds, true)));
+
+ // List of groupIds with view, edit and del assignments
+ foreach ($permissions as $row) {
+ // Check and see what permissions we have been provided for this selection
+ // If all permissions are 0, then the record is deleted
+ if (is_array($groupIds)) {
+ if (array_key_exists($row->groupId, $groupIds)) {
+ if(array_key_exists('view', $groupIds[$row->groupId])) {
+ $row->view = $groupIds[$row->groupId]['view'];
+ }
+
+ if(array_key_exists('edit', $groupIds[$row->groupId])) {
+ $row->edit = $groupIds[$row->groupId]['edit'];
+ }
+
+ if(array_key_exists('delete', $groupIds[$row->groupId])) {
+ $row->delete = $groupIds[$row->groupId]['delete'];
+ }
+
+ $row->save();
+ }
+ }
+ }
+ }
+
+ /**
+ * User Applications
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function myApplications(Request $request, Response $response)
+ {
+ $this->getState()->template = 'user-applications-form';
+ $this->getState()->setData([
+ 'applications' => $this->applicationFactory->getAuthorisedByUserId($this->getUser()->userId),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Get(
+ * path="/user/pref",
+ * operationId="userPrefGet",
+ * tags={"user"},
+ * summary="Retrieve User Preferences",
+ * description="User preferences for non-state information, such as Layout designer zoom levels",
+ * @SWG\Parameter(
+ * name="preference",
+ * in="query",
+ * description="An optional preference",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful response",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserOption")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function pref(Request $request, Response $response)
+ {
+ $requestedPreference = $request->getQueryParam('preference');
+
+ if (!empty($requestedPreference)) {
+ try {
+ $option = $this->getUser()->getOption($requestedPreference);
+ } catch (NotFoundException $exception) {
+ $option = [];
+ }
+ $this->getState()->setData($option);
+ } else {
+ $this->getState()->setData($this->getUser()->getUserOptions());
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/user/pref",
+ * operationId="userPrefEdit",
+ * tags={"user"},
+ * summary="Save User Preferences",
+ * description="Save User preferences for non-state information, such as Layout designer zoom levels",
+ * @SWG\Parameter(
+ * name="preference",
+ * in="body",
+ * required=true,
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserOption")
+ * )
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function prefEdit(Request $request, Response $response)
+ {
+ $parsedRequest = $this->getSanitizer($request->getParsedBody());
+
+ // Update this user preference with the preference array
+ $i = 0;
+ foreach ($parsedRequest->getArray('preference') as $pref) {
+ $i++;
+
+ $sanitizedPref = $this->getSanitizer($pref);
+
+ $option = $sanitizedPref->getString('option');
+ $value = $sanitizedPref->getString('value');
+
+ $this->getUser()->setOptionValue($option, $value);
+ }
+
+ if ($i > 0) {
+ $this->getUser()->save();
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => ($i == 1) ? __('Updated Preference') : __('Updated Preferences')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function membershipForm(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ // Groups we are assigned to
+ $groupsAssigned = $this->userGroupFactory->getByUserId($user->userId);
+
+ $this->getState()->template = 'user-form-membership';
+ $this->getState()->setData([
+ 'user' => $user,
+ 'extra' => [
+ 'userGroupsAssigned' => $groupsAssigned
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function assignUserGroup(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ // Go through each ID to assign
+ foreach ($sanitizedParams->getIntArray('userGroupId', ['default' => []]) as $userGroupId) {
+ $userGroup = $this->userGroupFactory->getById($userGroupId);
+
+ if (!$this->getUser()->checkEditable($userGroup)) {
+ throw new AccessDeniedException(__('Access Denied to UserGroup'));
+ }
+
+ $userGroup->assignUser($user);
+ $userGroup->save(['validate' => false]);
+ }
+
+ // Have we been provided with unassign id's as well?
+ foreach ($sanitizedParams->getIntArray('unassignUserGroupId', ['default' => []]) as $userGroupId) {
+ $userGroup = $this->userGroupFactory->getById($userGroupId);
+
+ if (!$this->getUser()->checkEditable($userGroup)) {
+ throw new AccessDeniedException(__('Access Denied to UserGroup'));
+ }
+
+ $userGroup->unassignUser($user);
+ $userGroup->save(['validate' => false]);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('%s assigned to User Groups'), $user->userName),
+ 'id' => $user->userId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Update the User Welcome Tutorial to Seen
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function userWelcomeSetUnSeen(Request $request, Response $response)
+ {
+ $this->getUser()->newUserWizard = 0;
+ $this->getUser()->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('%s has started the welcome tutorial'), $this->getUser()->userName)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Update the User Welcome Tutorial to Seen
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function userWelcomeSetSeen(Request $request, Response $response)
+ {
+ $this->getUser()->newUserWizard = 1;
+ $this->getUser()->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => sprintf(__('%s has seen the welcome tutorial'), $this->getUser()->userName)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Preferences Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function preferencesForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'user-form-preferences';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/user/pref",
+ * operationId="userPrefEditFromForm",
+ * tags={"user"},
+ * summary="Save User Preferences",
+ * description="Save User preferences from the Preferences form.",
+ * @SWG\Parameter(
+ * name="navigationMenuPosition",
+ * in="formData",
+ * required=true,
+ * type="string"
+ * ),
+ * @SWG\Parameter(
+ * name="useLibraryDuration",
+ * in="formData",
+ * required=false,
+ * type="integer"
+ * ),
+ * @SWG\Parameter(
+ * name="showThumbnailColumn",
+ * in="formData",
+ * required=false,
+ * type="integer"
+ * ),
+ * @SWG\Parameter(
+ * name="rememberFolderTreeStateGlobally",
+ * in="formData",
+ * required=false,
+ * type="integer"
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function prefEditFromForm(Request $request, Response $response)
+ {
+ $parsedParams = $this->getSanitizer($request->getParams());
+
+ $this->getUser()->setOptionValue('navigationMenuPosition', $parsedParams->getString('navigationMenuPosition', ['defaultOnEmptyString' => true]));
+ $this->getUser()->setOptionValue('useLibraryDuration', $parsedParams->getCheckbox('useLibraryDuration'));
+ $this->getUser()->setOptionValue('showThumbnailColumn', $parsedParams->getCheckbox('showThumbnailColumn'));
+ $this->getUser()->setOptionValue('isAlwaysUseManualAddUserForm', $parsedParams->getCheckbox('isAlwaysUseManualAddUserForm'));
+ $this->getUser()->setOptionValue('rememberFolderTreeStateGlobally', $parsedParams->getCheckbox('rememberFolderTreeStateGlobally'));
+
+ // Clear auto submits?
+ if ($parsedParams->getCheckbox('autoSubmitClearAll', ['checkboxReturnInteger' => false])) {
+ $this->getUser()->removeOptionByPrefix('autoSubmit.');
+ }
+
+ $this->getUser()->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Updated Preferences')
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * User Onboarding Form
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function onboardingForm(Request $request, Response $response)
+ {
+ // Only group admins or super admins can create Users.
+ if (!$this->getUser()->isSuperAdmin() && !$this->getUser()->isGroupAdmin()) {
+ throw new AccessDeniedException(__('Only super and group admins can create users'));
+ }
+
+ $this->getState()->template = 'user-form-onboarding';
+ $this->getState()->setData([
+ 'groups' => $this->userGroupFactory->query(null, [
+ 'isShownForAddUser' => 1
+ ])
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set home folder form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function setHomeFolderForm(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+ $user->setChildAclDependencies($this->userGroupFactory);
+
+ if (!$this->getUser()->checkEditable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'user-form-home-folder';
+ $this->getState()->setData([
+ 'user' => $user
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Set home folder form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function setHomeFolder(Request $request, Response $response, $id)
+ {
+ $user = $this->userFactory->getById($id);
+ $user->setChildAclDependencies($this->userGroupFactory);
+
+ if (!$this->getUser()->checkEditable($user)) {
+ throw new AccessDeniedException();
+ }
+
+ if (!$this->getUser()->featureEnabled('folder.userHome')) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Build a user entity and save it
+ $user->setChildAclDependencies($this->userGroupFactory);
+ $user->load();
+ $user->homeFolderId = $sanitizedParams->getInt('homeFolderId');
+ $user->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $user->userName),
+ 'id' => $user->userId,
+ 'data' => $user
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/UserGroup.php b/lib/Controller/UserGroup.php
new file mode 100644
index 0000000..802c359
--- /dev/null
+++ b/lib/Controller/UserGroup.php
@@ -0,0 +1,1097 @@
+.
+ */
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\Permission;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class UserGroup
+ * @package Xibo\Controller
+ */
+class UserGroup extends Base
+{
+ /**
+ * @var UserGroupFactory
+ */
+ private $userGroupFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * Set common dependencies.
+ * @param UserGroupFactory $userGroupFactory
+ * @param PermissionFactory $permissionFactory
+ * @param UserFactory $userFactory
+ */
+ public function __construct($userGroupFactory, $permissionFactory, $userFactory)
+ {
+ $this->userGroupFactory = $userGroupFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->userFactory = $userFactory;
+ }
+
+ /**
+ * Display page logic
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function displayPage(Request $request, Response $response)
+ {
+ $this->getState()->template = 'usergroup-page';
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Group Grid
+ * @SWG\Get(
+ * path="/group",
+ * operationId="userGroupSearch",
+ * tags={"usergroup"},
+ * summary="UserGroup Search",
+ * description="Search User Groups",
+ * @SWG\Parameter(
+ * name="userGroupId",
+ * in="query",
+ * description="Filter by UserGroup Id",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="userGroup",
+ * in="query",
+ * description="Filter by UserGroup Name",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserGroup")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function grid(Request $request, Response $response)
+ {
+ $sanitizedQueryParams = $this->getSanitizer($request->getQueryParams());
+ $filterBy = [
+ 'groupId' => $sanitizedQueryParams->getInt('userGroupId'),
+ 'group' => $sanitizedQueryParams->getString('userGroup'),
+ 'useRegexForName' => $sanitizedQueryParams->getCheckbox('useRegexForName'),
+ 'logicalOperatorName' => $sanitizedQueryParams->getString('logicalOperatorName'),
+ 'isUserSpecific' => 0,
+ 'userIdMember' => $sanitizedQueryParams->getInt('userIdMember'),
+ ];
+
+ $groups = $this->userGroupFactory->query(
+ $this->gridRenderSort($sanitizedQueryParams),
+ $this->gridRenderFilter($filterBy, $sanitizedQueryParams)
+ );
+
+ foreach ($groups as $group) {
+ /* @var \Xibo\Entity\UserGroup $group */
+
+ $group->setUnmatchedProperty(
+ 'libraryQuotaFormatted',
+ ByteFormatter::format($group->libraryQuota * 1024)
+ );
+
+ if ($this->isApi($request)) {
+ continue;
+ }
+
+ // we only want to show certain buttons, depending on the user logged in
+ if ($this->getUser()->featureEnabled('usergroup.modify')
+ && $this->getUser()->checkEditable($group)
+ ) {
+ // Edit
+ $group->buttons[] = array(
+ 'id' => 'usergroup_button_edit',
+ 'url' => $this->urlFor($request, 'group.edit.form', ['id' => $group->groupId]),
+ 'text' => __('Edit')
+ );
+
+ if ($this->getUser()->isSuperAdmin()) {
+ // Delete
+ $group->buttons[] = array(
+ 'id' => 'usergroup_button_delete',
+ 'url' => $this->urlFor($request, 'group.delete.form', ['id' => $group->groupId]),
+ 'text' => __('Delete')
+ );
+
+ $group->buttons[] = ['divider' => true];
+
+ // Copy
+ $group->buttons[] = array(
+ 'id' => 'usergroup_button_copy',
+ 'url' => $this->urlFor($request, 'group.copy.form', ['id' => $group->groupId]),
+ 'text' => __('Copy')
+ );
+
+ $group->buttons[] = ['divider' => true];
+ }
+
+ // Members
+ $group->buttons[] = array(
+ 'id' => 'usergroup_button_members',
+ 'url' => $this->urlFor($request, 'group.members.form', ['id' => $group->groupId]),
+ 'text' => __('Members')
+ );
+
+ if ($this->getUser()->isSuperAdmin()) {
+ // Features
+ $group->buttons[] = ['divider' => true];
+ $group->buttons[] = array(
+ 'id' => 'usergroup_button_page_security',
+ 'url' => $this->urlFor($request, 'group.acl.form', ['id' => $group->groupId]),
+ 'text' => __('Features'),
+ 'title' => __('Turn Features on/off for this User')
+ );
+ }
+ }
+ }
+
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = $this->userGroupFactory->countLast();
+ $this->getState()->setData($groups);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Form to Add a Group
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ function addForm(Request $request, Response $response)
+ {
+ $this->getState()->template = 'usergroup-form-add';
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Form to Edit a Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function editForm(Request $request, Response $response, $id)
+ {
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'usergroup-form-edit';
+ $this->getState()->setData([
+ 'group' => $group,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the Delete Group Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function deleteForm(Request $request, Response $response, $id)
+ {
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'usergroup-form-delete';
+ $this->getState()->setData([
+ 'group' => $group,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Add User Group
+ * @SWG\Post(
+ * path="/group",
+ * operationId="userGroupAdd",
+ * tags={"usergroup"},
+ * summary="UserGroup Add",
+ * description="Add User Group",
+ * @SWG\Parameter(
+ * name="group",
+ * in="formData",
+ * description="Name of the User Group",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="decription",
+ * in="formData",
+ * description="A description of the User Group",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="libraryQuota",
+ * in="formData",
+ * description="The quota that should be applied (KiB). Provide 0 for no quota",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isSystemNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive system notifications?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDisplayNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Display notifications
+ * for Displays they have permissions to see",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDataSetNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive DataSet notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isLayoutNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Layout notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isLibraryNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Library notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isReportNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Report notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isScheduleNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Schedule notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isCustomNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Custom notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isShownForAddUser",
+ * in="formData",
+ * description="Flag (0, 1), should this Group be shown in the Add User onboarding form.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="defaultHomePageId",
+ * in="formData",
+ * description="If this user has been created via the onboarding form, this should be the default home page",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserGroup")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function add(Request $request, Response $response)
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check permissions
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ // Build a user entity and save it
+ $group = $this->userGroupFactory->createEmpty();
+ $group->group = $sanitizedParams->getString('group');
+ $group->description = $sanitizedParams->getString('description');
+ $group->libraryQuota = $sanitizedParams->getInt('libraryQuota');
+
+ if ($this->getUser()->userTypeId == 1) {
+ $group->isSystemNotification = $sanitizedParams->getCheckbox('isSystemNotification');
+ $group->isDisplayNotification = $sanitizedParams->getCheckbox('isDisplayNotification');
+ $group->isDataSetNotification = $sanitizedParams->getCheckbox('isDataSetNotification');
+ $group->isCustomNotification = $sanitizedParams->getCheckbox('isCustomNotification');
+ $group->isLayoutNotification = $sanitizedParams->getCheckbox('isLayoutNotification');
+ $group->isLibraryNotification = $sanitizedParams->getCheckbox('isLibraryNotification');
+ $group->isReportNotification = $sanitizedParams->getCheckbox('isReportNotification');
+ $group->isScheduleNotification = $sanitizedParams->getCheckbox('isScheduleNotification');
+ $group->isShownForAddUser = $sanitizedParams->getCheckbox('isShownForAddUser');
+ $group->defaultHomepageId = $sanitizedParams->getString('defaultHomepageId');
+ }
+
+ // Save
+ $group->save();
+
+ // icondashboard does not need features, otherwise assign the feature matching selected homepage.
+ if ($group->defaultHomepageId !== 'icondashboard.view' && !empty($group->defaultHomepageId)) {
+ $group->features[] = $this->userGroupFactory->getHomepageByName($group->defaultHomepageId)->feature;
+ $group->saveFeatures();
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Added %s'), $group->group),
+ 'id' => $group->groupId,
+ 'data' => $group
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit User Group
+ * @SWG\Put(
+ * path="/group/{userGroupId}",
+ * operationId="userGroupEdit",
+ * tags={"usergroup"},
+ * summary="UserGroup Edit",
+ * description="Edit User Group",
+ * @SWG\Parameter(
+ * name="userGroupId",
+ * in="path",
+ * description="ID of the User Group",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="group",
+ * in="formData",
+ * description="Name of the User Group",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="decription",
+ * in="formData",
+ * description="A description of the User Group",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="libraryQuota",
+ * in="formData",
+ * description="The quota that should be applied (KiB). Provide 0 for no quota",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isSystemNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive system notifications?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDisplayNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Display notifications
+ * for Displays they have permissions to see",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isDataSetNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive DataSet notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isLayoutNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Layout notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isLibraryNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Library notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isReportNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Report notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isScheduleNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Schedule notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isCustomNotification",
+ * in="formData",
+ * description="Flag (0, 1), should members of this Group receive Custom notification emails?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isShownForAddUser",
+ * in="formData",
+ * description="Flag (0, 1), should this Group be shown in the Add User onboarding form.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="defaultHomePageId",
+ * in="formData",
+ * description="If this user has been created via the onboarding form, this should be the default home page",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserGroup")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function edit(Request $request, Response $response, $id)
+ {
+ // Check permissions
+ if (!$this->getUser()->isSuperAdmin() && !$this->getUser()->isGroupAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ $group->load();
+
+ $group->group = $sanitizedParams->getString('group');
+ $group->description = $sanitizedParams->getString('description');
+ $group->libraryQuota = $sanitizedParams->getInt('libraryQuota');
+
+ if ($this->getUser()->userTypeId == 1) {
+ $group->isSystemNotification = $sanitizedParams->getCheckbox('isSystemNotification');
+ $group->isDisplayNotification = $sanitizedParams->getCheckbox('isDisplayNotification');
+ $group->isDataSetNotification = $sanitizedParams->getCheckbox('isDataSetNotification');
+ $group->isCustomNotification = $sanitizedParams->getCheckbox('isCustomNotification');
+ $group->isLayoutNotification = $sanitizedParams->getCheckbox('isLayoutNotification');
+ $group->isLibraryNotification = $sanitizedParams->getCheckbox('isLibraryNotification');
+ $group->isReportNotification = $sanitizedParams->getCheckbox('isReportNotification');
+ $group->isScheduleNotification = $sanitizedParams->getCheckbox('isScheduleNotification');
+ $group->isShownForAddUser = $sanitizedParams->getCheckbox('isShownForAddUser');
+ $group->defaultHomepageId = $sanitizedParams->getString('defaultHomepageId');
+
+ // if we have homepage set assign matching feature if it does not already exist
+ if (!empty($group->defaultHomepageId)
+ && !in_array(
+ $this->userGroupFactory->getHomepageByName($group->defaultHomepageId)->feature,
+ $group->features
+ )
+ && $group->defaultHomepageId !== 'icondashboard.view'
+ ) {
+ $group->features[] = $this->userGroupFactory->getHomepageByName($group->defaultHomepageId)->feature;
+ $group->saveFeatures();
+ }
+ }
+
+ // Save
+ $group->save();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $group->group),
+ 'id' => $group->groupId,
+ 'data' => $group
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete User Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @SWG\Delete(
+ * path="/group/{userGroupId}",
+ * operationId="userGroupDelete",
+ * tags={"usergroup"},
+ * summary="Delete User Group",
+ * description="Delete User Group",
+ * @SWG\Parameter(
+ * name="userGroupId",
+ * in="path",
+ * description="The user Group ID to Delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ */
+ public function delete(Request $request, Response $response, $id)
+ {
+ // Check permissions
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ $group->delete();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Deleted %s'), $group->group),
+ 'id' => $group->groupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * ACL Form for the provided GroupId
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @param int|null $userId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function aclForm(Request $request, Response $response, $id, $userId = null)
+ {
+ // Check permissions to this function
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ // Get permissions for the group provided
+ $group = $this->userGroupFactory->getById($id);
+ $inheritedFeatures = ($userId !== null)
+ ? $this->userGroupFactory->getGroupFeaturesForUser($this->userFactory->getById($userId), false)
+ : [];
+
+ $data = [
+ 'groupId' => $id,
+ 'group' => $group->group,
+ 'isUserSpecific' => $group->isUserSpecific,
+ 'features' => $group->features,
+ 'inheritedFeatures' => $inheritedFeatures,
+ 'userGroupFactory' => $this->userGroupFactory,
+ ];
+
+ $this->getState()->template = 'usergroup-form-acl';
+ $this->getState()->setData($data);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * ACL update
+ * @param Request $request
+ * @param Response $response
+ * @param int $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function acl(Request $request, Response $response, $id)
+ {
+ // Check permissions to this function
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ // Load the Group we are working on
+ // Get the object
+ if ($id == 0) {
+ throw new InvalidArgumentException(__('Features form requested without a User Group'), 'id');
+ }
+
+ $features = $request->getParam('features', null);
+
+ if (!is_array($features)) {
+ $features = [];
+ }
+
+ $group = $this->userGroupFactory->getById($id);
+ $group->features = $features;
+ $group->saveFeatures();
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Features updated for %s'), $group->group),
+ 'id' => $group->groupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Shows the Members of a Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function membersForm(Request $request, Response $response, $id)
+ {
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ // Users in group
+ $usersAssigned = $this->userFactory->query(null, ['groupIds' => [$id]]);
+
+ $this->getState()->template = 'usergroup-form-members';
+ $this->getState()->setData([
+ 'group' => $group,
+ 'extra' => [
+ 'usersAssigned' => $usersAssigned
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Assign User to the User Group
+ * @SWG\Post(
+ * path="/group/members/assign/{userGroupId}",
+ * operationId="userGroupAssign",
+ * tags={"usergroup"},
+ * summary="Assign User to User Group",
+ * description="Assign User to User Group",
+ * @SWG\Parameter(
+ * name="userGroupId",
+ * in="path",
+ * description="ID of the user group to which assign the user",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="userId",
+ * in="formData",
+ * description="Array of userIDs to assign",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserGroup")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function assignUser(Request $request, Response $response, $id)
+ {
+ $this->getLog()->debug(sprintf('Assign User for groupId %d', $id));
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ // Load existing memberships.
+ $group->load();
+ $changesMade = false;
+
+ // Parse updated assignments from form.
+ $users = $sanitizedParams->getIntArray('userId', ['default' => []]);
+
+ foreach ($users as $userId) {
+ $this->getLog()->debug(sprintf('Assign User %d for groupId %d', $userId, $id));
+
+ $user = $this->userFactory->getById($userId);
+
+ if (!$this->getUser()->checkViewable($user)) {
+ throw new AccessDeniedException(__('Access Denied to User'));
+ }
+
+ $group->assignUser($user);
+ $changesMade = true;
+ }
+
+ // Check to see if unassign has been provided.
+ $users = $sanitizedParams->getIntArray('unassignUserId', ['default' => []]);
+
+ foreach ($users as $userId) {
+ $this->getLog()->debug(sprintf('Unassign User %d for groupId %d', $userId, $id));
+
+ $user = $this->userFactory->getById($userId);
+
+ if (!$this->getUser()->checkViewable($user)) {
+ throw new AccessDeniedException(__('Access Denied to User'));
+ }
+
+ $group->unassignUser($user);
+ $changesMade = true;
+ }
+
+ if ($changesMade) {
+ $group->save(['validate' => false]);
+ $message = sprintf(__('Membership set for %s'), $group->group);
+ } else {
+ $message = sprintf(__('No changes for %s'), $group->group);
+ }
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => $message,
+ 'id' => $group->groupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Unassign User to the User Group
+ * @SWG\Post(
+ * path="/group/members/unassign/{userGroupId}",
+ * operationId="userGroupUnassign",
+ * tags={"usergroup"},
+ * summary="Unassign User from User Group",
+ * description="Unassign User from User Group",
+ * @SWG\Parameter(
+ * name="userGroupId",
+ * in="path",
+ * description="ID of the user group from which to unassign the user",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="userId",
+ * in="formData",
+ * description="Array of userIDs to unassign",
+ * type="array",
+ * required=true,
+ * @SWG\Items(type="integer")
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/UserGroup")
+ * )
+ * )
+ * )
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function unassignUser(Request $request, Response $response, $id)
+ {
+ $group = $this->userGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ $users = $sanitizedParams->getIntArray('userId');
+
+ foreach ($users as $userId) {
+ $group->unassignUser($this->userFactory->getById($userId));
+ }
+
+ $group->save(['validate' => false]);
+
+ // Return
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Membership set for %s'), $group->group),
+ 'id' => $group->groupId
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Form to Copy Group
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function copyForm(Request $request, Response $response, $id)
+ {
+ $group = $this->userGroupFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getState()->template = 'usergroup-form-copy';
+ $this->getState()->setData([
+ 'group' => $group
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Post(
+ * path="/group/{userGroupId}/copy",
+ * operationId="userGroupCopy",
+ * tags={"usergroup"},
+ * summary="Copy User Group",
+ * description="Copy an user group, optionally copying the group members",
+ * @SWG\Parameter(
+ * name="userGroupId",
+ * in="path",
+ * description="The User Group ID to Copy",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="group",
+ * in="formData",
+ * description="The Group Name",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="copyMembers",
+ * in="formData",
+ * description="Flag indicating whether to copy group members",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="copyFeatures",
+ * in="formData",
+ * description="Flag indicating whether to copy group features",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/UserGroup"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function copy(Request $request, Response $response, $id)
+ {
+ $group = $this->userGroupFactory->getById($id);
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Check we have permission to view this group
+ if (!$this->getUser()->checkEditable($group)) {
+ throw new AccessDeniedException();
+ }
+
+ // Clone the group
+ $group->load([
+ 'loadUsers' => ($sanitizedParams->getCheckbox('copyMembers') == 1)
+ ]);
+ $newGroup = clone $group;
+ $newGroup->group = $sanitizedParams->getString('group');
+ $newGroup->save();
+
+ // Save features?
+ if ($sanitizedParams->getCheckbox('copyFeatures')) {
+ $newGroup->saveFeatures();
+ } else {
+ $newGroup->features = [];
+ }
+
+ // Copy permissions
+ foreach ($this->permissionFactory->getByGroupId('Page', $group->groupId) as $permission) {
+ /* @var Permission $permission */
+ $permission = clone $permission;
+ $permission->groupId = $newGroup->groupId;
+ $permission->save();
+ }
+
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Copied %s'), $group->group),
+ 'id' => $newGroup->groupId,
+ 'data' => $newGroup
+ ]);
+
+ return $this->render($request, $response);
+ }
+}
diff --git a/lib/Controller/Widget.php b/lib/Controller/Widget.php
new file mode 100644
index 0000000..b2f9df0
--- /dev/null
+++ b/lib/Controller/Widget.php
@@ -0,0 +1,2033 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Carbon\Carbon;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Event\DataSetDataTypeRequestEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Event\WidgetAddEvent;
+use Xibo\Event\WidgetDataRequestEvent;
+use Xibo\Event\WidgetEditOptionRequestEvent;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\TransitionFactory;
+use Xibo\Factory\WidgetAudioFactory;
+use Xibo\Factory\WidgetDataFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Render\WidgetDownloader;
+
+/**
+ * Controller for managing Widgets on Playlists/Layouts
+ */
+class Widget extends Base
+{
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Factory\ModuleTemplateFactory */
+ private $moduleTemplateFactory;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var PermissionFactory */
+ private $permissionFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @var TransitionFactory */
+ private $transitionFactory;
+
+ /** @var RegionFactory */
+ private $regionFactory;
+
+ /** @var WidgetAudioFactory */
+ protected $widgetAudioFactory;
+
+ /**
+ * Set common dependencies.
+ * @param ModuleFactory $moduleFactory
+ * @param \Xibo\Factory\ModuleTemplateFactory $moduleTemplateFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param MediaFactory $mediaFactory
+ * @param PermissionFactory $permissionFactory
+ * @param WidgetFactory $widgetFactory
+ * @param TransitionFactory $transitionFactory
+ * @param RegionFactory $regionFactory
+ * @param WidgetAudioFactory $widgetAudioFactory
+ */
+ public function __construct(
+ $moduleFactory,
+ $moduleTemplateFactory,
+ $playlistFactory,
+ $mediaFactory,
+ $permissionFactory,
+ $widgetFactory,
+ $transitionFactory,
+ $regionFactory,
+ $widgetAudioFactory,
+ private readonly WidgetDataFactory $widgetDataFactory
+ ) {
+ $this->moduleFactory = $moduleFactory;
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->transitionFactory = $transitionFactory;
+ $this->regionFactory = $regionFactory;
+ $this->widgetAudioFactory = $widgetAudioFactory;
+ }
+
+ // phpcs:disable
+ /**
+ * Add Widget
+ *
+ * @SWG\Post(
+ * path="/playlist/widget/{type}/{playlistId}",
+ * operationId="addWidget",
+ * tags={"widget"},
+ * summary="Add a Widget to a Playlist",
+ * description="Add a new Widget to a Playlist",
+ * @SWG\Parameter(
+ * name="type",
+ * in="path",
+ * description="The type of the Widget e.g. text. Media based Widgets like Image are added via POST /playlist/library/assign/{playlistId} call.",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="playlistId",
+ * in="path",
+ * description="The Playlist ID",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="Optional integer to say which position this assignment should occupy in the list. If more than one media item is being added, this will be the position of the first one.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="templateId",
+ * in="formData",
+ * description="If the module type provided has a dataType then provide the templateId to use.",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param string $type
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function addWidget(Request $request, Response $response, $type, $id)
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ $playlist = $this->playlistFactory->getById($id);
+ if (!$this->getUser()->checkEditable($playlist)) {
+ throw new AccessDeniedException(__('This Playlist is not shared with you with edit permission'));
+ }
+
+ // Check we have a permission factory
+ if ($this->permissionFactory == null) {
+ throw new ConfigurationException(
+ __('Sorry there is an error with this request, cannot set inherited permissions')
+ );
+ }
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ // Load some information about this playlist
+ // loadWidgets = true to keep the ordering correct
+ $playlist->load([
+ 'playlistIncludeRegionAssignments' => false,
+ 'loadWidgets' => true,
+ 'loadTags' => false
+ ]);
+
+ // Make sure this module type is supported
+ $module = $this->moduleFactory->getByType($type);
+ if ($module->enabled == 0) {
+ throw new NotFoundException(__('No module enabled of that type.'));
+ }
+
+ // Make sure it isn't a file based widget (which must be assigned not created)
+ if ($module->regionSpecific != 1) {
+ throw new InvalidArgumentException(
+ __('Sorry but a file based Widget must be assigned not created'),
+ 'type'
+ );
+ }
+
+ // If we're adding a canvas widget, then make sure we don't already have one and that we're on a region
+ if ($module->type === 'global') {
+ if (!$playlist->isRegionPlaylist()) {
+ throw new InvalidArgumentException(__('Canvas Widgets can only be added to a Zone'), 'regionId');
+ }
+
+ foreach ($playlist->widgets as $widget) {
+ if ($widget->type === 'global') {
+ throw new InvalidArgumentException(__('Only one Canvas Widget allowed per Playlist'), 'type');
+ }
+ }
+ }
+
+ // Grab a widget, set the type and default duration
+ $widget = $this->widgetFactory->create(
+ $this->getUser()->userId,
+ $playlist->playlistId,
+ $module->type,
+ $module->defaultDuration,
+ $module->schemaVersion
+ );
+
+ // Default status setting
+ $widget->setOptionValue(
+ 'enableStat',
+ 'attrib',
+ $this->getConfig()->getSetting('WIDGET_STATS_ENABLED_DEFAULT')
+ );
+
+ // Get the template
+ if ($module->isTemplateExpected()) {
+ $templateId = $params->getString('templateId', [
+ 'throw' => function () {
+ throw new InvalidArgumentException(__('Please select a template'), 'templateId');
+ }
+ ]);
+ if ($templateId !== 'elements') {
+ // Check it.
+ $template = $this->moduleTemplateFactory->getByDataTypeAndId($module->dataType, $templateId);
+
+ // Make sure its static
+ if ($template->type !== 'static') {
+ throw new InvalidArgumentException(
+ __('Expecting a static template'),
+ 'templateId'
+ );
+ }
+ }
+
+ // Set it
+ $widget->setOptionValue('templateId', 'attrib', $templateId);
+ }
+
+ // Assign this module to this Playlist in the appropriate place (which could be null)
+ $displayOrder = $params->getInt('displayOrder');
+ $playlist->assignWidget($widget, $displayOrder);
+
+ if ($playlist->isRegionPlaylist() && count($playlist->widgets) >= 2) {
+ // Convert this region to a `playlist` (if it is a zone)
+ $widgetRegion = $this->regionFactory->getById($playlist->regionId);
+ if ($widgetRegion->type === 'zone') {
+ $widgetRegion->type = 'playlist';
+ $widgetRegion->save();
+ }
+ }
+
+ // Dispatch the Edit Event
+ $this->getDispatcher()->dispatch(new WidgetAddEvent($module, $widget));
+
+ // Save the widget
+ $widget->calculateDuration($module)->save();
+
+ // Module add will have saved our widget with the correct playlistId and displayOrder
+ // if we have provided a displayOrder, then we ought to also save the Playlist so that new orders for those
+ // existing Widgets are also saved.
+ if ($displayOrder !== null) {
+ $playlist->save();
+ }
+
+ // Successful
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => sprintf(__('Added %s'), $module->name),
+ 'id' => $widget->widgetId,
+ 'data' => $widget
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Get Widget
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getWidget(Request $request, Response $response, $id)
+ {
+ // Load the widget
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ // Make sure we have permission
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Get a module for this widget
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // Media file?
+ $media = null;
+ if ($module->regionSpecific == 0) {
+ try {
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ } catch (NotFoundException $e) {
+ $this->getLog()->error('Library Widget does not have a Media Id. widgetId: ' . $id);
+ }
+ }
+
+ // Decorate the module properties with our current widgets data
+ $module->decorateProperties($widget);
+
+ // Do we have a static template assigned to this widget?
+ // we don't worry about elements here, the layout editor manages those for us.
+ $template = null;
+ $templateId = $widget->getOptionValue('templateId', null);
+ if ($module->isTemplateExpected() && !empty($templateId) && $templateId !== 'elements') {
+ $template = $this->moduleTemplateFactory->getByDataTypeAndId($module->dataType, $templateId);
+
+ // Decorate the template with any properties saved in the widget
+ $template->decorateProperties($widget);
+ }
+
+ // Pass to view
+ $this->getState()->template = '';
+ $this->getState()->setData([
+ 'module' => $module,
+ 'template' => $template,
+ 'media' => $media,
+ 'mediaEditable' => $media === null ? false : $this->getUser()->checkEditable($media),
+ 'commonProperties' => [
+ 'name' => $widget->getOptionValue('name', null),
+ 'enableStat' => $widget->getOptionValue('enableStat', null),
+ 'isRepeatData' => $widget->getOptionValue('isRepeatData', null),
+ 'showFallback' => $widget->getOptionValue('showFallback', null),
+ 'duration' => $widget->duration,
+ 'useDuration' => $widget->useDuration
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ // phpcs:disable
+ /**
+ * Edit Widget
+ *
+ * @SWG\Put(
+ * path="/playlist/widget/{id}",
+ * operationId="editWidget",
+ * tags={"widget"},
+ * summary="Edit a Widget",
+ * description="Edit a widget providing new properties to set on it",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The ID of the Widget",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="useDuration",
+ * in="formData",
+ * description="Set a duration on this widget, if unchecked the default or library duration will be used.",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="duration",
+ * in="formData",
+ * description="Duration to use on this widget",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="name",
+ * in="formData",
+ * description="An optional name for this widget",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="enableStat",
+ * in="formData",
+ * description="Should stats be enabled? On|Off|Inherit ",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="isRepeatData",
+ * in="formData",
+ * description="If this widget requires data, should that data be repeated to meet the number of items requested?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="showFallback",
+ * in="formData",
+ * description="If this widget requires data and allows fallback data how should that data be returned? (never, always, empty, error)",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="properties",
+ * in="formData",
+ * description="Add an additional parameter for each of the properties required this module and its template. Use the moduleProperties and moduleTemplateProperties calls to get a list of properties needed",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function editWidget(Request $request, Response $response, $id)
+ {
+ $params = $this->getSanitizer($request->getParams());
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // Handle common parameters.
+ $widget->useDuration = $params->getCheckbox('useDuration');
+
+ // If we enabled useDuration, then use the provided duration
+ $widget->duration = ($widget->useDuration == 1)
+ ? $params->getInt('duration', ['default' => $module->defaultDuration])
+ : $module->defaultDuration;
+
+ $widget->setOptionValue('name', 'attrib', $params->getString('name'));
+ $widget->setOptionValue('enableStat', 'attrib', $params->getString('enableStat'));
+
+ // Handle special common properties for widgets with data
+ if ($module->isDataProviderExpected()) {
+ $widget->setOptionValue('isRepeatData', 'attrib', $params->getCheckbox('isRepeatData'));
+
+ if ($module->fallbackData === 1) {
+ $widget->setOptionValue('showFallback', 'attrib', $params->getString('showFallback'));
+ }
+ }
+
+ // Validate common parameters if we don't have a validator present.
+ $widgetValidators = $module->getWidgetValidators();
+ if (count($widgetValidators) <= 0 && $widget->duration <= 0) {
+ throw new InvalidArgumentException(__('Duration needs to be a positive value'), 'duration');
+ }
+
+ // Set maximum duration - we do this regardless of the validator.
+ if ($widget->duration > 526000) {
+ throw new InvalidArgumentException(__('Duration must be lower than 526000'), 'duration');
+ }
+
+ // Save the template if provided
+ $templateId = $params->getString('templateId');
+ $template = null;
+ if (!empty($templateId) && $templateId !== 'elements') {
+ // We're allowed to change between static templates, but not between elements and static templates.
+ // We can't change away from elements
+ if ($widget->getOptionValue('templateId', null) === 'elements') {
+ throw new InvalidArgumentException(
+ __('This widget uses elements and can not be changed to a static template'),
+ 'templateId'
+ );
+ }
+
+ // We must be a static
+ $template = $this->moduleTemplateFactory->getByDataTypeAndId($module->dataType, $templateId);
+
+ // Make sure its static
+ if ($template->type !== 'static') {
+ throw new InvalidArgumentException(
+ __('You can only change to another template of the same type'),
+ 'templateId'
+ );
+ }
+
+ // Set it
+ $widget->setOptionValue('templateId', 'attrib', $templateId);
+ } else if ($templateId === 'elements') {
+ // If it was empty to start with and now its elements, we should set it.
+ $widget->setOptionValue('templateId', 'attrib', $templateId);
+ }
+
+ // If we did not set the template in this save, then pull it out so that we can save its properties
+ // don't do this for elements.
+ $existingTemplateId = $widget->getOptionValue('templateId', null);
+ if ($template === null && $existingTemplateId !== null && $existingTemplateId !== 'elements') {
+ $template = $this->moduleTemplateFactory->getByDataTypeAndId($module->dataType, $existingTemplateId);
+ }
+
+ // We're expecting all of our properties to be supplied for editing.
+ foreach ($module->properties as $property) {
+ if ($property->type === 'message') {
+ continue;
+ }
+ $property->setValueByType($params);
+ }
+
+ // Once they are set, validate them.
+ $module->validateProperties('save', ['duration' => $widget->duration]);
+
+ // Assert these properties on the widget.
+ $widget->applyProperties($module->properties);
+
+ // Assert the template properties
+ if ($template !== null) {
+ foreach ($template->properties as $property) {
+ if ($property->type === 'message') {
+ continue;
+ }
+ $property->setValueByType($params);
+ }
+
+ $template->validateProperties('save', ['duration' => $widget->duration]);
+
+ $widget->applyProperties($template->properties);
+ }
+
+ // Check to see if the media we've assigned exists.
+ foreach ($widget->mediaIds as $mediaId) {
+ try {
+ $this->mediaFactory->getById($mediaId);
+ } catch (NotFoundException) {
+ throw new InvalidArgumentException(sprintf(
+ __('Your library reference %d does not exist.'),
+ $mediaId
+ ), 'libraryRef');
+ }
+ }
+
+ // TODO: remove media which is no longer referenced, without removing primary media and/or media in elements
+
+ // If we have a validator interface, then use it now
+ foreach ($widgetValidators as $widgetValidator) {
+ $widgetValidator->validate($module, $widget, 'save');
+ }
+
+ // We've reached the end, so save
+ $widget->calculateDuration($module)->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => sprintf(__('Edited %s'), $module->name),
+ 'id' => $widget->widgetId,
+ 'data' => $widget
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete a Widget
+ * @SWG\Delete(
+ * path="/playlist/widget/{widgetId}",
+ * operationId="WidgetDelete",
+ * tags={"widget"},
+ * summary="Delete a Widget",
+ * description="Deleted a specified widget",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="The widget ID to delete",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * )
+ *)
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function deleteWidget(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ if (!$this->getUser()->checkDeleteable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with delete permission'));
+ }
+
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ // Delete clears these, so cache them.
+ $widgetMedia = $widget->mediaIds;
+
+ // Call Widget Delete
+ $widget->delete();
+
+ // Delete Media?
+ if ($sanitizedParams->getCheckbox('deleteMedia') == 1) {
+ foreach ($widgetMedia as $mediaId) {
+ $media = $this->mediaFactory->getById($mediaId);
+
+ // Check we have permissions to delete
+ if (!$this->getUser()->checkDeleteable($media)) {
+ throw new AccessDeniedException();
+ }
+
+ $this->getDispatcher()->dispatch(new MediaDeleteEvent($media), MediaDeleteEvent::$NAME);
+ $media->delete();
+ }
+ }
+
+ // Module name for the message
+ try {
+ $module = $this->moduleFactory->getByType($widget->type);
+ $message = sprintf(__('Deleted %s'), $module->name);
+ } catch (NotFoundException $notFoundException) {
+ $message = __('Deleted Widget');
+ }
+
+ // Successful
+ $this->getState()->hydrate(['message' => $message]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Widget Transition Form
+ * @param Request $request
+ * @param Response $response
+ * @param string $type
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function editWidgetTransitionForm(Request $request, Response $response, $type, $id)
+ {
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Pass to view
+ $this->getState()->template = 'module-form-transition';
+ $this->getState()->setData([
+ 'type' => $type,
+ 'widget' => $widget,
+ 'module' => $this->moduleFactory->getByType($widget->type),
+ 'transitions' => [
+ 'in' => $this->transitionFactory->getEnabledByType('in'),
+ 'out' => $this->transitionFactory->getEnabledByType('out'),
+ 'compassPoints' => array(
+ array('id' => 'N', 'name' => __('North')),
+ array('id' => 'NE', 'name' => __('North East')),
+ array('id' => 'E', 'name' => __('East')),
+ array('id' => 'SE', 'name' => __('South East')),
+ array('id' => 'S', 'name' => __('South')),
+ array('id' => 'SW', 'name' => __('South West')),
+ array('id' => 'W', 'name' => __('West')),
+ array('id' => 'NW', 'name' => __('North West'))
+ )
+ ],
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit Widget transition
+ * @SWG\Put(
+ * path="/playlist/widget/transition/{type}/{widgetId}",
+ * operationId="WidgetEditTransition",
+ * tags={"widget"},
+ * summary="Adds In/Out transition",
+ * description="Adds In/Out transition to a specified widget",
+ * @SWG\Parameter(
+ * name="type",
+ * in="path",
+ * description="Transition type, available options: in, out",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="The widget ID to add the transition to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="transitionType",
+ * in="formData",
+ * description="Type of a transition, available Options: fly, fadeIn, fadeOut",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="transitionDuration",
+ * in="formData",
+ * description="Duration of this transition in milliseconds",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="transitionDirection",
+ * in="formData",
+ * description="The direction for this transition, only appropriate for transitions that move, such as fly. Available options: N, NE, E, SE, S, SW, W, NW",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Widget"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new widget",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $type
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function editWidgetTransition(Request $request, Response $response, $type, $id)
+ {
+ $widget = $this->widgetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ $widget->load();
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ switch ($type) {
+ case 'in':
+ $widget->setOptionValue('transIn', 'attrib', $sanitizedParams->getString('transitionType'));
+ $widget->setOptionValue('transInDuration', 'attrib', $sanitizedParams->getInt('transitionDuration'));
+ $widget->setOptionValue(
+ 'transInDirection',
+ 'attrib',
+ $sanitizedParams->getString('transitionDirection')
+ );
+
+ break;
+
+ case 'out':
+ $widget->setOptionValue('transOut', 'attrib', $sanitizedParams->getString('transitionType'));
+ $widget->setOptionValue('transOutDuration', 'attrib', $sanitizedParams->getInt('transitionDuration'));
+ $widget->setOptionValue(
+ 'transOutDirection',
+ 'attrib',
+ $sanitizedParams->getString('transitionDirection')
+ );
+
+ break;
+
+ default:
+ throw new InvalidArgumentException(__('Unknown transition type'), 'type');
+ }
+
+ $widget->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => __('Edited Transition'),
+ 'id' => $widget->widgetId,
+ 'data' => $widget
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Widget Audio Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function widgetAudioForm(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Are we allowed to do this?
+ if ($widget->type === 'subplaylist') {
+ throw new InvalidArgumentException(
+ __('Audio cannot be attached to a Sub-Playlist Widget. Please attach it to the Widgets inside the Playlist'),
+ 'type'
+ );
+ }
+
+ $audioAvailable = true;
+ if ($widget->countAudio() > 0) {
+ $audio = $this->mediaFactory->getById($widget->getAudioIds()[0]);
+
+ $this->getLog()->debug('Found audio: ' . $audio->mediaId . ', isEdited = '
+ . $audio->isEdited . ', retired = ' . $audio->retired);
+
+ $audioAvailable = ($audio->isEdited == 0 && $audio->retired == 0);
+ }
+
+ // Pass to view
+ $this->getState()->template = 'module-form-audio';
+ $this->getState()->setData([
+ 'widget' => $widget,
+ 'module' => $this->moduleFactory->getByType($widget->type),
+ 'media' => $this->mediaFactory->getByMediaType('audio'),
+ 'isAudioAvailable' => $audioAvailable
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit an Audio Widget
+ * @SWG\Put(
+ * path="/playlist/widget/{widgetId}/audio",
+ * operationId="WidgetAssignedAudioEdit",
+ * tags={"widget"},
+ * summary="Parameters for edting/adding audio file to a specific widget",
+ * description="Parameters for edting/adding audio file to a specific widget",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="Id of a widget to which you want to add audio or edit existing audio",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="mediaId",
+ * in="formData",
+ * description="Id of a audio file in CMS library you wish to add to a widget",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="volume",
+ * in="formData",
+ * description="Volume percentage(0-100) for this audio to play at",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="loop",
+ * in="formData",
+ * description="Flag (0, 1) Should the audio loop if it finishes before the widget has finished?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Widget"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new widget",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function widgetAudio(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ // Are we allowed to do this?
+ if ($widget->type === 'subplaylist') {
+ throw new InvalidArgumentException(
+ __('Audio cannot be attached to a Sub-Playlist Widget. Please attach it to the Widgets inside the Playlist'),
+ 'type'
+ );
+ }
+
+ $widget->load();
+
+ // Pull in the parameters we are expecting from the form.
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $mediaId = $sanitizedParams->getInt('mediaId');
+ $volume = $sanitizedParams->getInt('volume', ['default' => 100]);
+ $loop = $sanitizedParams->getCheckbox('loop');
+
+ // Remove existing audio records.
+ foreach ($widget->audio as $audio) {
+ $widget->unassignAudio($audio);
+ }
+
+ if ($mediaId != 0) {
+ $widgetAudio = $this->widgetAudioFactory->createEmpty();
+ $widgetAudio->mediaId = $mediaId;
+ $widgetAudio->volume = $volume;
+ $widgetAudio->loop = $loop;
+
+ $widget->assignAudio($widgetAudio);
+ }
+
+ $widget->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => __('Edited Audio'),
+ 'id' => $widget->widgetId,
+ 'data' => $widget
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Delete an Assigned Audio Widget
+ * @SWG\Delete(
+ * path="/playlist/widget/{widgetId}/audio",
+ * operationId="WidgetAudioDelete",
+ * tags={"widget"},
+ * summary="Delete assigned audio widget",
+ * description="Delete assigned audio widget from specified widget ID",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="Id of a widget from which you want to delete the audio from",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * )
+ *)
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function widgetAudioDelete(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->getById($id);
+
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ $widget->load();
+
+ foreach ($widget->audio as $audio) {
+ $widget->unassignAudio($audio);
+ }
+
+ $widget->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => __('Removed Audio'),
+ 'id' => $widget->widgetId,
+ 'data' => $widget
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Get Data
+ * @param Request $request
+ * @param Response $response
+ * @param $regionId
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getData(Request $request, Response $response, $regionId, $id)
+ {
+ $region = $this->regionFactory->getById($regionId);
+ if (!$this->getUser()->checkViewable($region)) {
+ throw new AccessDeniedException(__('This Region is not shared with you'));
+ }
+
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+ if (!$this->getUser()->checkViewable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you'));
+ }
+
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // This is always a preview
+ // We only return data if a data provider is expected.
+ if (!$module->isDataProviderExpected()) {
+ return $response->withJson([]);
+ }
+
+ // Populate the widget with its properties.
+ $widget->load();
+ $module->decorateProperties($widget, true);
+
+ $dataProvider = $module->createDataProvider($widget);
+ $dataProvider->setMediaFactory($this->mediaFactory);
+ $dataProvider->setDisplayProperties(
+ $this->getConfig()->getSetting('DEFAULT_LAT'),
+ $this->getConfig()->getSetting('DEFAULT_LONG')
+ );
+ $dataProvider->setIsPreview(true);
+
+ $widgetInterface = $module->getWidgetProviderOrNull();
+ $widgetDataProviderCache = $this->moduleFactory->createWidgetDataProviderCache();
+ $cacheKey = $this->moduleFactory->determineCacheKey(
+ $module,
+ $widget,
+ 0,
+ $dataProvider,
+ $widgetInterface
+ );
+
+ $this->getLog()->debug('getData: cacheKey is ' . $cacheKey);
+
+ // Get the data modified date
+ $dataModifiedDt = null;
+ if ($widgetInterface !== null) {
+ $dataModifiedDt = $widgetInterface->getDataModifiedDt($dataProvider);
+
+ if ($dataModifiedDt !== null) {
+ $this->getLog()->debug('getData: data modifiedDt is ' . $dataModifiedDt->toAtomString());
+ }
+ }
+
+ // Will we use fallback data if available?
+ $showFallback = $widget->getOptionValue('showFallback', 'never');
+ if ($showFallback !== 'never') {
+ // What data type are we dealing with?
+ try {
+ $dataTypeFields = [];
+ foreach ($this->moduleFactory->getDataTypeById($module->dataType)->fields as $field) {
+ $dataTypeFields[$field->id] = $field->type;
+ }
+
+ // Potentially we will, so get the modifiedDt of this fallback data.
+ $fallbackModifiedDt = $this->widgetDataFactory->getModifiedDtForWidget($widget->widgetId);
+
+ if ($fallbackModifiedDt !== null) {
+ $this->getLog()->debug('getData: fallback modifiedDt is ' . $fallbackModifiedDt->toAtomString());
+
+ $dataModifiedDt = max($dataModifiedDt, $fallbackModifiedDt);
+ }
+ } catch (NotFoundException) {
+ $this->getLog()->info('getData: widget will fallback set where the module does not support it');
+ $dataTypeFields = null;
+ }
+ } else {
+ $dataTypeFields = null;
+ }
+
+ // Use the cache if we can.
+ if (!$widgetDataProviderCache->decorateWithCache($dataProvider, $cacheKey, $dataModifiedDt)
+ || $widgetDataProviderCache->isCacheMissOrOld()
+ ) {
+ $this->getLog()->debug('getData: Pulling fresh data');
+
+ $dataProvider->clearData();
+ $dataProvider->clearMeta();
+ $dataProvider->addOrUpdateMeta('showFallback', $showFallback);
+
+ try {
+ if ($widgetInterface !== null) {
+ $widgetInterface->fetchData($dataProvider);
+ } else {
+ $dataProvider->setIsUseEvent();
+ }
+
+ if ($dataProvider->isUseEvent()) {
+ $this->getDispatcher()->dispatch(
+ new WidgetDataRequestEvent($dataProvider),
+ WidgetDataRequestEvent::$NAME
+ );
+ }
+
+ // Before caching images, check to see if the data provider is handled
+ $isFallback = false;
+ if ($showFallback !== 'never'
+ && $dataTypeFields !== null
+ && (
+ count($dataProvider->getErrors()) > 0
+ || count($dataProvider->getData()) <= 0
+ || $showFallback === 'always'
+ )
+ ) {
+ // Error or no data.
+ $this->getLog()->debug('getData: eligible for fallback data');
+
+ // Pull in the fallback data
+ foreach ($this->widgetDataFactory->getByWidgetId($dataProvider->getWidgetId()) as $item) {
+ // Handle any special data types in the fallback data
+ foreach ($item->data as $itemId => $itemData) {
+ if (!empty($itemData)
+ && array_key_exists($itemId, $dataTypeFields)
+ && $dataTypeFields[$itemId] === 'image'
+ ) {
+ $item->data[$itemId] = $dataProvider->addLibraryFile($itemData);
+ }
+ }
+
+ $dataProvider->addItem($item->data);
+
+ // Indicate we've been handled by fallback data
+ $isFallback = true;
+ }
+
+ if ($isFallback) {
+ $dataProvider->addOrUpdateMeta('includesFallback', true);
+ }
+ }
+
+ // Remove fallback data from the cache if no-longer needed
+ if (!$isFallback) {
+ $dataProvider->addOrUpdateMeta('includesFallback', false);
+ }
+
+ // Do we have images?
+ $media = $dataProvider->getImages();
+ if (count($media) > 0) {
+ // Process the downloads.
+ $this->mediaFactory->processDownloads(function ($media) use ($widget) {
+ // Success
+ // We don't need to do anything else, references to mediaId will be built when we decorate
+ // the HTML.
+ // Nothing is linked to a display when in preview mode.
+ $this->getLog()->debug('getData: Successfully downloaded ' . $media->mediaId);
+ });
+ }
+
+ // Save to cache
+ if ($dataProvider->isHandled() || $isFallback) {
+ $widgetDataProviderCache->saveToCache($dataProvider);
+ } else {
+ // Unhandled data provider.
+ $this->getLog()->debug('getData: unhandled data provider and no fallback data');
+
+ $message = null;
+ foreach ($dataProvider->getErrors() as $error) {
+ $message .= $error . PHP_EOL;
+ }
+ throw new ConfigurationException($message ?? __('No data providers configured'));
+ }
+ } finally {
+ $widgetDataProviderCache->finaliseCache();
+ }
+ } else {
+ $this->getLog()->debug('getData: Returning cache');
+ }
+
+ // Add permissions needed to see linked media
+ $media = $widgetDataProviderCache->getCachedMediaIds();
+ $this->getLog()->debug('getData: linking ' . count($media) . ' images');
+
+ foreach ($media as $mediaId) {
+ // We link these module images to the user.
+ foreach ($this->permissionFactory->getAllByObjectId(
+ $this->getUser(),
+ 'Xibo\Entity\Media',
+ $mediaId,
+ ) as $permission) {
+ $permission->view = 1;
+ $permission->save();
+ }
+ }
+
+ // Decorate for output.
+ $data = $widgetDataProviderCache->decorateForPreview(
+ $dataProvider->getData(),
+ function (string $route, array $data, array $params = []) use ($request) {
+ return $this->urlFor($request, $route, $data, $params);
+ }
+ );
+
+ return $response->withJson([
+ 'data' => $data,
+ 'meta' => $dataProvider->getMeta(),
+ ]);
+ }
+
+ /**
+ * Get Resource
+ * @param Request $request
+ * @param Response $response
+ * @param $regionId
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getResource(Request $request, Response $response, $regionId, $id)
+ {
+ $this->setNoOutput();
+
+ $region = $this->regionFactory->getById($regionId);
+ if (!$this->getUser()->checkViewable($region)) {
+ throw new AccessDeniedException(__('This Region is not shared with you'));
+ }
+
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+ if (!$this->getUser()->checkViewable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you'));
+ }
+
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // 3 options
+ // ---------
+ // download a file
+ // render a canvas
+ // render a widget in a region
+
+ // Download a file
+ // ---------------
+ // anything that calls this and does not produce some HTML should output its associated
+ // file.
+ if ($module->regionSpecific == 0 && $module->renderAs != 'html') {
+ // Pull out the media
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+
+ // Create a downloader to deal with this.
+ $downloader = new WidgetDownloader(
+ $this->getConfig()->getSetting('LIBRARY_LOCATION'),
+ $this->getConfig()->getSetting('SENDFILE_MODE'),
+ $this->getConfig()->getSetting('DEFAULT_RESIZE_LIMIT', 6000)
+ );
+ $downloader->useLogger($this->getLog()->getLoggerInterface());
+ return $this->render($request, $downloader->download($media, $request, $response));
+ }
+
+ if ($region->type === 'canvas') {
+ // Render a canvas
+ // ---------------
+ // A canvas plays all widgets in the region at once.
+ // none of them will be anything other than elements
+ $widgets = $region->getPlaylist()->widgets;
+ } else {
+ // Render a widget in a region
+ // ---------------------------
+ // We have a widget
+ $widgets = [$widget];
+ }
+
+ // Templates
+ $templates = $this->widgetFactory->getTemplatesForWidgets($module, $widgets);
+
+ // Create a renderer to deal with this.
+ try {
+ $renderer = $this->moduleFactory->createWidgetHtmlRenderer();
+ $resource = $renderer->renderOrCache(
+ $region,
+ $widgets,
+ $templates
+ );
+
+ if (!empty($resource)) {
+ $resource = $renderer->decorateForPreview(
+ $region,
+ $resource,
+ function (string $route, array $data, array $params = []) use ($request) {
+ return $this->urlFor($request, $route, $data, $params);
+ },
+ $request,
+ );
+
+ $response->getBody()->write($resource);
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Failed to render widget, e: ' . $e->getMessage());
+ throw new ConfigurationException(__('Problem rendering widget'));
+ }
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Widget Expiry Form
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function widgetExpiryForm(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Parse out the dates
+ $fromDt = $widget->fromDt === \Xibo\Entity\Widget::$DATE_MIN
+ ? ''
+ : Carbon::createFromTimestamp($widget->fromDt)->format(DateFormatHelper::getSystemFormat());
+
+ $toDt = $widget->toDt === \Xibo\Entity\Widget::$DATE_MAX
+ ? ''
+ : Carbon::createFromTimestamp($widget->toDt)->format(DateFormatHelper::getSystemFormat());
+
+ // Pass to view
+ $this->getState()->template = 'module-form-expiry';
+ $this->getState()->setData([
+ 'module' => $this->moduleFactory->getByType($widget->type),
+ 'fromDt' => $fromDt,
+ 'toDt' => $toDt,
+ 'deleteOnExpiry' => $widget->getOptionValue('deleteOnExpiry', 0)
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Edit an Expiry Widget
+ * @SWG\Put(
+ * path="/playlist/widget/{widgetId}/expiry",
+ * operationId="WidgetAssignedExpiryEdit",
+ * tags={"widget"},
+ * summary="Set Widget From/To Dates",
+ * description="Control when this Widget is active on this Playlist",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="Id of a widget to which you want to add audio or edit existing audio",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="fromDt",
+ * in="formData",
+ * description="The From Date in Y-m-d H::i:s format",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="toDt",
+ * in="formData",
+ * description="The To Date in Y-m-d H::i:s format",
+ * type="string",
+ * required=false
+ * ),
+ * @SWG\Parameter(
+ * name="deleteOnExpiry",
+ * in="formData",
+ * description="Delete this Widget when it expires?",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation",
+ * @SWG\Schema(ref="#/definitions/Widget"),
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new widget",
+ * type="string"
+ * )
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function widgetExpiry(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ if ($playlist->isDynamic === 1) {
+ throw new InvalidArgumentException(__('This Playlist is dynamically managed so cannot accept manual assignments.'), 'isDynamic');
+ }
+
+ $widget->load();
+
+ // Pull in the parameters we are expecting from the form.
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+
+ if ($fromDt !== null) {
+ $widget->fromDt = $fromDt->format('U');
+ } else {
+ $widget->fromDt = \Xibo\Entity\Widget::$DATE_MIN;
+ }
+
+ if ($toDt !== null) {
+ $widget->toDt = $toDt->format('U');
+ } else {
+ $widget->toDt = \Xibo\Entity\Widget::$DATE_MAX;
+ }
+
+ // Delete on expiry?
+ $widget->setOptionValue('deleteOnExpiry', 'attrib', ($sanitizedParams->getCheckbox('deleteOnExpiry') ? 1 : 0));
+
+ // Save
+ $widget->save([
+ 'saveWidgetOptions' => true,
+ 'saveWidgetAudio' => false,
+ 'saveWidgetMedia' => false,
+ 'notify' => true,
+ 'notifyPlaylists' => true,
+ 'notifyDisplays' => false,
+ 'audit' => true
+ ]);
+
+ if ($this->isApi($request)) {
+ $widget->createdDt = Carbon::createFromTimestamp($widget->createdDt)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $widget->modifiedDt = Carbon::createFromTimestamp($widget->modifiedDt)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $widget->fromDt = Carbon::createFromTimestamp($widget->fromDt)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $widget->toDt = Carbon::createFromTimestamp($widget->toDt)
+ ->format(DateFormatHelper::getSystemFormat());
+ }
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => __('Edited Expiry'),
+ 'id' => $widget->widgetId,
+ 'data' => $widget
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/playlist/widget/{widgetId}/region",
+ * operationId="WidgetAssignedRegionSet",
+ * tags={"widget"},
+ * summary="Set Widget Region",
+ * description="Set the Region this Widget is intended for - only suitable for Drawer Widgets",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="Id of the Widget to set region on",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="targetRegionId",
+ * in="formData",
+ * description="The target regionId",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws AccessDeniedException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function widgetSetRegion(Request $request, Response $response, $id)
+ {
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isRegionPlaylist() || !$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Make sure we are on a Drawer Widget
+ $region = $this->regionFactory->getById($playlist->regionId);
+ if ($region->isDrawer !== 1) {
+ throw new InvalidArgumentException(
+ __('You can only set a target region on a Widget in the drawer.'),
+ 'widgetId'
+ );
+ }
+
+ // Store the target regionId
+ $widget->load();
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ $widget->setOptionValue('targetRegionId', 'attrib', $sanitizedParams->getInt('targetRegionId'));
+
+ // Save
+ $widget->save([
+ 'saveWidgetOptions' => true,
+ 'saveWidgetAudio' => false,
+ 'saveWidgetMedia' => false,
+ 'notify' => true,
+ 'notifyPlaylists' => true,
+ 'notifyDisplays' => false,
+ 'audit' => true
+ ]);
+
+ // Successful
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Target region set'),
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/playlist/widget/{widgetId}/elements",
+ * operationId="widgetSaveElements",
+ * tags={"widget"},
+ * summary="Save elements to a widget",
+ * description="Update a widget with elements associated with it",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="Id of the Widget to set region on",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="elements",
+ * in="body",
+ * description="JSON representing the elements assigned to this widget",
+ * @SWG\Schema(
+ * type="string"
+ * ),
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function saveElements(Request $request, Response $response, $id): Response
+ {
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Test to see if we are on a Region Specific Playlist or a standalone
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // If we are a region Playlist, we need to check whether the owning Layout is a draft or editable
+ if (!$playlist->isRegionPlaylist() || !$playlist->isEditable()) {
+ throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId');
+ }
+
+ // Store the target regionId
+ $widget->load();
+
+ // Get a list of elements that already exist and their mediaId's
+ $newMediaIds = [];
+ $existingMediaIds = [];
+ foreach (json_decode($widget->getOptionValue('elements', '[]'), true) as $widgetElement) {
+ foreach ($widgetElement['elements'] ?? [] as $element) {
+ if (!empty($element['mediaId'])) {
+ $existingMediaIds[] = intval($element['mediaId']);
+ }
+ }
+ }
+
+ $this->getLog()->debug('saveElements: there are ' . count($existingMediaIds) . ' existing mediaIds');
+
+ // Pull out elements directly from the request body
+ $elements = $request->getBody()->getContents();
+ $elementJson = json_decode($elements, true);
+ if ($elementJson === null) {
+ throw new InvalidArgumentException(__('Invalid element JSON'), 'body');
+ }
+
+ // Validate that we have elements remaining.
+ if (count($elementJson) <= 0) {
+ throw new InvalidArgumentException(
+ __('At least one element is required for this Widget. Please delete it if you no longer need it.'),
+ 'body',
+ );
+ }
+
+ // Parse the element JSON
+ $slots = [];
+ $uniqueSlots = 0;
+ $isMediaOnlyWidget = true;
+ $maxDuration = 1;
+
+ foreach ($elementJson as $widgetIndex => $widgetElement) {
+ foreach ($widgetElement['elements'] ?? [] as $elementIndex => $element) {
+ $this->getLog()->debug('saveElements: processing widget index ' . $widgetIndex
+ . ', element index ' . $elementIndex . ' with id ' . $element['id']);
+
+ $slotNo = 'slot_' . ($element['slot'] ?? 0);
+ if (!in_array($slotNo, $slots)) {
+ $slots[] = $slotNo;
+ $uniqueSlots++;
+ }
+
+ // Handle some of the common properties.
+ $elementParams = $this->getSanitizer([
+ 'elementName' => $element['elementName'],
+ ]);
+ $groupParams = $this->getSanitizer($element['groupProperties'] ?? []);
+
+ // Reassert safely
+ // TODO: we should parse out all of the fields available in the JSON provided.
+ $elementJson[$widgetIndex]['elements'][$elementIndex]['elementName']
+ = $elementParams->getString('elementName');
+ $elementGroupName = $groupParams->getString('elementGroupName');
+ if (!empty($elementGroupName)) {
+ $elementJson[$widgetIndex]['elements'][$elementIndex]['groupProperties']['elementGroupName']
+ = $elementGroupName;
+ }
+
+ // Handle elements with the mediaId property so that media is linked and unlinked correctly.
+ if (!empty($element['mediaId'])) {
+ $mediaId = intval($element['mediaId']);
+
+ if (!in_array($mediaId, $existingMediaIds)) {
+ // Make sure it exists, and we have permission to use it.
+ $media = $this->mediaFactory->getById($mediaId, false);
+ $maxDuration = $media->duration ?? 10;
+ }
+ $widget->assignMedia($mediaId);
+ $newMediaIds[] = $mediaId;
+ } else {
+ $isMediaOnlyWidget = false;
+ }
+
+ // Get this elements template
+ $elementTemplate = $this->moduleTemplateFactory->getByTypeAndId('element', $element['id']);
+
+ // Does this template extend another? if so combine their properties
+ if ($elementTemplate->extends !== null) {
+ $extendedTemplate = $this->moduleTemplateFactory->getByTypeAndId(
+ 'element',
+ $elementTemplate->extends->template,
+ );
+
+ $elementTemplate->properties = array_merge(
+ $elementTemplate->properties,
+ $extendedTemplate->properties
+ );
+ }
+
+ // Process element properties.
+ // Switch from {id:'',value:''} to key/value
+ $elementPropertyParams = [];
+ foreach (($element['properties'] ?? []) as $elementProperty) {
+ $elementPropertyParams[$elementProperty['id']] = $elementProperty['value'] ?? null;
+ }
+
+ $this->getLog()->debug('saveElements: parsed ' . count($elementPropertyParams)
+ . ' properties from request, there are ' . count($elementTemplate->properties)
+ . ' properties defined in the template');
+
+ // Load into a sanitizer
+ $elementProperties = $this->getSanitizer($elementPropertyParams);
+
+ // Process each property against its definition
+ foreach ($elementTemplate->properties as $property) {
+ if ($property->type === 'message') {
+ continue;
+ }
+
+ // Isolate properties per element
+ $property->setValueByType($elementProperties, null, true);
+
+ // Process properties from the mediaSelector component
+ if ($property->type === 'mediaSelector') {
+ if (!empty($property->value) && is_numeric($property->value)) {
+ $mediaId = intval($property->value);
+ $widget->assignMedia($mediaId);
+ $newMediaIds[] = $mediaId;
+ }
+ }
+ }
+
+ $elementTemplate->validateProperties('save');
+
+ // Convert these back into objects
+ // and reassert new properties.
+ $elementJson[$widgetIndex]['elements'][$elementIndex]['properties'] = [];
+ foreach ($elementTemplate->getPropertyValues(
+ false,
+ null,
+ false,
+ true
+ ) as $propertyKey => $propertyValue) {
+ $elementJson[$widgetIndex]['elements'][$elementIndex]['properties'][] = [
+ 'id' => $propertyKey,
+ 'value' => $propertyValue,
+ ];
+ }
+ }
+ }
+
+ if ($uniqueSlots > 0) {
+ $currentItemsPerPage = $widget->getOptionValue('itemsPerPage', null);
+
+ $widget->setOptionValue('itemsPerPage', 'attrib', $uniqueSlots);
+
+ // We should calculate the widget duration as it might have changed
+ if ($currentItemsPerPage != $uniqueSlots) {
+ $this->getLog()->debug('saveElements: updating unique slots to ' . $uniqueSlots
+ . ', currentItemsPerPage: ' . $currentItemsPerPage);
+
+ $module = $this->moduleFactory->getByType($widget->type);
+ $widget->calculateDuration($module);
+ }
+ }
+
+ // Save elements
+ $widget->setOptionValue('elements', 'raw', json_encode($elementJson));
+
+ // Unassign any mediaIds from elements which are no longer used.
+ foreach ($existingMediaIds as $existingMediaId) {
+ if (!in_array($existingMediaId, $newMediaIds)) {
+ $widget->unassignMedia($existingMediaId);
+ }
+ }
+
+ // Canvas-only layout without a custom duration
+ if ($widget->type == 'global' && $isMediaOnlyWidget && $widget->useDuration == 0) {
+ // Do we need to recalculate the duration?
+ if (count($newMediaIds) < count($existingMediaIds)) {
+ foreach ($newMediaIds as $newMediaId) {
+ $media = $this->mediaFactory->getById($newMediaId, false);
+ $maxDuration = max($media->duration, $maxDuration);
+ }
+ } else {
+ $maxDuration = max($widget->calculatedDuration, $maxDuration);
+ }
+
+ $widget->calculatedDuration = $maxDuration;
+ }
+
+ // Save, without auditing widget options.
+ $widget->save([
+ 'saveWidgetOptions' => true,
+ 'saveWidgetAudio' => false,
+ 'saveWidgetMedia' => true,
+ 'notifyDisplays' => false,
+ 'audit' => true,
+ 'auditWidgetOptions' => false,
+ 'auditMessage' => 'Elements Updated',
+ ]);
+
+ // Successful
+ $this->getState()->hydrate([
+ 'httpStatus' => 204,
+ 'message' => __('Saved elements'),
+ 'data' => $elementJson,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @param $id
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ControllerNotImplemented
+ */
+ public function additionalWidgetEditOptions(Request $request, Response $response, $id)
+ {
+ $params = $this->getSanitizer($request->getParams());
+
+ // Load the widget
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ // Sanitizer options
+ $sanitizerOptions = [
+ 'throw' => function () {
+ throw new InvalidArgumentException(__('Please supply a propertyId'), 'propertyId');
+ },
+ 'rules' => ['notEmpty' => []],
+ ];
+
+ // Which property is this for?
+ $propertyId = $params->getString('propertyId', $sanitizerOptions);
+ $propertyValue = $params->getString($propertyId);
+
+ // Dispatch an event to service this widget.
+ $event = new WidgetEditOptionRequestEvent($widget, $propertyId, $propertyValue);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+
+ // Return the options.
+ $options = $event->getOptions();
+ $this->getState()->template = 'grid';
+ $this->getState()->recordsTotal = count($options);
+ $this->getState()->setData($options);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * @SWG\Put(
+ * path="/playlist/widget/{widgetId}/dataType",
+ * operationId="widgetGetDataType",
+ * tags={"widget"},
+ * summary="Widget DataType",
+ * description="Get DataType for a Widget according to the widgets module definition",
+ * @SWG\Parameter(
+ * name="widgetId",
+ * in="path",
+ * description="Id of the Widget",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataType",
+ * in="body",
+ * description="A JSON representation of your dataType",
+ * @SWG\Schema(
+ * type="string"
+ * ),
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=200,
+ * description="successful operation"
+ * )
+ * )
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @param int $id the widgetId
+ * @return \Psr\Http\Message\ResponseInterface|Response
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getDataType(Request $request, Response $response, int $id): Response
+ {
+ if (empty($id)) {
+ throw new InvalidArgumentException(__('Please provide a widgetId'), 'id');
+ }
+
+ // Load the widget
+ $widget = $this->widgetFactory->loadByWidgetId($id);
+
+ // Does this widget have a data type?
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // Does this module have a data type?
+ // We have special handling for dataset because the data type returned is variable.
+ if ($module->dataType === 'dataset') {
+ // Raise an event to get the modifiedDt of this dataSet
+ $event = new DataSetDataTypeRequestEvent($widget->getOptionValue('dataSetId', 0));
+ $this->getDispatcher()->dispatch($event, DataSetDataTypeRequestEvent::$NAME);
+ return $response->withJson($event->getDataType());
+ } else if ($module->isDataProviderExpected()) {
+ return $response->withJson($this->moduleFactory->getDataTypeById($module->dataType));
+ } else {
+ throw new NotFoundException(__('Widget does not have a data type'));
+ }
+ }
+}
diff --git a/lib/Controller/WidgetData.php b/lib/Controller/WidgetData.php
new file mode 100644
index 0000000..b3c3727
--- /dev/null
+++ b/lib/Controller/WidgetData.php
@@ -0,0 +1,414 @@
+.
+ */
+
+namespace Xibo\Controller;
+
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\WidgetDataFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Controller for managing Widget Data
+ */
+class WidgetData extends Base
+{
+ public function __construct(
+ private readonly WidgetDataFactory $widgetDataFactory,
+ private readonly WidgetFactory $widgetFactory,
+ private readonly ModuleFactory $moduleFactory
+ ) {
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Get(
+ * path="/playlist/widget/data/{id}",
+ * operationId="getWidgetData",
+ * tags={"widget"},
+ * summary="Get data for Widget",
+ * description="Return all of the fallback data currently assigned to this Widget",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Widget ID that this data should be added to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="#/definitions/WidgetData")
+ * )
+ * )
+ * )
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function get(Request $request, Response $response, int $id): Response
+ {
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ return $response->withJson($this->widgetDataFactory->getByWidgetId($widget->widgetId));
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Post(
+ * path="/playlist/widget/data/{id}",
+ * operationId="addWidgetData",
+ * tags={"widget"},
+ * summary="Add a data to a Widget",
+ * description="Add fallback data to a data Widget",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Widget ID that this data should be added to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="data",
+ * in="path",
+ * description="A JSON formatted string containing a single data item for this widget's data type",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="Optional integer to say which position this data should appear if there is more than one data item",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=201,
+ * description="successful operation",
+ * @SWG\Header(
+ * header="Location",
+ * description="Location of the new record",
+ * type="string"
+ * )
+ * )
+ * )
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function add(Request $request, Response $response, int $id): Response
+ {
+ // Check that we have permission to edit this widget
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Get the other params.
+ $params = $this->getSanitizer($request->getParams());
+ $widgetData = $this->widgetDataFactory
+ ->create(
+ $widget->widgetId,
+ $this->parseAndValidate($widget, $params->getArray('data')),
+ $params->getInt('displayOrder', ['default' => 1]),
+ )
+ ->save();
+
+ // Update the widget modified dt
+ $widget->modifiedDt =
+
+ // Successful
+ $this->getState()->hydrate([
+ 'httpStatus' => 201,
+ 'message' => __('Added data for Widget'),
+ 'id' => $widgetData->id,
+ 'data' => $widgetData,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Put(
+ * path="/playlist/widget/data/{id}/{dataId}",
+ * operationId="editWidgetData",
+ * tags={"widget"},
+ * summary="Edit data on a Widget",
+ * description="Edit fallback data on a data Widget",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Widget ID that this data is attached to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataId",
+ * in="path",
+ * description="The ID of the data to be edited",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="data",
+ * in="path",
+ * description="A JSON formatted string containing a single data item for this widget's data type",
+ * type="string",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="displayOrder",
+ * in="formData",
+ * description="Optional integer to say which position this data should appear if there is more than one data item",
+ * type="integer",
+ * required=false
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function edit(Request $request, Response $response, int $id, int $dataId): Response
+ {
+ // Check that we have permission to edit this widget
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Make sure this dataId is for this widget
+ $widgetData = $this->widgetDataFactory->getById($dataId);
+
+ if ($id !== $widgetData->widgetId) {
+ throw new AccessDeniedException(__('This widget data does not belong to this widget'));
+ }
+
+ // Get params and process the edit
+ $params = $this->getSanitizer($request->getParams());
+ $widgetData->data = $this->parseAndValidate($widget, $params->getArray('data'));
+ $widgetData->displayOrder = $params->getInt('displayOrder', ['default' => 1]);
+ $widgetData->save();
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => __('Edited data for Widget'),
+ 'id' => $widgetData->id,
+ 'data' => $widgetData,
+ 'httpStatus' => 204,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Delete(
+ * path="/playlist/widget/data/{id}/{dataId}",
+ * operationId="deleteWidgetData",
+ * tags={"widget"},
+ * summary="Delete data on a Widget",
+ * description="Delete fallback data on a data Widget",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Widget ID that this data is attached to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataId",
+ * in="path",
+ * description="The ID of the data to be deleted",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function delete(Request $request, Response $response, int $id, int $dataId): Response
+ {
+ // Check that we have permission to edit this widget
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Make sure this dataId is for this widget
+ $widgetData = $this->widgetDataFactory->getById($dataId);
+
+ if ($id !== $widgetData->widgetId) {
+ throw new AccessDeniedException(__('This widget data does not belong to this widget'));
+ }
+
+ // Delete it.
+ $widgetData->delete();
+
+ // Successful
+ $this->getState()->hydrate(['message' => __('Deleted'), 'httpStatus' => 204]);
+
+ return $this->render($request, $response);
+ }
+
+ // phpcs:disable
+ /**
+ * @SWG\Definition(
+ * definition="WidgetDataOrder",
+ * @SWG\Property(
+ * property="dataId",
+ * type="integer",
+ * description="Data ID"
+ * ),
+ * @SWG\Property(
+ * property="displayOrder",
+ * type="integer",
+ * description="Desired display order"
+ * )
+ * )
+ *
+ * @SWG\Post(
+ * path="/playlist/widget/data/{id}/order",
+ * operationId="orderWidgetData",
+ * tags={"widget"},
+ * summary="Update the order of data on a Widget",
+ * description="Provide all data to be ordered on a widget",
+ * @SWG\Parameter(
+ * name="id",
+ * in="path",
+ * description="The Widget ID that this data is attached to",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="dataId",
+ * in="path",
+ * description="The ID of the data to be deleted",
+ * type="integer",
+ * required=true
+ * ),
+ * @SWG\Parameter(
+ * name="order",
+ * in="body",
+ * description="An array of any widget data records that should be re-ordered",
+ * @SWG\Schema(
+ * type="array",
+ * @SWG\Items(ref="WidgetDataOrder")
+ * ),
+ * required=true
+ * ),
+ * @SWG\Response(
+ * response=204,
+ * description="successful operation"
+ * )
+ * )
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ // phpcs:enable
+ public function setOrder(Request $request, Response $response, int $id): Response
+ {
+ // Check that we have permission to edit this widget
+ $widget = $this->widgetFactory->getById($id);
+ if (!$this->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException(__('This Widget is not shared with you with edit permission'));
+ }
+
+ // Expect an array of `id` in order.
+ $params = $this->getSanitizer($request->getParams());
+ foreach ($params->getArray('order', ['default' => []]) as $item) {
+ $itemParams = $this->getSanitizer($item);
+
+ // Make sure this dataId is for this widget
+ $widgetData = $this->widgetDataFactory->getById($itemParams->getInt('dataId'));
+ $widgetData->displayOrder = $itemParams->getInt('displayOrder');
+
+ if ($id !== $widgetData->widgetId) {
+ throw new AccessDeniedException(__('This widget data does not belong to this widget'));
+ }
+
+ // Save it
+ $widgetData->save();
+ }
+
+ // Successful
+ $this->getState()->hydrate([
+ 'message' => __('Updated the display order for data on Widget'),
+ 'id' => $widget->widgetId,
+ 'httpStatus' => 204,
+ ]);
+
+ return $this->render($request, $response);
+ }
+
+ /**
+ * Parse and validate the data provided in params.
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function parseAndValidate(\Xibo\Entity\Widget $widget, array $item): array
+ {
+ // Check that this module is a data widget
+ $module = $this->moduleFactory->getByType($widget->type);
+ if (!$module->isDataProviderExpected()) {
+ throw new InvalidArgumentException(__('This is not a data widget'));
+ }
+
+ if ($module->fallbackData !== 1) {
+ throw new InvalidArgumentException(__('Fallback data is not expected for this Widget'));
+ }
+
+ // Parse out the data string we've been given and make sure it's valid according to this widget's datatype
+ $data = [];
+ $params = $this->getSanitizer($item);
+
+ $dataType = $this->moduleFactory->getDataTypeById($module->dataType);
+ foreach ($dataType->fields as $field) {
+ if ($field->isRequired && !$params->hasParam($field->id)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Data is missing a field called %s',
+ $field->title
+ ));
+ }
+
+ $value = match ($field->type) {
+ 'number' => $params->getDouble($field->id),
+ default => $params->getString($field->id),
+ };
+ $data[$field->id] = $value;
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/Dependencies/Controllers.php b/lib/Dependencies/Controllers.php
new file mode 100644
index 0000000..6f0cde8
--- /dev/null
+++ b/lib/Dependencies/Controllers.php
@@ -0,0 +1,647 @@
+.
+ */
+
+namespace Xibo\Dependencies;
+
+use Psr\Container\ContainerInterface;
+
+/**
+ * Helper class to add controllers to DI
+ */
+class Controllers
+{
+ /**
+ * Register controllers with DI
+ */
+ public static function registerControllersWithDi()
+ {
+ return [
+ '\Xibo\Controller\Action' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Action(
+ $c->get('actionFactory'),
+ $c->get('layoutFactory'),
+ $c->get('regionFactory'),
+ $c->get('widgetFactory'),
+ $c->get('moduleFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Applications' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Applications(
+ $c->get('session'),
+ $c->get('applicationFactory'),
+ $c->get('applicationRedirectUriFactory'),
+ $c->get('applicationScopeFactory'),
+ $c->get('userFactory'),
+ $c->get('pool'),
+ $c->get('connectorFactory')
+ );
+
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\AuditLog' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\AuditLog(
+ $c->get('auditLogFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Campaign' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Campaign(
+ $c->get('campaignFactory'),
+ $c->get('layoutFactory'),
+ $c->get('tagFactory'),
+ $c->get('folderFactory'),
+ $c->get('displayGroupFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Connector' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Connector(
+ $c->get('connectorFactory'),
+ $c->get('widgetFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Clock' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Clock(
+ $c->get('session')
+ );
+
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Command' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Command(
+ $c->get('commandFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DataSet' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DataSet(
+ $c->get('dataSetFactory'),
+ $c->get('dataSetColumnFactory'),
+ $c->get('userFactory'),
+ $c->get('folderFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DataSetColumn' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DataSetColumn(
+ $c->get('dataSetFactory'),
+ $c->get('dataSetColumnFactory'),
+ $c->get('dataSetColumnTypeFactory'),
+ $c->get('dataTypeFactory'),
+ $c->get('pool')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DataSetData' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DataSetData(
+ $c->get('dataSetFactory'),
+ $c->get('mediaFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DataSetRss' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DataSetRss(
+ $c->get('dataSetRssFactory'),
+ $c->get('dataSetFactory'),
+ $c->get('dataSetColumnFactory'),
+ $c->get('pool'),
+ $c->get('store')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DayPart' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DayPart(
+ $c->get('dayPartFactory'),
+ $c->get('scheduleFactory'),
+ $c->get('displayNotifyService')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Developer' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Developer(
+ $c->get('moduleFactory'),
+ $c->get('moduleTemplateFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Display' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Display(
+ $c->get('store'),
+ $c->get('pool'),
+ $c->get('playerActionService'),
+ $c->get('displayFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('displayTypeFactory'),
+ $c->get('layoutFactory'),
+ $c->get('displayProfileFactory'),
+ $c->get('displayEventFactory'),
+ $c->get('requiredFileFactory'),
+ $c->get('tagFactory'),
+ $c->get('notificationFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('playerVersionFactory'),
+ $c->get('dayPartFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DisplayGroup' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DisplayGroup(
+ $c->get('playerActionService'),
+ $c->get('displayFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('layoutFactory'),
+ $c->get('moduleFactory'),
+ $c->get('mediaFactory'),
+ $c->get('commandFactory'),
+ $c->get('tagFactory'),
+ $c->get('campaignFactory'),
+ $c->get('folderFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\DisplayProfile' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\DisplayProfile(
+ $c->get('pool'),
+ $c->get('displayProfileFactory'),
+ $c->get('commandFactory'),
+ $c->get('playerVersionFactory'),
+ $c->get('dayPartFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Fault' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Fault(
+ $c->get('store'),
+ $c->get('logFactory'),
+ $c->get('displayFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Folder' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Folder(
+ $c->get('folderFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Font' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Font(
+ $c->get('fontFactory')
+ );
+ $controller->useMediaService($c->get('mediaService'));
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\IconDashboard' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\IconDashboard();
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Layout' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Layout(
+ $c->get('session'),
+ $c->get('userFactory'),
+ $c->get('resolutionFactory'),
+ $c->get('layoutFactory'),
+ $c->get('moduleFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('tagFactory'),
+ $c->get('mediaFactory'),
+ $c->get('dataSetFactory'),
+ $c->get('campaignFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('pool'),
+ $c->get('mediaService'),
+ $c->get('widgetFactory'),
+ $c->get('widgetDataFactory'),
+ $c->get('playlistFactory'),
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Library' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Library(
+ $c->get('userFactory'),
+ $c->get('moduleFactory'),
+ $c->get('tagFactory'),
+ $c->get('mediaFactory'),
+ $c->get('widgetFactory'),
+ $c->get('permissionFactory'),
+ $c->get('layoutFactory'),
+ $c->get('playlistFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('displayFactory'),
+ $c->get('scheduleFactory'),
+ $c->get('folderFactory')
+ );
+ $controller->useMediaService($c->get('mediaService'));
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Logging' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Logging(
+ $c->get('store'),
+ $c->get('logFactory'),
+ $c->get('userFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Login' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Login(
+ $c->get('session'),
+ $c->get('userFactory'),
+ $c->get('pool')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ if ($c->has('flash')) {
+ $controller->setFlash($c->get('flash'));
+ }
+ return $controller;
+ },
+ '\Xibo\Controller\Maintenance' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Maintenance(
+ $c->get('store'),
+ $c->get('mediaFactory'),
+ $c->get('mediaService')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\MediaManager' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\MediaManager(
+ $c->get('store'),
+ $c->get('moduleFactory'),
+ $c->get('mediaFactory'),
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\MenuBoard' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\MenuBoard(
+ $c->get('menuBoardFactory'),
+ $c->get('folderFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\MenuBoardCategory' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\MenuBoardCategory(
+ $c->get('menuBoardFactory'),
+ $c->get('menuBoardCategoryFactory'),
+ $c->get('mediaFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\MenuBoardProduct' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\MenuBoardProduct(
+ $c->get('menuBoardFactory'),
+ $c->get('menuBoardCategoryFactory'),
+ $c->get('menuBoardProductOptionFactory'),
+ $c->get('mediaFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\PlaylistDashboard' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\PlaylistDashboard(
+ $c->get('playlistFactory'),
+ $c->get('moduleFactory'),
+ $c->get('widgetFactory'),
+ $c->get('mediaFactory'),
+ $c
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Module' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Module(
+ $c->get('moduleFactory'),
+ $c->get('moduleTemplateFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Notification' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Notification(
+ $c->get('notificationFactory'),
+ $c->get('userNotificationFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('displayNotifyService')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\PlayerFault' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\PlayerFault(
+ $c->get('playerFaultFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\PlayerSoftware' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\PlayerSoftware(
+ $c->get('pool'),
+ $c->get('playerVersionFactory'),
+ $c->get('displayProfileFactory'),
+ $c->get('displayFactory')
+ );
+ $controller->useMediaService($c->get('mediaService'));
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Playlist' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Playlist(
+ $c->get('playlistFactory'),
+ $c->get('mediaFactory'),
+ $c->get('widgetFactory'),
+ $c->get('moduleFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('userFactory'),
+ $c->get('tagFactory'),
+ $c->get('layoutFactory'),
+ $c->get('displayFactory'),
+ $c->get('scheduleFactory'),
+ $c->get('folderFactory'),
+ $c->get('regionFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Preview' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Preview(
+ $c->get('layoutFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Pwa' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Pwa(
+ $c->get('displayFactory'),
+ $c,
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Region' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Region(
+ $c->get('regionFactory'),
+ $c->get('widgetFactory'),
+ $c->get('transitionFactory'),
+ $c->get('moduleFactory'),
+ $c->get('layoutFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Report' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Report(
+ $c->get('reportService')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\SavedReport' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\SavedReport(
+ $c->get('reportService'),
+ $c->get('reportScheduleFactory'),
+ $c->get('savedReportFactory'),
+ $c->get('mediaFactory'),
+ $c->get('userFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\ScheduleReport' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\ScheduleReport(
+ $c->get('reportService'),
+ $c->get('reportScheduleFactory'),
+ $c->get('savedReportFactory'),
+ $c->get('mediaFactory'),
+ $c->get('userFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\SyncGroup' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\SyncGroup(
+ $c->get('syncGroupFactory'),
+ $c->get('folderFactory')
+ );
+
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Resolution' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Resolution(
+ $c->get('resolutionFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Schedule' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Schedule(
+ $c->get('session'),
+ $c->get('scheduleFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('campaignFactory'),
+ $c->get('commandFactory'),
+ $c->get('displayFactory'),
+ $c->get('layoutFactory'),
+ $c->get('dayPartFactory'),
+ $c->get('scheduleReminderFactory'),
+ $c->get('scheduleExclusionFactory'),
+ $c->get('syncGroupFactory'),
+ $c->get('scheduleCriteriaFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\CypressTest' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\CypressTest(
+ $c->get('store'),
+ $c->get('session'),
+ $c->get('scheduleFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('campaignFactory'),
+ $c->get('displayFactory'),
+ $c->get('layoutFactory'),
+ $c->get('dayPartFactory'),
+ $c->get('folderFactory'),
+ $c->get('commandFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Sessions' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Sessions(
+ $c->get('store'),
+ $c->get('sessionFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Settings' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Settings(
+ $c->get('layoutFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('transitionFactory'),
+ $c->get('userFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Stats' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Stats(
+ $c->get('store'),
+ $c->get('timeSeriesStore'),
+ $c->get('reportService'),
+ $c->get('displayFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\StatusDashboard' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\StatusDashboard(
+ $c->get('store'),
+ $c->get('pool'),
+ $c->get('userFactory'),
+ $c->get('displayFactory'),
+ $c->get('displayGroupFactory'),
+ $c->get('mediaFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Task' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Task(
+ $c->get('store'),
+ $c->get('timeSeriesStore'),
+ $c->get('pool'),
+ $c->get('taskFactory'),
+ $c
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Tag' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Tag(
+ $c->get('displayGroupFactory'),
+ $c->get('layoutFactory'),
+ $c->get('tagFactory'),
+ $c->get('userFactory'),
+ $c->get('displayFactory'),
+ $c->get('mediaFactory'),
+ $c->get('scheduleFactory'),
+ $c->get('campaignFactory'),
+ $c->get('playlistFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Template' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Template(
+ $c->get('layoutFactory'),
+ $c->get('tagFactory'),
+ $c->get('resolutionFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Transition' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Transition(
+ $c->get('transitionFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\User' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\User(
+ $c->get('userFactory'),
+ $c->get('userTypeFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('permissionFactory'),
+ $c->get('applicationFactory'),
+ $c->get('sessionFactory'),
+ $c->get('mediaService')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\UserGroup' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\UserGroup(
+ $c->get('userGroupFactory'),
+ $c->get('permissionFactory'),
+ $c->get('userFactory')
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\Widget' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\Widget(
+ $c->get('moduleFactory'),
+ $c->get('moduleTemplateFactory'),
+ $c->get('playlistFactory'),
+ $c->get('mediaFactory'),
+ $c->get('permissionFactory'),
+ $c->get('widgetFactory'),
+ $c->get('transitionFactory'),
+ $c->get('regionFactory'),
+ $c->get('widgetAudioFactory'),
+ $c->get('widgetDataFactory'),
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ '\Xibo\Controller\WidgetData' => function (ContainerInterface $c) {
+ $controller = new \Xibo\Controller\WidgetData(
+ $c->get('widgetDataFactory'),
+ $c->get('widgetFactory'),
+ $c->get('moduleFactory'),
+ );
+ $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService'));
+ return $controller;
+ },
+ ];
+ }
+}
diff --git a/lib/Dependencies/Factories.php b/lib/Dependencies/Factories.php
new file mode 100644
index 0000000..bb8f001
--- /dev/null
+++ b/lib/Dependencies/Factories.php
@@ -0,0 +1,536 @@
+.
+ */
+
+namespace Xibo\Dependencies;
+
+use Psr\Container\ContainerInterface;
+
+/**
+ * Helper class to add factories to DI.
+ */
+class Factories
+{
+ /**
+ * Register Factories with DI
+ */
+ public static function registerFactoriesWithDi()
+ {
+ return [
+ 'actionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ActionFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'apiRequestsFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ApplicationRequestsFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'applicationFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ApplicationFactory(
+ $c->get('user'),
+ $c->get('applicationRedirectUriFactory'),
+ $c->get('applicationScopeFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'applicationRedirectUriFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ApplicationRedirectUriFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'applicationScopeFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ApplicationScopeFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'auditLogFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\AuditLogFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'bandwidthFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\BandwidthFactory(
+ $c->get('pool'),
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'campaignFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\CampaignFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('permissionFactory'),
+ $c->get('scheduleFactory'),
+ $c->get('displayNotifyService')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'commandFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\CommandFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'connectorFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ConnectorFactory(
+ $c->get('pool'),
+ $c->get('configService'),
+ $c->get('jwtService'),
+ $c->get('playerActionService'),
+ $c
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'dataSetColumnFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DataSetColumnFactory(
+ $c->get('dataTypeFactory'),
+ $c->get('dataSetColumnTypeFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'dataSetColumnTypeFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DataSetColumnTypeFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'dataSetFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DataSetFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService'),
+ $c->get('pool'),
+ $c->get('dataSetColumnFactory'),
+ $c->get('permissionFactory'),
+ $c->get('displayNotifyService')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'dataSetRssFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DataSetRssFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'dataTypeFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DataTypeFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'dayPartFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DayPartFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'displayFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DisplayFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('displayNotifyService'),
+ $c->get('configService'),
+ $c->get('displayGroupFactory'),
+ $c->get('displayProfileFactory'),
+ $c->get('folderFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'displayEventFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DisplayEventFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'displayGroupFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DisplayGroupFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('permissionFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'displayTypeFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DisplayTypeFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'displayProfileFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\DisplayProfileFactory(
+ $c->get('configService'),
+ $c->get('commandFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'folderFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\FolderFactory(
+ $c->get('permissionFactory'),
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'fontFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\FontFactory(
+ $c->get('configService')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'layoutFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\LayoutFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService'),
+ $c->get('permissionFactory'),
+ $c->get('regionFactory'),
+ $c->get('tagFactory'),
+ $c->get('campaignFactory'),
+ $c->get('mediaFactory'),
+ $c->get('moduleFactory'),
+ $c->get('moduleTemplateFactory'),
+ $c->get('resolutionFactory'),
+ $c->get('widgetFactory'),
+ $c->get('widgetOptionFactory'),
+ $c->get('playlistFactory'),
+ $c->get('widgetAudioFactory'),
+ $c->get('actionFactory'),
+ $c->get('folderFactory'),
+ $c->get('fontFactory'),
+ $c->get('widgetDataFactory'),
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+
+ if ($c->has('pool')) {
+ $repository->usePool($c->get('pool'));
+ }
+
+ return $repository;
+ },
+ 'logFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\LogFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'mediaFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\MediaFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService'),
+ $c->get('permissionFactory'),
+ $c->get('playlistFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'menuBoardCategoryFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\MenuBoardCategoryFactory(
+ $c->get('menuBoardProductOptionFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'menuBoardProductOptionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\MenuBoardProductOptionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'menuBoardFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\MenuBoardFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService'),
+ $c->get('pool'),
+ $c->get('permissionFactory'),
+ $c->get('menuBoardCategoryFactory'),
+ $c->get('displayNotifyService')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'moduleFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ModuleFactory(
+ $c->get('configService')->getSetting('LIBRARY_LOCATION') . 'widget',
+ $c->get('pool'),
+ $c->get('view'),
+ $c->get('configService')
+ );
+ $repository
+ ->setAclDependencies(
+ $c->get('user'),
+ $c->get('userFactory')
+ )
+ ->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'moduleTemplateFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ModuleTemplateFactory(
+ $c->get('pool'),
+ $c->get('view'),
+ );
+ $repository
+ ->setAclDependencies(
+ $c->get('user'),
+ $c->get('userFactory')
+ )
+ ->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'notificationFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\NotificationFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('userGroupFactory'),
+ $c->get('displayGroupFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'permissionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\PermissionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'playerFaultFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\PlayerFaultFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'playerVersionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\PlayerVersionFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'playlistFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\PlaylistFactory(
+ $c->get('configService'),
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('permissionFactory'),
+ $c->get('widgetFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'regionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\RegionFactory(
+ $c->get('permissionFactory'),
+ $c->get('regionOptionFactory'),
+ $c->get('playlistFactory'),
+ $c->get('actionFactory'),
+ $c->get('campaignFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'regionOptionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\RegionOptionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'requiredFileFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\RequiredFileFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'reportScheduleFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ReportScheduleFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'resolutionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ResolutionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'savedReportFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\SavedReportFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService'),
+ $c->get('mediaFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'scheduleFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ScheduleFactory(
+ $c->get('configService'),
+ $c->get('pool'),
+ $c->get('displayGroupFactory'),
+ $c->get('dayPartFactory'),
+ $c->get('userFactory'),
+ $c->get('scheduleReminderFactory'),
+ $c->get('scheduleExclusionFactory'),
+ $c->get('user'),
+ $c->get('scheduleCriteriaFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'scheduleReminderFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ScheduleReminderFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('configService')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'scheduleExclusionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ScheduleExclusionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'scheduleCriteriaFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\ScheduleCriteriaFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'sessionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\SessionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'syncGroupFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\SyncGroupFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('permissionFactory'),
+ $c->get('displayFactory'),
+ $c->get('scheduleFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'tagFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\TagFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'taskFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\TaskFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'transitionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\TransitionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'userFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\UserFactory(
+ $c->get('configService'),
+ $c->get('permissionFactory'),
+ $c->get('userOptionFactory'),
+ $c->get('applicationScopeFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'userGroupFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\UserGroupFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'userNotificationFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\UserNotificationFactory(
+ $c->get('user'),
+ $c->get('userFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'userOptionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\UserOptionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'userTypeFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\UserTypeFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'widgetFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\WidgetFactory(
+ $c->get('user'),
+ $c->get('userFactory'),
+ $c->get('widgetOptionFactory'),
+ $c->get('widgetMediaFactory'),
+ $c->get('widgetAudioFactory'),
+ $c->get('permissionFactory'),
+ $c->get('displayNotifyService'),
+ $c->get('actionFactory'),
+ $c->get('moduleTemplateFactory')
+ );
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'widgetMediaFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\WidgetMediaFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'widgetAudioFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\WidgetAudioFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'widgetOptionFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\WidgetOptionFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ 'widgetDataFactory' => function (ContainerInterface $c) {
+ $repository = new \Xibo\Factory\WidgetDataFactory();
+ $repository->useBaseDependenciesService($c->get('RepositoryBaseDependenciesService'));
+ return $repository;
+ },
+ ];
+ }
+}
diff --git a/lib/Entity/Action.php b/lib/Entity/Action.php
new file mode 100644
index 0000000..5f669cd
--- /dev/null
+++ b/lib/Entity/Action.php
@@ -0,0 +1,300 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Action
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Action implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Action Id")
+ * @var int
+ */
+ public $actionId;
+
+ /**
+ * @SWG\Property(description="The Owner Id")
+ * @var int
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="The Action trigger type")
+ * @var string
+ */
+ public $triggerType;
+
+ /**
+ * @SWG\Property(description="The Action trigger code")
+ * @var string
+ */
+ public $triggerCode;
+
+ /**
+ * @SWG\Property(description="The Action type")
+ * @var string
+ */
+ public $actionType;
+
+ /**
+ * @SWG\Property(description="The Action source (layout, region or widget)")
+ * @var string
+ */
+ public $source;
+
+ /**
+ * @SWG\Property(description="The Action source Id (layoutId, regionId or widgetId)")
+ * @var int
+ */
+ public $sourceId;
+
+ /**
+ * @SWG\Property(description="The Action target (region)")
+ * @var string
+ */
+ public $target;
+
+ /**
+ * @SWG\Property(description="The Action target Id (regionId)")
+ * @var int
+ */
+ public $targetId;
+
+ /**
+ * @SWG\Property(description="Widget ID that will be loaded as a result of navigate to Widget Action type")
+ * @var int
+ */
+ public $widgetId;
+
+ /**
+ * @SWG\Property(description="Layout Code identifier")
+ * @var string
+ */
+ public $layoutCode;
+
+ /**
+ * @SWG\Property(description="Layout Id associated with this Action")
+ * @var int
+ */
+ public $layoutId;
+
+ /** @var \Xibo\Factory\PermissionFactory */
+ private $permissionFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function __clone()
+ {
+ $this->hash = null;
+ $this->actionId = null;
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->actionId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->ownerId = $ownerId;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('ActionId %d, Trigger Type %s, Trigger Code %s, Action Type %s, Source %s, SourceId %s, Target %s, TargetId %d', $this->actionId, $this->triggerType, $this->triggerCode, $this->actionType, $this->source, $this->sourceId, $this->target, $this->targetId);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ // on add we expect only layoutId, actionType, target and targetId
+ if ($this->layoutId == null) {
+ throw new InvalidArgumentException(__('No layoutId specified'), 'layoutId');
+ }
+
+ if (!in_array($this->actionType, ['next', 'previous', 'navLayout', 'navWidget'])) {
+ throw new InvalidArgumentException(__('Invalid action type'), 'actionType');
+ }
+
+ if (!in_array(strtolower($this->source), ['layout', 'region', 'widget'])) {
+ throw new InvalidArgumentException(__('Invalid source'), 'source');
+ }
+
+ if (!in_array(strtolower($this->target), ['region', 'screen'])) {
+ throw new InvalidArgumentException(__('Invalid target'), 'target');
+ }
+
+ if ($this->target == 'region' && $this->targetId == null) {
+ throw new InvalidArgumentException(__('Please select a Region'), 'targetId');
+ }
+
+ if ($this->triggerType === 'webhook' && $this->triggerCode === null) {
+ throw new InvalidArgumentException(__('Please provide trigger code'), 'triggerCode');
+ }
+
+ if ($this->triggerType === 'keyPress' && $this->triggerCode === null) {
+ throw new InvalidArgumentException(__('Please provide trigger key'), 'triggerKey');
+ }
+
+ if (!in_array($this->triggerType, ['touch', 'webhook', 'keyPress'])) {
+ throw new InvalidArgumentException(__('Invalid trigger type'), 'triggerType');
+ }
+
+ if ($this->actionType === 'navLayout' && $this->layoutCode == '') {
+ throw new InvalidArgumentException(__('Please enter Layout code'), 'layoutCode');
+ }
+
+ if ($this->actionType === 'navWidget' && $this->widgetId == null) {
+ throw new InvalidArgumentException(__('Please create a Widget to be loaded'), 'widgetId');
+ }
+ }
+
+ /**
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'notifyLayout' => false
+ ], $options);
+
+ $this->getLog()->debug('Saving ' . $this);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->actionId == null || $this->actionId == 0) {
+ $this->add();
+ $this->loaded = true;
+ } else {
+ $this->update();
+ }
+
+ if ($options['notifyLayout'] && $this->layoutId != null) {
+ $this->notifyLayout($this->layoutId);
+ }
+ }
+
+ public function add()
+ {
+ $this->actionId = $this->getStore()->insert('INSERT INTO `action` (ownerId, triggerType, triggerCode, actionType, source, sourceId, target, targetId, widgetId, layoutCode, layoutId) VALUES (:ownerId, :triggerType, :triggerCode, :actionType, :source, :sourceId, :target, :targetId, :widgetId, :layoutCode, :layoutId)', [
+ 'ownerId' => $this->ownerId,
+ 'triggerType' => $this->triggerType,
+ 'triggerCode' => $this->triggerCode,
+ 'actionType' => $this->actionType,
+ 'source' => $this->source,
+ 'sourceId' => $this->sourceId,
+ 'target' => $this->target,
+ 'targetId' => $this->targetId,
+ 'widgetId' => $this->widgetId,
+ 'layoutCode' => $this->layoutCode,
+ 'layoutId' => $this->layoutId
+ ]);
+
+ }
+
+ public function update()
+ {
+ $this->getStore()->update('UPDATE `action` SET ownerId = :ownerId, triggerType = :triggerType, triggerCode = :triggerCode, actionType = :actionType, source = :source, sourceId = :sourceId, target = :target, targetId = :targetId, widgetId = :widgetId, layoutCode = :layoutCode, layoutId = :layoutId WHERE actionId = :actionId', [
+ 'ownerId' => $this->ownerId,
+ 'triggerType' => $this->triggerType,
+ 'triggerCode' => $this->triggerCode,
+ 'actionType' => $this->actionType,
+ 'source' => $this->source,
+ 'sourceId' => $this->sourceId,
+ 'target' => $this->target,
+ 'targetId' => $this->targetId,
+ 'actionId' => $this->actionId,
+ 'widgetId' => $this->widgetId,
+ 'layoutCode' => $this->layoutCode,
+ 'layoutId' => $this->layoutId
+ ]);
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `action` WHERE actionId = :actionId', ['actionId' => $this->actionId]);
+ }
+
+ /**
+ * Notify the Layout (set to building)
+ * @param $layoutId
+ */
+ public function notifyLayout($layoutId)
+ {
+ $this->getLog()->debug(sprintf('Saving Interactive Action ID %d triggered layout ID %d build', $this->actionId, $layoutId));
+
+ $this->getStore()->update('
+ UPDATE `layout` SET `status` = 3, `modifiedDT` = :modifiedDt WHERE layoutId = :layoutId
+ ', [
+ 'layoutId' => $layoutId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+
+}
\ No newline at end of file
diff --git a/lib/Entity/Application.php b/lib/Entity/Application.php
new file mode 100644
index 0000000..c72ccf4
--- /dev/null
+++ b/lib/Entity/Application.php
@@ -0,0 +1,458 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use Xibo\Factory\ApplicationRedirectUriFactory;
+use Xibo\Factory\ApplicationScopeFactory;
+use Xibo\Helper\Random;
+use Xibo\OAuth\ScopeEntity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class Application
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition
+ */
+class Application implements \JsonSerializable, ClientEntityInterface
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(
+ * description="Application Key"
+ * )
+ * @var string
+ */
+ public $key;
+
+ /**
+ * @SWG\Property(
+ * description="Private Secret Key"
+ * )
+ * @var string
+ */
+ public $secret;
+
+ /**
+ * @SWG\Property(
+ * description="Application Name"
+ * )
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(
+ * description="Application Owner"
+ * )
+ * @var string
+ */
+ public $owner;
+
+ /**
+ * @SWG\Property(
+ * description="Application Session Expiry"
+ * )
+ * @var int
+ */
+ public $expires;
+
+ /**
+ * @SWG\Property(
+ * description="The Owner of this Application"
+ * )
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether to allow the authorizationCode Grant Type")
+ * @var int
+ */
+ public $authCode = 0;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether to allow the clientCredentials Grant Type")
+ * @var int
+ */
+ public $clientCredentials = 0;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this Application will be confidential or not (can it keep a secret?)")
+ * @var int
+ */
+ public $isConfidential = 1;
+
+ /** * @var ApplicationRedirectUri[] */
+ public $redirectUris = [];
+
+ /** * @var ApplicationScope[] */
+ public $scopes = [];
+
+ /**
+ * @SWG\Property(description="Application description")
+ * @var string
+ */
+ public $description;
+ /**
+ * @SWG\Property(description="Path to Application logo")
+ * @var string
+ */
+ public $logo;
+ /**
+ * @SWG\Property(description="Path to Application Cover Image")
+ * @var string
+ */
+ public $coverImage;
+ /**
+ * @SWG\Property(description="Company name associated with this Application")
+ * @var string
+ */
+ public $companyName;
+ /**
+ * @SWG\Property(description="URL to Application terms")
+ * @var string
+ */
+ public $termsUrl;
+ /**
+ * @SWG\Property(description="URL to Application privacy policy")
+ * @var string
+ */
+ public $privacyUrl;
+
+ /** @var ApplicationRedirectUriFactory */
+ private $applicationRedirectUriFactory;
+
+ /** @var ApplicationScopeFactory */
+ private $applicationScopeFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ApplicationRedirectUriFactory $applicationRedirectUriFactory
+ * @param ApplicationScopeFactory $applicationScopeFactory
+ */
+ public function __construct($store, $log, $dispatcher, $applicationRedirectUriFactory, $applicationScopeFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->applicationRedirectUriFactory = $applicationRedirectUriFactory;
+ $this->applicationScopeFactory = $applicationScopeFactory;
+ }
+
+ public function __serialize(): array
+ {
+ return $this->jsonSerialize();
+ }
+
+ public function __unserialize(array $data): void
+ {
+ foreach ($data as $key => $value) {
+ $this->{$key} = $value;
+ }
+ }
+
+ /**
+ * @param ApplicationRedirectUri $redirectUri
+ */
+ public function assignRedirectUri($redirectUri)
+ {
+ $this->load();
+
+ // Assert client id
+ $redirectUri->clientId = $this->key;
+
+ if (!in_array($redirectUri, $this->redirectUris)) {
+ $this->redirectUris[] = $redirectUri;
+ }
+ }
+
+ /**
+ * Unassign RedirectUri
+ * @param ApplicationRedirectUri $redirectUri
+ */
+ public function unassignRedirectUri($redirectUri)
+ {
+ $this->load();
+
+ $this->redirectUris = array_udiff($this->redirectUris, [$redirectUri], function($a, $b) {
+ /**
+ * @var ApplicationRedirectUri $a
+ * @var ApplicationRedirectUri $b
+ */
+ return $a->getId() - $b->getId();
+ });
+ }
+
+ /**
+ * @param ApplicationScope $scope
+ */
+ public function assignScope($scope)
+ {
+ if (!in_array($scope, $this->scopes)) {
+ $this->scopes[] = $scope;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param ApplicationScope $scope
+ */
+ public function unassignScope($scope)
+ {
+ $this->scopes = array_udiff($this->scopes, [$scope], function ($a, $b) {
+ /**
+ * @var ApplicationScope $a
+ * @var ApplicationScope $b
+ */
+ return $a->getId() !== $b->getId();
+ });
+ }
+
+ /**
+ * Get the hash for password verify
+ * @return string
+ */
+ public function getHash()
+ {
+ return password_hash($this->secret, PASSWORD_DEFAULT);
+ }
+
+ /**
+ * Load
+ * @return $this
+ */
+ public function load()
+ {
+ if ($this->loaded || empty($this->key)) {
+ return $this;
+ }
+
+ // Redirects
+ $this->redirectUris = $this->applicationRedirectUriFactory->getByClientId($this->key);
+
+ // Get scopes
+ $this->scopes = $this->applicationScopeFactory->getByClientId($this->key);
+
+ $this->loaded = true;
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function save()
+ {
+ if ($this->key == null || $this->key == '') {
+ // Make a new secret.
+ $this->resetSecret();
+
+ // Add
+ $this->add();
+ } else {
+ // Edit
+ $this->edit();
+ }
+
+ $this->getLog()->debug('Saving redirect uris: ' . json_encode($this->redirectUris));
+
+ foreach ($this->redirectUris as $redirectUri) {
+ $redirectUri->save();
+ }
+
+ $this->manageScopeAssignments();
+
+ return $this;
+ }
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->load();
+
+ foreach ($this->redirectUris as $redirectUri) {
+ $redirectUri->delete();
+ }
+
+ // Clear link table for this Application
+ $this->getStore()->update('DELETE FROM `oauth_lkclientuser` WHERE clientId = :id', ['id' => $this->key]);
+
+ // Clear out everything owned by this client
+ $this->getStore()->update('DELETE FROM `oauth_client_scopes` WHERE `clientId` = :id', ['id' => $this->key]);
+ $this->getStore()->update('DELETE FROM `oauth_clients` WHERE `id` = :id', ['id' => $this->key]);
+ }
+
+ /**
+ * Reset Secret
+ */
+ public function resetSecret()
+ {
+ $this->secret = Random::generateString(254);
+ }
+
+ private function add()
+ {
+ // Make an ID
+ $this->key = Random::generateString(40);
+
+ // Simple Insert for now
+ $this->getStore()->insert('
+ INSERT INTO `oauth_clients` (`id`, `secret`, `name`, `userId`, `authCode`, `clientCredentials`, `isConfidential`, `description`, `logo`, `coverImage`, `companyName`, `termsUrl`, `privacyUrl`)
+ VALUES (:id, :secret, :name, :userId, :authCode, :clientCredentials, :isConfidential, :description, :logo, :coverImage, :companyName, :termsUrl, :privacyUrl)
+ ', [
+ 'id' => $this->key,
+ 'secret' => $this->secret,
+ 'name' => $this->name,
+ 'userId' => $this->userId,
+ 'authCode' => $this->authCode,
+ 'clientCredentials' => $this->clientCredentials,
+ 'isConfidential' => $this->isConfidential,
+ 'description' => $this->description,
+ 'logo' => $this->logo,
+ 'coverImage' => $this->coverImage,
+ 'companyName' => $this->companyName,
+ 'termsUrl' => $this->termsUrl,
+ 'privacyUrl' => $this->privacyUrl
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `oauth_clients` SET
+ `id` = :id,
+ `secret` = :secret,
+ `name` = :name,
+ `userId` = :userId,
+ `authCode` = :authCode,
+ `clientCredentials` = :clientCredentials,
+ `isConfidential` = :isConfidential,
+ `description` = :description,
+ `logo` = :logo,
+ `coverImage` = :coverImage,
+ `companyName` = :companyName,
+ `termsUrl` = :termsUrl,
+ `privacyUrl` = :privacyUrl
+ WHERE `id` = :id
+ ', [
+ 'id' => $this->key,
+ 'secret' => $this->secret,
+ 'name' => $this->name,
+ 'userId' => $this->userId,
+ 'authCode' => $this->authCode,
+ 'clientCredentials' => $this->clientCredentials,
+ 'isConfidential' => $this->isConfidential,
+ 'description' => $this->description,
+ 'logo' => $this->logo,
+ 'coverImage' => $this->coverImage,
+ 'companyName' => $this->companyName,
+ 'termsUrl' => $this->termsUrl,
+ 'privacyUrl' => $this->privacyUrl
+ ]);
+ }
+
+ /**
+ * Compare the original assignments with the current assignments and delete any that are missing, add any new ones
+ */
+ private function manageScopeAssignments()
+ {
+ $i = 0;
+ $params = ['clientId' => $this->key];
+ $unassignIn = '';
+
+ foreach ($this->scopes as $link) {
+ $this->getStore()->update('
+ INSERT INTO `oauth_client_scopes` (clientId, scopeId) VALUES (:clientId, :scopeId)
+ ON DUPLICATE KEY UPDATE scopeId = scopeId', [
+ 'clientId' => $this->key,
+ 'scopeId' => $link->id
+ ]);
+
+ $i++;
+ $unassignIn .= ',:scopeId' . $i;
+ $params['scopeId' . $i] = $link->id;
+ }
+
+ // Unlink any NOT in the collection
+ $sql = 'DELETE FROM `oauth_client_scopes` WHERE clientId = :clientId AND scopeId NOT IN (\'0\'' . $unassignIn . ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /** @inheritDoc */
+ public function getIdentifier()
+ {
+ return $this->key;
+ }
+
+ /** @inheritDoc */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /** @inheritDoc */
+ public function getRedirectUri()
+ {
+ $count = count($this->redirectUris);
+
+ if ($count <= 0) {
+ return null;
+ } else if (count($this->redirectUris) == 1) {
+ return $this->redirectUris[0]->redirectUri;
+ } else {
+ return array_map(function($el) {
+ return $el->redirectUri;
+ }, $this->redirectUris);
+ }
+ }
+
+ /**
+ * @return \League\OAuth2\Server\Entities\ScopeEntityInterface[]
+ */
+ public function getScopes()
+ {
+ $scopes = [];
+ foreach ($this->scopes as $applicationScope) {
+ $scope = new ScopeEntity();
+ $scope->setIdentifier($applicationScope->getId());
+ $scopes[] = $scope;
+ }
+ return $scopes;
+ }
+
+ /** @inheritDoc */
+ public function isConfidential()
+ {
+ return $this->isConfidential === 1;
+ }
+}
diff --git a/lib/Entity/ApplicationRedirectUri.php b/lib/Entity/ApplicationRedirectUri.php
new file mode 100644
index 0000000..d107536
--- /dev/null
+++ b/lib/Entity/ApplicationRedirectUri.php
@@ -0,0 +1,123 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class ApplicationRedirectUri
+ * @package Xibo\Entity
+ */
+class ApplicationRedirectUri implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @var int
+ */
+ public $id;
+
+ /**
+ * @var string
+ */
+ public $clientId;
+
+ /**
+ * @var string
+ */
+ public $redirectUri;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function __serialize(): array
+ {
+ return $this->jsonSerialize();
+ }
+
+ public function __unserialize(array $data): void
+ {
+ foreach ($data as $key => $value) {
+ $this->{$key} = $value;
+ }
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * Save
+ */
+ public function save()
+ {
+ if ($this->id == null)
+ $this->add();
+ else
+ $this->edit();
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `oauth_client_redirect_uris` WHERE `id` = :id', ['id' => $this->id]);
+ }
+
+ private function add()
+ {
+ $this->id = $this->getStore()->insert('
+ INSERT INTO `oauth_client_redirect_uris` (`client_id`, `redirect_uri`)
+ VALUES (:clientId, :redirectUri)
+ ', [
+ 'clientId' => $this->clientId,
+ 'redirectUri' => $this->redirectUri
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `oauth_client_redirect_uris`
+ SET `redirect_uri` = :redirectUri
+ WHERE `id` = :id
+ ',[
+ 'id' => $this->id,
+ 'redirectUri' => $this->redirectUri
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/ApplicationRequest.php b/lib/Entity/ApplicationRequest.php
new file mode 100644
index 0000000..1f2629f
--- /dev/null
+++ b/lib/Entity/ApplicationRequest.php
@@ -0,0 +1,98 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Application Request
+ * @SWG\Definition()
+ */
+class ApplicationRequest implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The request ID")
+ * @var int
+ */
+ public $requestId;
+
+ /**
+ * @SWG\Property(description="The user ID")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="The application ID")
+ * @var string
+ */
+ public $applicationId;
+
+ /**
+ * @SWG\Property(description="The request route")
+ * @var string
+ */
+ public $url;
+
+ /**
+ * @SWG\Property(description="The request method")
+ * @var string
+ */
+ public $method;
+
+ /**
+ * @SWG\Property(description="The request start time")
+ * @var string
+ */
+ public $startTime;
+
+ /**
+ * @SWG\Property(description="The request end time")
+ * @var string
+ */
+ public $endTime;
+
+ /**
+ * @SWG\Property(description="The request duration")
+ * @var int
+ */
+ public $duration;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param EventDispatcherInterface $dispatcher
+ */
+ public function __construct(
+ StorageServiceInterface $store,
+ LogServiceInterface $log,
+ EventDispatcherInterface $dispatcher
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+}
diff --git a/lib/Entity/ApplicationScope.php b/lib/Entity/ApplicationScope.php
new file mode 100644
index 0000000..e6615b1
--- /dev/null
+++ b/lib/Entity/ApplicationScope.php
@@ -0,0 +1,111 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class ApplicationScope
+ * @package Xibo\Entity
+ */
+class ApplicationScope implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @var string
+ */
+ public $id;
+
+ /**
+ * @var string
+ */
+ public $description;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function __serialize(): array
+ {
+ return $this->jsonSerialize();
+ }
+
+ public function __unserialize(array $data): void
+ {
+ foreach ($data as $key => $value) {
+ $this->{$key} = $value;
+ }
+ }
+
+ /**
+ * Get Id
+ * @return string
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * Check whether this scope has permission for this route
+ * @param string $method
+ * @param string $requestedRoute
+ * @return bool
+ */
+ public function checkRoute(string $method, string $requestedRoute): bool
+ {
+ $routes = $this->getStore()->select('
+ SELECT `route`
+ FROM `oauth_scope_routes`
+ WHERE `scopeId` = :scope
+ AND `method` LIKE :method
+ ', [
+ 'scope' => $this->getId(),
+ 'method' => '%' . $method . '%',
+ ]);
+
+ $this->getLog()->debug('checkRoute: there are ' . count($routes) . ' potential routes for the scope '
+ . $this->getId() . ' with ' . $method);
+
+ // We need to look through each route and run the regex against our requested route.
+ $grantAccess = false;
+ foreach ($routes as $route) {
+ $regexResult = preg_match($route['route'], $requestedRoute);
+ if ($regexResult === 1) {
+ $grantAccess = true;
+ break;
+ }
+ }
+
+ return $grantAccess;
+ }
+}
diff --git a/lib/Entity/AuditLog.php b/lib/Entity/AuditLog.php
new file mode 100644
index 0000000..03815b2
--- /dev/null
+++ b/lib/Entity/AuditLog.php
@@ -0,0 +1,108 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class AuditLog
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class AuditLog implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Log Id")
+ * @var int
+ */
+ public $logId;
+
+ /**
+ * @SWG\Property(description="The Log Date")
+ * @var int
+ */
+ public $logDate;
+
+ /**
+ * @SWG\Property(description="The userId of the User that took this action")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="Message describing the action taken")
+ * @var string
+ */
+ public $message;
+
+ /**
+ * @SWG\Property(description="The effected entity")
+ * @var string
+ */
+ public $entity;
+
+ /**
+ * @SWG\Property(description="The effected entityId")
+ * @var int
+ */
+ public $entityId;
+
+ /**
+ * @SWG\Property(description="A JSON representation of the object after it was changed")
+ * @var string
+ */
+ public $objectAfter;
+
+ /**
+ * @SWG\Property(description="The User Name of the User that took this action")
+ * @var string
+ */
+ public $userName;
+
+ /**
+ * @SWG\Property(description="The IP Address of the User that took this action")
+ * @var string
+ */
+ public $ipAddress;
+
+ /**
+ * @SWG\Property(description="Session history id.")
+ * @var int
+ */
+ public $sessionHistoryId;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+}
diff --git a/lib/Entity/Bandwidth.php b/lib/Entity/Bandwidth.php
new file mode 100644
index 0000000..3dff513
--- /dev/null
+++ b/lib/Entity/Bandwidth.php
@@ -0,0 +1,89 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DeadlockException;
+
+
+/**
+ * Class Bandwidth
+ * @package Xibo\Entity
+ *
+ */
+class Bandwidth
+{
+ use EntityTrait;
+
+ public static $REGISTER = 1;
+ public static $RF = 2;
+ public static $SCHEDULE = 3;
+ public static $GETFILE = 4;
+ public static $GETRESOURCE = 5;
+ public static $MEDIAINVENTORY = 6;
+ public static $NOTIFYSTATUS = 7;
+ public static $SUBMITSTATS = 8;
+ public static $SUBMITLOG = 9;
+ public static $REPORTFAULT = 10;
+ public static $SCREENSHOT = 11;
+ public static $GET_DATA = 12;
+ public static $GET_DEPENDENCY = 13;
+
+ public $displayId;
+ public $type;
+ public $size;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function save()
+ {
+ try {
+ // This runs on the "isolated" connection because we do not want a failure here to impact the
+ // main transaction we've just completed (we log bandwidth at the end).
+ // Running on a separate transaction is cleaner than committing what we already have (debatable)
+ $this->getStore()->updateWithDeadlockLoop('
+ INSERT INTO `bandwidth` (Month, Type, DisplayID, Size)
+ VALUES (:month, :type, :displayId, :size)
+ ON DUPLICATE KEY UPDATE Size = Size + :size2
+ ', [
+ 'month' => strtotime(date('m') . '/02/' . date('Y') . ' 00:00:00'),
+ 'type' => $this->type,
+ 'displayId' => $this->displayId,
+ 'size' => $this->size,
+ 'size2' => $this->size
+ ], 'isolated', false, true);
+ } catch (DeadlockException $deadlockException) {
+ $this->getLog()->error('Deadlocked inserting bandwidth');
+ }
+ }
+}
diff --git a/lib/Entity/Campaign.php b/lib/Entity/Campaign.php
new file mode 100644
index 0000000..11c0b66
--- /dev/null
+++ b/lib/Entity/Campaign.php
@@ -0,0 +1,1031 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Campaign
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Campaign implements \JsonSerializable
+{
+ use EntityTrait;
+ use TagLinkTrait;
+
+ public static $availableTypes = ['ad', 'list', 'media', 'playlist'];
+
+ /**
+ * @SWG\Property(description="The Campaign Id")
+ * @var int
+ */
+ public $campaignId;
+
+ /**
+ * @SWG\Property(description="The userId of the User that owns this Campaign")
+ * @var int
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="The type of campaign, either list, ad, playlist or media")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The name of the Campaign")
+ * @var string
+ */
+ public $campaign;
+
+ /**
+ * @SWG\Property(description="A 0|1 flag to indicate whether this is a Layout specific Campaign or not.")
+ * @var int
+ */
+ public $isLayoutSpecific = 0;
+
+ /**
+ * @SWG\Property(description="The number of Layouts associated with this Campaign")
+ * @var int
+ */
+ public $numberLayouts;
+
+ /**
+ * @SWG\Property(description="The total duration of the campaign (sum of layout's durations)")
+ * @var int
+ */
+ public $totalDuration;
+
+ /**
+ * @SWG\Property(description="Tags associated with this Campaign, array of TagLink objects")
+ * @var TagLink[]
+ */
+ public $tags = [];
+
+ /**
+ * @SWG\Property(description="The id of the Folder this Campaign belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Campaign")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this Campaign has cycle based playback enabled")
+ * @var int
+ */
+ public $cyclePlaybackEnabled;
+
+ /**
+ * @SWG\Property(description="In cycle based playback, how many plays should each Layout have before moving on?")
+ * @var int
+ */
+ public $playCount;
+
+ /**
+ * @SWG\Property(description="In list campaign types, how should the layouts play out?")
+ * @var string
+ */
+ public $listPlayOrder;
+
+ /**
+ * @SWG\Property(description="For an ad campaign, what's the target type, plays|budget|imp")
+ * @var string
+ */
+ public $targetType;
+
+ /**
+ * @SWG\Property(description="For an ad campaign, what's the target (expressed in targetType)")
+ * @var int
+ */
+ public $target;
+
+ /**
+ * @SWG\Property(description="For an ad campaign, what's the start date")
+ * @var int
+ */
+ public $startDt;
+
+ /**
+ * @SWG\Property(description="For an ad campaign, what's the end date")
+ * @var int
+ */
+ public $endDt;
+
+ /**
+ * @SWG\Property(description="The number of plays achived by this campaign")
+ * @var int
+ */
+ public $plays;
+
+ /**
+ * @SWG\Property(description="The amount of spend in cents/pence/etc")
+ * @var double
+ */
+ public $spend;
+
+ /**
+ * @SWG\Property(description="The number of impressions achived by this campaign")
+ * @var double
+ */
+ public $impressions;
+
+ /**
+ * @SWG\Property(description="The latest proof of play ID aggregated into the stats")
+ * @var int
+ */
+ public $lastPopId;
+
+ /**
+ * @SWG\Property(description="Reference field 1")
+ * @var string
+ */
+ public $ref1;
+
+ /**
+ * @SWG\Property(description="Reference field 1")
+ * @var string
+ */
+ public $ref2;
+
+ /**
+ * @SWG\Property(description="Reference field 1")
+ * @var string
+ */
+ public $ref3;
+
+ /**
+ * @SWG\Property(description="Reference field 1")
+ * @var string
+ */
+ public $ref4;
+
+ /**
+ * @SWG\Property(description="Reference field 1")
+ * @var string
+ */
+ public $ref5;
+
+ public $createdAt;
+ public $modifiedAt;
+ public $modifiedBy;
+ public $modifiedByName;
+
+ /** @var \Xibo\Entity\LayoutOnCampaign[] */
+ public $layouts = [];
+
+ /** @var int[] */
+ public $displayGroupIds = [];
+
+ /**
+ * @var Permission[]
+ */
+ private $permissions = [];
+
+ /**
+ * @var Schedule[]
+ */
+ private $events = [];
+
+ // Private
+ /** @var TagLink[] */
+ private $unlinkTags = [];
+ /** @var TagLink[] */
+ private $linkTags = [];
+
+ /** @var bool Have the Layout assignments been loaded? */
+ private $layoutAssignmentsLoaded = false;
+
+ /** @var bool Have the Layout assignments changed? */
+ private $layoutAssignmentsChanged = false;
+
+ private $displayGroupAssignmentsChanged = false;
+
+ // Internal tracking variables for when we're incrementing plays/spend and impressions.
+ private $additionalPlays = 0;
+ private $additionalSpend = 0.0;
+ private $additionalImpressions = 0.0;
+
+ /** @var \Xibo\Factory\CampaignFactory */
+ private $campaignFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param PermissionFactory $permissionFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ CampaignFactory $campaignFactory,
+ $permissionFactory,
+ $scheduleFactory,
+ $displayNotifyService
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->campaignFactory = $campaignFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ public function __clone()
+ {
+ $this->campaignId = null;
+ $this->tags = [];
+ $this->linkTags = [];
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf(
+ 'CampaignId %d, Campaign %s, LayoutSpecific %d',
+ $this->campaignId,
+ $this->campaign,
+ $this->isLayoutSpecific
+ );
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->campaignId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->ownerId = $ownerId;
+ }
+
+ /**
+ * @return \Carbon\Carbon|false|null
+ */
+ public function getStartDt()
+ {
+ return $this->startDt == 0 ? null : Carbon::createFromTimestamp($this->startDt);
+ }
+
+ /**
+ * @return \Carbon\Carbon|false|null
+ */
+ public function getEndDt()
+ {
+ return $this->endDt == 0 ? null : Carbon::createFromTimestamp($this->endDt);
+ }
+
+ /**
+ * @param \Carbon\Carbon|null $testDate
+ * @return \Xibo\Entity\CampaignProgress
+ */
+ public function getProgress(?Carbon $testDate = null): CampaignProgress
+ {
+ $progress = new CampaignProgress();
+
+ if ($this->type !== 'ad' || $this->startDt == null || $this->endDt == null) {
+ $progress->progressTime = 0;
+ $progress->progressTarget = 0;
+ return $progress;
+ }
+
+ if ($testDate === null) {
+ $testDate = Carbon::now();
+ }
+ $startDt = $this->getStartDt();
+ $endDt = $this->getEndDt();
+
+ // if start and end date are the same
+ // set the daysTotal to 1, to avoid potential division by 0 later on.
+ $progress->daysTotal = ($this->startDt === $this->endDt) ? 1 : $endDt->diffInDays($startDt);
+
+ $progress->targetPerDay = $this->target / $progress->daysTotal;
+
+ if ($startDt->isAfter($testDate)) {
+ $progress->progressTime = 0;
+ $progress->progressTarget = 0;
+ } else {
+ if ($testDate->isAfter($endDt)) {
+ // We've finished.
+ $progress->daysIn = $progress->daysTotal;
+ $progress->progressTime = 100;
+ } else {
+ $progress->daysIn = $testDate->diffInDays($startDt);
+
+ // Use hours to calculate more accurate progress
+ $hoursTotal = $progress->daysTotal * 24;
+ $hoursIn = $testDate->diffInHours($startDt);
+ $progress->progressTime = $hoursIn / $hoursTotal * 100;
+ }
+
+ if ($this->targetType === 'budget') {
+ $progress->progressTarget = ($this->spend / $this->target) * 100;
+ } else if ($this->targetType === 'imp') {
+ $progress->progressTarget = ($this->impressions / $this->target) * 100;
+ } else {
+ $progress->progressTarget = ($this->plays / $this->target) * 100;
+ }
+ }
+ return $progress;
+ }
+
+ /**
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadPermissions' => true,
+ 'loadEvents' => true,
+ 'loadDisplayGroupIds' => true,
+ ], $options);
+
+ // If we are already loaded, then don't do it again
+ if ($this->campaignId == null || $this->loaded) {
+ return;
+ }
+
+ // Permissions
+ if ($options['loadPermissions']) {
+ $this->permissions = $this->permissionFactory->getByObjectId('Campaign', $this->campaignId);
+ }
+
+ // Events
+ if ($options['loadEvents']) {
+ $this->events = $this->scheduleFactory->getByCampaignId($this->campaignId);
+ }
+
+ if ($options['loadDisplayGroupIds']) {
+ $this->displayGroupIds = $this->loadDisplayGroupIds();
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * @return \Xibo\Entity\LayoutOnCampaign[]
+ */
+ public function loadLayouts(): array
+ {
+ if (!$this->layoutAssignmentsLoaded && $this->campaignId !== null) {
+ $this->layouts = $this->campaignFactory->getLinkedLayouts($this->campaignId);
+ $this->layoutAssignmentsLoaded = true;
+ }
+ return $this->layouts;
+ }
+
+ /**
+ * @param int $displayOrder
+ * @return \Xibo\Entity\LayoutOnCampaign
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getLayoutAt(int $displayOrder): LayoutOnCampaign
+ {
+ foreach ($this->layouts as $layout) {
+ if ($layout->displayOrder === $displayOrder) {
+ return $layout;
+ }
+ }
+ throw new NotFoundException();
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!in_array($this->type, self::$availableTypes)) {
+ throw new InvalidArgumentException(__('Invalid type'), 'type');
+ }
+
+ if (!v::stringType()->notEmpty()->validate($this->campaign)) {
+ throw new InvalidArgumentException(__('Name cannot be empty'), 'name');
+ }
+
+ if ($this->cyclePlaybackEnabled === 1 && empty($this->playCount)) {
+ throw new InvalidArgumentException(__('Please enter play count'), 'playCount');
+ }
+
+ if ($this->type === 'ad') {
+ if (!in_array($this->targetType, ['plays', 'budget', 'imp'])) {
+ throw new InvalidArgumentException(__('Invalid target type'), 'targetType');
+ }
+
+ if ($this->target <= 0) {
+ throw new InvalidArgumentException(__('Please enter a target'), 'target');
+ }
+
+ if ($this->campaignId !== null && count($this->displayGroupIds) <= 0) {
+ throw new InvalidArgumentException(__('Please select one or more displays'), 'displayGroupId[]');
+ }
+
+ if ($this->startDt !== null && $this->endDt !== null && $this->startDt > $this->endDt) {
+ throw new InvalidArgumentException(
+ __('Cannot set end date to be earlier than the start date.'),
+ 'endDt'
+ );
+ }
+ } else {
+ if ($this->listPlayOrder !== 'round' && $this->listPlayOrder !== 'block') {
+ throw new InvalidArgumentException(
+ __('Please choose either round-robin or block play order for this list'),
+ 'listPlayOrder'
+ );
+ }
+ }
+ }
+
+ /**
+ * Save this Campaign
+ * @param array $options
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'notify' => true,
+ 'collectNow' => true,
+ 'saveTags' => true,
+ 'isTagEdit' => false
+ ], $options);
+
+ $this->getLog()->debug('Saving ' . $this);
+
+ // Manually load display group IDs when editing only campaign tags.
+ if ($options['isTagEdit']) {
+ $this->displayGroupIds = $this->loadDisplayGroupIds();
+ }
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->campaignId == null || $this->campaignId == 0) {
+ $this->add();
+ $this->loaded = true;
+ } else {
+ $this->update();
+ }
+
+ if ($options['saveTags']) {
+ // Remove unwanted ones
+ if (is_array($this->unlinkTags)) {
+ foreach ($this->unlinkTags as $tag) {
+ $this->unlinkTagFromEntity('lktagcampaign', 'campaignId', $this->campaignId, $tag->tagId);
+ }
+ }
+
+ // Save the tags
+ if (is_array($this->linkTags)) {
+ foreach ($this->linkTags as $tag) {
+ $this->linkTagToEntity('lktagcampaign', 'campaignId', $this->campaignId, $tag->tagId, $tag->value);
+ }
+ }
+ }
+
+ // Manage assignments
+ $this->manageAssignments();
+
+ // Notify anyone interested of the changes
+ $this->notify($options);
+ }
+
+ /**
+ * Delete Campaign
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function delete()
+ {
+ $this->load();
+
+ // Unassign display groups
+ $this->getStore()->update('DELETE FROM `lkcampaigndisplaygroup` WHERE campaignId = :campaignId', [
+ 'campaignId' => $this->campaignId,
+ ]);
+
+ // Unassign all Layouts
+ $this->layouts = [];
+ $this->unlinkLayouts();
+
+ // Delete all permissions
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->delete();
+ }
+
+ // Unassign all Tags
+ $this->unlinkAllTagsFromEntity('lktagcampaign', 'campaignId', $this->campaignId);
+
+ // Notify anyone interested of the changes
+ // we do this before we delete from the DB (otherwise notify won't find anything)
+ $this->notify();
+
+ // Delete all events
+ foreach ($this->events as $event) {
+ /* @var Schedule $event */
+ $event->setDisplayNotifyService($this->displayNotifyService);
+ $event->delete();
+ }
+
+ if ($this->type === 'ad') {
+ foreach ($this->scheduleFactory->getByParentCampaignId($this->campaignId) as $adEvent) {
+ $adEvent->delete();
+ }
+ }
+
+ // Delete the Actual Campaign
+ $this->getStore()->update('DELETE FROM `campaign` WHERE CampaignID = :campaignId', ['campaignId' => $this->campaignId]);
+ }
+
+ /**
+ * Assign Layout
+ * @param int $layoutId
+ * @param int|null $displayOrder
+ * @param int|null $dayPartId
+ * @param string|null $daysOfWeek
+ * @param string|null $geoFence
+ * @return \Xibo\Entity\LayoutOnCampaign
+ */
+ public function assignLayout(
+ int $layoutId,
+ ?int $displayOrder = null,
+ ?int $dayPartId = null,
+ ?string $daysOfWeek = null,
+ ?string $geoFence = null
+ ): LayoutOnCampaign {
+ $this->getLog()->debug('assignLayout: starting with layoutId: ' . $layoutId);
+
+ // Load the layouts we do have already
+ $this->loadLayouts();
+
+ // Make a new assignment
+ $assignment = $this->campaignFactory->createEmptyLayoutAssignment();
+ $assignment->layoutId = $layoutId;
+
+ // Props
+ $assignment->displayOrder = empty($displayOrder) ? count($this->layouts) + 1 : $displayOrder;
+ $assignment->dayPartId = $dayPartId;
+ $assignment->daysOfWeek = $daysOfWeek;
+ $assignment->geoFence = $geoFence;
+
+ // We've changed assignments.
+ $this->layoutAssignmentsChanged = true;
+ $this->layouts[] = $assignment;
+ $this->numberLayouts++;
+
+ return $assignment;
+ }
+
+ /**
+ * Unassign Layout
+ * @param int $layoutId
+ * @param int|null $displayOrder
+ * @return \Xibo\Entity\Campaign
+ */
+ public function unassignLayout(
+ int $layoutId,
+ ?int $displayOrder = null
+ ): Campaign {
+ // Load the layouts we do have already
+ $this->loadLayouts();
+
+ $countBefore = count($this->layouts);
+ $this->getLog()->debug('unassignLayout: Count before assign = ' . $countBefore);
+
+ // Keep track of keys to remove
+ $existingKeys = [];
+
+ foreach ($this->layouts as $key => $existing) {
+ $this->getLog()->debug('unassignLayout: Comparing existing ['
+ . $existing->layoutId . ', ' . $existing->displayOrder
+ . '] with unassign [' . $layoutId . ', ' . $displayOrder . '].');
+
+ // Does this layoutId match?
+ if ($layoutId === $existing->layoutId) {
+ // Are we looking to remove a specific one?
+ if ($displayOrder === null || $displayOrder === $existing->displayOrder) {
+ $existingKeys[] = $key;
+ $this->layoutAssignmentsChanged = true;
+ }
+ }
+ }
+
+ // Remove the keys necessary
+ foreach ($existingKeys as $existingKey) {
+ $this->getLog()->debug('Removing item at key ' . $existingKey);
+ unset($this->layouts[$existingKey]);
+ }
+
+ return $this;
+ }
+
+ private function orderLayoutAssignments(): void
+ {
+ // Sort the layouts by their display order
+ usort($this->layouts, function ($a, $b) {
+ if ($a->displayOrder === null) {
+ return 1;
+ }
+
+ if ($a->displayOrder === $b->displayOrder) {
+ return 0;
+ }
+
+ return ($a->displayOrder < $b->displayOrder) ? -1 : 1;
+ });
+ }
+
+ /**
+ * Unassign all layouts
+ * @return $this
+ */
+ public function unassignAllLayouts(): Campaign
+ {
+ $this->layoutAssignmentsChanged = true;
+ $this->numberLayouts = 0;
+ $this->layouts = [];
+ return $this;
+ }
+
+ /**
+ * Load displayGroupIds
+ * @return int[]
+ */
+ public function loadDisplayGroupIds(): array
+ {
+ $displayGroupIds = [];
+ foreach ($this->getStore()->select('SELECT * FROM lkcampaigndisplaygroup WHERE campaignId = :campaignId', [
+ 'campaignId' => $this->campaignId,
+ ]) as $link) {
+ $displayGroupIds[] = intval($link['displayGroupId']);
+ }
+ return $displayGroupIds;
+ }
+
+ /**
+ * @param $displayGroupIds
+ * @return $this
+ */
+ public function replaceDisplayGroupIds($displayGroupIds): Campaign
+ {
+ $this->displayGroupAssignmentsChanged = true;
+ $this->displayGroupIds = $displayGroupIds;
+ return $this;
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->campaignId = $this->getStore()->insert('
+ INSERT INTO `campaign` (
+ campaign,
+ type,
+ isLayoutSpecific,
+ userId,
+ cyclePlaybackEnabled,
+ playCount,
+ listPlayOrder,
+ targetType,
+ target,
+ folderId,
+ permissionsFolderId
+ )
+ VALUES (
+ :campaign,
+ :type,
+ :isLayoutSpecific,
+ :userId,
+ :cyclePlaybackEnabled,
+ :playCount,
+ :listPlayOrder,
+ :targetType,
+ :target,
+ :folderId,
+ :permissionsFolderId
+ )
+ ', [
+ 'campaign' => $this->campaign,
+ 'type' => $this->type,
+ 'isLayoutSpecific' => $this->isLayoutSpecific,
+ 'userId' => $this->ownerId,
+ 'cyclePlaybackEnabled' => ($this->cyclePlaybackEnabled == null) ? 0 : $this->cyclePlaybackEnabled,
+ 'listPlayOrder' => $this->listPlayOrder,
+ 'playCount' => $this->playCount,
+ 'targetType' => empty($this->targetType) ? null : $this->targetType,
+ 'target' => empty($this->target) ? null : $this->target,
+ 'folderId' => ($this->folderId == null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this->permissionsFolderId
+ ]);
+ }
+
+ /**
+ * Update
+ */
+ private function update()
+ {
+ $this->getStore()->update('
+ UPDATE `campaign`
+ SET campaign = :campaign,
+ userId = :userId,
+ cyclePlaybackEnabled = :cyclePlaybackEnabled,
+ playCount = :playCount,
+ listPlayOrder = :listPlayOrder,
+ ref1 = :ref1,
+ ref2 = :ref2,
+ ref3 = :ref3,
+ ref4 = :ref4,
+ ref5 = :ref5,
+ targetType = :targetType,
+ target = :target,
+ startDt = :startDt,
+ endDt = :endDt,
+ folderId = :folderId,
+ permissionsFolderId = :permissionsFolderId,
+ modifiedBy = :modifiedBy
+ WHERE campaignID = :campaignId
+ ', [
+ 'campaignId' => $this->campaignId,
+ 'campaign' => $this->campaign,
+ 'userId' => $this->ownerId,
+ 'cyclePlaybackEnabled' => ($this->cyclePlaybackEnabled == null) ? 0 : $this->cyclePlaybackEnabled,
+ 'playCount' => $this->playCount,
+ 'listPlayOrder' => $this->listPlayOrder,
+ 'targetType' => empty($this->targetType) ? null : $this->targetType,
+ 'target' => empty($this->target) ? null : $this->target,
+ 'startDt' => empty($this->startDt) ? null : $this->startDt,
+ 'endDt' => empty($this->endDt) ? null : $this->endDt,
+ 'ref1' => empty($this->ref1) ? null : $this->ref1,
+ 'ref2' => empty($this->ref2) ? null : $this->ref2,
+ 'ref3' => empty($this->ref3) ? null : $this->ref3,
+ 'ref4' => empty($this->ref4) ? null : $this->ref4,
+ 'ref5' => empty($this->ref5) ? null : $this->ref5,
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId,
+ 'modifiedBy' => $this->modifiedBy,
+ ]);
+ }
+
+ /**
+ * Manage the assignments
+ */
+ private function manageAssignments()
+ {
+ if ($this->layoutAssignmentsChanged) {
+ $this->getLog()->debug('Managing Assignments on ' . $this);
+ $this->unlinkLayouts();
+ $this->linkLayouts();
+ } else {
+ $this->getLog()->debug('Assignments have not changed on ' . $this);
+ }
+
+ if ($this->displayGroupAssignmentsChanged) {
+ $this->getStore()->update('DELETE FROM `lkcampaigndisplaygroup` WHERE campaignId = :campaignId', [
+ 'campaignId' => $this->campaignId,
+ ]);
+
+ foreach ($this->displayGroupIds as $displayGroupId) {
+ $this->getStore()->update('
+ INSERT INTO `lkcampaigndisplaygroup` (campaignId, displayGroupId)
+ VALUES (:campaignId, :displayGroupId)
+ ON DUPLICATE KEY UPDATE campaignId = :campaignId
+ ', [
+ 'campaignId' => $this->campaignId,
+ 'displayGroupId' => $displayGroupId,
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Link Layout
+ */
+ private function linkLayouts()
+ {
+ // Don't do anything if we don't have any layouts
+ if (count($this->layouts) <= 0) {
+ return;
+ }
+
+ $this->orderLayoutAssignments();
+
+ // Update the layouts, in order to have display order 1 to n
+ $i = 0;
+ $sql = '
+ INSERT INTO `lkcampaignlayout` (campaignID, layoutID, displayOrder, dayPartId, daysOfWeek, geoFence)
+ VALUES
+ ';
+ $params = ['campaignId' => $this->campaignId];
+
+ foreach ($this->layouts as $layout) {
+ $i++;
+ $layout->displayOrder = $i;
+
+ $sql .= '(
+ :campaignId,
+ :layoutId_' . $i . ',
+ :displayOrder_' . $i . ',
+ :dayPartId_' . $i . ',
+ :daysOfWeek_' . $i . ',
+ :geoFence_' . $i . '
+ ),';
+
+ $params['layoutId_' . $i] = $layout->layoutId;
+ $params['displayOrder_' . $i] = $layout->displayOrder;
+ $params['dayPartId_' . $i] = $layout->dayPartId == null ? null : $layout->dayPartId;
+ $params['daysOfWeek_' . $i] = $layout->daysOfWeek == null ? null : $layout->daysOfWeek;
+ $params['geoFence_' . $i] = $layout->geoFence == null ? null : json_encode($layout->geoFence);
+ }
+
+ $sql = rtrim($sql, ',');
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Unlink Layout
+ */
+ private function unlinkLayouts()
+ {
+ // Delete all the links
+ $this->getStore()->update('DELETE FROM `lkcampaignlayout` WHERE campaignId = :campaignId', [
+ 'campaignId' => $this->campaignId
+ ]);
+ }
+
+ /**
+ * Notify displays of this campaign change
+ * @param array $options
+ */
+ private function notify($options = [])
+ {
+ $options = array_merge([
+ 'notify' => true,
+ 'collectNow' => true,
+ ], $options);
+
+ // Do we notify?
+ if ($options['notify']) {
+ $this->getLog()->debug('CampaignId ' . $this->campaignId . ' wants to notify.');
+
+ $notify = $this->displayNotifyService->init();
+
+ // Should we collect immediately
+ if ($options['collectNow']) {
+ $notify->collectNow();
+ }
+
+ // Notify
+ $notify->notifyByCampaignId($this->campaignId);
+
+ if (!empty($options['layoutCode'])) {
+ $this->getLog()->debug('CampaignId ' . $this->campaignId . ' wants to notify with Layout Code ' . $options['layoutCode']);
+ $notify->notifyByLayoutCode($options['layoutCode']);
+ }
+ }
+ }
+
+ /**
+ * Add to the number of plays
+ * @param int $plays
+ * @param double $spend
+ * @param double $impressions
+ * @return $this
+ */
+ public function incrementPlays(int $plays, $spend, $impressions): Campaign
+ {
+ $this->plays += $plays;
+ $this->additionalPlays += $plays;
+ $this->spend += $spend;
+ $this->additionalSpend += $spend;
+ $this->impressions += $impressions;
+ $this->additionalImpressions += $impressions;
+ return $this;
+ }
+
+ /**
+ * Save increments to the number of plays
+ * @return $this
+ */
+ public function saveIncrementPlays(): Campaign
+ {
+ $this->getStore()->update('
+ UPDATE `campaign`
+ SET `plays` = `plays` + :plays,
+ `spend` = `spend` + :spend,
+ `impressions` = `impressions` + :impressions
+ WHERE campaignId = :campaignId
+ ', [
+ 'plays' => $this->additionalPlays,
+ 'spend' => $this->additionalSpend,
+ 'impressions' => $this->additionalImpressions,
+ 'campaignId' => $this->campaignId,
+ ]);
+ return $this;
+ }
+
+ /**
+ * Overwrite the number of plays/spend and impressions
+ * @return $this
+ */
+ public function overwritePlays(): Campaign
+ {
+
+ $this->getStore()->update('
+ UPDATE `campaign`
+ SET `plays` = :plays,
+ `spend` = :spend,
+ `impressions` = :impressions
+ WHERE campaignId = :campaignId
+ ', [
+ 'plays' => $this->plays,
+ 'spend' => $this->spend,
+ 'impressions' => $this->impressions,
+ 'campaignId' => $this->campaignId,
+ ]);
+ return $this;
+ }
+}
diff --git a/lib/Entity/CampaignProgress.php b/lib/Entity/CampaignProgress.php
new file mode 100644
index 0000000..95b97f5
--- /dev/null
+++ b/lib/Entity/CampaignProgress.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+/**
+ * Campaign Progress
+ */
+class CampaignProgress implements \JsonSerializable
+{
+ /** @var int */
+ public $daysIn = 0;
+
+ /** @var int */
+ public $daysTotal = 0;
+
+ /** @var float */
+ public $targetPerDay = 0.0;
+
+ /** @var float */
+ public $progressTime = 0.0;
+
+ /** @var float */
+ public $progressTarget = 0.0;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'daysIn' => $this->daysIn,
+ 'daysTotal' => $this->daysTotal,
+ 'targetPerDay' => $this->targetPerDay,
+ 'progressTime' => $this->progressTime,
+ 'progressTarget' => $this->progressTarget,
+ ];
+ }
+}
diff --git a/lib/Entity/Command.php b/lib/Entity/Command.php
new file mode 100644
index 0000000..90fd0d0
--- /dev/null
+++ b/lib/Entity/Command.php
@@ -0,0 +1,353 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Command
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Command implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(
+ * description="Command Id"
+ * )
+ * @var int
+ */
+ public $commandId;
+
+ /**
+ * @SWG\Property(
+ * description="Command Name"
+ * )
+ * @var string
+ */
+ public $command;
+
+ /**
+ * @SWG\Property(
+ * description="Unique Code"
+ * )
+ * @var string
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(
+ * description="Description"
+ * )
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(
+ * description="User Id"
+ * )
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(
+ * description="Command String"
+ * )
+ * @var string
+ */
+ public $commandString;
+
+ /**
+ * @SWG\Property(
+ * description="Validation String"
+ * )
+ * @var string
+ */
+ public $validationString;
+
+ /**
+ * @SWG\Property(
+ * description="DisplayProfileId if specific to a Display Profile"
+ * )
+ * @var int
+ */
+ public $displayProfileId;
+
+ /**
+ * @SWG\Property(
+ * description="Command String specific to the provided DisplayProfile"
+ * )
+ * @var string
+ */
+ public $commandStringDisplayProfile;
+
+ /**
+ * @SWG\Property(
+ * description="Validation String specific to the provided DisplayProfile"
+ * )
+ * @var string
+ */
+ public $validationStringDisplayProfile;
+
+ /**
+ * @SWG\Property(
+ * description="A comma separated list of player types this command is available on"
+ * )
+ * @var string
+ */
+ public $availableOn;
+
+ /**
+ * @SWG\Property(
+ * description="Define if execution of this command should create an alert on success, failure, always or never."
+ * )
+ * @var string
+ */
+ public $createAlertOn;
+
+ /**
+ * @SWG\Property(
+ * description="Create Alert On specific to the provided DisplayProfile."
+ * )
+ */
+ public $createAlertOnDisplayProfile;
+
+ /**
+ * @SWG\Property(description="A comma separated list of groups/users with permissions to this Command")
+ * @var string
+ */
+ public $groupsWithPermissions;
+
+ /**
+ * Command constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->commandId;
+ }
+
+ /**
+ * Get OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCommandString()
+ {
+ return empty($this->commandStringDisplayProfile) ? $this->commandString : $this->commandStringDisplayProfile;
+ }
+
+ /**
+ * @return string
+ */
+ public function getValidationString()
+ {
+ return empty($this->validationStringDisplayProfile)
+ ? $this->validationString
+ : $this->validationStringDisplayProfile;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCreateAlertOn(): string
+ {
+ return empty($this->createAlertOnDisplayProfile)
+ ? $this->createAlertOn
+ : $this->createAlertOnDisplayProfile;
+ }
+
+ /**
+ * @return array
+ */
+ public function getAvailableOn()
+ {
+ return empty($this->availableOn) ? [] : explode(',', $this->availableOn);
+ }
+
+ /**
+ * @param string $type Player Type
+ * @return bool
+ */
+ public function isAvailableOn($type)
+ {
+ $availableOn = $this->getAvailableOn();
+ return count($availableOn) <= 0 || in_array($type, $availableOn);
+ }
+
+ /**
+ * @return bool
+ */
+ public function isReady()
+ {
+ return !empty($this->getCommandString());
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->length(1, 254)->validate($this->command)) {
+ throw new InvalidArgumentException(
+ __('Please enter a command name between 1 and 254 characters'),
+ 'command'
+ );
+ }
+
+ if (!v::alpha('_')->NoWhitespace()->notEmpty()->length(1, 50)->validate($this->code)) {
+ throw new InvalidArgumentException(
+ __('Please enter a code between 1 and 50 characters containing only alpha characters and no spaces'),
+ 'code'
+ );
+ }
+
+ if (!v::stringType()->length(0, 1000)->validate($this->description)) {
+ throw new InvalidArgumentException(
+ __('Please enter a description between 1 and 1000 characters'),
+ 'description'
+ );
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ *
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge($options, ['validate' => true]);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->commandId == null) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->getStore()->update(
+ 'DELETE FROM `command` WHERE `commandId` = :commandId',
+ ['commandId' => $this->commandId]
+ );
+ }
+
+ private function add()
+ {
+ $this->commandId = $this->getStore()->insert('
+ INSERT INTO `command` (
+ `command`,
+ `code`,
+ `description`,
+ `userId`,
+ `commandString`,
+ `validationString`,
+ `availableOn`,
+ `createAlertOn`
+ )
+ VALUES (
+ :command,
+ :code,
+ :description,
+ :userId,
+ :commandString,
+ :validationString,
+ :availableOn,
+ :createAlertOn
+ )
+ ', [
+ 'command' => $this->command,
+ 'code' => $this->code,
+ 'description' => $this->description,
+ 'userId' => $this->userId,
+ 'commandString' => $this->commandString,
+ 'validationString' => $this->validationString,
+ 'availableOn' => $this->availableOn,
+ 'createAlertOn' => $this->createAlertOn
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `command` SET
+ `command` = :command,
+ `code` = :code,
+ `description` = :description,
+ `userId` = :userId,
+ `commandString` = :commandString,
+ `validationString` = :validationString,
+ `availableOn` = :availableOn,
+ `createAlertOn` = :createAlertOn
+ WHERE `commandId` = :commandId
+ ', [
+ 'command' => $this->command,
+ 'code' => $this->code,
+ 'description' => $this->description,
+ 'userId' => $this->userId,
+ 'commandId' => $this->commandId,
+ 'commandString' => $this->commandString,
+ 'validationString' => $this->validationString,
+ 'availableOn' => $this->availableOn,
+ 'createAlertOn' => $this->createAlertOn
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/Connector.php b/lib/Entity/Connector.php
new file mode 100644
index 0000000..7da3d36
--- /dev/null
+++ b/lib/Entity/Connector.php
@@ -0,0 +1,135 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Connector\ConnectorInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Represents the database object for a Connector
+ *
+ * @SWG\Definition()
+ */
+class Connector implements \JsonSerializable
+{
+ use EntityTrait;
+
+ // Status properties
+ public $isInstalled = true;
+ public $isSystem = true;
+
+ // Database properties
+ public $connectorId;
+ public $className;
+ public $settings;
+ public $isEnabled;
+ public $isVisible;
+
+ // Decorated properties
+ public $title;
+ public $description;
+ public $thumbnail;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @param \Xibo\Connector\ConnectorInterface $connector
+ * @return $this
+ */
+ public function decorate(ConnectorInterface $connector): Connector
+ {
+ $this->title = $connector->getTitle();
+ $this->description = $connector->getDescription();
+ $this->thumbnail = $connector->getThumbnail();
+ if (empty($this->thumbnail)) {
+ $this->thumbnail = 'theme/default/img/connectors/placeholder.png';
+ }
+ return $this;
+ }
+
+ public function save()
+ {
+ if ($this->connectorId == null || $this->connectorId == 0) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ private function add()
+ {
+ $this->connectorId = $this->getStore()->insert('
+ INSERT INTO `connectors` (`className`, `isEnabled`, `isVisible`, `settings`)
+ VALUES (:className, :isEnabled, :isVisible, :settings)
+ ', [
+ 'className' => $this->className,
+ 'isEnabled' => $this->isEnabled,
+ 'isVisible' => $this->isVisible,
+ 'settings' => json_encode($this->settings)
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `connectors` SET
+ `className` = :className,
+ `isEnabled` = :isEnabled,
+ `isVisible` = :isVisible,
+ `settings` = :settings
+ WHERE connectorId = :connectorId
+ ', [
+ 'connectorId' => $this->connectorId,
+ 'className' => $this->className,
+ 'isEnabled' => $this->isEnabled,
+ 'isVisible' => $this->isVisible,
+ 'settings' => json_encode($this->settings)
+ ]);
+ }
+
+ /**
+ * @return void
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function delete()
+ {
+ if ($this->isSystem) {
+ throw new InvalidArgumentException(__('Sorry we cannot delete a system connector.'), 'isSystem');
+ }
+
+ $this->getStore()->update('DELETE FROM `connectors` WHERE connectorId = :connectorId', [
+ 'connectorId' => $this->connectorId
+ ]);
+ }
+}
diff --git a/lib/Entity/DataSet.php b/lib/Entity/DataSet.php
new file mode 100644
index 0000000..e5ac07b
--- /dev/null
+++ b/lib/Entity/DataSet.php
@@ -0,0 +1,1412 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Carbon\Factory;
+use Respect\Validation\Validator as v;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\DataSetColumnFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Definition\Sql;
+
+/**
+ * Class DataSet
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DataSet implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The dataSetId")
+ * @var int
+ */
+ public $dataSetId;
+
+ /**
+ * @SWG\Property(description="The dataSet Name")
+ * @var string
+ */
+ public $dataSet;
+
+ /**
+ * @SWG\Property(description="The dataSet description")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="The userId of the User that owns this DataSet")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="Timestamp indicating the date/time this DataSet was edited last")
+ * @var int
+ */
+ public $lastDataEdit;
+
+ /**
+ * @SWG\Property(description="The user name of the User that owns this DataSet")
+ * @var string
+ */
+ public $owner;
+
+ /**
+ * @SWG\Property(description="A comma separated list of Groups/Users that have permission to this DataSet")
+ * @var string
+ */
+ public $groupsWithPermissions;
+
+ /**
+ * @SWG\Property(description="A code for this Data Set")
+ * @var string
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="Flag to indicate whether this DataSet is a lookup table")
+ * @var int
+ */
+ public $isLookup = 0;
+
+ /**
+ * @SWG\Property(description="Flag to indicate whether this DataSet is Remote")
+ * @var int
+ */
+ public $isRemote = 0;
+
+ /**
+ * @SWG\Property(description="Flag to indicate whether this DataSet is Real time")
+ * @var int
+ */
+ public $isRealTime = 0;
+
+ /**
+ * @SWG\Property(description="Indicates the source of the data connector. Requires the Real time flag. Can be null,
+ * user-defined, or a connector.")
+ * @var string
+ */
+ public $dataConnectorSource;
+
+ /**
+ * @SWG\Property(description="Method to fetch the Data, can be GET or POST")
+ * @var string
+ */
+ public $method;
+
+ /**
+ * @SWG\Property(description="URI to call to fetch Data from. Replacements are {{DATE}}, {{TIME}} and, in case this is a sequencial used DataSet, {{COL.NAME}} where NAME is a ColumnName from the underlying DataSet.")
+ * @var string
+ */
+ public $uri;
+
+ /**
+ * @SWG\Property(description="Data to send as POST-Data to the remote host with the same Replacements as in the URI.")
+ * @var string
+ */
+ public $postData;
+
+ /**
+ * @SWG\Property(description="Authentication method, can be none, digest, basic")
+ * @var string
+ */
+ public $authentication;
+
+ /**
+ * @SWG\Property(description="Username to authenticate with")
+ * @var string
+ */
+ public $username;
+
+ /**
+ * @SWG\Property(description="Corresponding password")
+ * @var string
+ */
+ public $password;
+
+ /**
+ * @SWG\Property(description="Comma separated string of custom HTTP headers")
+ * @var string
+ */
+ public $customHeaders;
+
+ /**
+ * @SWG\Property(description="Custom User agent")
+ * @var string
+ */
+ public $userAgent;
+
+ /**
+ * @SWG\Property(description="Time in seconds this DataSet should fetch new Datas from the remote host")
+ * @var int
+ */
+ public $refreshRate;
+
+ /**
+ * @SWG\Property(description="Time in seconds when this Dataset should be cleared. If here is a lower value than in RefreshRate it will be cleared when the data is refreshed")
+ * @var int
+ */
+ public $clearRate;
+
+ /**
+ * @SWG\Property(description="Flag whether to truncate DataSet data if no new data is pulled from remote source")
+ * @var int
+ */
+ public $truncateOnEmpty;
+
+ /**
+ * @SWG\Property(description="DataSetID of the DataSet which should be fetched and present before the Data from this DataSet are fetched")
+ * @var int
+ */
+ public $runsAfter;
+
+ /**
+ * @SWG\Property(description="Last Synchronisation Timestamp")
+ * @var int
+ */
+ public $lastSync = 0;
+
+ /**
+ * @SWG\Property(description="Last Clear Timestamp")
+ * @var int
+ */
+ public $lastClear = 0;
+
+ /**
+ * @SWG\Property(description="Root-Element form JSON where the data are stored in")
+ * @var String
+ */
+ public $dataRoot;
+
+ /**
+ * @SWG\Property(description="Optional function to use for summarize or count unique fields in a remote request")
+ * @var String
+ */
+ public $summarize;
+
+ /**
+ * @SWG\Property(description="JSON-Element below the Root-Element on which the consolidation should be applied on")
+ * @var String
+ */
+ public $summarizeField;
+
+ /**
+ * @SWG\Property(description="The source id for remote dataSet, 1 - JSON, 2 - CSV")
+ * @var integer
+ */
+ public $sourceId;
+
+ /**
+ * @SWG\Property(description="A flag whether to ignore the first row, for CSV source remote dataSet")
+ * @var integer
+ */
+ public $ignoreFirstRow;
+
+ /**
+ * @SWG\Property(description="Soft limit on number of rows per DataSet, if left empty the global DataSet row limit will be used.")
+ * @var integer
+ */
+ public $rowLimit = null;
+
+ /**
+ * @SWG\Property(description="Type of action that should be taken on next remote DataSet sync - stop, fifo or truncate")
+ * @var string
+ */
+ public $limitPolicy;
+
+ /**
+ * @SWG\Property(description="Custom separator for CSV source, comma will be used by default")
+ * @var string
+ */
+ public $csvSeparator;
+
+ /**
+ * @SWG\Property(description="The id of the Folder this DataSet belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this DataSet")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ /** @var array Permissions */
+ private $permissions = [];
+
+ /**
+ * @var DataSetColumn[]
+ */
+ public $columns = [];
+
+ private $countLast = 0;
+
+ /** @var \Xibo\Helper\SanitizerService */
+ private $sanitizerService;
+
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var DataSetColumnFactory */
+ private $dataSetColumnFactory;
+
+ /** @var PermissionFactory */
+ private $permissionFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param SanitizerService $sanitizerService
+ * @param ConfigServiceInterface $config
+ * @param PoolInterface $pool
+ * @param DataSetFactory $dataSetFactory
+ * @param DataSetColumnFactory $dataSetColumnFactory
+ * @param PermissionFactory $permissionFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct($store, $log, $dispatcher, $sanitizerService, $config, $pool, $dataSetFactory, $dataSetColumnFactory, $permissionFactory, $displayNotifyService)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->sanitizerService = $sanitizerService;
+ $this->config = $config;
+ $this->pool = $pool;
+ $this->dataSetFactory = $dataSetFactory;
+ $this->dataSetColumnFactory = $dataSetColumnFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ return $this->sanitizerService->getSanitizer($array);
+ }
+
+ /**
+ * Clone
+ */
+ public function __clone()
+ {
+ $this->dataSetId = null;
+
+ $this->columns = array_map(function ($object) { return clone $object; }, $this->columns);
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->dataSetId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Set the owner of this DataSet
+ * @param $userId
+ */
+ public function setOwner($userId)
+ {
+ $this->userId = $userId;
+ }
+
+ /**
+ * Get the Count of Records in the last getData()
+ * @return int
+ */
+ public function countLast()
+ {
+ return $this->countLast;
+ }
+
+ /**
+ * Get the Display Notify Service
+ * @return DisplayNotifyServiceInterface
+ */
+ public function getDisplayNotifyService(): DisplayNotifyServiceInterface
+ {
+ return $this->displayNotifyService->init();
+ }
+
+ /**
+ * Get Column
+ * @param int[Optional] $dataSetColumnId
+ * @return DataSetColumn[]|DataSetColumn
+ * @throws NotFoundException when the heading is provided and the column cannot be found
+ */
+ public function getColumn($dataSetColumnId = 0)
+ {
+ $this->load();
+
+ if ($dataSetColumnId != 0) {
+
+ foreach ($this->columns as $column) {
+ /* @var DataSetColumn $column */
+ if ($column->dataSetColumnId == $dataSetColumnId)
+ return $column;
+ }
+
+ throw new NotFoundException(sprintf(__('Column %s not found'), $dataSetColumnId));
+
+ } else {
+ return $this->columns;
+ }
+ }
+
+ /**
+ * Get Column
+ * @param string $dataSetColumn
+ * @return DataSetColumn[]|DataSetColumn
+ * @throws NotFoundException when the heading is provided and the column cannot be found
+ */
+ public function getColumnByName($dataSetColumn)
+ {
+ $this->load();
+
+ foreach ($this->columns as $column) {
+ /* @var DataSetColumn $column */
+ if ($column->heading == $dataSetColumn)
+ return $column;
+ }
+
+ throw new NotFoundException(sprintf(__('Column %s not found'), $dataSetColumn));
+ }
+
+ /**
+ * @param string[] $columns Column Names to select
+ * @return array
+ * @throws InvalidArgumentException
+ */
+ public function getUniqueColumnValues($columns)
+ {
+ $this->load();
+
+ $select = '';
+ foreach ($columns as $heading) {
+ // Check this exists
+ $found = false;
+ foreach ($this->columns as $column) {
+ if ($column->heading == $heading) {
+ // Formula column?
+ if ($column->dataSetColumnTypeId == 2) {
+ $select .= str_replace(
+ Sql::DISALLOWED_KEYWORDS,
+ '',
+ htmlspecialchars_decode($column->formula, ENT_QUOTES)
+ ) . ' AS `' . $column->heading . '`,';
+ } else {
+ $select .= '`' . $column->heading . '`,';
+ }
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ throw new InvalidArgumentException(__('Unknown Column ' . $heading));
+ }
+ }
+ $select = rtrim($select, ',');
+ // $select is safe
+
+ return $this->getStore()->select('SELECT DISTINCT ' . $select . ' FROM `dataset_' . $this->dataSetId . '`', []);
+ }
+
+ /**
+ * Get DataSet Data
+ * @param array $filterBy
+ * @param array $options
+ * @param array $extraParams Extra params to apply to the final query
+ * @return array
+ * @throws NotFoundException
+ */
+ public function getData($filterBy = [], $options = [], $extraParams = [])
+ {
+ $sanitizer = $this->getSanitizer($filterBy);
+
+ $start = $sanitizer->getInt('start', ['default' => 0]);
+ $size = $sanitizer->getInt('size', ['default' => 0]);
+ $filter = $filterBy['filter'] ?? '';
+ $ordering = $sanitizer->getString('order');
+ $displayId = $sanitizer->getInt('displayId', ['default' => 0]);
+
+ $options = array_merge([
+ 'includeFormulaColumns' => true,
+ 'requireTotal' => true,
+ 'connection' => 'default'
+ ], $options);
+
+ // Params (start from extraParams supplied)
+ $params = $extraParams;
+
+ // Fetch display tag value/s
+ if ($filter != '' && $displayId != 0) {
+ // Define the regular expression to match [Tag:...]
+ $pattern = '/\[Tag:[^]]+\]/';
+
+ // Find all instances of [Tag:...]
+ preg_match_all($pattern, $filter, $matches);
+
+ // Check if matches were found
+ if (!empty($matches[0])) {
+ $displayTags = [];
+
+ // Iterate through the matches and process each tag
+ foreach ($matches[0] as $tagString) {
+ // Remove the enclosing [Tag:] brackets
+ $tagContent = substr($tagString, 5, -1);
+
+ // Explode the tag content by ":" to separate tagName and defaultValue (if present)
+ $parts = explode(':', $tagContent);
+ $tagName = $parts[0];
+ $defaultTagValue = $parts[1] ?? '';
+
+ $displayTags[] = [
+ 'tagString' => $tagString,
+ 'tagName' => $tagName,
+ 'defaultValue' => $defaultTagValue
+ ];
+ }
+
+ $tagCount = 1;
+
+ // Loop through each tag and get the actual tag value from the database
+ foreach ($displayTags as $tag) {
+ $tagSanitizer = $this->getSanitizer($tag);
+
+ $tagName = $tagSanitizer->getString('tagName');
+ $defaultTagValue = $tagSanitizer->getString('defaultValue');
+ $tagString = $tag['tagString'];
+
+ $query = 'SELECT `lktagdisplaygroup`.`value` AS tagValue
+ FROM `lkdisplaydg`
+ INNER JOIN `displaygroup`
+ ON `displaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId
+ AND `displaygroup`.isDisplaySpecific = 1
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId
+ INNER JOIN `tag` ON `lktagdisplaygroup`.tagId = `tag`.tagId
+ WHERE `lkdisplaydg`.displayId = :displayId
+ AND `tag`.`tag` = :tagName
+ LIMIT 1';
+
+ $tagParams = [
+ 'displayId' => $displayId,
+ 'tagName' => $tagName
+ ];
+
+ // Execute the query
+ $results = $this->getStore()->select($query, $tagParams);
+
+ // Determine the tag value
+ if (!empty($results)) {
+ $tagValue = !empty($results[0]['tagValue']) ? $results[0]['tagValue'] : '';
+ } else {
+ // Use default tag value if no tag is found
+ $tagValue = $defaultTagValue;
+ }
+
+ // Replace the tag string in the filter with the actual tag value or default value
+ $filter = str_replace($tagString, ':tagValue_'.$tagCount, $filter);
+ $params['tagValue_'.$tagCount] = $tagValue;
+
+ $tagCount++;
+ }
+ }
+ }
+
+ // Sanitize the filter options provided
+ // Get the Latitude and Longitude ( might be used in a formula )
+ if ($displayId == 0) {
+ $displayGeoLocation =
+ "ST_GEOMFROMTEXT('POINT(" . $this->config->getSetting('DEFAULT_LAT') .
+ ' ' . $this->config->getSetting('DEFAULT_LONG') . ")')";
+ } else {
+ $displayGeoLocation = '(SELECT GeoLocation FROM `display` WHERE DisplayID =' . $displayId. ')';
+ }
+
+ // Build a SQL statement, based on the columns for this dataset
+ $this->load();
+
+ $select = 'SELECT * FROM ( ';
+ $body = 'SELECT id';
+
+ // Keep track of the columns we are allowed to order by
+ $allowedOrderCols = ['id'];
+
+ // Are there any client side formulas
+ $clientSideFormula = [];
+
+ // Select (columns)
+ foreach ($this->getColumn() as $column) {
+ /* @var DataSetColumn $column */
+ if ($column->dataSetColumnTypeId == 2 && !$options['includeFormulaColumns']) {
+ continue;
+ }
+
+ // Formula column?
+ if ($column->dataSetColumnTypeId == 2) {
+ // Is this a client side column?
+ if (str_starts_with($column->formula, '$')) {
+ $clientSideFormula[] = $column;
+ continue;
+ }
+
+ $count = 0;
+ $formula = str_ireplace(
+ Sql::DISALLOWED_KEYWORDS,
+ '',
+ htmlspecialchars_decode($column->formula, ENT_QUOTES),
+ $count
+ );
+
+ if ($count > 0) {
+ $this->getLog()->error(
+ 'Formula contains disallowed keywords on DataSet ID ' . $this->dataSetId
+ );
+ continue;
+ }
+
+ $formula = str_replace('[DisplayId]', $displayId, $formula);
+
+ $heading = str_replace('[DisplayGeoLocation]', $displayGeoLocation, $formula)
+ . ' AS `' . $column->heading . '`';
+ } else {
+ $heading = '`' . $column->heading . '`';
+ }
+
+ $allowedOrderCols[] = $column->heading;
+
+ $body .= ', ' . $heading;
+ }
+
+ $body .= ' FROM `dataset_' . $this->dataSetId . '`) dataset WHERE 1 = 1 ';
+
+ // Filtering
+ if ($filter != '') {
+ // Support display filtering.
+ $filter = str_replace('[DisplayId]', $displayId, $filter);
+ $filter = str_ireplace(Sql::DISALLOWED_KEYWORDS, '', $filter);
+
+ $body .= ' AND ' . $filter;
+ }
+
+ // Filter by ID
+ if ($sanitizer->getInt('id') !== null) {
+ $body .= ' AND id = :id ';
+ $params['id'] = $sanitizer->getInt('id');
+ }
+
+ // Ordering
+ $order = '';
+ if ($ordering != '') {
+ $order = ' ORDER BY ';
+
+ $ordering = explode(',', $ordering);
+
+ foreach ($ordering as $orderPair) {
+ // Sanitize the clause
+ $sanitized = str_replace('`', '', str_replace(' ASC', '', str_replace(' DESC', '', $orderPair)));
+
+ // Check allowable
+ if (!in_array($sanitized, $allowedOrderCols)) {
+ $found = false;
+ $this->getLog()->info('Potentially disallowed column: ' . $sanitized);
+ // the gridRenderSort will strip spaces on column names go through allowed order columns
+ // and see if we can find a match by stripping spaces from the heading
+ foreach ($allowedOrderCols as $allowedOrderCol) {
+ $this->getLog()->info('Checking spaces in original name : ' . $sanitized);
+ if (str_replace(' ', '', $allowedOrderCol) === $sanitized) {
+ $found = true;
+ // put the column heading with the space as sanitized to make sql happy.
+ $sanitized = $allowedOrderCol;
+ }
+ }
+
+ // we tried, but it was not found, omit this pair
+ if (!$found) {
+ continue;
+ }
+ }
+
+ // Substitute
+ if (strripos($orderPair, ' DESC')) {
+ $order .= sprintf(' `%s` DESC,', $sanitized);
+ } else if (strripos($orderPair, ' ASC')) {
+ $order .= sprintf(' `%s` ASC,', $sanitized);
+ } else {
+ $order .= sprintf(' `%s`,', $sanitized);
+ }
+ }
+
+ $order = trim($order, ',');
+
+ // if after all that we still do not have any column name to order by, default to order by id
+ if (trim($order) === 'ORDER BY') {
+ $order = ' ORDER BY id ';
+ }
+ } else {
+ $order = ' ORDER BY id ';
+ }
+
+ // Limit
+ $limit = '';
+ if ($start != 0 || $size != 0) {
+ // Substitute in
+
+ // handle case where lower limit is set to > 0 and upper limit to 0 https://github.com/xibosignage/xibo/issues/2187
+ // it is with <= 0 because in some Widgets we calculate the size as upper - lower, https://github.com/xibosignage/xibo/issues/2263.
+ if ($start != 0 && $size <= 0) {
+ $size = PHP_INT_MAX;
+ }
+
+ $limit = sprintf(' LIMIT %d, %d ', $start, $size);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ $data = $this->getStore()->select($sql, $params, $options['connection']);
+
+ // If there are limits run some SQL to work out the full payload of rows
+ if ($options['requireTotal']) {
+ $results = $this->getStore()->select(
+ 'SELECT COUNT(*) AS total FROM (' . $body,
+ $params,
+ $options['connection']
+ );
+ $this->countLast = intval($results[0]['total']);
+ }
+
+ // Are there any client side formulas?
+ if (count($clientSideFormula) > 0) {
+ $renderedData = [];
+ foreach ($data as $item) {
+ foreach ($clientSideFormula as $column) {
+ // Run the formula and add the resulting value to the list
+ $value = null;
+ try {
+ if (substr($column->formula, 0, strlen('$dateFormat(')) === '$dateFormat(') {
+ // Pull out the column name and date format
+ $details = explode(',', str_replace(')', '', str_replace('$dateFormat(', '', $column->formula)));
+
+ if (isset($details[2])) {
+ $language = str_replace(' ', '', $details[2]);
+ } else {
+ $language = $this->config->getSetting('DEFAULT_LANGUAGE', 'en_GB');
+ }
+
+ $carbonFactory = new Factory(['locale' => $language], Carbon::class);
+ $value = $carbonFactory->parse($item[$details[0]])->translatedFormat($details[1]);
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('DataSet client side formula error in dataSetId ' . $this->dataSetId . ' with column formula ' . $column->formula);
+ }
+
+ $item[$column->heading] = $value;
+ }
+
+ $renderedData[] = $item;
+ }
+ } else {
+ $renderedData = $data;
+ }
+
+ return $renderedData;
+ }
+
+ /**
+ * Assign a column
+ * @param DataSetColumn $column
+ */
+ public function assignColumn($column)
+ {
+ $this->load();
+
+ // Set the dataSetId
+ $column->dataSetId = $this->dataSetId;
+
+ // Set the column order if we need to
+ if ($column->columnOrder == 0)
+ $column->columnOrder = count($this->columns) + 1;
+
+ $this->columns[] = $column;
+ }
+
+ /**
+ * Has Data?
+ * @return bool
+ */
+ public function hasData()
+ {
+ return $this->getStore()->exists('SELECT id FROM `dataset_' . $this->dataSetId . '` LIMIT 1', [], 'isolated');
+ }
+
+ /**
+ * Returns a Timestamp for the next Synchronisation process.
+ * @return int Seconds
+ */
+ public function getNextSyncTime()
+ {
+ return $this->lastSync + $this->refreshRate;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isTruncateEnabled()
+ {
+ return $this->clearRate !== 0;
+ }
+
+ /**
+ * Returns a Timestamp for the next Clearing process.
+ * @return int Seconds
+ */
+ public function getNextClearTime()
+ {
+ return $this->lastClear + $this->clearRate;
+ }
+
+ /**
+ * Returns if there is a consolidation field and method present or not.
+ * @return boolean
+ */
+ public function doConsolidate()
+ {
+ return ($this->summarizeField != null) && ($this->summarizeField != '')
+ && ($this->summarize != null) && ($this->summarize != '');
+ }
+
+ /**
+ * Returns the last Part of the Fieldname on which the consolidation should be applied on
+ * @return String
+ */
+ public function getConsolidationField()
+ {
+ $pos = strrpos($this->summarizeField, '.');
+ if ($pos !== false) {
+ return substr($this->summarizeField, $pos + 1);
+ }
+ return $this->summarizeField;
+ }
+
+ /**
+ * Tests if this DataSet contains parameters for getting values on the dependant DataSet
+ * @return boolean
+ */
+ public function containsDependantFieldsInRequest()
+ {
+ return strpos($this->postData, '{{COL.') !== false || strpos($this->uri, '{{COL.') !== false;
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ * @throws DuplicateEntityException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->length(null, 50)->validate($this->dataSet)) {
+ throw new InvalidArgumentException(__('Name must be between 1 and 50 characters'), 'dataSet');
+ }
+
+ if ($this->description != null && !v::stringType()->length(null, 254)->validate($this->description)) {
+ throw new InvalidArgumentException(__('Description can not be longer than 254 characters'), 'description');
+ }
+
+ // If we are a remote dataset do some additional checks
+ if ($this->isRemote === 1) {
+ if (!v::stringType()->notEmpty()->validate($this->uri)) {
+ throw new InvalidArgumentException(__('A remote DataSet must have a URI.'), 'uri');
+ }
+
+ if ($this->rowLimit > $this->config->getSetting('DATASET_HARD_ROW_LIMIT')) {
+ throw new InvalidArgumentException(__('DataSet row limit cannot be larger than the CMS dataSet row limit'));
+ }
+
+ // Check if the length is within the current URI character limit
+ if (!v::stringType()->length(null, 250)->validate($this->uri)) {
+ throw new InvalidArgumentException(__('URI can not be longer than 250 characters'), 'uri');
+ }
+ }
+
+ // Does this dataset have existing columns?
+ // Additional pre-checking for column headings when copying existing datasets
+ if (!empty($this->columns)) {
+ foreach ($this->columns as $column) {
+ if (!v::stringType()->notEmpty()->noWhitespace()->validate($column->heading)) {
+ throw new InvalidArgumentException(__(
+ 'Cannot copy this Dataset due to invalid column headings. Please remove any spaces.'),
+ 'dataSet'
+ );
+ }
+ }
+ }
+
+ try {
+ $existing = $this->dataSetFactory->getByName($this->dataSet, $this->userId);
+
+ if ($this->dataSetId == 0 || $this->dataSetId != $existing->dataSetId) {
+ throw new DuplicateEntityException(sprintf(__('There is already dataSet called %s. Please choose another name.'), $this->dataSet));
+ }
+ }
+ catch (NotFoundException $e) {
+ // This is good
+ }
+ }
+
+ /**
+ * Load all known information
+ */
+ public function load()
+ {
+ if ($this->loaded || $this->dataSetId == 0)
+ return;
+
+ // Load Columns
+ $this->columns = $this->dataSetColumnFactory->getByDataSetId($this->dataSetId);
+
+ // Load Permissions
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->getId());
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Save this DataSet
+ * @param array $options
+ * @throws InvalidArgumentException
+ * @throws DuplicateEntityException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'saveColumns' => true,
+ 'activate' => true,
+ 'notify' => true,
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->dataSetId == 0) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+
+ // Columns
+ if ($options['saveColumns']) {
+ foreach ($this->columns as $column) {
+ $column->dataSetId = $this->dataSetId;
+ $column->save($options);
+ }
+ }
+
+ // We've been touched
+ if ($options['activate']) {
+ $this->setActive();
+ }
+
+ // Notify Displays?
+ if ($options['notify']) {
+ $this->notify();
+ }
+ }
+
+ /**
+ * @param int $time
+ * @return $this
+ */
+ public function saveLastSync($time)
+ {
+ $this->lastSync = $time;
+
+ $this->getStore()->update('UPDATE `dataset` SET lastSync = :lastSync WHERE dataSetId = :dataSetId', [
+ 'dataSetId' => $this->dataSetId,
+ 'lastSync' => $this->lastSync
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * @param int $time
+ * @return $this
+ */
+ public function saveLastClear($time)
+ {
+ $this->lastSync = $time;
+
+ $this->getStore()->update('UPDATE `dataset` SET lastClear = :lastClear WHERE dataSetId = :dataSetId', [
+ 'dataSetId' => $this->dataSetId,
+ 'lastClear' => $this->lastClear
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Is this DataSet active currently
+ * @return bool
+ */
+ public function isActive()
+ {
+ $cache = $this->pool->getItem('/dataset/accessed/' . $this->dataSetId);
+ return $cache->isHit();
+ }
+
+ /**
+ * Indicate that this DataSet has been accessed recently
+ * @return $this
+ */
+ public function setActive()
+ {
+ $this->getLog()->debug('Setting ' . $this->dataSetId . ' as active');
+
+ $cache = $this->pool->getItem('/dataset/accessed/' . $this->dataSetId);
+ $cache->set('true');
+ $cache->expiresAfter(intval($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD')) * 1.5);
+ $this->pool->saveDeferred($cache);
+ return $this;
+ }
+
+ /**
+ * Delete DataSet
+ * @throws ConfigurationException
+ * @throws InvalidArgumentException
+ */
+ public function delete()
+ {
+ $this->load();
+
+ if ($this->isLookup) {
+ throw new ConfigurationException(__('Lookup Tables cannot be deleted'));
+ }
+
+ // check if any other DataSet depends on this DataSet
+ if ($this->getStore()->exists(
+ 'SELECT dataSetId FROM dataset WHERE runsAfter = :runsAfter AND dataSetId <> :dataSetId',
+ [
+ 'runsAfter' => $this->dataSetId,
+ 'dataSetId' => $this->dataSetId
+ ])) {
+ throw new InvalidArgumentException(__('Cannot delete because this DataSet is set as dependent DataSet for another DataSet'), 'dataSetId');
+ }
+
+ // Make sure we're able to delete
+ if ($this->getStore()->exists('
+ SELECT widgetId
+ FROM `widgetoption`
+ WHERE `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'dataSetId\'
+ AND `widgetoption`.value = :dataSetId
+ ', ['dataSetId' => $this->dataSetId])) {
+ throw new InvalidArgumentException(__('Cannot delete because DataSet is in use on one or more Layouts.'), 'dataSetId');
+ }
+
+ if ($this->getStore()->exists('
+ SELECT `eventId`
+ FROM `schedule`
+ WHERE `dataSetId` = :dataSetId
+ ', ['dataSetId' => $this->dataSetId])) {
+ throw new InvalidArgumentException(
+ __('Cannot delete because DataSet is in use on one or more Data Connector schedules.'),
+ 'dataSetId'
+ );
+ }
+
+ // Delete Permissions
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->deleteAll();
+ }
+
+ // Delete Columns
+ foreach ($this->columns as $column) {
+ $column->delete(true);
+ }
+
+ // Delete any dataSet rss
+ $this->getStore()->update('DELETE FROM `datasetrss` WHERE dataSetId = :dataSetId', ['dataSetId' => $this->dataSetId]);
+
+ // Delete the data set
+ $this->getStore()->update('DELETE FROM `dataset` WHERE dataSetId = :dataSetId', ['dataSetId' => $this->dataSetId]);
+
+ // The last thing we do is drop the dataSet table
+ $this->dropTable();
+ }
+
+ /**
+ * Delete all data
+ */
+ public function deleteData()
+ {
+ // The last thing we do is drop the dataSet table
+ $this->getStore()->update('TRUNCATE TABLE `dataset_' . $this->dataSetId . '`', []);
+ $this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` AUTO_INCREMENT = 1', []);
+ $this->getStore()->commitIfNecessary();
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $columns = 'DataSet, Description, UserID, `code`, `isLookup`, `isRemote`, `lastDataEdit`,';
+ $columns .= '`lastClear`, `folderId`, `permissionsFolderId`, `isRealTime`, `dataConnectorSource`';
+ $values = ':dataSet, :description, :userId, :code, :isLookup, :isRemote,';
+ $values .= ':lastDataEdit, :lastClear, :folderId, :permissionsFolderId, :isRealTime, :dataConnectorSource';
+
+ $params = [
+ 'dataSet' => $this->dataSet,
+ 'description' => $this->description,
+ 'userId' => $this->userId,
+ 'code' => ($this->code == '') ? null : $this->code,
+ 'isLookup' => $this->isLookup,
+ 'isRemote' => $this->isRemote,
+ 'isRealTime' => $this->isRealTime,
+ 'dataConnectorSource' => $this->dataConnectorSource,
+ 'lastDataEdit' => 0,
+ 'lastClear' => 0,
+ 'folderId' => ($this->folderId === null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this-> permissionsFolderId,
+ ];
+
+ // Insert the extra columns we expect for a remote DataSet
+ if ($this->isRemote === 1) {
+ $columns .= ', `method`, `uri`, `postData`, `authentication`, `username`, `password`, `customHeaders`, `userAgent`, `refreshRate`, `clearRate`, `truncateOnEmpty`, `runsAfter`, `dataRoot`, `lastSync`, `summarize`, `summarizeField`, `sourceId`, `ignoreFirstRow`, `rowLimit`, `limitPolicy`, `csvSeparator`';
+ $values .= ', :method, :uri, :postData, :authentication, :username, :password, :customHeaders, :userAgent, :refreshRate, :clearRate, :truncateOnEmpty, :runsAfter, :dataRoot, :lastSync, :summarize, :summarizeField, :sourceId, :ignoreFirstRow, :rowLimit, :limitPolicy, :csvSeparator';
+
+ $params['method'] = $this->method;
+ $params['uri'] = $this->uri;
+ $params['postData'] = $this->postData;
+ $params['authentication'] = $this->authentication;
+ $params['username'] = $this->username;
+ $params['password'] = $this->password;
+ $params['customHeaders'] = $this->customHeaders;
+ $params['userAgent'] = $this->userAgent;
+ $params['refreshRate'] = $this->refreshRate;
+ $params['clearRate'] = $this->clearRate;
+ $params['truncateOnEmpty'] = $this->truncateOnEmpty ?? 0;
+ $params['runsAfter'] = $this->runsAfter;
+ $params['dataRoot'] = $this->dataRoot;
+ $params['summarize'] = $this->summarize;
+ $params['summarizeField'] = $this->summarizeField;
+ $params['sourceId'] = $this->sourceId;
+ $params['ignoreFirstRow'] = $this->ignoreFirstRow;
+ $params['lastSync'] = 0;
+ $params['rowLimit'] = $this->rowLimit;
+ $params['limitPolicy'] = $this->limitPolicy;
+ $params['csvSeparator'] = $this->csvSeparator;
+ }
+
+ // Do the insert
+ $this->dataSetId = $this->getStore()->insert('INSERT INTO `dataset` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // Create the data table for this dataSet
+ $this->createTable();
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $sql = '
+ `DataSet` = :dataSet,
+ `Description` = :description,
+ `userId` = :userId,
+ `lastDataEdit` = :lastDataEdit,
+ `code` = :code,
+ `isLookup` = :isLookup,
+ `isRemote` = :isRemote,
+ `isRealTime` = :isRealTime,
+ `dataConnectorSource` = :dataConnectorSource,
+ `folderId` = :folderId,
+ `permissionsFolderId` = :permissionsFolderId
+ ';
+ $params = [
+ 'dataSetId' => $this->dataSetId,
+ 'dataSet' => $this->dataSet,
+ 'description' => $this->description,
+ 'userId' => $this->userId,
+ 'lastDataEdit' => $this->lastDataEdit,
+ 'code' => $this->code,
+ 'isLookup' => $this->isLookup,
+ 'isRemote' => $this->isRemote,
+ 'isRealTime' => $this->isRealTime,
+ 'dataConnectorSource' => $this->dataConnectorSource,
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId
+ ];
+
+ if ($this->isRemote) {
+ $sql .= ', method = :method, uri = :uri, postData = :postData, authentication = :authentication, `username` = :username, `password` = :password, `customHeaders` = :customHeaders, `userAgent` = :userAgent, refreshRate = :refreshRate, clearRate = :clearRate, truncateOnEmpty = :truncateOnEmpty, runsAfter = :runsAfter, `dataRoot` = :dataRoot, `summarize` = :summarize, `summarizeField` = :summarizeField, `sourceId` = :sourceId, `ignoreFirstRow` = :ignoreFirstRow , `rowLimit` = :rowLimit, `limitPolicy` = :limitPolicy, `csvSeparator` = :csvSeparator ';
+
+ $params['method'] = $this->method;
+ $params['uri'] = $this->uri;
+ $params['postData'] = $this->postData;
+ $params['authentication'] = $this->authentication;
+ $params['username'] = $this->username;
+ $params['password'] = $this->password;
+ $params['customHeaders'] = $this->customHeaders;
+ $params['userAgent'] = $this->userAgent;
+ $params['refreshRate'] = $this->refreshRate;
+ $params['clearRate'] = $this->clearRate;
+ $params['truncateOnEmpty'] = $this->truncateOnEmpty ?? 0;
+ $params['runsAfter'] = $this->runsAfter;
+ $params['dataRoot'] = $this->dataRoot;
+ $params['summarize'] = $this->summarize;
+ $params['summarizeField'] = $this->summarizeField;
+ $params['sourceId'] = $this->sourceId;
+ $params['ignoreFirstRow'] = $this->ignoreFirstRow;
+ $params['rowLimit'] = $this->rowLimit;
+ $params['limitPolicy'] = $this->limitPolicy;
+ $params['csvSeparator'] = $this->csvSeparator;
+ }
+
+ $this->getStore()->update('UPDATE dataset SET ' . $sql . ' WHERE DataSetID = :dataSetId', $params);
+ }
+
+ /**
+ * Create the realised table structure for this DataSet
+ */
+ private function createTable()
+ {
+ // Create the data table for this dataset
+ $this->getStore()->update('
+ CREATE TABLE `dataset_' . $this->dataSetId . '` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ PRIMARY KEY (`id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 AUTO_INCREMENT=1
+ ', []);
+ }
+
+ private function dropTable()
+ {
+ $this->getStore()->update('DROP TABLE IF EXISTS dataset_' . $this->dataSetId, [], 'isolated', false, false, true);
+ }
+
+ /**
+ * Rebuild the dataSet table
+ * @throws GeneralException
+ */
+ public function rebuild()
+ {
+ $this->load();
+
+ // Drop the data table
+ $this->dropTable();
+
+ // Add the data table
+ $this->createTable();
+
+ foreach ($this->columns as $column) {
+ /* @var \Xibo\Entity\DataSetColumn $column */
+ $column->dataSetId = $this->dataSetId;
+ $column->save(['rebuilding' => true]);
+ }
+ }
+
+ /**
+ * Notify displays of this campaign change
+ */
+ public function notify()
+ {
+ $this->getLog()->debug('DataSet ' . $this->dataSetId . ' wants to notify');
+
+ $this->getDisplayNotifyService()->collectNow()->notifyByDataSetId($this->dataSetId);
+ }
+
+ /**
+ * Add a row
+ * @param array $row
+ * @return int
+ */
+ public function addRow($row)
+ {
+ $this->getLog()->debug('Adding row ' . var_export($row, true));
+
+ // Update the last edit date on this dataSet
+ $this->lastDataEdit = Carbon::now()->format('U');
+
+ // Build a query to insert
+ $params = [];
+ $keys = array_keys($row);
+
+ $sql = 'INSERT INTO `dataset_' . $this->dataSetId
+ . '` (`' . implode('`, `', $keys) . '`) VALUES (';
+
+ $i = 0;
+ foreach ($row as $value) {
+ $i++;
+ $sql .= ':value' . $i . ',';
+ $params['value' . $i] = $value;
+ }
+ $sql = rtrim($sql, ',');
+ $sql .= ')';
+
+ return $this->getStore()->insert($sql, $params);
+ }
+
+ /**
+ * Edit a row
+ * @param int $rowId
+ * @param array $row
+ */
+ public function editRow($rowId, $row)
+ {
+ $this->getLog()->debug(sprintf('Editing row %s', var_export($row, true)));
+
+ // Update the last edit date on this dataSet
+ $this->lastDataEdit = Carbon::now()->format('U');
+
+ // Params
+ $params = ['id' => $rowId];
+
+ // Generate a SQL statement
+ $sql = 'UPDATE `dataset_' . $this->dataSetId . '` SET';
+
+ $i = 0;
+ foreach ($row as $key => $value) {
+ $i++;
+ $sql .= ' `' . $key . '` = :value' . $i . ',';
+ $params['value' . $i] = $value;
+ }
+
+ $sql = rtrim($sql, ',');
+
+ $sql .= ' WHERE `id` = :id ';
+
+
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Delete Row
+ * @param $rowId
+ */
+ public function deleteRow($rowId)
+ {
+ $this->lastDataEdit = Carbon::now()->format('U');
+
+ $this->getStore()->update('DELETE FROM `dataset_' . $this->dataSetId . '` WHERE id = :id', [
+ 'id' => $rowId
+ ]);
+ }
+
+ /**
+ * Copy Row
+ * @param int $dataSetIdSource
+ * @param int $dataSetIdTarget
+ */
+ public function copyRows($dataSetIdSource, $dataSetIdTarget)
+ {
+ $this->getStore()->insert('INSERT INTO `dataset_' . $dataSetIdTarget . '` SELECT * FROM `dataset_' . $dataSetIdSource . '` ' ,[]);
+ }
+
+ /**
+ * Clear DataSet cache
+ */
+ public function clearCache()
+ {
+ $this->getLog()->debug('Force sync detected, clear cache for remote dataSet ID ' . $this->dataSetId);
+ $this->pool->deleteItem('/dataset/cache/' . $this->dataSetId);
+ }
+
+ private function getScriptPath(): string
+ {
+ return $this->config->getSetting('LIBRARY_LOCATION')
+ . 'data_connectors' . DIRECTORY_SEPARATOR
+ . 'dataSet_' . $this->dataSetId . '.js';
+ }
+
+ public function getScript(): string
+ {
+ if ($this->isRealTime == 0) {
+ return '';
+ }
+
+ $path = $this->getScriptPath();
+ return (file_exists($path))
+ ? file_get_contents($path)
+ : '';
+ }
+
+ public function saveScript(string $script): void
+ {
+ if ($this->isRealTime == 1) {
+ $path = $this->getScriptPath();
+ file_put_contents($path, $script);
+ file_put_contents($path . '.md5', md5_file($path));
+ }
+ }
+}
diff --git a/lib/Entity/DataSetColumn.php b/lib/Entity/DataSetColumn.php
new file mode 100644
index 0000000..a88fffb
--- /dev/null
+++ b/lib/Entity/DataSetColumn.php
@@ -0,0 +1,521 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Illuminate\Support\Str;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\DataSetColumnFactory;
+use Xibo\Factory\DataSetColumnTypeFactory;
+use Xibo\Factory\DataTypeFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Definition\Sql;
+
+/**
+ * Class DataSetColumn
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DataSetColumn implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this DataSetColumn")
+ * @var int
+ */
+ public $dataSetColumnId;
+
+ /**
+ * @SWG\Property(description="The ID of the DataSet that this Column belongs to")
+ * @var int
+ */
+ public $dataSetId;
+
+ /**
+ * @SWG\Property(description="The Column Heading")
+ * @var string
+ */
+ public $heading;
+
+ /**
+ * @SWG\Property(description="The ID of the DataType for this Column")
+ * @var int
+ */
+ public $dataTypeId;
+
+ /**
+ * @SWG\Property(description="The ID of the ColumnType for this Column")
+ * @var int
+ */
+ public $dataSetColumnTypeId;
+
+ /**
+ * @SWG\Property(description="Comma separated list of valid content for drop down columns")
+ * @var string
+ */
+ public $listContent;
+
+ /**
+ * @SWG\Property(description="The order this column should be displayed")
+ * @var int
+ */
+ public $columnOrder;
+
+ /**
+ * @SWG\Property(description="A MySQL formula for this column")
+ * @var string
+ */
+ public $formula;
+
+ /**
+ * @SWG\Property(description="The data type for this Column")
+ * @var string
+ */
+ public $dataType;
+
+ /**
+ * @SWG\Property(description="The data field of the remote DataSet as a JSON-String")
+ * @var string
+ */
+ public $remoteField;
+
+ /**
+ * @SWG\Property(description="Does this column show a filter on the data entry page?")
+ * @var string
+ */
+ public $showFilter = 0;
+
+ /**
+ * @SWG\Property(description="Does this column allow a sorting on the data entry page?")
+ * @var string
+ */
+ public $showSort = 0;
+
+ /**
+ * @SWG\Property(description="The column type for this Column")
+ * @var string
+ */
+ public $dataSetColumnType;
+
+ /**
+ * @SWG\Property(description="Help text that should be displayed when entering data for this Column.")
+ * @var string
+ */
+ public $tooltip;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether value must be provided for this Column.")
+ * @var int
+ */
+ public $isRequired = 0;
+
+ /**
+ * @SWG\Property(description="Date format of dates in the source for remote DataSet.")
+ * @var string
+ */
+ public $dateFormat;
+
+ /** @var DataSetColumnFactory */
+ private $dataSetColumnFactory;
+
+ /** @var DataTypeFactory */
+ private $dataTypeFactory;
+
+ /** @var DataSetColumnTypeFactory */
+ private $dataSetColumnTypeFactory;
+
+ /**
+ * The prior dataset column id, when cloning
+ * @var int
+ */
+ public $priorDatasetColumnId;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param DataSetColumnFactory $dataSetColumnFactory
+ * @param DataTypeFactory $dataTypeFactory
+ * @param DataSetColumnTypeFactory $dataSetColumnTypeFactory
+ */
+ public function __construct($store, $log, $dispatcher, $dataSetColumnFactory, $dataTypeFactory, $dataSetColumnTypeFactory)
+ {
+ $this->excludeProperty('priorDatasetColumnId');
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->dataSetColumnFactory = $dataSetColumnFactory;
+ $this->dataTypeFactory = $dataTypeFactory;
+ $this->dataSetColumnTypeFactory = $dataSetColumnTypeFactory;
+ }
+
+ /**
+ * Clone
+ */
+ public function __clone()
+ {
+ $this->priorDatasetColumnId = $this->dataSetColumnId;
+ $this->dataSetColumnId = null;
+ $this->dataSetId = null;
+ }
+
+ /**
+ * List Content Array
+ * @return array
+ */
+ public function listContentArray()
+ {
+ return explode(',', $this->listContent);
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ */
+ public function validate($options = []): void
+ {
+ $options = array_merge([
+ 'testFormulas' => true,
+ 'allowSpacesInHeading' => false,
+ ], $options);
+
+ if ($this->dataSetId == 0 || $this->dataSetId == '') {
+ throw new InvalidArgumentException(__('Missing dataSetId'), 'dataSetId');
+ }
+
+ if ($this->dataTypeId == 0 || $this->dataTypeId == '') {
+ throw new InvalidArgumentException(__('Missing dataTypeId'), 'dataTypeId');
+ }
+
+ if ($this->dataSetColumnTypeId == 0 || $this->dataSetColumnTypeId == '') {
+ throw new InvalidArgumentException(__('Missing dataSetColumnTypeId'), 'dataSetColumnTypeId');
+ }
+
+ if ($this->heading == '') {
+ throw new InvalidArgumentException(__('Please provide a column heading.'), 'heading');
+ }
+
+ // Column heading should not allow reserved/disallowed SQL words
+ if (Str::contains($this->heading, Sql::DISALLOWED_KEYWORDS, true)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('Headings cannot contain reserved words, such as %s'),
+ implode(', ', Sql::DISALLOWED_KEYWORDS),
+ ),
+ 'heading',
+ );
+ }
+
+ // We allow spaces here for backwards compatibility, but only on import and edit.
+ $additionalCharacters = $options['allowSpacesInHeading'] ? ' ' : '';
+ if (!v::stringType()->alnum($additionalCharacters)->validate($this->heading)
+ || strtolower($this->heading) == 'id'
+ ) {
+ throw new InvalidArgumentException(sprintf(
+ __('Please provide an alternative column heading %s can not be used.'),
+ $this->heading
+ ), 'heading');
+ }
+
+ if ($this->dataSetColumnTypeId == 2 && $this->formula == '') {
+ throw new InvalidArgumentException(__('Please enter a valid formula'), 'formula');
+ }
+
+ // Make sure this column name is unique
+ $columns = $this->dataSetColumnFactory->getByDataSetId($this->dataSetId);
+
+ foreach ($columns as $column) {
+ if ($column->heading == $this->heading
+ && ($this->dataSetColumnId == null || $column->dataSetColumnId != $this->dataSetColumnId)
+ ) {
+ throw new InvalidArgumentException(
+ __('A column already exists with this name, please choose another'),
+ 'heading',
+ );
+ }
+ }
+
+ // Check the actual values
+ try {
+ $this->dataTypeFactory->getById($this->dataTypeId);
+ } catch (NotFoundException $e) {
+ throw new InvalidArgumentException(__('Provided Data Type doesn\'t exist'), 'datatype');
+ }
+
+ try {
+ $dataSetColumnType = $this->dataSetColumnTypeFactory->getById($this->dataSetColumnTypeId);
+
+ // If we are a remote column, validate we have a field
+ if (strtolower($dataSetColumnType->dataSetColumnType) === 'remote'
+ && ($this->remoteField === '' || $this->remoteField === null)) {
+ throw new InvalidArgumentException(
+ __('Remote field is required when the column type is set to Remote'),
+ 'remoteField',
+ );
+ }
+ } catch (NotFoundException) {
+ throw new InvalidArgumentException(
+ __('Provided DataSet Column Type doesn\'t exist'),
+ 'dataSetColumnTypeId',
+ );
+ }
+
+ // Should we validate the list content?
+ if ($this->dataSetColumnId != 0 && $this->listContent != '') {
+ // Look up all DataSet data in this table to make sure that the existing data is covered by the list content
+ $list = $this->listContentArray();
+
+ // Add an empty field
+ $list[] = '';
+
+ // We can check this is valid by building up a NOT IN sql statement, if we get results we know it's not good
+ $select = '';
+
+ $dbh = $this->getStore()->getConnection('isolated');
+
+ for ($i=0; $i < count($list); $i++) {
+ if (!empty($list[$i])) {
+ $list_val = $dbh->quote($list[$i]);
+ $select .= $list_val . ',';
+ }
+ }
+
+ $select = rtrim($select, ',');
+
+ // $select has been quoted in the for loop
+ // always test the original value of the column (we won't have changed the actualised table yet)
+ $SQL = 'SELECT id FROM `dataset_' . $this->dataSetId . '` WHERE `' . $this->getOriginalValue('heading') . '` NOT IN (' . $select . ')';//phpcs:ignore
+
+ $sth = $dbh->prepare($SQL);
+ $sth->execute();
+
+ if ($sth->fetch()) {
+ throw new InvalidArgumentException(
+ __('New list content value is invalid as it does not include values for existing data'),
+ 'listcontent'
+ );
+ }
+ }
+
+ // if formula dataSetType is set and formula is not empty, try to execute the SQL to validate it - we're
+ // ignoring client side formulas here.
+ if ($options['testFormulas']
+ && $this->dataSetColumnTypeId == 2
+ && $this->formula != ''
+ && !str_starts_with($this->formula, '$')
+ ) {
+ try {
+ $count = 0;
+ $formula = str_ireplace(
+ Sql::DISALLOWED_KEYWORDS,
+ '',
+ htmlspecialchars_decode($this->formula, ENT_QUOTES),
+ $count
+ );
+
+ if ($count > 0) {
+ throw new InvalidArgumentException(__('Formula contains disallowed keywords.'));
+ }
+
+ $formula = str_replace('[DisplayId]', 0, $formula);
+ $formula = str_replace('[DisplayGeoLocation]', "ST_GEOMFROMTEXT('POINT(51.504 -0.104)')", $formula);
+
+ $this->getStore()->select('
+ SELECT *
+ FROM (
+ SELECT `id`, ' . $formula . ' AS `' . $this->heading . '`
+ FROM `dataset_' . $this->dataSetId . '`
+ ) dataset
+ ', [], 'isolated');
+ } catch (\Exception $e) {
+ $this->getLog()->debug('Formula validation failed with following message ' . $e->getMessage());
+ throw new InvalidArgumentException(__('Provided formula is invalid'), 'formula');
+ }
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'rebuilding' => false,
+ ], $options);
+
+ if ($options['validate'] && !$options['rebuilding']) {
+ $this->validate($options);
+ }
+
+ if ($this->dataSetColumnId == 0) {
+ $this->add();
+ } else {
+ $this->edit($options);
+ }
+ }
+
+ /**
+ * Delete
+ */
+ public function delete(bool $isDeletingDataset = false): void
+ {
+ $this->getStore()->update('DELETE FROM `datasetcolumn` WHERE DataSetColumnID = :dataSetColumnId', ['dataSetColumnId' => $this->dataSetColumnId]);
+
+ // Delete column (unless remote, or dropping the whole dataset)
+ if (!$isDeletingDataset && $this->dataSetColumnTypeId !== 2) {
+ $this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` DROP `' . $this->heading . '`', []);
+ }
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->dataSetColumnId = $this->getStore()->insert('
+ INSERT INTO `datasetcolumn` (DataSetID, Heading, DataTypeID, ListContent, ColumnOrder, DataSetColumnTypeID, Formula, RemoteField, `showFilter`, `showSort`, `tooltip`, `isRequired`, `dateFormat`)
+ VALUES (:dataSetId, :heading, :dataTypeId, :listContent, :columnOrder, :dataSetColumnTypeId, :formula, :remoteField, :showFilter, :showSort, :tooltip, :isRequired, :dateFormat)
+ ', [
+ 'dataSetId' => $this->dataSetId,
+ 'heading' => $this->heading,
+ 'dataTypeId' => $this->dataTypeId,
+ 'listContent' => $this->listContent,
+ 'columnOrder' => $this->columnOrder,
+ 'dataSetColumnTypeId' => $this->dataSetColumnTypeId,
+ 'formula' => $this->formula,
+ 'remoteField' => $this->remoteField,
+ 'showFilter' => $this->showFilter,
+ 'showSort' => $this->showSort,
+ 'tooltip' => $this->tooltip,
+ 'isRequired' => $this->isRequired,
+ 'dateFormat' => $this->dateFormat
+ ]);
+
+ // Add Column to Underlying Table
+ if (($this->dataSetColumnTypeId == 1) || ($this->dataSetColumnTypeId == 3)) {
+ // Use a separate connection for DDL (it operates outside transactions)
+ $this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` ADD `' . $this->heading . '` ' . $this->sqlDataType() . ' NULL', [], 'isolated', false, false);
+ }
+ }
+
+ /**
+ * Edit
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ private function edit($options)
+ {
+ $params = [
+ 'dataSetId' => $this->dataSetId,
+ 'heading' => $this->heading,
+ 'dataTypeId' => $this->dataTypeId,
+ 'listContent' => $this->listContent,
+ 'columnOrder' => $this->columnOrder,
+ 'dataSetColumnTypeId' => $this->dataSetColumnTypeId,
+ 'formula' => $this->formula,
+ 'dataSetColumnId' => $this->dataSetColumnId,
+ 'remoteField' => $this->remoteField,
+ 'showFilter' => $this->showFilter,
+ 'showSort' => $this->showSort,
+ 'tooltip' => $this->tooltip,
+ 'isRequired' => $this->isRequired,
+ 'dateFormat' => $this->dateFormat
+ ];
+
+ $sql = '
+ UPDATE `datasetcolumn` SET
+ dataSetId = :dataSetId,
+ Heading = :heading,
+ ListContent = :listContent,
+ ColumnOrder = :columnOrder,
+ DataTypeID = :dataTypeId,
+ DataSetColumnTypeID = :dataSetColumnTypeId,
+ Formula = :formula,
+ RemoteField = :remoteField,
+ `showFilter` = :showFilter,
+ `showSort` = :showSort,
+ `tooltip` = :tooltip,
+ `isRequired` = :isRequired,
+ `dateFormat` = :dateFormat
+ WHERE dataSetColumnId = :dataSetColumnId
+ ';
+
+ $this->getStore()->update($sql, $params);
+
+ try {
+ if ($options['rebuilding'] && ($this->dataSetColumnTypeId == 1 || $this->dataSetColumnTypeId == 3)) {
+ $this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` ADD `' . $this->heading . '` ' . $this->sqlDataType() . ' NULL', [], 'isolated', false, false);
+
+ } else if (($this->dataSetColumnTypeId == 1 || $this->dataSetColumnTypeId == 3)
+ && ($this->hasPropertyChanged('heading') || $this->hasPropertyChanged('dataTypeId'))) {
+ $sql = 'ALTER TABLE `dataset_' . $this->dataSetId . '` CHANGE `' . $this->getOriginalValue('heading') . '` `' . $this->heading . '` ' . $this->sqlDataType() . ' NULL DEFAULT NULL';
+ $this->getStore()->update($sql, [], 'isolated', false, false);
+ }
+ } catch (\PDOException $PDOException) {
+ $this->getLog()->error('Unable to change DataSetColumn because ' . $PDOException->getMessage());
+ throw new InvalidArgumentException(__('Existing data is incompatible with your new configuration'), 'dataSetData');
+ }
+ }
+
+ /**
+ * Get the SQL Data Type for this Column Definition
+ * @return string
+ */
+ private function sqlDataType()
+ {
+ $dataType = null;
+
+ switch ($this->dataTypeId) {
+
+ case 2:
+ $dataType = 'DOUBLE';
+ break;
+
+ case 3:
+ $dataType = 'DATETIME';
+ break;
+
+ case 5:
+ $dataType = 'INT';
+ break;
+
+ case 1:
+ case 6:
+ $dataType = 'TEXT';
+ break;
+
+ case 4:
+ default:
+ $dataType = 'VARCHAR(1000)';
+ }
+
+ return $dataType;
+ }
+}
diff --git a/lib/Entity/DataSetColumnType.php b/lib/Entity/DataSetColumnType.php
new file mode 100644
index 0000000..0eb84ca
--- /dev/null
+++ b/lib/Entity/DataSetColumnType.php
@@ -0,0 +1,59 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class DataSetColumnType
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DataSetColumnType implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID for this DataSetColumnType")
+ * @var int
+ */
+ public $dataSetColumnTypeId;
+
+ /**
+ * @SWG\Property(description="The name for this DataSetColumnType")
+ * @var string
+ */
+ public $dataSetColumnType;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+}
diff --git a/lib/Entity/DataSetRss.php b/lib/Entity/DataSetRss.php
new file mode 100644
index 0000000..b034d34
--- /dev/null
+++ b/lib/Entity/DataSetRss.php
@@ -0,0 +1,164 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+
+use Xibo\Helper\Random;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class DataSetRss
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DataSetRss implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public $id;
+ public $dataSetId;
+ public $titleColumnId;
+ public $summaryColumnId;
+ public $contentColumnId;
+ public $publishedDateColumnId;
+
+ public $psk;
+ public $title;
+ public $author;
+
+ public $sort;
+ public $filter;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @return array|mixed
+ */
+ public function getFilter()
+ {
+ return ($this->filter == '') ? ['filter' => '', 'useFilteringClause' => 0, 'filterClauses' => []] : json_decode($this->filter, true);
+ }
+
+ /**
+ * @return array|mixed
+ */
+ public function getSort()
+ {
+ return ($this->sort == '') ? ['sort' => '', 'useOrderingClause' => 0, 'orderClauses' => []] : json_decode($this->sort, true);
+ }
+
+ /**
+ * Save
+ */
+ public function save()
+ {
+ if ($this->id == null) {
+ $this->add();
+
+ $this->audit($this->id, 'Added', []);
+ } else {
+ $this->edit();
+
+ $this->audit($this->id, 'Saved');
+ }
+ }
+
+ /**
+ * @return $this
+ * @throws \Exception
+ */
+ public function setNewPsk()
+ {
+ $this->psk = Random::generateString(12);
+ return $this;
+ }
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `datasetrss` WHERE id = :id', ['id' => $this->id]);
+
+ $this->audit($this->id, 'Deleted');
+ }
+
+ private function add()
+ {
+ $this->id = $this->getStore()->insert('
+ INSERT INTO datasetrss (dataSetId, psk, title, author, titleColumnId, summaryColumnId, contentColumnId, publishedDateColumnId, sort, filter) VALUES
+ (:dataSetId, :psk, :title, :author, :titleColumnId, :summaryColumnId, :contentColumnId, :publishedDateColumnId, :sort, :filter)
+ ', [
+ 'dataSetId' => $this->dataSetId,
+ 'psk' => $this->psk,
+ 'title' => $this->title,
+ 'author' => $this->author,
+ 'titleColumnId' => $this->titleColumnId,
+ 'summaryColumnId' => $this->summaryColumnId,
+ 'contentColumnId' => $this->contentColumnId,
+ 'publishedDateColumnId' => $this->publishedDateColumnId,
+ 'sort' => $this->sort,
+ 'filter' => $this->filter
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `datasetrss` SET
+ psk = :psk,
+ title = :title,
+ author = :author,
+ titleColumnId = :titleColumnId,
+ summaryColumnId = :summaryColumnId,
+ contentColumnId = :contentColumnId,
+ publishedDateColumnId = :publishedDateColumnId,
+ sort = :sort,
+ filter = :filter
+ WHERE id = :id
+ ', [
+ 'id' => $this->id,
+ 'psk' => $this->psk,
+ 'title' => $this->title,
+ 'author' => $this->author,
+ 'titleColumnId' => $this->titleColumnId,
+ 'summaryColumnId' => $this->summaryColumnId,
+ 'contentColumnId' => $this->contentColumnId,
+ 'publishedDateColumnId' => $this->publishedDateColumnId,
+ 'sort' => $this->sort,
+ 'filter' => $this->filter
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/DataType.php b/lib/Entity/DataType.php
new file mode 100644
index 0000000..5695498
--- /dev/null
+++ b/lib/Entity/DataType.php
@@ -0,0 +1,61 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+
+/**
+ * Class DataType
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DataType implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID for this DataType")
+ * @var int
+ */
+ public $dataTypeId;
+
+ /**
+ * @SWG\Property(description="The Name for this DataType")
+ * @var string
+ */
+ public $dataType;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+}
diff --git a/lib/Entity/DayPart.php b/lib/Entity/DayPart.php
new file mode 100644
index 0000000..b393e0a
--- /dev/null
+++ b/lib/Entity/DayPart.php
@@ -0,0 +1,357 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Xibo\Event\DayPartDeleteEvent;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DayPart
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DayPart implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Daypart")
+ * @var int
+ */
+ public $dayPartId;
+ public $name;
+ public $description;
+ public $isRetired;
+ public $userId;
+
+ public $startTime;
+ public $endTime;
+ public $exceptions;
+
+ /**
+ * @SWG\Property(description="A readonly flag determining whether this DayPart is always")
+ * @var int
+ */
+ public $isAlways = 0;
+
+ /**
+ * @SWG\Property(description="A readonly flag determining whether this DayPart is custom")
+ * @var int
+ */
+ public $isCustom = 0;
+
+ /** @var Carbon $adjustedStart Adjusted start datetime */
+ public $adjustedStart;
+
+ /** @var Carbon Adjusted end datetime */
+ public $adjustedEnd;
+
+ private $timeHash;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @param ScheduleFactory $scheduleFactory
+ * @param \Xibo\Service\DisplayNotifyServiceInterface $displayNotifyService
+ * @return $this
+ */
+ public function setScheduleFactory($scheduleFactory, DisplayNotifyServiceInterface $displayNotifyService)
+ {
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ return $this;
+ }
+ /**
+ * Calculate time hash
+ * @return string
+ */
+ private function calculateTimeHash()
+ {
+ $hash = $this->startTime . $this->endTime;
+
+ foreach ($this->exceptions as $exception) {
+ $hash .= $exception['day'] . $exception['start'] . $exception['end'];
+ }
+
+ return md5($hash);
+ }
+
+ public function isSystemDayPart(): bool
+ {
+ return ($this->isAlways || $this->isCustom);
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->dayPartId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->userId = $ownerId;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ $this->getLog()->debug('Validating daypart ' . $this->name);
+
+ if (!v::stringType()->notEmpty()->validate($this->name))
+ throw new InvalidArgumentException(__('Name cannot be empty'), 'name');
+
+ // Check the start/end times are in the correct format (H:i)
+ if ((strlen($this->startTime) != 8 && strlen($this->startTime) != 5) || (strlen($this->endTime) != 8 && strlen($this->endTime) != 5))
+ throw new InvalidArgumentException(__('Start/End time are empty or in an incorrect format'), 'start/end time');
+
+ foreach ($this->exceptions as $exception) {
+ if ((strlen($exception['start']) != 8 && strlen($exception['start']) != 5) || (strlen($exception['end']) != 8 && strlen($exception['end']) != 5))
+ throw new InvalidArgumentException(sprintf(__('Exception Start/End time for %s are empty or in an incorrect format'), $exception['day']), 'exception start/end time');
+ }
+ }
+
+ /**
+ * Load
+ * @return $this
+ */
+ public function load()
+ {
+ $this->timeHash = $this->calculateTimeHash();
+
+ return $this;
+ }
+
+ /**
+ * @param \Carbon\Carbon $date
+ * @return void
+ */
+ public function adjustForDate(Carbon $date)
+ {
+ // Matching exceptions?
+ // we use a lookup because the form control uses the below date abbreviations
+ $dayOfWeekLookup = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+ foreach ($this->exceptions as $exception) {
+ if ($exception['day'] === $dayOfWeekLookup[$date->dayOfWeekIso]) {
+ $this->adjustedStart = $date->copy()->setTimeFromTimeString($exception['start']);
+ $this->adjustedEnd = $date->copy()->setTimeFromTimeString($exception['end']);
+ if ($this->adjustedStart >= $this->adjustedEnd) {
+ $this->adjustedEnd->addDay();
+ }
+ return;
+ }
+ }
+
+ // No matching exceptions.
+ $this->adjustedStart = $date->copy()->setTimeFromTimeString($this->startTime);
+ $this->adjustedEnd = $date->copy()->setTimeFromTimeString($this->endTime);
+ if ($this->adjustedStart >= $this->adjustedEnd) {
+ $this->adjustedEnd->addDay();
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws InvalidArgumentException
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'recalculateHash' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->dayPartId == 0) {
+ $this->add();
+ } else {
+ // Update
+ $this->update();
+
+ // When we change user on reassignAllTo, we do save dayPart,
+ // however it will not have required childObjectDependencies to run the below checks
+ // it is also not needed to run them when we just changed the owner.
+ if ($options['recalculateHash']) {
+ // Compare the time hash with a new time hash to see if we need to update associated schedules
+ if ($this->timeHash != $this->calculateTimeHash()) {
+ $this->handleEffectedSchedules();
+ } else {
+ $this->getLog()->debug('Daypart hash identical, no need to update schedules. ' . $this->timeHash . ' vs ' . $this->calculateTimeHash());
+ }
+ }
+ }
+ }
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ if ($this->isSystemDayPart()) {
+ throw new InvalidArgumentException('Cannot delete system dayParts');
+ }
+
+ $this->getDispatcher()->dispatch(new DayPartDeleteEvent($this), DayPartDeleteEvent::$NAME);
+
+ // Delete all events using this daypart
+ $schedules = $this->scheduleFactory->getByDayPartId($this->dayPartId);
+
+ foreach ($schedules as $schedule) {
+ $schedule->delete();
+ }
+
+ // Delete the daypart
+ $this->getStore()->update('DELETE FROM `daypart` WHERE dayPartId = :dayPartId', ['dayPartId' => $this->dayPartId]);
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->dayPartId = $this->getStore()->insert('
+ INSERT INTO `daypart` (`name`, `description`, `isRetired`, `userId`, `startTime`, `endTime`, `exceptions`)
+ VALUES (:name, :description, :isRetired, :userId, :startTime, :endTime, :exceptions)
+ ', [
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'isRetired' => $this->isRetired,
+ 'userId' => $this->userId,
+ 'startTime' => $this->startTime,
+ 'endTime' => $this->endTime,
+ 'exceptions' => json_encode(is_array($this->exceptions) ? $this->exceptions : [])
+ ]);
+ }
+
+ /**
+ * Update
+ */
+ private function update()
+ {
+ $this->getStore()->update('
+ UPDATE `daypart`
+ SET `name` = :name,
+ `description` = :description,
+ `isRetired` = :isRetired,
+ `userId` = :userId,
+ `startTime` = :startTime,
+ `endTime` = :endTime,
+ `exceptions` = :exceptions
+ WHERE `daypart`.dayPartId = :dayPartId
+ ', [
+ 'dayPartId' => $this->dayPartId,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'isRetired' => $this->isRetired,
+ 'userId' => $this->userId,
+ 'startTime' => $this->startTime,
+ 'endTime' => $this->endTime,
+ 'exceptions' => json_encode(is_array($this->exceptions) ? $this->exceptions : [])
+ ]);
+ }
+
+ /**
+ * Handles schedules effected by an update
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ private function handleEffectedSchedules()
+ {
+ $now = Carbon::now()->format('U');
+
+ // Get all schedules that use this dayPart and exist after the current time.
+ $schedules = $this->scheduleFactory->query(null, ['dayPartId' => $this->dayPartId, 'futureSchedulesFrom' => $now]);
+
+ $this->getLog()->debug('Daypart update effects ' . count($schedules) . ' schedules.');
+
+ foreach ($schedules as $schedule) {
+ /** @var Schedule $schedule */
+ $schedule
+ ->setDisplayNotifyService($this->displayNotifyService)
+ ->load();
+
+ // Is this schedule a recurring event?
+ if ($schedule->recurrenceType != '' && $schedule->fromDt < $now) {
+ $this->getLog()->debug('Schedule is for a recurring event which has already recurred');
+
+ // Split the scheduled event, adjusting only the recurring end date on the original event
+ $newSchedule = clone $schedule;
+ $schedule->recurrenceRange = $now;
+ $schedule->save();
+
+ // Adjusting the fromdt on the new event
+ $newSchedule->fromDt = Carbon::now()->addDay()->format('U');
+ $newSchedule->save();
+ } else {
+ $this->getLog()->debug('Schedule is for a single event');
+
+ // Update just this single event to have the new date/time
+ $schedule->save();
+ }
+ }
+ }
+}
diff --git a/lib/Entity/Display.php b/lib/Entity/Display.php
new file mode 100644
index 0000000..27d57b9
--- /dev/null
+++ b/lib/Entity/Display.php
@@ -0,0 +1,1576 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\TriggerTaskEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\DisplayProfileFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DeadlockException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Display
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Display implements \JsonSerializable
+{
+ public static $STATUS_DONE = 1;
+ public static $STATUS_DOWNLOADING = 2;
+ public static $STATUS_PENDING = 3;
+
+ use EntityTrait;
+ use TagLinkTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Display")
+ * @var int
+ */
+ public $displayId;
+
+ /**
+ * @SWG\Property(description="The Display Type ID of this Display")
+ * @var int
+ */
+ public $displayTypeId;
+
+ /**
+ * @SWG\Property(description="The Venue ID of this Display")
+ * @var int
+ */
+ public $venueId;
+
+ /**
+ * @SWG\Property(description="The Location Address of this Display")
+ * @var string
+ */
+ public $address;
+
+ /**
+ * @SWG\Property(description="Is this Display mobile?")
+ * @var int
+ */
+ public $isMobile;
+
+ /**
+ * @SWG\Property(description="The Languages supported in this display location")
+ * @var string
+ */
+ public $languages;
+
+ /**
+ * @SWG\Property(description="The type of this Display")
+ * @var string
+ */
+ public $displayType;
+
+ /**
+ * @SWG\Property(description="The screen size of this Display")
+ * @var int
+ */
+ public $screenSize;
+
+ /**
+ * @SWG\Property(description="Is this Display Outdoor?")
+ * @var int
+ */
+ public $isOutdoor;
+
+ /**
+ * @SWG\Property(description="The custom ID (an Id of any external system) of this Display")
+ * @var string
+ */
+ public $customId;
+
+ /**
+ * @SWG\Property(description="The Cost Per Play of this Display")
+ * @var double
+ */
+ public $costPerPlay;
+
+ /**
+ * @SWG\Property(description="The Impressions Per Play of this Display")
+ * @var double
+ */
+ public $impressionsPerPlay;
+
+ /**
+ * @SWG\Property(description="Optional Reference 1")
+ * @var string
+ */
+ public $ref1;
+
+ /**
+ * @SWG\Property(description="Optional Reference 2")
+ * @var string
+ */
+ public $ref2;
+
+ /**
+ * @SWG\Property(description="Optional Reference 3")
+ * @var string
+ */
+ public $ref3;
+
+ /**
+ * @SWG\Property(description="Optional Reference 4")
+ * @var string
+ */
+ public $ref4;
+
+ /**
+ * @SWG\Property(description="Optional Reference 5")
+ * @var string
+ */
+ public $ref5;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this Display is recording Auditing Information from XMDS")
+ * @var int
+ */
+ public $auditingUntil;
+
+ /**
+ * @SWG\Property(description="The Name of this Display")
+ * @var string
+ */
+ public $display;
+
+ /**
+ * @SWG\Property(description="The Description of this Display")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="The ID of the Default Layout")
+ * @var int
+ */
+ public $defaultLayoutId = 4;
+
+ /**
+ * @SWG\Property(description="The Display Unique Identifier also called hardware key")
+ * @var string
+ */
+ public $license;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this Display is licensed or not")
+ * @var int
+ */
+ public $licensed;
+ private $currentlyLicensed;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this Display is currently logged in")
+ * @var int
+ */
+ public $loggedIn;
+
+ /**
+ * @SWG\Property(description="A timestamp in CMS time for the last time the Display accessed XMDS")
+ * @var int
+ */
+ public $lastAccessed;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the default layout is interleaved with the Schedule")
+ * @var int
+ */
+ public $incSchedule;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the Display will send email alerts.")
+ * @var int
+ */
+ public $emailAlert;
+
+ /**
+ * @SWG\Property(description="A timeout in seconds for the Display to send email alerts.")
+ * @var int
+ */
+ public $alertTimeout;
+
+ /**
+ * @SWG\Property(description="The MAC Address of the Display")
+ * @var string
+ */
+ public $clientAddress;
+
+ /**
+ * @SWG\Property(description="The media inventory status of the Display")
+ * @var int
+ */
+ public $mediaInventoryStatus;
+
+ /**
+ * @SWG\Property(description="The current Mac Address of the Player")
+ * @var string
+ */
+ public $macAddress;
+
+ /**
+ * @SWG\Property(description="A timestamp indicating the last time the Mac Address changed")
+ * @var int
+ */
+ public $lastChanged;
+
+ /**
+ * @SWG\Property(description="A count of Mac Address changes")
+ * @var int
+ */
+ public $numberOfMacAddressChanges;
+
+ /**
+ * @SWG\Property(description="A timestamp indicating the last time a WOL command was sent")
+ * @var int
+ */
+ public $lastWakeOnLanCommandSent;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether Wake On Lan is enabled")
+ * @var int
+ */
+ public $wakeOnLanEnabled;
+
+ /**
+ * @SWG\Property(description="A h:i string indicating the time to send a WOL command")
+ * @var string
+ */
+ public $wakeOnLanTime;
+
+ /**
+ * @SWG\Property(description="The broad cast address for this Display")
+ * @var string
+ */
+ public $broadCastAddress;
+
+ /**
+ * @SWG\Property(description="The secureOn WOL settings for this display.")
+ * @var string
+ */
+ public $secureOn;
+
+ /**
+ * @SWG\Property(description="The CIDR WOL settings for this display")
+ * @var string
+ */
+ public $cidr;
+
+ /**
+ * @SWG\Property(description="The display Latitude")
+ * @var double
+ */
+ public $latitude;
+
+ /**
+ * @SWG\Property(description="The display longitude")
+ * @var double
+ */
+ public $longitude;
+
+ /**
+ * @SWG\Property(description="A string representing the player type")
+ * @var string
+ */
+ public $clientType;
+
+ /**
+ * @SWG\Property(description="A string representing the player version")
+ * @var string
+ */
+ public $clientVersion;
+
+ /**
+ * @SWG\Property(description="A number representing the Player version code")
+ * @var int
+ */
+ public $clientCode;
+
+ /**
+ * @SWG\Property(description="The display settings profile ID for this Display")
+ * @var int
+ */
+ public $displayProfileId;
+
+ /**
+ * @SWG\Property(description="The current layout ID reported via XMDS")
+ * @var int
+ */
+ public $currentLayoutId;
+
+ /**
+ * @SWG\Property(description="A flag indicating that a screen shot should be taken by the Player")
+ * @var int
+ */
+ public $screenShotRequested;
+
+ /**
+ * @SWG\Property(description="The number of bytes of storage available on the device.")
+ * @var int
+ */
+ public $storageAvailableSpace;
+
+ /**
+ * @SWG\Property(description="The number of bytes of storage in total on the device")
+ * @var int
+ */
+ public $storageTotalSpace;
+
+ /**
+ * @SWG\Property(description="The ID of the Display Group for this Device")
+ * @var int
+ */
+ public $displayGroupId;
+
+ /**
+ * @SWG\Property(description="The current layout")
+ * @var string
+ */
+ public $currentLayout;
+
+ /**
+ * @SWG\Property(description="The default layout")
+ * @var string
+ */
+ public $defaultLayout;
+
+ /**
+ * @SWG\Property(description="The Display Groups this Display belongs to")
+ * @var DisplayGroup[]
+ */
+ public $displayGroups = [];
+
+ /**
+ * @SWG\Property(description="The Player Subscription Channel")
+ * @var string
+ */
+ public $xmrChannel;
+
+ /**
+ * @SWG\Property(description="The Player Public Key")
+ * @var string
+ */
+ public $xmrPubKey;
+
+ /**
+ * @SWG\Property(description="The last command success, 0 = failure, 1 = success, 2 = unknown")
+ * @var int
+ */
+ public $lastCommandSuccess = 0;
+
+ /**
+ * @SWG\Property(description="The Device Name for the device hardware associated with this Display")
+ * @var string
+ */
+ public $deviceName;
+
+ /**
+ * @SWG\Property(description="The Display Timezone, or empty to use the CMS timezone")
+ * @var string
+ */
+ public $timeZone;
+
+ /**
+ * @SWG\Property(description="Tags associated with this Display, array of TagLink objects")
+ * @var TagLink[]
+ */
+ public $tags = [];
+
+ /**
+ * @SWG\Property(description="The configuration options that will overwrite Display Profile Config")
+ * @var string|array
+ */
+ public $overrideConfig = [];
+
+ /**
+ * @SWG\Property(description="The display bandwidth limit")
+ * @var int
+ */
+ public $bandwidthLimit;
+
+ /**
+ * @SWG\Property(description="The new CMS Address")
+ * @var string
+ */
+ public $newCmsAddress;
+
+ /**
+ * @SWG\Property(description="The new CMS Key")
+ * @var string
+ */
+ public $newCmsKey;
+
+ /**
+ * @SWG\Property(description="The orientation of the Display, either landscape or portrait")
+ * @var string
+ */
+ public $orientation;
+
+ /**
+ * @SWG\Property(description="The resolution of the Display expressed as a string in the format WxH")
+ * @var string
+ */
+ public $resolution;
+
+ /**
+ * @SWG\Property(description="Status of the commercial licence for this Display. 0 - Not licensed, 1 - licensed, 2 - trial licence, 3 - not applicable")
+ * @var int
+ */
+ public $commercialLicence;
+
+ /**
+ * @SWG\Property(description="The TeamViewer serial number for this Display")
+ * @var string
+ */
+ public $teamViewerSerial;
+
+ /**
+ * @SWG\Property(description="The Webkey serial number for this Display")
+ * @var string
+ */
+ public $webkeySerial;
+
+ /**
+ * @SWG\Property(description="A comma separated list of groups/users with permissions to this Display")
+ * @var string
+ */
+ public $groupsWithPermissions;
+
+ /**
+ * @SWG\Property(description="The datetime this entity was created")
+ * @var string
+ */
+ public $createdDt;
+
+ /**
+ * @SWG\Property(description="The datetime this entity was last modified")
+ * @var string
+ */
+ public $modifiedDt;
+
+ /**
+ * @SWG\Property(description="The id of the Folder this Display belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Display")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ /**
+ * @SWG\Property(description="The count of Player reported faults")
+ * @var int
+ */
+ public $countFaults;
+
+ /**
+ * @SWG\Property(description="LAN IP Address, if available on the Player")
+ * @var string
+ */
+ public $lanIpAddress;
+
+ /**
+ * @SWG\Property(description="The Display Group ID this Display is synced to")
+ * @var int
+ */
+ public $syncGroupId;
+
+ /**
+ * @SWG\Property(description="The OS version of the Display")
+ * @var string
+ */
+ public $osVersion;
+
+ /**
+ * @SWG\Property(description="The SDK version of the Display")
+ * @var string
+ */
+ public $osSdk;
+
+ /**
+ * @SWG\Property(description="The manufacturer of the Display")
+ * @var string
+ */
+ public $manufacturer;
+
+ /**
+ * @SWG\Property(description="The brand of the Display")
+ * @var string
+ */
+ public $brand;
+
+ /**
+ * @SWG\Property(description="The model of the Display")
+ * @var string
+ */
+ public $model;
+
+ /** @var array The configuration from the Display Profile */
+ private $profileConfig;
+
+ /** @var array Combined config */
+ private $combinedConfig;
+
+ /** @var \Xibo\Entity\DisplayProfile the resolved DisplayProfile for this Display */
+ private $_displayProfile;
+
+ private $datesToFormat = ['auditingUntil'];
+
+ /**
+ * Commands
+ * @var array[Command]
+ */
+ private $commands = null;
+
+ public static $saveOptionsMinimum = ['validate' => false, 'audit' => false, 'setModifiedDt' => false];
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param DisplayProfileFactory $displayProfileFactory
+ * @param DisplayFactory $displayFactory
+ */
+ public function __construct($store, $log, $dispatcher, $config, $displayGroupFactory, $displayProfileFactory, $displayFactory, $folderFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->excludeProperty('mediaInventoryXml');
+ $this->setPermissionsClass('Xibo\Entity\DisplayGroup');
+ $this->setCanChangeOwner(false);
+
+ $this->config = $config;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->displayProfileFactory = $displayProfileFactory;
+ $this->displayFactory = $displayFactory;
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->displayGroupId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ // No owner
+ return 0;
+ }
+
+ /**
+ * Get the cache key
+ * @return string
+ */
+ public static function getCachePrefix()
+ {
+ return 'display/';
+ }
+
+ /**
+ * Get the cache key
+ * @return string
+ */
+ public function getCacheKey()
+ {
+ return self::getCachePrefix() . $this->displayId;
+ }
+
+ /**
+ * @return \Xibo\Entity\DisplayProfile
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getDisplayProfile(): DisplayProfile
+ {
+ if ($this->_displayProfile === null) {
+ try {
+ if ($this->displayProfileId == 0) {
+ // Load the default profile
+ $displayProfile = $this->displayProfileFactory->getDefaultByType($this->clientType);
+ } else {
+ // Load the specified profile
+ $displayProfile = $this->displayProfileFactory->getById($this->displayProfileId);
+ }
+ } catch (NotFoundException) {
+ $this->getLog()->error('getDisplayProfile: Cannot get display profile, '
+ . $this->clientType . ' not found.');
+
+ $displayProfile = $this->displayProfileFactory->getUnknownProfile($this->clientType);
+ }
+
+ // Set our display profile
+ $this->_displayProfile = $displayProfile;
+ }
+
+ return $this->_displayProfile;
+ }
+
+ /**
+ * @return array
+ */
+ public function getLanguages()
+ {
+ return empty($this->languages) ? [] : explode(',', $this->languages);
+ }
+
+ /**
+ * @return bool true is this display is a PWA
+ */
+ public function isPwa(): bool
+ {
+ return $this->clientType === 'chromeOS';
+ }
+
+ /**
+ * @return bool true is this display supports WebSocket XMR
+ */
+ public function isWebSocketXmrSupported(): bool
+ {
+ return $this->clientType === 'chromeOS'
+ || ($this->clientType === 'windows' && $this->clientCode >= 406)
+ || ($this->clientType === 'android' && $this->clientCode >= 408);
+ }
+
+ /**
+ * Is this display auditing?
+ * return bool
+ */
+ public function isAuditing(): bool
+ {
+ $this->getLog()->debug(sprintf(
+ 'Testing whether this display is auditing. %d vs %d.',
+ $this->auditingUntil,
+ Carbon::now()->format('U')
+ ));
+
+ // Test $this->auditingUntil against the current date.
+ return (!empty($this->auditingUntil) && $this->auditingUntil >= Carbon::now()->format('U'));
+ }
+
+ /**
+ * Does this display has elevated log level?
+ * @return bool
+ * @throws NotFoundException
+ */
+ public function isElevatedLogging(): bool
+ {
+ $elevatedUntil = $this->getSetting('elevateLogsUntil', 0);
+
+ $this->getLog()->debug(sprintf(
+ 'Testing whether this display has elevated log level. %d vs %d.',
+ $elevatedUntil,
+ Carbon::now()->format('U')
+ ));
+
+ return (!empty($elevatedUntil) && $elevatedUntil >= Carbon::now()->format('U'));
+ }
+
+ /**
+ * Get current log level for this Display
+ * @return string
+ * @throws NotFoundException
+ */
+ public function getLogLevel(): string
+ {
+ $restingLogLevel = $this->getSetting('logLevel', 'error');
+ $isElevated = $this->isElevatedLogging();
+
+ return $isElevated ? 'audit' : $restingLogLevel;
+ }
+
+ /**
+ * Set the Media Status to Incomplete
+ */
+ public function notify()
+ {
+ $this->getLog()->debug($this->display . ' requests notify');
+
+ $this->displayFactory->getDisplayNotifyService()->collectNow()->notifyByDisplayId($this->displayId);
+ }
+
+ /**
+ * Validate the Object as it stands
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->display)) {
+ throw new InvalidArgumentException(__('Can not have a display without a name'), 'name');
+ }
+
+ if (!v::stringType()->notEmpty()->validate($this->license)) {
+ throw new InvalidArgumentException(__('Can not have a display without a hardware key'), 'license');
+ }
+
+ if ($this->wakeOnLanEnabled == 1 && $this->wakeOnLanTime == '') {
+ throw new InvalidArgumentException(
+ __('Wake on Lan is enabled, but you have not specified a time to wake the display'),
+ 'wakeonlan'
+ );
+ }
+ // Broadcast Address
+ if ($this->broadCastAddress != '' && !v::ip()->validate($this->broadCastAddress)) {
+ throw new InvalidArgumentException(
+ __('BroadCast Address is not a valid IP Address'),
+ 'broadCastAddress'
+ );
+ }
+
+ // CIDR
+ if (!empty($this->cidr) && !v::numeric()->between(0, 32)->validate($this->cidr)) {
+ throw new InvalidArgumentException(
+ __('CIDR subnet mask is not a number within the range of 0 to 32.'),
+ 'cidr'
+ );
+ }
+
+ // secureOn
+ if ($this->secureOn != '') {
+ $this->secureOn = strtoupper($this->secureOn);
+ $this->secureOn = str_replace(':', '-', $this->secureOn);
+
+ if ((!preg_match('/([A-F0-9]{2}[-]){5}([0-9A-F]){2}/', $this->secureOn))
+ || (strlen($this->secureOn) != 17)
+ ) {
+ throw new InvalidArgumentException(
+ __('Pattern of secureOn-password is not "xx-xx-xx-xx-xx-xx" (x = digit or CAPITAL letter)'),
+ 'secureOn'
+ );
+ }
+ }
+
+ // Mac Address Changes
+ if ($this->hasPropertyChanged('macAddress')) {
+ // Mac address change detected
+ $this->numberOfMacAddressChanges++;
+ $this->lastChanged = Carbon::now()->format('U');
+ }
+
+ // Lat/Long
+ if (!empty($this->longitude) && !v::longitude()->validate($this->longitude)) {
+ throw new InvalidArgumentException(__('The longitude entered is not valid.'), 'longitude');
+ }
+
+ if (!empty($this->latitude) && !v::latitude()->validate($this->latitude)) {
+ throw new InvalidArgumentException(__('The latitude entered is not valid.'), 'latitude');
+ }
+
+ if ($this->bandwidthLimit !== null && !v::intType()->min(0)->validate($this->bandwidthLimit)) {
+ throw new InvalidArgumentException(
+ __('Bandwidth limit must be a whole number greater than 0.'),
+ 'bandwidthLimit'
+ );
+ }
+
+ // do we have default Layout set?
+ if (empty($this->defaultLayoutId)) {
+ // do we have global default Layout ?
+ $globalDefaultLayoutId = $this->config->getSetting('DEFAULT_LAYOUT');
+ if (!empty($globalDefaultLayoutId)) {
+ $this->getLog()->debug(
+ 'No default Layout set on Display ID '
+ . $this->displayId
+ . ' falling back to global default Layout.'
+ );
+ $this->defaultLayoutId = $globalDefaultLayoutId;
+ $this->notify();
+ } else {
+ // we have no Default Layout and no global Default Layout
+ $this->getLog()->error(
+ 'No global default Layout set and no default Layout set for Display ID ' . $this->displayId
+ );
+ throw new InvalidArgumentException(
+ __('Please set a Default Layout directly on this Display or in CMS Administrator Settings'),
+ 'defaultLayoutId'
+ );
+ }
+ }
+ }
+
+ /**
+ * Check if there is display slot available, returns true when there are display slots available, return false if there are no display slots available
+ * @return boolean
+ */
+ public function isDisplaySlotAvailable()
+ {
+ $maxDisplays = $this->config->GetSetting('MAX_LICENSED_DISPLAYS');
+
+ // Check the number of licensed displays
+ if ($maxDisplays > 0) {
+ $this->getLog()->debug(sprintf('Testing authorised displays against %d maximum. Currently authorised = %d, authorised = %d.', $maxDisplays, $this->currentlyLicensed, $this->licensed));
+
+ if ($this->currentlyLicensed != $this->licensed && $this->licensed == 1) {
+ $countLicensed = $this->getStore()->select('SELECT COUNT(DisplayID) AS CountLicensed FROM display WHERE licensed = 1', []);
+
+ $this->getLog()->debug(sprintf('There are %d authorised displays and we the maximum is %d', $countLicensed[0]['CountLicensed'], $maxDisplays));
+
+ if (intval($countLicensed[0]['CountLicensed']) + 1 > $maxDisplays) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Load
+ * @throws NotFoundException
+ */
+ public function load()
+ {
+ if ($this->loaded)
+ return;
+
+ // Load this displays group membership
+ $this->displayGroups = $this->displayGroupFactory->getByDisplayId($this->displayId);
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Save the media inventory status
+ */
+ public function saveMediaInventoryStatus()
+ {
+ try {
+ $this->getStore()->updateWithDeadlockLoop('UPDATE `display` SET mediaInventoryStatus = :mediaInventoryStatus WHERE displayId = :displayId', [
+ 'mediaInventoryStatus' => $this->mediaInventoryStatus,
+ 'displayId' => $this->displayId
+ ]);
+ } catch (DeadlockException $deadlockException) {
+ $this->getLog()->error('Media Inventory Status save failed due to deadlock');
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'audit' => true,
+ 'checkDisplaySlotAvailability' => true,
+ 'setModifiedDt' => true,
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($options['checkDisplaySlotAvailability']) {
+ // Check if there are display slots available
+ $maxDisplays = $this->config->GetSetting('MAX_LICENSED_DISPLAYS');
+
+ if (!$this->isDisplaySlotAvailable()) {
+ throw new InvalidArgumentException(sprintf(
+ __('You have exceeded your maximum number of authorised displays. %d'),
+ $maxDisplays
+ ), 'maxDisplays');
+ }
+ }
+
+ if ($this->displayId == null || $this->displayId == 0) {
+ $this->add();
+ } else {
+ $this->edit($options);
+ }
+
+ if ($options['audit'] && $this->getChangedProperties() != []) {
+ $this->getLog()->audit('Display', $this->displayId, 'Display Saved', $this->getChangedProperties());
+ }
+
+ // Trigger an update of all dynamic DisplayGroups?
+ if ($this->hasPropertyChanged('display') || $this->hasPropertyChanged('tags')) {
+ // Background update.
+ $this->getDispatcher()->dispatch(
+ new TriggerTaskEvent('\Xibo\XTR\MaintenanceRegularTask', 'DYNAMIC_DISPLAY_GROUP_ASSESSED'),
+ TriggerTaskEvent::$NAME
+ );
+ }
+ }
+
+ /**
+ * Delete
+ * @throws GeneralException
+ */
+ public function delete()
+ {
+ $this->load();
+
+ // Delete references
+ $this->getStore()->update('DELETE FROM `display_media` WHERE displayId = :displayId', [
+ 'displayId' => $this->displayId
+ ]);
+ $this->getStore()->update('DELETE FROM `requiredfile` WHERE displayId = :displayId', [
+ 'displayId' => $this->displayId
+ ]);
+ $this->getStore()->update('DELETE FROM `player_faults` WHERE displayId = :displayId', [
+ 'displayId' => $this->displayId
+ ]);
+ $this->getStore()->update('DELETE FROM `schedule_sync` WHERE displayId = :displayId', [
+ 'displayId' => $this->displayId
+ ]);
+
+ // Remove our display from any groups it is assigned to
+ foreach ($this->displayGroups as $displayGroup) {
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+ $displayGroup->unassignDisplay($this);
+ $displayGroup->save(['validate' => false, 'manageDynamicDisplayLinks' => false, 'allowNotify' => false]);
+ }
+
+ // Delete our display specific group
+ $displayGroup = $this->displayGroupFactory->getById($this->displayGroupId);
+ $this->getDispatcher()->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->delete();
+
+ // Delete the display
+ $this->getStore()->update('DELETE FROM `display` WHERE displayId = :displayId', [
+ 'displayId' => $this->displayId
+ ]);
+
+ $this->getLog()->audit('Display', $this->displayId, 'Display Deleted', [
+ 'displayId' => $this->displayId,
+ 'display' => $this->display,
+ ]);
+ }
+
+ /**
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ private function add()
+ {
+ $this->displayId = $this->getStore()->insert('
+ INSERT INTO display (display, auditingUntil, defaultlayoutid, license, licensed, lastAccessed, inc_schedule, email_alert, alert_timeout, clientAddress, xmrChannel, xmrPubKey, lastCommandSuccess, macAddress, lastChanged, lastWakeOnLanCommandSent, client_type, client_version, client_code, overrideConfig, newCmsAddress, newCmsKey, commercialLicence, lanIpAddress, syncGroupId, osVersion, osSdk, manufacturer, brand, model)
+ VALUES (:display, :auditingUntil, :defaultlayoutid, :license, :licensed, :lastAccessed, :inc_schedule, :email_alert, :alert_timeout, :clientAddress, :xmrChannel, :xmrPubKey, :lastCommandSuccess, :macAddress, :lastChanged, :lastWakeOnLanCommandSent, :clientType, :clientVersion, :clientCode, :overrideConfig, :newCmsAddress, :newCmsKey, :commercialLicence, :lanIpAddress, :syncGroupId, :osVersion, :osSdk, :manufacturer, :brand, :model)
+ ', [
+ 'display' => $this->display,
+ 'auditingUntil' => 0,
+ 'defaultlayoutid' => $this->defaultLayoutId,
+ 'license' => $this->license,
+ 'licensed' => $this->licensed,
+ 'lastAccessed' => $this->lastAccessed,
+ 'inc_schedule' => 0,
+ 'email_alert' => 0,
+ 'alert_timeout' => 0,
+ 'clientAddress' => $this->clientAddress,
+ 'xmrChannel' => $this->xmrChannel,
+ 'xmrPubKey' => ($this->xmrPubKey === null) ? '' : $this->xmrPubKey,
+ 'lastCommandSuccess' => $this->lastCommandSuccess,
+ 'macAddress' => $this->macAddress,
+ 'lastChanged' => ($this->lastChanged === null) ? 0 : $this->lastChanged,
+ 'lastWakeOnLanCommandSent' => ($this->lastWakeOnLanCommandSent === null) ? 0 : $this->lastWakeOnLanCommandSent,
+ 'clientType' => $this->clientType,
+ 'clientVersion' => $this->clientVersion,
+ 'clientCode' => $this->clientCode,
+ 'overrideConfig' => ($this->overrideConfig == '') ? null : json_encode($this->overrideConfig),
+ 'newCmsAddress' => null,
+ 'newCmsKey' => null,
+ 'commercialLicence' => $this->commercialLicence,
+ 'lanIpAddress' => empty($this->lanIpAddress) ? null : $this->lanIpAddress,
+ 'syncGroupId' => empty($this->syncGroupId) ? null : $this->syncGroupId,
+ 'osVersion' => $this->osVersion,
+ 'osSdk' => $this->osSdk,
+ 'manufacturer' => $this->manufacturer,
+ 'brand' => $this->brand,
+ 'model' => $this->model,
+ ]);
+
+
+ $displayGroup = $this->displayGroupFactory->create();
+ $displayGroup->displayGroup = $this->display;
+ $displayGroup->tags = $this->tags;
+
+ // this is added from xmds, by default new displays will end up in root folder.
+ // Can be overridden per DISPLAY_DEFAULT_FOLDER setting
+ $folderId = $this->folderId ?? 1;
+
+ // If folderId is not set to Root Folder
+ // We need to check what permissionsFolderId should be set on the Display Group
+ if ($folderId !== 1) {
+ // just in case protect against no longer existing Folder.
+ try {
+ $folder = $this->folderFactory->getById($folderId, 0);
+
+ $displayGroup->folderId = $folder->getId();
+ $displayGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ } catch (NotFoundException $e) {
+ $this->getLog()->error('Display Default Folder no longer exists');
+
+ // if the Folder from settings no longer exists, default to Root Folder.
+ $displayGroup->folderId = 1;
+ $displayGroup->permissionsFolderId = 1;
+ }
+ } else {
+ $displayGroup->folderId = 1;
+ $displayGroup->permissionsFolderId = 1;
+ }
+
+ $displayGroup->setDisplaySpecificDisplay($this);
+
+ $this->getLog()->debug('Creating display specific group with userId ' . $displayGroup->userId);
+
+ $displayGroup->save();
+ }
+
+
+ /**
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ private function edit($options = [])
+ {
+ $this->getStore()->update('
+ UPDATE display
+ SET display = :display,
+ defaultlayoutid = :defaultLayoutId,
+ displayTypeId = :displayTypeId,
+ venueId = :venueId,
+ address = :address,
+ isMobile = :isMobile,
+ languages = :languages,
+ screenSize = :screenSize,
+ isOutdoor = :isOutdoor,
+ `customId` = :customId,
+ costPerPlay = :costPerPlay,
+ impressionsPerPlay = :impressionsPerPlay,
+ inc_schedule = :incSchedule,
+ license = :license,
+ licensed = :licensed,
+ auditingUntil = :auditingUntil,
+ email_alert = :emailAlert,
+ alert_timeout = :alertTimeout,
+ WakeOnLan = :wakeOnLanEnabled,
+ WakeOnLanTime = :wakeOnLanTime,
+ lastWakeOnLanCommandSent = :lastWakeOnLanCommandSent,
+ BroadCastAddress = :broadCastAddress,
+ SecureOn = :secureOn,
+ Cidr = :cidr,
+ GeoLocation = POINT(:latitude, :longitude),
+ displayprofileid = :displayProfileId,
+ lastaccessed = :lastAccessed,
+ loggedin = :loggedIn,
+ ClientAddress = :clientAddress,
+ MediaInventoryStatus = :mediaInventoryStatus,
+ client_type = :clientType,
+ client_version = :clientVersion,
+ client_code = :clientCode,
+ MacAddress = :macAddress,
+ LastChanged = :lastChanged,
+ NumberOfMacAddressChanges = :numberOfMacAddressChanges,
+ screenShotRequested = :screenShotRequested,
+ storageAvailableSpace = :storageAvailableSpace,
+ storageTotalSpace = :storageTotalSpace,
+ osVersion = :osVersion,
+ osSdk = :osSdk,
+ manufacturer = :manufacturer,
+ brand = :brand,
+ model = :model,
+ xmrChannel = :xmrChannel,
+ xmrPubKey = :xmrPubKey,
+ `lastCommandSuccess` = :lastCommandSuccess,
+ `deviceName` = :deviceName,
+ `timeZone` = :timeZone,
+ `overrideConfig` = :overrideConfig,
+ `newCmsAddress` = :newCmsAddress,
+ `newCmsKey` = :newCmsKey,
+ `orientation` = :orientation,
+ `resolution` = :resolution,
+ `commercialLicence` = :commercialLicence,
+ `teamViewerSerial` = :teamViewerSerial,
+ `webkeySerial` = :webkeySerial,
+ `lanIpAddress` = :lanIpAddress,
+ `syncGroupId` = :syncGroupId
+ WHERE displayid = :displayId
+ ', [
+ 'display' => $this->display,
+ 'defaultLayoutId' => $this->defaultLayoutId,
+ 'displayTypeId' => $this->displayTypeId === 0 ? null : $this->displayTypeId,
+ 'venueId' => $this->venueId === 0 ? null : $this->venueId,
+ 'address' => $this->address,
+ 'isMobile' => $this->isMobile,
+ 'languages' => $this->languages,
+ 'screenSize' => $this->screenSize,
+ 'isOutdoor' => $this->isOutdoor,
+ 'customId' => $this->customId,
+ 'costPerPlay' => $this->costPerPlay,
+ 'impressionsPerPlay' => $this->impressionsPerPlay,
+ 'incSchedule' => ($this->incSchedule == null) ? 0 : $this->incSchedule,
+ 'license' => $this->license,
+ 'licensed' => $this->licensed,
+ 'auditingUntil' => ($this->auditingUntil == null) ? 0 : $this->auditingUntil,
+ 'emailAlert' => $this->emailAlert,
+ 'alertTimeout' => $this->alertTimeout,
+ 'wakeOnLanEnabled' => $this->wakeOnLanEnabled,
+ 'wakeOnLanTime' => $this->wakeOnLanTime,
+ 'lastWakeOnLanCommandSent' => $this->lastWakeOnLanCommandSent,
+ 'broadCastAddress' => $this->broadCastAddress,
+ 'secureOn' => $this->secureOn,
+ 'cidr' => $this->cidr,
+ 'latitude' => $this->latitude,
+ 'longitude' => $this->longitude,
+ 'displayProfileId' => ($this->displayProfileId == null) ? null : $this->displayProfileId,
+ 'lastAccessed' => $this->lastAccessed,
+ 'loggedIn' => $this->loggedIn,
+ 'clientAddress' => $this->clientAddress,
+ 'mediaInventoryStatus' => $this->mediaInventoryStatus,
+ 'clientType' => $this->clientType,
+ 'clientVersion' => $this->clientVersion,
+ 'clientCode' => $this->clientCode,
+ 'macAddress' => $this->macAddress,
+ 'lastChanged' => $this->lastChanged,
+ 'numberOfMacAddressChanges' => $this->numberOfMacAddressChanges,
+ 'screenShotRequested' => $this->screenShotRequested,
+ 'storageAvailableSpace' => $this->storageAvailableSpace,
+ 'storageTotalSpace' => $this->storageTotalSpace,
+ 'xmrChannel' => $this->xmrChannel,
+ 'xmrPubKey' => ($this->xmrPubKey === null) ? '' : $this->xmrPubKey,
+ 'lastCommandSuccess' => $this->lastCommandSuccess,
+ 'deviceName' => $this->deviceName,
+ 'timeZone' => $this->timeZone,
+ 'overrideConfig' => ($this->overrideConfig == '') ? null : json_encode($this->overrideConfig),
+ 'newCmsAddress' => $this->newCmsAddress,
+ 'newCmsKey' => $this->newCmsKey,
+ 'orientation' => $this->orientation,
+ 'resolution' => $this->resolution,
+ 'commercialLicence' => $this->commercialLicence,
+ 'teamViewerSerial' => empty($this->teamViewerSerial) ? null : $this->teamViewerSerial,
+ 'webkeySerial' => empty($this->webkeySerial) ? null : $this->webkeySerial,
+ 'lanIpAddress' => empty($this->lanIpAddress) ? null : $this->lanIpAddress,
+ 'syncGroupId' => empty($this->syncGroupId) ? null : $this->syncGroupId,
+ 'displayId' => $this->displayId,
+ 'osVersion' => $this->osVersion,
+ 'osSdk' => $this->osSdk,
+ 'manufacturer' => $this->manufacturer,
+ 'brand' => $this->brand,
+ 'model' => $this->model,
+ ]);
+
+ // Maintain the Display Group
+ if ($this->hasPropertyChanged('display')
+ || $this->hasPropertyChanged('description')
+ || $this->hasPropertyChanged('tags')
+ || $this->hasPropertyChanged('bandwidthLimit')
+ || $this->hasPropertyChanged('folderId')
+ || $this->hasPropertyChanged('ref1')
+ || $this->hasPropertyChanged('ref2')
+ || $this->hasPropertyChanged('ref3')
+ || $this->hasPropertyChanged('ref4')
+ || $this->hasPropertyChanged('ref5')
+ ) {
+ $this->getLog()->debug('Display specific DisplayGroup properties need updating');
+
+ $displayGroup = $this->displayGroupFactory->getById($this->displayGroupId);
+ $displayGroup->load();
+ $displayGroup->displayGroup = $this->display;
+ $displayGroup->description = $this->description;
+ $displayGroup->bandwidthLimit = $this->bandwidthLimit;
+ $displayGroup->ref1 = $this->ref1;
+ $displayGroup->ref2 = $this->ref2;
+ $displayGroup->ref3 = $this->ref3;
+ $displayGroup->ref4 = $this->ref4;
+ $displayGroup->ref5 = $this->ref5;
+
+ // Tags
+ $saveTags = false;
+ if ($this->hasPropertyChanged('tags')) {
+ $saveTags = true;
+ $displayGroup->updateTagLinks($this->tags);
+ }
+
+ // If the folderId has changed, we should check this user has permissions to the new folderId
+ // it shouldn't ever be null, but just in case.
+ $displayGroup->folderId = ($this->folderId == null) ? 1 : $this->folderId;
+ if ($this->hasPropertyChanged('folderId')) {
+ $folder = $this->folderFactory->getById($displayGroup->folderId, 0);
+ // We have permission, so assert the new folder's permission id
+ $displayGroup->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+ }
+
+ // manageDisplayLinks is false because we never update a display specific display group's display links
+ $displayGroup->save([
+ 'validate' => false,
+ 'saveGroup' => true,
+ 'manageLinks' => false,
+ 'manageDisplayLinks' => false,
+ 'manageDynamicDisplayLinks' => false,
+ 'allowNotify' => true,
+ 'saveTags' => $saveTags,
+ 'setModifiedDt' => $options['setModifiedDt'],
+ ]);
+ } else if ($options['setModifiedDt']) {
+ // Bump the modified date.
+ $this->store->update('
+ UPDATE displaygroup
+ SET `modifiedDt` = :modifiedDt
+ WHERE displayGroupId = :displayGroupId
+ ', [
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'displayGroupId' => $this->displayGroupId
+ ]);
+ }
+ }
+
+ /**
+ * Get the Settings Profile for this Display
+ * @param array $options
+ * @return array
+ * @throws GeneralException
+ */
+ public function getSettings($options = [])
+ {
+ $options = array_merge([
+ 'displayOverride' => false
+ ], $options);
+
+ return $this->setConfig($options);
+ }
+
+ /**
+ * @return Command[]
+ */
+ public function getCommands()
+ {
+ if ($this->commands == null) {
+ $displayProfile = $this->getDisplayProfile();
+
+ // Set any commands
+ $this->commands = $displayProfile->commands;
+ }
+
+ return $this->commands;
+ }
+
+ /**
+ * Get a particular setting
+ * @param string $key
+ * @param mixed $default
+ * @param array $options
+ * @return mixed
+ * @throws NotFoundException
+ */
+ public function getSetting($key, $default = null, $options = [])
+ {
+ $options = array_merge([
+ 'displayOverride' => true,
+ 'displayOnly' => false
+ ], $options);
+
+ $this->setConfig($options);
+
+ // Find
+ $return = $default;
+ if ($options['displayOnly']) {
+ // Only get an option if set from the override config on this display
+ foreach ($this->overrideConfig as $row) {
+ if ($row['name'] == $key || $row['name'] == ucfirst($key)) {
+ $return = array_key_exists('value', $row) ? $row['value'] : ((array_key_exists('default', $row)) ? $row['default'] : $default);
+ break;
+ }
+ }
+ } else if ($options['displayOverride']) {
+ // Get the option from the combined array of config
+ foreach ($this->combinedConfig as $row) {
+ if ($row['name'] == $key || $row['name'] == ucfirst($key)) {
+ $return = array_key_exists('value', $row) ? $row['value'] : ((array_key_exists('default', $row)) ? $row['default'] : $default);
+ break;
+ }
+ }
+ } else {
+ // Get the option from the profile only
+ foreach ($this->profileConfig as $row) {
+ if ($row['name'] == $key || $row['name'] == ucfirst($key)) {
+ $return = array_key_exists('value', $row) ? $row['value'] : ((array_key_exists('default', $row)) ? $row['default'] : $default);
+ break;
+ }
+ }
+ }
+
+ return $return;
+ }
+
+ /**
+ * Set the config array
+ * @param array $options
+ * @return array
+ * @throws NotFoundException
+ */
+ private function setConfig(array $options = []): array
+ {
+ $options = array_merge([
+ 'displayOverride' => false
+ ], $options);
+
+ if ($this->profileConfig == null) {
+ $this->load();
+
+ // Get the display profile
+ try {
+ $displayProfile = $this->getDisplayProfile();
+ } catch (NotFoundException) {
+ $displayProfile = $this->displayProfileFactory->getUnknownProfile($this->clientType);
+ }
+
+ // Merge in any overrides we have on our display.
+ $this->profileConfig = $displayProfile->getProfileConfig();
+ $this->combinedConfig = $this->mergeConfigs($this->profileConfig, $this->overrideConfig);
+ }
+
+ return ($options['displayOverride']) ? $this->combinedConfig : $this->profileConfig;
+ }
+
+ /**
+ * Merge two configs
+ * @param $default
+ * @param $override
+ * @return array
+ */
+ private function mergeConfigs($default, $override): array
+ {
+ // No overrides, then nothing to do.
+ if (empty($override) || !is_array($override)) {
+ return $default;
+ }
+
+ // Merge the settings together
+ foreach ($default as &$defaultItem) {
+ for ($i = 0; $i < count($override); $i++) {
+ if ($defaultItem['name'] == $override[$i]['name']) {
+ // For special json fields, we need to decode, merge, encode and save instead
+ if (in_array($defaultItem['name'], ['timers', 'pictureOptions', 'lockOptions'])
+ && isset($defaultItem['value']) && isset($override[$i]['value'])
+ ) {
+ // Decode values
+ $defaultItemValueDecoded = json_decode($defaultItem['value'], true);
+ $overrideValueDecoded = json_decode($override[$i]['value'], true);
+
+ // Merge values, encode and save
+ $defaultItem['value'] = json_encode(array_merge(
+ $defaultItemValueDecoded,
+ $overrideValueDecoded
+ ));
+ } else {
+ // merge
+ $defaultItem = array_merge($defaultItem, $override[$i]);
+ }
+ break;
+ }
+ }
+ }
+
+ // Merge the remainder
+ return $default;
+ }
+
+ /**
+ * @param PoolInterface $pool
+ * @return int|null
+ */
+ public function getCurrentLayoutId($pool, LayoutFactory $layoutFactory)
+ {
+ $item = $pool->getItem('/currentLayoutId/' . $this->displayId);
+
+ $data = $item->get();
+
+ if ($item->isHit()) {
+ $this->currentLayoutId = $data;
+
+ try {
+ $this->currentLayout = $layoutFactory->getById($this->currentLayoutId)->layout;
+ }
+ catch (NotFoundException $notFoundException) {
+ // This is ok
+ }
+ } else {
+ $this->getLog()->debug('Cache miss for setCurrentLayoutId on display ' . $this->display);
+ }
+
+ return $this->currentLayoutId;
+ }
+
+ /**
+ * @param PoolInterface $pool
+ * @param int $currentLayoutId
+ * @return $this
+ * @throws \Exception
+ */
+ public function setCurrentLayoutId($pool, $currentLayoutId)
+ {
+ // Cache it
+ $this->getLog()->debug('Caching currentLayoutId with Pool');
+
+ $item = $pool->getItem('/currentLayoutId/' . $this->displayId);
+ $item->set($currentLayoutId);
+ $item->expiresAfter(new \DateInterval('P1W'));
+
+ $pool->saveDeferred($item);
+
+ return $this;
+ }
+
+ /**
+ * @param PoolInterface $pool
+ * @return int|null
+ */
+ public function getCurrentScreenShotTime($pool)
+ {
+ $item = $pool->getItem('/screenShotTime/' . $this->displayId);
+
+ return $item->get();
+ }
+
+ /**
+ * @param PoolInterface $pool
+ * @param string $date
+ * @return $this
+ * @throws \Exception
+ */
+ public function setCurrentScreenShotTime($pool, $date)
+ {
+ // Cache it
+ $this->getLog()->debug('Caching currentLayoutId with Pool');
+
+ $item = $pool->getItem('/screenShotTime/' . $this->displayId);
+ $item->set($date);
+ $item->expiresAfter(new \DateInterval('P1W'));
+
+ $pool->saveDeferred($item);
+
+ return $this;
+ }
+
+ /**
+ * @param PoolInterface $pool
+ * @return array
+ */
+ public function getStatusWindow($pool)
+ {
+ $item = $pool->getItem('/statusWindow/' . $this->displayId);
+
+ if ($item->isMiss()) {
+ return [];
+ } else {
+ // special handling for Android
+ if ($this->clientType === 'android') {
+ return nl2br($item->get());
+ } else {
+ return $item->get();
+ }
+ }
+ }
+
+ /**
+ * @param PoolInterface $pool
+ * @param array $status
+ * @return $this
+ */
+ public function setStatusWindow($pool, $status)
+ {
+ // Cache it
+ $this->getLog()->debug('Caching statusWindow with Pool');
+
+ $item = $pool->getItem('/statusWindow/' . $this->displayId);
+ $item->set($status);
+ $item->expiresAfter(new \DateInterval('P1D'));
+
+ $pool->saveDeferred($item);
+
+ return $this;
+ }
+
+ /**
+ * Check if this Display is set as Lead Display on any Sync Group
+ * @return bool
+ */
+ public function isLead(): bool
+ {
+ $syncGroups = $this->getStore()->select(
+ 'SELECT syncGroupId FROM `syncgroup` WHERE `syncgroup`.leadDisplayId = :displayId',
+ ['displayId' => $this->displayId]
+ );
+
+ return count($syncGroups) > 0;
+ }
+}
diff --git a/lib/Entity/DisplayEvent.php b/lib/Entity/DisplayEvent.php
new file mode 100644
index 0000000..2c29000
--- /dev/null
+++ b/lib/Entity/DisplayEvent.php
@@ -0,0 +1,225 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class DisplayEvent
+ * @package Xibo\Entity
+ */
+class DisplayEvent implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public $displayEventId;
+ public $displayId;
+ public $eventDate;
+ public $start;
+ public $end;
+ public $eventTypeId;
+ public $refId;
+ public $detail;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param EventDispatcherInterface $dispatcher
+ */
+ public function __construct(
+ StorageServiceInterface $store,
+ LogServiceInterface $log,
+ EventDispatcherInterface $dispatcher
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Save displayevent
+ * @return void
+ */
+ public function save(): void
+ {
+ if ($this->displayEventId == null) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ /**
+ * Add a new displayevent
+ * @return void
+ */
+ private function add(): void
+ {
+ $this->displayEventId = $this->getStore()->insert('
+ INSERT INTO `displayevent` (eventDate, start, end, displayID, eventTypeId, refId, detail)
+ VALUES (:eventDate, :start, :end, :displayId, :eventTypeId, :refId, :detail)
+ ', [
+ 'eventDate' => Carbon::now()->format('U'),
+ 'start' => $this->start,
+ 'end' => $this->end,
+ 'displayId' => $this->displayId,
+ 'eventTypeId' => $this->eventTypeId,
+ 'refId' => $this->refId,
+ 'detail' => $this->detail,
+ ]);
+ }
+
+ /**
+ * Edit displayevent
+ * @return void
+ */
+ private function edit(): void
+ {
+ $this->getStore()->update('
+ UPDATE displayevent
+ SET end = :end,
+ displayId = :displayId,
+ eventTypeId = :eventTypeId,
+ refId = :refId,
+ detail = :detail
+ WHERE displayEventId = :displayEventId
+ ', [
+ 'displayEventId' => $this->displayEventId,
+ 'end' => $this->end,
+ 'displayId' => $this->displayId,
+ 'eventTypeId' => $this->eventTypeId,
+ 'refId' => $this->refId,
+ 'detail' => $this->detail,
+ ]);
+ }
+
+
+ /**
+ * Record end date for specified display and event type.
+ * @param int $displayId
+ * @param int|null $date
+ * @param int $eventTypeId
+ * @return void
+ */
+ public function eventEnd(int $displayId, int $eventTypeId = 1, string $detail = null, ?int $date = null): void
+ {
+ $this->getLog()->debug(
+ sprintf(
+ 'displayEvent : end display alert for eventType %s and displayId %d',
+ $this->getEventNameFromId($eventTypeId),
+ $displayId
+ )
+ );
+
+ $this->getStore()->update(
+ "UPDATE `displayevent` SET `end` = :toDt, `detail` = CONCAT_WS('. ', NULLIF(`detail`, ''), :detail)
+ WHERE displayId = :displayId
+ AND `end` IS NULL
+ AND eventTypeId = :eventTypeId",
+ [
+ 'toDt' => $date ?? Carbon::now()->format('U'),
+ 'displayId' => $displayId,
+ 'eventTypeId' => $eventTypeId,
+ 'detail' => $detail,
+ ]
+ );
+ }
+
+ /**
+ * Record end date for specified display, event type and refId
+ * @param int $displayId
+ * @param int $eventTypeId
+ * @param int $refId
+ * @param int|null $date
+ * @return void
+ */
+ public function eventEndByReference(int $displayId, int $eventTypeId, int $refId, string $detail = null, ?int $date = null): void
+ {
+ $this->getLog()->debug(
+ sprintf(
+ 'displayEvent : end display alert for refId %d, displayId %d and eventType %s',
+ $refId,
+ $displayId,
+ $this->getEventNameFromId($eventTypeId),
+ )
+ );
+
+ // When updating the event end, concatenate the end message to the current message
+ $this->getStore()->update(
+ "UPDATE `displayevent` SET
+ `end` = :toDt,
+ `detail` = CONCAT_WS('. ', NULLIF(`detail`, ''), :detail)
+ WHERE displayId = :displayId
+ AND `end` IS NULL
+ AND eventTypeId = :eventTypeId
+ AND refId = :refId",
+ [
+ 'toDt' => $date ?? Carbon::now()->format('U'),
+ 'displayId' => $displayId,
+ 'eventTypeId' => $eventTypeId,
+ 'refId' => $refId,
+ 'detail' => $detail,
+ ]
+ );
+ }
+
+ /**
+ * Match event type string from log to eventTypeId in database.
+ * @param string $eventType
+ * @return int
+ */
+ public function getEventIdFromString(string $eventType): int
+ {
+ return match ($eventType) {
+ 'Display Up/down' => 1,
+ 'App Start' => 2,
+ 'Power Cycle' => 3,
+ 'Network Cycle' => 4,
+ 'TV Monitoring' => 5,
+ 'Player Fault' => 6,
+ 'Command' => 7,
+ default => 8
+ };
+ }
+
+ /**
+ * Match eventTypeId from database to string event name.
+ * @param int $eventTypeId
+ * @return string
+ */
+ public function getEventNameFromId(int $eventTypeId): string
+ {
+ return match ($eventTypeId) {
+ 1 => __('Display Up/down'),
+ 2 => __('App Start'),
+ 3 => __('Power Cycle'),
+ 4 => __('Network Cycle'),
+ 5 => __('TV Monitoring'),
+ 6 => __('Player Fault'),
+ 7 => __('Command'),
+ default => __('Other')
+ };
+ }
+}
diff --git a/lib/Entity/DisplayGroup.php b/lib/Entity/DisplayGroup.php
new file mode 100644
index 0000000..69030b1
--- /dev/null
+++ b/lib/Entity/DisplayGroup.php
@@ -0,0 +1,1115 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DisplayGroup
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DisplayGroup implements \JsonSerializable
+{
+ use EntityTrait;
+ use TagLinkTrait;
+
+ /**
+ * @SWG\Property(
+ * description="The displayGroup Id"
+ * )
+ * @var int
+ */
+ public $displayGroupId;
+
+ /**
+ * @SWG\Property(
+ * description="The displayGroup Name"
+ * )
+ * @var string
+ */
+ public $displayGroup;
+
+ /**
+ * @SWG\Property(
+ * description="The displayGroup Description"
+ * )
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(
+ * description="A flag indicating whether this displayGroup is a single display displayGroup",
+ * )
+ * @var int
+ */
+ public $isDisplaySpecific = 0;
+
+ /**
+ * @SWG\Property(
+ * description="A flag indicating whether this displayGroup is dynamic",
+ * )
+ * @var int
+ */
+ public $isDynamic = 0;
+
+ /**
+ * @SWG\Property(
+ * description="Criteria for this dynamic group. A comma separated set of regular expressions to apply",
+ * )
+ * @var string
+ */
+ public $dynamicCriteria;
+
+ /**
+ * @SWG\Property(description="Which logical operator should be used when filtering by multiple dynamic criteria? OR|AND")
+ * @var string
+ */
+ public $dynamicCriteriaLogicalOperator;
+
+ /**
+ * @SWG\Property(
+ * description="Criteria for this dynamic group. A comma separated set of tags to apply",
+ * )
+ * @var string
+ */
+ public $dynamicCriteriaTags;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether to filter by exact Tag match")
+ * @var int
+ */
+ public $dynamicCriteriaExactTags;
+
+ /**
+ * @SWG\Property(description="Which logical operator should be used when filtering by multiple Tags? OR|AND")
+ * @var string
+ */
+ public $dynamicCriteriaTagsLogicalOperator;
+
+ /**
+ * @SWG\Property(
+ * description="The UserId who owns this display group",
+ * )
+ * @var int
+ */
+ public $userId = 0;
+
+ /**
+ * @SWG\Property(description="Tags associated with this Display Group, array of TagLink objects")
+ * @var TagLink[]
+ */
+ public $tags = [];
+
+ /**
+ * @SWG\Property(description="The display bandwidth limit")
+ * @var int
+ */
+ public $bandwidthLimit;
+
+ /**
+ * @SWG\Property(description="A comma separated list of groups/users with permissions to this DisplayGroup")
+ * @var string
+ */
+ public $groupsWithPermissions;
+
+ /**
+ * @SWG\Property(description="The datetime this entity was created")
+ * @var string
+ */
+ public $createdDt;
+
+ /**
+ * @SWG\Property(description="The datetime this entity was last modified")
+ * @var string
+ */
+ public $modifiedDt;
+
+ /**
+ * @SWG\Property(description="The id of the Folder this Display Group belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Display Group")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ /**
+ * @SWG\Property(description="Optional Reference 1")
+ * @var string
+ */
+ public $ref1;
+
+ /**
+ * @SWG\Property(description="Optional Reference 2")
+ * @var string
+ */
+ public $ref2;
+
+ /**
+ * @SWG\Property(description="Optional Reference 3")
+ * @var string
+ */
+ public $ref3;
+
+ /**
+ * @SWG\Property(description="Optional Reference 4")
+ * @var string
+ */
+ public $ref4;
+
+ /**
+ * @SWG\Property(description="Optional Reference 5")
+ * @var string
+ */
+ public $ref5;
+
+ // Child Items the Display Group is linked to
+ public $displays = [];
+ public $media = [];
+ public $layouts = [];
+ public $events = [];
+ private $displayGroups = [];
+ private $permissions = [];
+ /** @var TagLink[] */
+ private $unlinkTags = [];
+ /** @var TagLink[] */
+ private $linkTags = [];
+ private $jsonInclude = ['displayGroupId', 'displayGroup'];
+
+ // Track original assignments
+ private $originalDisplayGroups = [];
+
+ /**
+ * Is notify required during save?
+ * @var bool
+ */
+ private $notifyRequired = false;
+
+ /**
+ * Is collect required?
+ * @var bool
+ */
+ private $collectRequired = true;
+
+ /**
+ * @var bool Are we allowed to notify?
+ */
+ private $allowNotify = true;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param PermissionFactory $permissionFactory
+ */
+ public function __construct($store, $log, $dispatcher, $displayGroupFactory, $permissionFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->permissionFactory = $permissionFactory;
+ }
+
+ public function setDisplayFactory(DisplayFactory $displayFactory)
+ {
+ $this->displayFactory = $displayFactory;
+ }
+
+ public function __clone()
+ {
+ $this->displayGroupId = null;
+ $this->originalDisplayGroups = [];
+ $this->loaded = false;
+
+ if ($this->isDynamic) {
+ $this->clearDisplays()->clearDisplayGroups();
+ }
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->displayGroupId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Set the owner of this group
+ * @param $userId
+ */
+ public function setOwner($userId)
+ {
+ $this->userId = $userId;
+ }
+
+ /**
+ * @return bool
+ */
+ public function canChangeOwner()
+ {
+ return $this->isDisplaySpecific == 0;
+ }
+
+ /**
+ * Set Collection Required
+ * If true will send a player action to collect immediately
+ * @param bool|true $collectRequired
+ */
+ public function setCollectRequired($collectRequired = true)
+ {
+ $this->collectRequired = $collectRequired;
+ }
+
+ /**
+ * Set the Owner of this Group
+ * @param Display $display
+ * @throws NotFoundException
+ */
+ public function setDisplaySpecificDisplay($display)
+ {
+ $this->load();
+
+ $this->isDisplaySpecific = 1;
+ $this->assignDisplay($display);
+ }
+
+ public function clearDisplays(): DisplayGroup
+ {
+ $this->displays = [];
+ return $this;
+ }
+
+ public function clearDisplayGroups(): DisplayGroup
+ {
+ $this->displayGroups = [];
+ return $this;
+ }
+
+ public function clearTags(): DisplayGroup
+ {
+ $this->tags = [];
+ return $this;
+ }
+
+ public function clearLayouts(): DisplayGroup
+ {
+ $this->layouts = [];
+ return $this;
+ }
+
+ public function clearMedia(): DisplayGroup
+ {
+ $this->media = [];
+ return $this;
+ }
+
+ /**
+ * Set the Media Status to Incomplete
+ * @param int[] $displayIds
+ */
+ public function notify($displayIds = [])
+ {
+ if ($this->allowNotify) {
+
+ $notify = $this->displayFactory->getDisplayNotifyService();
+
+ if ($this->collectRequired)
+ $notify->collectNow();
+
+ if (count($displayIds) > 0) {
+ foreach ($displayIds as $displayId) {
+ $notify->notifyByDisplayId($displayId);
+ }
+ } else {
+ $notify->notifyByDisplayGroupId($this->displayGroupId);
+ }
+ }
+ }
+
+ /**
+ * Assign Display
+ * @param Display $display
+ * @throws NotFoundException
+ */
+ public function assignDisplay($display)
+ {
+ $found = false;
+ foreach ($this->displays as $existingDisplay) {
+ if ($existingDisplay->getId() === $display->getId()) {
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found)
+ $this->displays[] = $display;
+ }
+
+ /**
+ * Unassign Display
+ * @param Display $display
+ * @throws NotFoundException
+ */
+ public function unassignDisplay($display)
+ {
+ // Changes made?
+ $countBefore = count($this->displays);
+
+ $this->displays = array_udiff($this->displays, [$display], function($a, $b) {
+ /**
+ * @var Display $a
+ * @var Display $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ // Notify if necessary
+ if ($countBefore !== count($this->displays))
+ $this->notifyRequired = true;
+ }
+
+ /**
+ * Assign DisplayGroup
+ * @param DisplayGroup $displayGroup
+ * @throws NotFoundException
+ */
+ public function assignDisplayGroup($displayGroup)
+ {
+ if (!in_array($displayGroup, $this->displayGroups))
+ $this->displayGroups[] = $displayGroup;
+ }
+
+ /**
+ * Unassign DisplayGroup
+ * @param DisplayGroup $displayGroup
+ * @throws NotFoundException
+ */
+ public function unassignDisplayGroup($displayGroup)
+ {
+ // Changes made?
+ $countBefore = count($this->displayGroups);
+
+ $this->displayGroups = array_udiff($this->displayGroups, [$displayGroup], function($a, $b) {
+ /**
+ * @var DisplayGroup $a
+ * @var DisplayGroup $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ // Notify if necessary
+ if ($countBefore !== count($this->displayGroups))
+ $this->notifyRequired = true;
+ }
+
+ /**
+ * Assign Media
+ * @param Media $media
+ * @throws NotFoundException
+ */
+ public function assignMedia($media)
+ {
+ if (!in_array($media, $this->media)) {
+ $this->media[] = $media;
+
+ // We should notify
+ $this->notifyRequired = true;
+ }
+ }
+
+ /**
+ * Unassign Media
+ * @param Media $media
+ * @throws NotFoundException
+ */
+ public function unassignMedia($media)
+ {
+ // Changes made?
+ $countBefore = count($this->media);
+
+ $this->media = array_udiff($this->media, [$media], function($a, $b) {
+ /**
+ * @var Media $a
+ * @var Media $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ // Notify if necessary
+ if ($countBefore !== count($this->media))
+ $this->notifyRequired = true;
+ }
+
+ /**
+ * Assign Layout
+ * @param Layout $layout
+ * @throws NotFoundException
+ */
+ public function assignLayout($layout)
+ {
+ if (!in_array($layout, $this->layouts)) {
+ $this->layouts[] = $layout;
+
+ // We should notify
+ $this->notifyRequired = true;
+ }
+ }
+
+ /**
+ * Unassign Layout
+ * @param Layout $layout
+ * @throws NotFoundException
+ */
+ public function unassignLayout($layout)
+ {
+ // Changes made?
+ $countBefore = count($this->layouts);
+
+ $this->layouts = array_udiff($this->layouts, [$layout], function($a, $b) {
+ /**
+ * @var Layout $a
+ * @var Layout $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ // Notify if necessary
+ if ($countBefore !== count($this->layouts))
+ $this->notifyRequired = true;
+ }
+
+ /**
+ * Load the contents for this display group
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadTags' => true
+ ], $options);
+
+ if ($this->loaded || $this->displayGroupId == null || $this->displayGroupId == 0) {
+ return;
+ }
+
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->displayGroupId);
+
+ $this->displayGroups = $this->displayGroupFactory->getByParentId($this->displayGroupId);
+
+ // Set the originals
+ $this->originalDisplayGroups = $this->displayGroups;
+
+ // We are loaded
+ $this->loaded = true;
+ }
+
+ /**
+ * Validate this display
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->displayGroup)) {
+ throw new InvalidArgumentException(__('Please enter a display group name'), 'displayGroup');
+ }
+
+ if (!empty($this->description) && !v::stringType()->length(null, 254)->validate($this->description)) {
+ throw new InvalidArgumentException(__('Description can not be longer than 254 characters'), 'description');
+ }
+
+ if ($this->isDisplaySpecific == 0) {
+ // Check the name
+ $result = $this->getStore()->select('SELECT DisplayGroup FROM displaygroup WHERE DisplayGroup = :displayGroup AND IsDisplaySpecific = 0 AND displayGroupId <> :displayGroupId', [
+ 'displayGroup' => $this->displayGroup,
+ 'displayGroupId' => (($this->displayGroupId == null) ? 0 : $this->displayGroupId)
+ ]);
+
+ if (count($result) > 0) {
+ throw new DuplicateEntityException(sprintf(__('You already own a display group called "%s". Please choose another name.'), $this->displayGroup));
+ }
+ // If we are dynamic, then make sure we have some criteria
+ if ($this->isDynamic == 1 && ($this->dynamicCriteria == '' && $this->dynamicCriteriaTags == '')) {
+ throw new InvalidArgumentException(__('Dynamic Display Groups must have at least one Criteria specified.'), 'dynamicCriteria');
+ }
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'saveGroup' => true,
+ 'manageLinks' => true,
+ 'manageDisplayLinks' => true,
+ 'manageDynamicDisplayLinks' => true,
+ 'allowNotify' => true,
+ 'saveTags' => true,
+ 'setModifiedDt' => true,
+ ], $options);
+
+ // Should we allow notification or not?
+ $this->allowNotify = $options['allowNotify'];
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->displayGroupId == null || $this->displayGroupId == 0) {
+ $this->add();
+ $this->loaded = true;
+ } else if ($options['saveGroup']) {
+ $this->edit($options);
+ }
+
+ if ($options['saveTags']) {
+ // Remove unwanted ones
+ if (is_array($this->unlinkTags)) {
+ foreach ($this->unlinkTags as $tag) {
+ $this->unlinkTagFromEntity('lktagdisplaygroup', 'displayGroupId', $this->displayGroupId, $tag->tagId);
+ }
+ }
+
+ // Save the tags
+ if (is_array($this->linkTags)) {
+ foreach ($this->linkTags as $tag) {
+ $this->linkTagToEntity('lktagdisplaygroup', 'displayGroupId', $this->displayGroupId, $tag->tagId, $tag->value);
+ }
+ }
+ }
+
+ if ($this->loaded) {
+ $this->getLog()->debug('Manage links');
+
+ if ($options['manageLinks']) {
+ // Handle any changes in the media linked
+ $this->linkMedia();
+ $this->unlinkMedia();
+
+ // Handle any changes in the layouts linked
+ $this->linkLayouts();
+ $this->unlinkLayouts();
+ }
+
+ if ($options['manageDisplayLinks']) {
+ // Handle any changes in the displays linked
+ $this->manageDisplayLinks($options['manageDynamicDisplayLinks']);
+
+ // Handle any group links
+ $this->manageDisplayGroupLinks();
+ }
+
+ } else if ($this->isDynamic == 1 && $options['manageDynamicDisplayLinks']) {
+ $this->manageDisplayLinks();
+ }
+
+ // Set media incomplete if necessary
+ if ($this->notifyRequired) {
+ $this->notify();
+ }
+ }
+
+ /**
+ * Delete
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function delete()
+ {
+ // Load everything for the delete
+ $this->load();
+
+ // Delete things this group can own
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->delete();
+ }
+
+ foreach ($this->events as $event) {
+ /* @var Schedule $event */
+ $event->unassignDisplayGroup($this);
+ $event->save([
+ 'audit' => false,
+ 'validate' => false,
+ 'deleteOrphaned' => true,
+ 'notify' => false
+ ]);
+ }
+
+ $this->unlinkAllTagsFromEntity('lktagdisplaygroup', 'displayGroupId', $this->displayGroupId);
+
+ // Delete assignments
+ $this->removeAssignments();
+
+ // delete link to ad campaign.
+ $this->getStore()->update('DELETE FROM `lkcampaigndisplaygroup` WHERE displayGroupId = :displayGroupId', [
+ 'displayGroupId' => $this->displayGroupId
+ ]);
+
+ // Delete the Group itself
+ $this->getStore()->update('DELETE FROM `displaygroup` WHERE DisplayGroupID = :displayGroupId', ['displayGroupId' => $this->displayGroupId]);
+ }
+
+ /**
+ * Remove any assignments
+ */
+ public function removeAssignments()
+ {
+ $this->displays = [];
+ $this->displayGroups = [];
+ $this->layouts = [];
+ $this->media = [];
+
+ $this->unlinkDisplays();
+ $this->unlinkAllDisplayGroups();
+ $this->unlinkLayouts();
+ $this->unlinkMedia();
+
+ // Delete Notifications
+ // NB: notifications aren't modelled as child objects because there could be many thousands of notifications on each
+ // displaygroup. We consider the notification to be the parent here and it manages the assignments.
+ // This does mean that we might end up with an empty notification (not assigned to anything)
+ $this->getStore()->update('DELETE FROM `lknotificationdg` WHERE `displayGroupId` = :displayGroupId', ['displayGroupId' => $this->displayGroupId]);
+ }
+
+ private function add()
+ {
+ $time = Carbon::now()->format(DateFormatHelper::getSystemFormat());
+
+ $this->displayGroupId = $this->getStore()->insert('
+ INSERT INTO displaygroup (DisplayGroup, IsDisplaySpecific, Description, `isDynamic`, `dynamicCriteria`, `dynamicCriteriaLogicalOperator`, `dynamicCriteriaTags`, `dynamicCriteriaExactTags`, `dynamicCriteriaTagsLogicalOperator`, `userId`, `createdDt`, `modifiedDt`, `folderId`, `permissionsFolderId`, `ref1`, `ref2`, `ref3`, `ref4`, `ref5`)
+ VALUES (:displayGroup, :isDisplaySpecific, :description, :isDynamic, :dynamicCriteria, :dynamicCriteriaLogicalOperator, :dynamicCriteriaTags, :dynamicCriteriaExactTags, :dynamicCriteriaTagsLogicalOperator, :userId, :createdDt, :modifiedDt, :folderId, :permissionsFolderId, :ref1, :ref2, :ref3, :ref4, :ref5)
+ ', [
+ 'displayGroup' => $this->displayGroup,
+ 'isDisplaySpecific' => $this->isDisplaySpecific,
+ 'description' => $this->description,
+ 'isDynamic' => $this->isDynamic,
+ 'dynamicCriteria' => $this->dynamicCriteria,
+ 'dynamicCriteriaLogicalOperator' => $this->dynamicCriteriaLogicalOperator ?? 'OR',
+ 'dynamicCriteriaTags' => $this->dynamicCriteriaTags,
+ 'dynamicCriteriaExactTags' => $this->dynamicCriteriaExactTags ?? 0,
+ 'dynamicCriteriaTagsLogicalOperator' => $this->dynamicCriteriaTagsLogicalOperator ?? 'OR',
+ 'userId' => $this->userId,
+ 'createdDt' => $time,
+ 'modifiedDt' => $time,
+ 'folderId' => ($this->folderId === null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this-> permissionsFolderId,
+ 'ref1' => $this->ref1,
+ 'ref2' => $this->ref2,
+ 'ref3' => $this->ref3,
+ 'ref4' => $this->ref4,
+ 'ref5' => $this->ref5
+ ]);
+
+ // Insert my self link
+ $this->getStore()->insert('INSERT INTO `lkdgdg` (`parentId`, `childId`, `depth`) VALUES (:parentId, :childId, 0)', [
+ 'parentId' => $this->displayGroupId,
+ 'childId' => $this->displayGroupId
+ ]);
+ }
+
+ private function edit($options = [])
+ {
+ $this->getLog()->debug(sprintf('Updating Display Group. %s, %d', $this->displayGroup, $this->displayGroupId));
+
+ $this->getStore()->update('
+ UPDATE displaygroup
+ SET DisplayGroup = :displayGroup,
+ Description = :description,
+ `isDynamic` = :isDynamic,
+ `dynamicCriteria` = :dynamicCriteria,
+ `dynamicCriteriaLogicalOperator` = :dynamicCriteriaLogicalOperator,
+ `dynamicCriteriaTags` = :dynamicCriteriaTags,
+ `dynamicCriteriaExactTags` = :dynamicCriteriaExactTags,
+ `dynamicCriteriaTagsLogicalOperator` = :dynamicCriteriaTagsLogicalOperator,
+ `bandwidthLimit` = :bandwidthLimit,
+ `userId` = :userId,
+ `modifiedDt` = :modifiedDt,
+ `folderId` = :folderId,
+ `permissionsFolderId` = :permissionsFolderId,
+ `ref1` = :ref1,
+ `ref2` = :ref2,
+ `ref3` = :ref3,
+ `ref4` = :ref4,
+ `ref5` = :ref5
+ WHERE DisplayGroupID = :displayGroupId
+ ', [
+ 'displayGroup' => $this->displayGroup,
+ 'description' => $this->description,
+ 'displayGroupId' => $this->displayGroupId,
+ 'isDynamic' => $this->isDynamic,
+ 'dynamicCriteria' => $this->dynamicCriteria,
+ 'dynamicCriteriaLogicalOperator' => $this->dynamicCriteriaLogicalOperator ?? 'OR',
+ 'dynamicCriteriaTags' => $this->dynamicCriteriaTags,
+ 'dynamicCriteriaExactTags' => $this->dynamicCriteriaExactTags ?? 0,
+ 'dynamicCriteriaTagsLogicalOperator' => $this->dynamicCriteriaTagsLogicalOperator ?? 'OR',
+ 'bandwidthLimit' => $this->bandwidthLimit,
+ 'userId' => $this->userId,
+ 'modifiedDt' => $options['setModifiedDt']
+ ? Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ : $this->modifiedDt,
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId,
+ 'ref1' => $this->ref1,
+ 'ref2' => $this->ref2,
+ 'ref3' => $this->ref3,
+ 'ref4' => $this->ref4,
+ 'ref5' => $this->ref5
+ ]);
+ }
+
+ /**
+ * Manage the links to this display, dynamic or otherwise
+ * @var bool $manageDynamic
+ * @throws NotFoundException
+ */
+ private function manageDisplayLinks($manageDynamic = true)
+ {
+ $this->getLog()->debug('Manage display links. Manage Dynamic = ' . $manageDynamic . ', Dynamic = ' . $this->isDynamic);
+ $difference = [];
+
+ if ($this->isDynamic == 1 && $manageDynamic) {
+
+ $this->getLog()->info('Managing Display Links for Dynamic Display Group ' . $this->displayGroup);
+
+ $originalDisplays = ($this->loaded) ? $this->displays : $this->displayFactory->getByDisplayGroupId($this->displayGroupId);
+
+ // Update the linked displays based on the filter criteria
+ // these displays must be permission checked based on the owner of the group NOT the logged in user
+ $this->displays = $this->displayFactory->query(null, [
+ 'display' => $this->dynamicCriteria,
+ 'logicalOperatorName' => $this->dynamicCriteriaLogicalOperator,
+ 'tags' => $this->dynamicCriteriaTags,
+ 'exactTags' => $this->dynamicCriteriaExactTags,
+ 'logicalOperator' => $this->dynamicCriteriaTagsLogicalOperator,
+ 'userCheckUserId' => $this->getOwnerId(),
+ 'useRegexForName' => true
+ ]);
+
+ $this->getLog()->debug(sprintf('There are %d original displays and %d displays that match the filter criteria now.', count($originalDisplays), count($this->displays)));
+
+ // Map our arrays to simple displayId lists
+ $displayIds = array_map(function ($element) { return $element->displayId; }, $this->displays);
+ $originalDisplayIds = array_map(function ($element) { return $element->displayId; }, $originalDisplays);
+
+ $difference = array_merge(array_diff($displayIds, $originalDisplayIds), array_diff($originalDisplayIds, $displayIds));
+
+ // This is a dynamic display group
+ // only manage the links that have changed
+ if (count($difference) > 0) {
+ $this->getLog()->debug(count($difference) . ' changes in dynamic Displays, will notify individually');
+
+ $this->notifyRequired = true;
+ } else {
+ $this->getLog()->debug('No changes in dynamic Displays, wont notify');
+
+ $this->notifyRequired = false;
+ }
+ }
+
+ // Manage the links we've made either way
+ // Link
+ $this->linkDisplays();
+
+ // Check if we should notify
+ if ($this->notifyRequired) {
+ // We must notify before we unlink
+ $this->notify($difference);
+ }
+
+ // Unlink
+ // we never unlink from a display specific display group, unless we're deleting which does not call
+ // manage display links.
+ if ($this->isDisplaySpecific == 0) {
+ $this->unlinkDisplays();
+ }
+
+ // Don't do it again
+ $this->notifyRequired = false;
+ }
+
+ /**
+ * Manage display group links
+ * @throws InvalidArgumentException
+ */
+ private function manageDisplayGroupLinks()
+ {
+ $this->linkDisplayGroups();
+ $this->unlinkDisplayGroups();
+
+ // Check for circular references
+ // this is a lazy last minute check as we can't really tell if there is a circular reference unless
+ // we've inserted the records already.
+ if ($this->getStore()->exists('SELECT depth FROM `lkdgdg` WHERE parentId = :parentId AND childId = parentId AND depth > 0', ['parentId' => $this->displayGroupId]))
+ throw new InvalidArgumentException(__('This assignment creates a circular reference'));
+ }
+
+ private function linkDisplays()
+ {
+ foreach ($this->displays as $display) {
+ /* @var Display $display */
+ $this->getStore()->update('INSERT INTO lkdisplaydg (DisplayGroupID, DisplayID) VALUES (:displayGroupId, :displayId) ON DUPLICATE KEY UPDATE DisplayID = DisplayID', [
+ 'displayGroupId' => $this->displayGroupId,
+ 'displayId' => $display->displayId
+ ]);
+ }
+ }
+
+ private function unlinkDisplays()
+ {
+ // Unlink any displays that are NOT in the collection
+ $params = ['displayGroupId' => $this->displayGroupId];
+
+ $sql = 'DELETE FROM lkdisplaydg WHERE DisplayGroupID = :displayGroupId AND DisplayID NOT IN (0';
+
+ $i = 0;
+ foreach ($this->displays as $display) {
+ /* @var Display $display */
+ $i++;
+ $sql .= ',:displayId' . $i;
+ $params['displayId' . $i] = $display->displayId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Links the display groups that have been added to the OM
+ * adding them to the closure table `lkdgdg`
+ */
+ private function linkDisplayGroups()
+ {
+ $links = array_udiff($this->displayGroups, $this->originalDisplayGroups, function($a, $b) {
+ /**
+ * @var DisplayGroup $a
+ * @var DisplayGroup $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ $this->getLog()->debug('Linking %d display groups to Display Group %s', count($links), $this->displayGroup);
+
+ foreach ($links as $displayGroup) {
+ /* @var DisplayGroup $displayGroup */
+ $this->getStore()->insert('
+ INSERT INTO lkdgdg (parentId, childId, depth)
+ SELECT p.parentId, c.childId, p.depth + c.depth + 1
+ FROM lkdgdg p, lkdgdg c
+ WHERE p.childId = :parentId AND c.parentId = :childId
+ ', [
+ 'parentId' => $this->displayGroupId,
+ 'childId' => $displayGroup->displayGroupId
+ ]);
+ }
+ }
+
+ /**
+ * Unlinks the display groups that have been removed from the OM
+ * removing them from the closure table `lkdgdg`
+ */
+ private function unlinkDisplayGroups()
+ {
+ $links = array_udiff($this->originalDisplayGroups, $this->displayGroups, function($a, $b) {
+ /**
+ * @var DisplayGroup $a
+ * @var DisplayGroup $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ $this->getLog()->debug('Unlinking ' . count($links) . ' display groups to Display Group ' . $this->displayGroup);
+
+ foreach ($links as $displayGroup) {
+ /* @var DisplayGroup $displayGroup */
+ // Only ever delete 1 because if there are more than 1, we can assume that it is linked at that level from
+ // somewhere else
+ // https://github.com/xibosignage/xibo/issues/1417
+ $linksToDelete = $this->getStore()->select('
+ SELECT DISTINCT link.parentId, link.childId, link.depth
+ FROM `lkdgdg` p
+ INNER JOIN `lkdgdg` link
+ ON p.parentId = link.parentId
+ INNER JOIN `lkdgdg` c
+ ON c.childId = link.childId
+ WHERE p.childId = :parentId
+ AND c.parentId = :childId
+ ', [
+ 'parentId' => $this->displayGroupId,
+ 'childId' => $displayGroup->displayGroupId
+ ]);
+
+ foreach ($linksToDelete as $linkToDelete) {
+ $this->getStore()->update('
+ DELETE FROM `lkdgdg`
+ WHERE parentId = :parentId
+ AND childId = :childId
+ AND depth = :depth
+ LIMIT 1
+ ', [
+ 'parentId' => $linkToDelete['parentId'],
+ 'childId' => $linkToDelete['childId'],
+ 'depth' => $linkToDelete['depth']
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Unlinks all display groups
+ * usually in preparation for a delete
+ */
+ private function unlinkAllDisplayGroups()
+ {
+ $this->getStore()->update('
+ DELETE link
+ FROM `lkdgdg` p, `lkdgdg` link, `lkdgdg` c, `lkdgdg` to_delete
+ WHERE p.parentId = link.parentId AND c.childId = link.childId
+ AND p.childId = to_delete.parentId AND c.parentId = to_delete.childId
+ AND (to_delete.parentId = :parentId OR to_delete.childId = :childId)
+ AND to_delete.depth < 2
+ ', [
+ 'parentId' => $this->displayGroupId,
+ 'childId' => $this->displayGroupId
+ ]);
+ }
+
+ private function linkMedia()
+ {
+ foreach ($this->media as $media) {
+ /* @var Media $media */
+ $this->getStore()->update('INSERT INTO `lkmediadisplaygroup` (mediaid, displaygroupid) VALUES (:mediaId, :displayGroupId) ON DUPLICATE KEY UPDATE mediaid = mediaid', [
+ 'displayGroupId' => $this->displayGroupId,
+ 'mediaId' => $media->mediaId
+ ]);
+ }
+ }
+
+ private function unlinkMedia()
+ {
+ // Unlink any media that is NOT in the collection
+ $params = ['displayGroupId' => $this->displayGroupId];
+
+ $sql = 'DELETE FROM `lkmediadisplaygroup` WHERE DisplayGroupID = :displayGroupId AND mediaId NOT IN (0';
+
+ $i = 0;
+ foreach ($this->media as $media) {
+ /* @var Media $media */
+ $i++;
+ $sql .= ',:mediaId' . $i;
+ $params['mediaId' . $i] = $media->mediaId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ private function linkLayouts()
+ {
+ foreach ($this->layouts as $layout) {
+ /* @var Layout $media */
+ $this->getStore()->update('INSERT INTO `lklayoutdisplaygroup` (layoutid, displaygroupid) VALUES (:layoutId, :displayGroupId) ON DUPLICATE KEY UPDATE layoutid = layoutid', [
+ 'displayGroupId' => $this->displayGroupId,
+ 'layoutId' => $layout->layoutId
+ ]);
+ }
+ }
+
+ private function unlinkLayouts()
+ {
+ // Unlink any layout that is NOT in the collection
+ $params = ['displayGroupId' => $this->displayGroupId];
+
+ $sql = 'DELETE FROM `lklayoutdisplaygroup` WHERE DisplayGroupID = :displayGroupId AND layoutId NOT IN (0';
+
+ $i = 0;
+ foreach ($this->layouts as $layout) {
+ /* @var Layout $layout */
+ $i++;
+ $sql .= ',:layoutId' . $i;
+ $params['layoutId' . $i] = $layout->layoutId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+}
diff --git a/lib/Entity/DisplayProfile.php b/lib/Entity/DisplayProfile.php
new file mode 100644
index 0000000..2c6ad4b
--- /dev/null
+++ b/lib/Entity/DisplayProfile.php
@@ -0,0 +1,611 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\CommandFactory;
+use Xibo\Factory\DisplayProfileFactory;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+
+/**
+ * Class DisplayProfile
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DisplayProfile implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Display Profile")
+ * @var int
+ */
+ public $displayProfileId;
+
+ /**
+ * @SWG\Property(description="The name of this Display Profile")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The player type that this Display Profile is for")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The configuration options for this Profile")
+ * @var string[]
+ */
+ public $config;
+
+ /**
+ * @SWG\Property(description="A flag indicating if this profile should be used as the Default for the client type")
+ * @var int
+ */
+ public $isDefault;
+
+ /**
+ * @SWG\Property(description="The userId of the User that owns this profile")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="The default configuration options for this Profile")
+ * @var string[]
+ */
+ public $configDefault;
+
+ /**
+ * Commands associated with this profile.
+ * @var Command[]
+ */
+ public $commands = [];
+
+ public $isCustom;
+
+ /** @var string the client type */
+ private $clientType;
+
+ /** @var array Combined configuration */
+ private $configCombined = [];
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $configService;
+
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param CommandFactory $commandFactory
+ * @param DisplayProfileFactory $displayProfileFactory
+ */
+ public function __construct($store, $log, $dispatcher, $config, $commandFactory, $displayProfileFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->configService = $config;
+ $this->commandFactory = $commandFactory;
+ $this->displayProfileFactory = $displayProfileFactory;
+ }
+
+ public function __clone()
+ {
+ $this->displayProfileId = null;
+ $this->commands = [];
+ $this->isDefault = 0;
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->displayProfileId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Get Setting
+ * @param $setting
+ * @param null $default
+ * @param bool $fromDefault
+ * @return mixed
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getSetting($setting, $default = null, $fromDefault = false): mixed
+ {
+ $this->load();
+
+ $configs = ($fromDefault) ? $this->configDefault : $this->getProfileConfig();
+
+ foreach ($configs as $config) {
+ if ($config['name'] == $setting || $config['name'] == ucfirst($setting)) {
+ $default = $config['value'] ?? ($config['default'] ?? $default);
+ break;
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Set setting
+ * @param $setting
+ * @param $value
+ * @param boolean $ownConfig if provided will set the values on this object and not on the member config object
+ * @param array|null $config
+ * @return $this
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function setSetting($setting, $value, $ownConfig = true, &$config = null)
+ {
+ $this->load();
+
+ $found = false;
+
+ // Get the setting from default
+ // Which object do we operate on.
+ if ($ownConfig) {
+ $config = $this->config;
+ $default = $this->getSetting($setting, null, true);
+ } else {
+ // we are editing Display object, as such we want the $default to come from display profile assigned to our display
+ $default = $this->getSetting($setting, null, false);
+ }
+
+ // Check to see if we have this setting already
+ for ($i = 0; $i < count($config); $i++) {
+ if ($config[$i]['name'] == $setting || $config[$i]['name'] == ucfirst($setting)) {
+ // We found the setting - is the value different to the default?
+ if ($value !== $default) {
+ $config[$i]['value'] = $value;
+ $config[$i]['name'] = lcfirst($setting);
+ } else {
+ // the value is the same as the default - unset it
+ $this->getLog()->debug('Setting [' . $setting . '] identical to the default, unsetting.');
+ unset($config[$i]);
+ $config = array_values($config);
+ }
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found && $value !== $default) {
+ $this->getLog()->debug('Setting [' . $setting . '] not yet in the profile config, and different to the default. ' . var_export($value, true) . ' --- ' . var_export($default, true));
+ // The config option isn't in our array yet, so add it
+ $config[] = [
+ 'name' => lcfirst($setting),
+ 'value' => $value
+ ];
+ }
+
+ if ($ownConfig) {
+ // Reset our object
+ $this->config = $config;
+
+ // Reload our combined array
+ $this->configCombined = $this->mergeConfigs($this->configDefault, $this->config);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Merge two configs
+ * @param $default
+ * @param $override
+ * @return array
+ */
+ private function mergeConfigs($default, $override): array
+ {
+ foreach ($default as &$defaultItem) {
+ for ($i = 0; $i < count($override); $i++) {
+ if ($defaultItem['name'] == $override[$i]['name']) {
+ // merge
+ $defaultItem = array_merge($defaultItem, $override[$i]);
+ break;
+ }
+ }
+ }
+
+ // Merge the remainder
+ return $default;
+ }
+
+ /**
+ * @param $clientType
+ */
+ public function setClientType($clientType)
+ {
+ $this->clientType = $clientType;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCustom(): bool
+ {
+ return $this->isCustom;
+ }
+
+ /**
+ * Get the client type
+ * @return string
+ */
+ public function getClientType()
+ {
+ return (empty($this->clientType)) ? $this->type : $this->clientType;
+ }
+
+ /**
+ * Assign Command
+ * @param Command $command
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function assignCommand($command)
+ {
+ $this->load([]);
+
+ $assigned = false;
+
+ foreach ($this->commands as $alreadyAssigned) {
+ /* @var Command $alreadyAssigned */
+ if ($alreadyAssigned->getId() == $command->getId()) {
+ $alreadyAssigned->commandString = $command->commandString;
+ $alreadyAssigned->validationString = $command->validationString;
+ $alreadyAssigned->createAlertOn = $command->createAlertOn;
+ $assigned = true;
+ break;
+ }
+ }
+
+ if (!$assigned) {
+ $this->commands[] = $command;
+ }
+ }
+
+ /**
+ * Unassign Command
+ * @param Command $command
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function unassignCommand($command)
+ {
+ $this->load([]);
+
+ $this->commands = array_udiff($this->commands, [$command], function ($a, $b) {
+ /**
+ * @var Command $a
+ * @var Command $b
+ */
+ return $a->getId() - $b->getId();
+ });
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->userId = $ownerId;
+ }
+
+ /**
+ * Load
+ * @param array $options
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function load($options = []): void
+ {
+ $this->getLog()->debug('load: Loading display profile, type: ' . $this->clientType
+ . ' id: ' . $this->displayProfileId);
+
+ $options = array_merge([
+ 'loadConfig' => true,
+ 'loadCommands' => true
+ ], $options);
+
+ if ($this->loaded) {
+ return;
+ }
+
+ // Load in our default config from this class, based on the client type we are
+ $this->configDefault = $this->displayProfileFactory->loadForType($this->getClientType());
+
+ // Get our combined config
+ $this->configCombined = [];
+
+ if ($options['loadConfig']) {
+ if (!is_array($this->config) && !empty($this->config)) {
+ $this->config = json_decode($this->config, true);
+ }
+
+ // handle cases when config is empty
+ if (empty($this->config)) {
+ $this->config = [];
+ }
+
+ $this->getLog()->debug('Config loaded: ' . json_encode($this->config, JSON_PRETTY_PRINT));
+
+ // Populate our combined config accordingly
+ $this->configCombined = $this->mergeConfigs($this->configDefault, $this->config);
+ }
+
+ $this->getLog()->debug('Config Combined is: ' . json_encode($this->configCombined, JSON_PRETTY_PRINT));
+
+ // Load any commands
+ if ($options['loadCommands']) {
+ $this->commands = $this->commandFactory->getByDisplayProfileId($this->displayProfileId, $this->type);
+ }
+
+ // We are loaded
+ $this->loaded = true;
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->name))
+ throw new InvalidArgumentException(__('Missing name'), 'name');
+
+ if (!v::stringType()->notEmpty()->validate($this->type))
+ throw new InvalidArgumentException(__('Missing type'), 'type');
+
+ for ($j = 0; $j < count($this->config); $j++) {
+ if ($this->config[$j]['name'] == 'MaxConcurrentDownloads' && $this->config[$j]['value'] <= 0 && $this->type = 'windows') {
+ throw new InvalidArgumentException(__('Concurrent downloads must be a positive number'), 'MaxConcurrentDownloads');
+ }
+
+ if ($this->config[$j]['name'] == 'maxRegionCount' && !v::intType()->min(0)->validate($this->config[$j]['value'])) {
+ throw new InvalidArgumentException(__('Maximum Region Count must be a positive number'), 'maxRegionCount');
+ }
+ }
+ // Check there is only 1 default (including this one)
+ $sql = '
+ SELECT COUNT(*) AS cnt
+ FROM `displayprofile`
+ WHERE `type` = :type
+ AND isdefault = 1
+ ';
+
+ $params = ['type' => $this->type];
+
+ if ($this->displayProfileId != 0) {
+ $sql .= ' AND displayprofileid <> :displayProfileId ';
+ $params['displayProfileId'] = $this->displayProfileId;
+ }
+
+ $count = $this->getStore()->select($sql, $params);
+
+ if ($count[0]['cnt'] + $this->isDefault > 1) {
+ throw new InvalidArgumentException(__('Only 1 default per display type is allowed.'), 'isDefault');
+ }
+ }
+
+ /**
+ * Save
+ * @param bool $validate
+ * @throws InvalidArgumentException
+ */
+ public function save($validate = true)
+ {
+ if ($validate)
+ $this->validate();
+
+ if ($this->displayProfileId == null || $this->displayProfileId == 0)
+ $this->add();
+ else
+ $this->edit();
+
+ $this->manageAssignments();
+ }
+
+ /**
+ * Delete
+ * @throws InvalidArgumentException
+ */
+ public function delete()
+ {
+ $this->commands = [];
+ $this->manageAssignments();
+
+ if ($this->getStore()->exists('SELECT displayId FROM display WHERE displayProfileId = :displayProfileId', ['displayProfileId' => $this->displayProfileId]) ) {
+ throw new InvalidArgumentException(__('This Display Profile is currently assigned to one or more Displays'), 'displayProfileId');
+ }
+
+ if ($this->isDefault === 1) {
+ throw new InvalidArgumentException(__('Cannot delete default Display Profile.'), 'isDefault');
+ }
+
+ $this->getStore()->update('DELETE FROM `displayprofile` WHERE displayprofileid = :displayProfileId', ['displayProfileId' => $this->displayProfileId]);
+ }
+
+ /**
+ * Manage Assignments
+ */
+ private function manageAssignments()
+ {
+ $this->getLog()->debug('Managing Assignment for Display Profile: %d. %d commands.', $this->displayProfileId, count($this->commands));
+
+ // Link
+ foreach ($this->commands as $command) {
+ /* @var Command $command */
+ $this->getStore()->update('
+ INSERT INTO `lkcommanddisplayprofile` (
+ `commandId`,
+ `displayProfileId`,
+ `commandString`,
+ `validationString`,
+ `createAlertOn`
+ )
+ VALUES (
+ :commandId,
+ :displayProfileId,
+ :commandString,
+ :validationString,
+ :createAlertOn
+ )
+ ON DUPLICATE KEY UPDATE
+ commandString = :commandString2,
+ validationString = :validationString2,
+ createAlertOn = :createAlertOn2
+ ', [
+ 'commandId' => $command->commandId,
+ 'displayProfileId' => $this->displayProfileId,
+ 'commandString' => $command->commandString,
+ 'validationString' => $command->validationString,
+ 'createAlertOn' => $command->createAlertOn,
+ 'commandString2' => $command->commandString,
+ 'validationString2' => $command->validationString,
+ 'createAlertOn2' => $command->createAlertOn
+ ]);
+ }
+
+ // Unlink
+ $params = ['displayProfileId' => $this->displayProfileId];
+
+ $sql = 'DELETE FROM `lkcommanddisplayprofile`
+ WHERE `displayProfileId` = :displayProfileId AND `commandId` NOT IN (0';
+
+ $i = 0;
+ foreach ($this->commands as $command) {
+ /* @var Command $command */
+ $i++;
+ $sql .= ',:commandId' . $i;
+ $params['commandId' . $i] = $command->commandId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ private function add()
+ {
+ $this->displayProfileId = $this->getStore()->insert('
+ INSERT INTO `displayprofile` (`name`, type, config, isdefault, userid, isCustom)
+ VALUES (:name, :type, :config, :isDefault, :userId, :isCustom)
+ ', [
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'config' => ($this->config == '') ? '[]' : json_encode($this->config),
+ 'isDefault' => $this->isDefault,
+ 'userId' => $this->userId,
+ 'isCustom' => $this->isCustom ?? 0
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `displayprofile`
+ SET `name` = :name, type = :type, config = :config, isdefault = :isDefault, isCustom = :isCustom
+ WHERE displayprofileid = :displayProfileId', [
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'config' => ($this->config == '') ? '[]' : json_encode($this->config),
+ 'isDefault' => $this->isDefault,
+ 'isCustom' => $this->isCustom ?? 0,
+ 'displayProfileId' => $this->displayProfileId
+ ]);
+ }
+
+ /**
+ * @return array
+ */
+ public function getProfileConfig(): array
+ {
+ return $this->configCombined;
+ }
+
+ public function getCustomEditTemplate()
+ {
+ if ($this->isCustom()) {
+ return $this->displayProfileFactory->getCustomEditTemplate($this->getClientType());
+ } else {
+ $this->getLog()->error(
+ 'Attempting to get Custom Edit template for Display Profile ' .
+ $this->getClientType() . ' that is not custom'
+ );
+ return null;
+ }
+ }
+
+ public function handleCustomFields($sanitizedParams, $config = null, $display = null)
+ {
+ return $this->displayProfileFactory->handleCustomFields($this, $sanitizedParams, $config, $display);
+ }
+
+ /**
+ * Does this display profile has elevated log level?
+ * @return bool
+ * @throws NotFoundException
+ */
+ public function isElevatedLogging(): bool
+ {
+ $elevatedUntil = $this->getSetting('elevateLogsUntil', 0);
+
+ $this->getLog()->debug(sprintf(
+ 'Testing whether this display profile has elevated log level. %d vs %d.',
+ $elevatedUntil,
+ Carbon::now()->format('U')
+ ));
+
+ return (!empty($elevatedUntil) && $elevatedUntil >= Carbon::now()->format('U'));
+ }
+}
diff --git a/lib/Entity/DisplayType.php b/lib/Entity/DisplayType.php
new file mode 100644
index 0000000..6a7e32e
--- /dev/null
+++ b/lib/Entity/DisplayType.php
@@ -0,0 +1,61 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class DisplayType
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class DisplayType implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID for this DisplayType")
+ * @var int
+ */
+ public $displayTypeId;
+
+ /**
+ * @SWG\Property(description="The Name for this DisplayType")
+ * @var string
+ */
+ public $displayType;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+}
diff --git a/lib/Entity/EntityTrait.php b/lib/Entity/EntityTrait.php
new file mode 100644
index 0000000..42145c4
--- /dev/null
+++ b/lib/Entity/EntityTrait.php
@@ -0,0 +1,437 @@
+.
+ */
+namespace Xibo\Entity;
+
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\ObjectVars;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class EntityTrait
+ * used by all entities
+ * @package Xibo\Entity
+ */
+trait EntityTrait
+{
+ private $hash = null;
+ private $loaded = false;
+ private $permissionsClass = null;
+ private $canChangeOwner = true;
+
+ public $buttons = [];
+ private $jsonExclude = ['buttons', 'jsonExclude', 'originalValues', 'jsonInclude', 'datesToFormat'];
+
+ /** @var array Original values hydrated */
+ protected $originalValues = [];
+
+ /** @var array Unmatched properties */
+ private $unmatchedProperties = [];
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var LogServiceInterface
+ */
+ private $log;
+
+ /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */
+ private $dispatcher;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ protected function setCommonDependencies($store, $log, $dispatcher)
+ {
+ $this->store = $store;
+ $this->log = $log;
+ $this->dispatcher = $dispatcher;
+ return $this;
+ }
+
+ /**
+ * Get Store
+ * @return StorageServiceInterface
+ */
+ protected function getStore()
+ {
+ return $this->store;
+ }
+
+ /**
+ * Get Log
+ * @return LogServiceInterface
+ */
+ protected function getLog()
+ {
+ return $this->log;
+ }
+
+ /**
+ * @return \Symfony\Component\EventDispatcher\EventDispatcherInterface
+ */
+ public function getDispatcher(): EventDispatcherInterface
+ {
+ if ($this->dispatcher === null) {
+ $this->getLog()->error('getDispatcher: [entity] No dispatcher found, returning an empty one');
+ $this->dispatcher = new EventDispatcher();
+ }
+
+ return $this->dispatcher;
+ }
+
+ /**
+ * Hydrate an entity with properties
+ *
+ * @param array $properties
+ * @param array $options
+ *
+ * @return self
+ */
+ public function hydrate(array $properties, $options = [])
+ {
+ $intProperties = (array_key_exists('intProperties', $options)) ? $options['intProperties'] : [];
+ $doubleProperties = (array_key_exists('doubleProperties', $options)) ? $options['doubleProperties'] : [];
+ $stringProperties = (array_key_exists('stringProperties', $options)) ? $options['stringProperties'] : [];
+ $htmlStringProperties = (array_key_exists('htmlStringProperties', $options))
+ ? $options['htmlStringProperties'] : [];
+
+ foreach ($properties as $prop => $val) {
+ // Parse the property
+ if ((stripos(strrev($prop), 'dI') === 0 || in_array($prop, $intProperties))
+ && !in_array($prop, $stringProperties)
+ ) {
+ $val = intval($val);
+ } else if (in_array($prop, $doubleProperties)) {
+ $val = doubleval($val);
+ } else if (in_array($prop, $stringProperties) && $val !== null) {
+ $val = htmlspecialchars($val);
+ } else if (in_array($prop, $htmlStringProperties)) {
+ $val = htmlentities($val);
+ }
+
+ if (property_exists($this, $prop)) {
+ $this->{$prop} = $val;
+ $this->originalValues[$prop] = $val;
+ } else {
+ $this->unmatchedProperties[$prop] = $val;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Reset originals to current values
+ */
+ public function setOriginals()
+ {
+ foreach ($this->jsonSerialize() as $key => $value) {
+ $this->originalValues[$key] = $value;
+ }
+ }
+
+ /**
+ * Get the original value of a property
+ * @param string $property
+ * @return null|mixed
+ */
+ public function getOriginalValue($property)
+ {
+ return (isset($this->originalValues[$property])) ? $this->originalValues[$property] : null;
+ }
+
+ /**
+ * @param string $property
+ * @param mixed $value
+ * @return $this
+ */
+ public function setOriginalValue(string $property, $value)
+ {
+ $this->originalValues[$property] = $value;
+ return $this;
+ }
+
+ /**
+ * Has the provided property been changed from its original value
+ * @param string $property
+ * @return bool
+ */
+ public function hasPropertyChanged($property)
+ {
+ if (!property_exists($this, $property))
+ return true;
+
+ return $this->getOriginalValue($property) != $this->{$property};
+ }
+
+ /**
+ * @param $property
+ * @return bool
+ */
+ public function propertyOriginallyExisted($property)
+ {
+ return array_key_exists($property, $this->originalValues);
+ }
+
+ /**
+ * Get all changed properties for this entity
+ * @param bool $jsonEncodeArrays
+ * @return array
+ */
+ public function getChangedProperties($jsonEncodeArrays = false)
+ {
+ $changedProperties = [];
+
+ foreach ($this->jsonSerialize() as $key => $value) {
+ if (!is_array($value) && !is_object($value) && $this->propertyOriginallyExisted($key) && $this->hasPropertyChanged($key)) {
+ if (isset($this->datesToFormat) && in_array($key, $this->datesToFormat)) {
+ $original = empty($this->getOriginalValue($key))
+ ? $this->getOriginalValue($key)
+ : Carbon::createFromTimestamp($this->getOriginalValue($key))->format(DateFormatHelper::getSystemFormat());
+ $new = empty($value)
+ ? $value
+ : Carbon::createFromTimestamp($value)->format(DateFormatHelper::getSystemFormat());
+ $changedProperties[$key] = $original . ' > ' . $new;
+ } else {
+ $changedProperties[$key] = $this->getOriginalValue($key) . ' > ' . $value;
+ }
+ }
+
+ if (is_array($value) && $jsonEncodeArrays && $this->propertyOriginallyExisted($key) && $this->hasPropertyChanged($key)) {
+ $changedProperties[$key] = json_encode($this->getOriginalValue($key)) . ' > ' . json_encode($value);
+ }
+ }
+
+ return $changedProperties;
+ }
+
+ /**
+ * Get an unmatched property
+ * @param string $property
+ * @param mixed $default The default value to return if the unmatched property doesn't exist.
+ * @return null|mixed
+ */
+ public function getUnmatchedProperty(string $property, mixed $default = null): mixed
+ {
+ return $this->unmatchedProperties[$property] ?? $default;
+ }
+
+ /**
+ * @param string $property
+ * @param mixed $value
+ * @return $this
+ */
+ public function setUnmatchedProperty(string $property, mixed $value)
+ {
+ $this->unmatchedProperties[$property] = $value;
+ return $this;
+ }
+
+ /**
+ * Json Serialize
+ */
+ public function jsonSerialize(): array
+ {
+ $properties = ObjectVars::getObjectVars($this);
+ $json = [];
+ foreach ($properties as $key => $value) {
+ if (!in_array($key, $this->jsonExclude)) {
+ $json[$key] = $value;
+ }
+ }
+
+ // Output unmatched properties too?
+ if (!in_array('unmatchedProperties', $this->jsonExclude)) {
+ foreach ($this->unmatchedProperties as $key => $value) {
+ if (!in_array($key, $this->jsonExclude)) {
+ $json[$key] = $value;
+ }
+ }
+ }
+
+ return $json;
+ }
+
+ public function jsonForAudit(): array
+ {
+ $properties = ObjectVars::getObjectVars($this);
+ $json = [];
+ foreach ($properties as $key => $value) {
+ if (in_array($key, $this->jsonInclude)) {
+ $json[$key] = $value;
+ }
+ }
+ return $json;
+ }
+
+ /**
+ * To Array
+ * @param bool $jsonEncodeArrays
+ * @return array
+ */
+ public function toArray($jsonEncodeArrays = false)
+ {
+ $objectAsJson = $this->jsonSerialize();
+
+ foreach ($objectAsJson as $key => $value) {
+ if (isset($this->datesToFormat) && in_array($key, $this->datesToFormat)) {
+ $objectAsJson[$key] = Carbon::createFromTimestamp($value)->format(DateFormatHelper::getSystemFormat());
+ }
+
+ if ($jsonEncodeArrays) {
+ if (is_array($value)) {
+ $objectAsJson[$key] = json_encode($value);
+ }
+ }
+ }
+
+ return $objectAsJson;
+ }
+
+ /**
+ * Add a property to the excluded list
+ * @param string $property
+ */
+ public function excludeProperty($property)
+ {
+ $this->jsonExclude[] = $property;
+ }
+
+ /**
+ * Remove a property from the excluded list
+ * @param string $property
+ */
+ public function includeProperty($property)
+ {
+ $this->jsonExclude = array_diff($this->jsonExclude, [$property]);
+ }
+
+ /**
+ * Get the Permissions Class
+ * @return string
+ */
+ public function permissionsClass()
+ {
+ return ($this->permissionsClass == null) ? get_class($this) : $this->permissionsClass;
+ }
+
+ /**
+ * Set the Permissions Class
+ * @param string $class
+ */
+ protected function setPermissionsClass($class)
+ {
+ $this->permissionsClass = $class;
+ }
+
+ /**
+ * Can the owner change?
+ * @return bool
+ */
+ public function canChangeOwner()
+ {
+ return $this->canChangeOwner && method_exists($this, 'setOwner');
+ }
+
+ /**
+ * @param bool $bool Can the owner be changed?
+ */
+ protected function setCanChangeOwner($bool)
+ {
+ $this->canChangeOwner = $bool;
+ }
+
+ /**
+ * @param $entityId
+ * @param $message
+ * @param null $changedProperties
+ * @param bool $jsonEncodeArrays
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ protected function audit($entityId, $message, $changedProperties = null, $jsonEncodeArrays = false)
+ {
+ $class = substr(get_class($this), strrpos(get_class($this), '\\') + 1);
+
+ if ($changedProperties === null) {
+ // No properties provided, so we should work them out
+ // If we have originals, then get changed, otherwise get the current object state
+ $changedProperties = (count($this->originalValues) <= 0)
+ ? $this->toArray($jsonEncodeArrays)
+ : $this->getChangedProperties($jsonEncodeArrays);
+ } else if ($changedProperties !== false && count($changedProperties) <= 0) {
+ // Only audit if properties have been provided
+ return;
+ }
+
+ $this->getLog()->audit($class, $entityId, $message, $changedProperties);
+ }
+
+ /**
+ * Compare two arrays, both keys and values.
+ *
+ * @param $array1
+ * @param $array2
+ * @param bool $compareValues
+ * @return array
+ */
+ public function compareMultidimensionalArrays($array1, $array2, $compareValues = true)
+ {
+ $result = [];
+
+ // go through arrays, compare keys and values
+ // the compareValues flag is there for tag unlink - we're interested only in array keys
+ foreach ($array1 as $key => $value) {
+
+ if (!is_array($array2) || !array_key_exists($key, $array2)) {
+ $result[$key] = $value;
+ continue;
+ }
+
+ if ($value != $array2[$key] && $compareValues) {
+ $result[$key] = $value;
+ }
+ }
+
+ return $result;
+ }
+
+ public function updateFolders($table)
+ {
+ $this->getStore()->update('UPDATE `'. $table .'` SET permissionsFolderId = :permissionsFolderId, folderId = :folderId WHERE folderId = :oldFolderId', [
+ 'permissionsFolderId' => $this->permissionsFolderId,
+ 'folderId' => $this->folderId,
+ 'oldFolderId' => $this->getOriginalValue('folderId')
+ ]);
+ }
+}
diff --git a/lib/Entity/Folder.php b/lib/Entity/Folder.php
new file mode 100644
index 0000000..cf69f6c
--- /dev/null
+++ b/lib/Entity/Folder.php
@@ -0,0 +1,471 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Folder
+ * @package Xibo\Entity
+ * @SWG\Definition()
+ */
+class Folder implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Folder")
+ * @var int
+ */
+ public $id;
+
+ /**
+ * @SWG\Property(description="The type of folder (home or root)")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The name of this Folder")
+ * @var string
+ */
+ public $text;
+
+ /**
+ * @SWG\Property(description="The folderId of the parent of this Folder")
+ * @var int
+ */
+ public $parentId;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this is root Folder")
+ * @var int
+ */
+ public $isRoot;
+
+ /**
+ * @SWG\Property(description="An array of children folderIds")
+ * @var string
+ */
+ public $children;
+
+ public $permissionsFolderId;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ private $permissions = [];
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param FolderFactory $folderFactory
+ * @param PermissionFactory $permissionFactory
+ */
+ public function __construct($store, $log, $dispatcher, $folderFactory, $permissionFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->setPermissionsClass('Xibo\Entity\Folder');
+ $this->folderFactory = $folderFactory;
+ $this->permissionFactory = $permissionFactory;
+ }
+
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * When you set ACL on a folder the permissionsFolderId on the folder record is set to null
+ * any objects inside this folder get the permissionsFolderId set to this folderId
+ * @return int
+ */
+ public function getPermissionFolderIdOrThis(): int
+ {
+ return $this->permissionsFolderId == null ? $this->id : $this->permissionsFolderId;
+ }
+
+ /**
+ * Get Owner Id
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return -1;
+ }
+
+ public function getParentId()
+ {
+ return $this->parentId;
+ }
+
+ public function isRoot(): bool
+ {
+ return $this->isRoot === 1;
+ }
+
+ public function getChildren()
+ {
+ return explode(',', $this->children);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->length(1, 254)->validate($this->text)) {
+ throw new InvalidArgumentException(__('Folder needs to have a name, between 1 and 254 characters.'), 'folderName');
+ }
+
+ if (empty($this->parentId)) {
+ throw new InvalidArgumentException(__('Folder needs a specified parent Folder id'), 'parentId');
+ }
+ }
+
+ public function load()
+ {
+ if ($this->loaded || $this->id == null) {
+ return;
+ }
+
+ // Permissions
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->id);
+ $this->loaded = true;
+ }
+
+ /**
+ * @param bool $validate
+ * @throws InvalidArgumentException
+ */
+ public function save($validate = true)
+ {
+ if ($validate) {
+ $this->validate();
+ }
+
+ if ($this->id == null || $this->id == 0) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ public function delete()
+ {
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->delete();
+ }
+
+ $this->manageChildren('delete');
+
+ $this->getStore()->update('DELETE FROM `folder` WHERE folderId = :folderId', [
+ 'folderId' => $this->id
+ ]);
+ }
+
+ private function add()
+ {
+ $parent = $this->folderFactory->getById($this->parentId);
+
+ $this->id = $this->getStore()->insert('INSERT INTO `folder` (folderName, parentId, isRoot, permissionsFolderId) VALUES (:folderName, :parentId, :isRoot, :permissionsFolderId)',
+ [
+ 'folderName' => $this->text,
+ 'parentId' => $this->parentId,
+ 'isRoot' => 0,
+ 'permissionsFolderId' => ($parent->permissionsFolderId == null) ? $this->parentId : $parent->permissionsFolderId
+ ]);
+
+ $this->manageChildren('add');
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('UPDATE `folder` SET folderName = :folderName, parentId = :parentId WHERE folderId = :folderId',
+ [
+ 'folderId' => $this->id,
+ 'folderName' => $this->text,
+ 'parentId' => $this->parentId
+ ]);
+ }
+
+ /**
+ * Manages folder tree structure
+ *
+ * If mode delete is passed then it will remove selected folder and all its children down the tree
+ * Then update children property on parent accordingly
+ *
+ * On add mode we just add this folder id to parent children property
+ *
+ * @param $mode
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function manageChildren($mode)
+ {
+ $parent = $this->folderFactory->getById($this->parentId);
+ $parentChildren = array_filter(explode(',', $parent->children ?? ''));
+ $children = array_filter(explode(',', $this->children ?? ''));
+
+ if ($mode === 'delete') {
+ // remove this folder from children of the parent
+ foreach ($parentChildren as $index => $child) {
+ if ((int)$child === (int)$this->id) {
+ unset($parentChildren[$index]);
+ }
+ }
+
+ // remove this folder children
+ foreach ($children as $child) {
+ $childObject = $this->folderFactory->getById($child);
+ $childObject->manageChildren('delete');
+ $this->getStore()->update('DELETE FROM `folder` WHERE folderId = :folderId', [
+ 'folderId' => $childObject->id
+ ]);
+ }
+ } else {
+ $parentChildren[] = $this->id;
+ }
+
+ $updatedChildren = implode(',', array_filter($parentChildren));
+
+ $this->getStore()->update('UPDATE `folder` SET children = :children WHERE folderId = :folderId', [
+ 'folderId' => $this->parentId,
+ 'children' => $updatedChildren
+ ]);
+ }
+
+ /**
+ * Manages folder permissions
+ *
+ * When permissions are added on folder, this starts new ACL from that folder and is cascaded down to all folders under this folder
+ * permissionsFolderId is also updated on all relevant objects that are in this folder or under this folder in folder tree structure
+ *
+ * When permissions are removed from a folder, this sets the permissionsFolderId to parent folderId (or parent permissionsFolderId)
+ * same is cascaded down the folder tree and all relevant objects
+ *
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function managePermissions()
+ {
+ // this function happens after permissions are inserted into permission table
+ // with that we can look up if there are any permissions for edited folder and act accordingly.
+ $permissionExists = $this->getStore()->exists('SELECT permissionId FROM permission INNER JOIN permissionentity ON permission.entityId = permissionentity.entityId WHERE objectId = :folderId AND permissionentity.entity = :folderEntity', [
+ 'folderId' => $this->id,
+ 'folderEntity' => 'Xibo\Entity\Folder'
+ ]);
+
+ if ($permissionExists) {
+ // if we added/edited permission on this folder, then new ACL starts here, cascade this folderId as permissionFolderId to all children
+ $this->getStore()->update('UPDATE `folder` SET permissionsFolderId = NULL WHERE folderId = :folderId', [
+ 'folderId' => $this->id
+ ]);
+ $permissionFolderId = $this->id;
+ } else {
+ // if there are no permissions for this folder, basically reset the permissions on this folder and its children
+ if ($this->id === 1 && $this->isRoot()) {
+ $permissionFolderId = 1;
+ } else {
+ $parent = $this->folderFactory->getById($this->parentId);
+ $permissionFolderId = ($parent->permissionsFolderId == null) ? $parent->id : $parent->permissionsFolderId;
+ }
+
+ $this->getStore()->update('UPDATE `folder` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'folderId' => $this->id,
+ 'permissionsFolderId' => $permissionFolderId
+ ]);
+ }
+
+ $this->updateChildObjects($permissionFolderId, $this->id);
+
+
+ $this->manageChildPermissions($permissionFolderId);
+ }
+
+ /**
+ * Helper recursive function to make sure all folders under the edited parent folder have correct permissionsFolderId set on them
+ * along with all relevant objects in those folders.
+ *
+ *
+ * @param $permissionFolderId
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function manageChildPermissions($permissionFolderId)
+ {
+ $children = array_filter(explode(',', $this->children ?? ''));
+
+ foreach ($children as $child) {
+
+ $this->updateChildObjects($permissionFolderId, $child);
+
+ $childObject = $this->folderFactory->getById($child);
+ $childObject->manageChildPermissions($permissionFolderId);
+ }
+ }
+
+ private function updateChildObjects($permissionFolderId, $folderId)
+ {
+ $this->getStore()->update('UPDATE `folder` SET permissionsFolderId = :permissionsFolderId WHERE parentId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `media` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `campaign` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `displaygroup` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `dataset` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `playlist` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `menu_board` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+
+ $this->getStore()->update('UPDATE `syncgroup` SET permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'permissionsFolderId' => $permissionFolderId,
+ 'folderId' => $folderId
+ ]);
+ }
+
+ /**
+ * Update old parent, new parent records with adjusted children
+ * Update current folders records with new parent, permissionsFolderId
+ * Recursively go through the current folder's children folder and objects and adjust permissionsFolderId if needed.
+ * @param int $oldParentFolder
+ * @param int $newParentFolder
+ */
+ public function updateFoldersAfterMove(int $oldParentFolderId, int $newParentFolderId)
+ {
+ $oldParentFolder = $this->folderFactory->getById($oldParentFolderId, 0);
+ $newParentFolder = $this->folderFactory->getById($newParentFolderId, 0);
+
+ // new parent folder that adopted this folder, adjust children
+ $newParentChildren = array_filter(explode(',', $newParentFolder->children));
+ $newParentChildren[] = $this->id;
+ $newParentUpdatedChildren = implode(',', array_filter($newParentChildren));
+ $this->getStore()->update('UPDATE `folder` SET children = :children WHERE folderId = :folderId', [
+ 'folderId' => $newParentFolder->id,
+ 'children' => $newParentUpdatedChildren
+ ]);
+
+ // old parent that gave this folder for adoption, adjust children
+ $oldParentChildren = array_filter(explode(',', $oldParentFolder->children));
+ foreach ($oldParentChildren as $index => $child) {
+ if ((int)$child === $this->id) {
+ unset($oldParentChildren[$index]);
+ }
+ }
+
+ $oldParentUpdatedChildren = implode(',', array_filter($oldParentChildren));
+
+ $this->getStore()->update('UPDATE `folder` SET children = :children WHERE folderId = :folderId', [
+ 'folderId' => $oldParentFolder->id,
+ 'children' => $oldParentUpdatedChildren
+ ]);
+
+ // if we had permissions set on this folder, then permissionsFolderId stays as it was
+ if ($this->getPermissionFolderId() !== null) {
+ $this->permissionsFolderId = $newParentFolder->getPermissionFolderIdOrThis();
+ $this->manageChildPermissions($this->permissionsFolderId);
+ }
+
+ $this->getStore()->update('UPDATE `folder` SET parentId = :parentId, permissionsFolderId = :permissionsFolderId WHERE folderId = :folderId', [
+ 'parentId' => $newParentFolder->id,
+ 'permissionsFolderId' => $this->permissionsFolderId,
+ 'folderId' => $this->id
+ ]);
+ }
+
+ /**
+ * We do not allow moving a parent Folder inside of one of its sub-folders
+ * If that's what was requested, throw an error
+ * @param int $newParentFolderId
+ * @return bool
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function isTheSameBranch(int $newParentFolderId): bool
+ {
+ $children = array_filter(explode(',', $this->children ?? ''));
+ $found = false;
+
+ foreach ($children as $child) {
+ if ((int)$child === $newParentFolderId) {
+ $found = true;
+ break;
+ }
+ $childObject = $this->folderFactory->getById($child);
+ $childObject->isTheSameBranch($newParentFolderId);
+ }
+
+ return $found;
+ }
+
+ /**
+ * Check if this folder is used as Home Folder for any existing Users
+ * @return bool
+ */
+ public function isHome(): bool
+ {
+ $userIds = $this->getStore()->select('SELECT userId FROM `user` WHERE `user`.homeFolderId = :folderId', [
+ 'folderId' => $this->id
+ ]);
+
+ return count($userIds) > 0;
+ }
+}
diff --git a/lib/Entity/Font.php b/lib/Entity/Font.php
new file mode 100644
index 0000000..46db023
--- /dev/null
+++ b/lib/Entity/Font.php
@@ -0,0 +1,208 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Factory\FontFactory;
+use Xibo\Support\Exception\DuplicateEntityException;
+
+/**
+ * Class Font
+ * @package Xibo\Entity
+ * @SWG\Definition()
+ */
+class Font
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Font ID")
+ * @var int
+ */
+ public $id;
+
+ /**
+ * @SWG\Property(description="The Font created date")
+ * @var string
+ */
+ public $createdAt;
+
+ /**
+ * @SWG\Property(description="The Font modified date")
+ * @var string
+ */
+ public $modifiedAt;
+
+ /**
+ * @SWG\Property(description="The name of the user that modified this font last")
+ * @var string
+ */
+ public $modifiedBy;
+
+ /**
+ * @SWG\Property(description="The Font name")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The Font file name")
+ * @var string
+ */
+ public $fileName;
+
+ /**
+ * @SWG\Property(description="The Font family name")
+ * @var string
+ */
+ public $familyName;
+
+ /**
+ * @SWG\Property(description="The Font file size in bytes")
+ * @var int
+ */
+ public $size;
+
+ /**
+ * @SWG\Property(description="A MD5 checksum of the stored font file")
+ * @var string
+ */
+ public $md5;
+
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var FontFactory */
+ private $fontFactory;
+
+ /**
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher, $config, $fontFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->config = $config;
+ $this->fontFactory = $fontFactory;
+ }
+
+ public function getFilePath()
+ {
+ return $this->config->getSetting('LIBRARY_LOCATION') . 'fonts/' . $this->fileName;
+ }
+
+ /**
+ * @throws DuplicateEntityException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge($options, ['validate' => true]);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->id === null || $this->id === 0) {
+ $this->add();
+
+ $this->audit($this->id, 'Added font', [
+ 'mediaId' => $this->id,
+ 'name' => $this->name,
+ 'fileName' => $this->fileName,
+ ]);
+ } else {
+ $this->edit();
+ }
+ }
+
+ private function add()
+ {
+ $this->id = $this->getStore()->insert('
+ INSERT INTO `fonts` (`createdAt`, `modifiedAt`, modifiedBy, name, fileName, familyName, size, md5)
+ VALUES (:createdAt, :modifiedAt, :modifiedBy, :name, :fileName, :familyName, :size, :md5)
+ ', [
+ 'createdAt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedAt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedBy' => $this->modifiedBy,
+ 'name' => $this->name,
+ 'fileName' => $this->fileName,
+ 'familyName' => $this->familyName,
+ 'size' => $this->size,
+ 'md5' => $this->md5
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('UPDATE `fonts` SET modifiedAt = :modifiedAt, modifiedBy = :modifiedBy, name = :name WHERE id = :id', [
+ 'modifiedAt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedBy' => $this->modifiedBy,
+ 'name' => $this->name,
+ 'id' => $this->id
+ ]);
+ }
+
+ public function delete()
+ {
+ // delete record
+ $this->getStore()->update('DELETE FROM `fonts` WHERE id = :id', [
+ 'id' => $this->id
+ ]);
+
+ // delete file
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+
+ if (file_exists($libraryLocation . 'fonts/' . $this->fileName)) {
+ unlink($libraryLocation . 'fonts/' . $this->fileName);
+ }
+ }
+
+ /**
+ * Ensures no duplicate fonts are created
+ *
+ * @throws DuplicateEntityException
+ */
+ private function validate(): void
+ {
+ // Prevents uploading the same file under a different name
+ if ($this->fontFactory->query(null, ['md5' => $this->md5])) {
+ throw new DuplicateEntityException(__('This font file already exists in the library.'));
+ }
+
+ // Keeps unique file storage
+ if ($this->fontFactory->query(null, ['fileName' => $this->fileName])) {
+ throw new DuplicateEntityException(__('Similar font filename already exists in the library.'));
+ }
+
+ // Prevents multiple fonts with same name in UI
+ if ($this->fontFactory->query(null, ['name' => $this->name])) {
+ throw new DuplicateEntityException(__('Similar font name already exists in the library.'));
+ }
+ }
+}
diff --git a/lib/Entity/HelpLink.php b/lib/Entity/HelpLink.php
new file mode 100644
index 0000000..26f9528
--- /dev/null
+++ b/lib/Entity/HelpLink.php
@@ -0,0 +1,55 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+/**
+ * A simple help link, used by the help service.
+ */
+class HelpLink implements \JsonSerializable
+{
+ public $title;
+ public $summary;
+ public $url;
+ public $isAllowWhiteLabel;
+
+ /**
+ * @param $array
+ */
+ public function __construct($array)
+ {
+ $this->title = $array['title'] ?? '';
+ $this->summary = $array['summary'] ?? '';
+ $this->url = $array['url'] ?? '';
+ $this->isAllowWhiteLabel = $array['isAllowWhiteLabel'] ?? true;
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'title' => $this->title,
+ 'summary' => $this->summary,
+ 'url' => $this->url,
+ 'isAllowWhiteLabel' => $this->isAllowWhiteLabel,
+ ];
+ }
+}
diff --git a/lib/Entity/Homepage.php b/lib/Entity/Homepage.php
new file mode 100644
index 0000000..70574b6
--- /dev/null
+++ b/lib/Entity/Homepage.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+/**
+ * Class Homepage
+ * @package Xibo\Entity
+ */
+class Homepage implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public $homepage;
+ public $feature;
+ public $title;
+ public $description;
+
+ /**
+ * Homepage constructor.
+ * @param $homepage
+ * @param $feature
+ * @param $title
+ * @param $description
+ */
+ public function __construct($homepage, $feature, $title, $description)
+ {
+ $this->homepage = $homepage;
+ $this->feature = $feature;
+ $this->title = $title;
+ $this->description = $description;
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/Layout.php b/lib/Entity/Layout.php
new file mode 100644
index 0000000..74d5a37
--- /dev/null
+++ b/lib/Entity/Layout.php
@@ -0,0 +1,3132 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Xibo\Event\LayoutBuildEvent;
+use Xibo\Event\LayoutBuildRegionEvent;
+use Xibo\Event\SubPlaylistValidityEvent;
+use Xibo\Factory\ActionFactory;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\FontFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\ModuleTemplateFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Factory\WidgetDataFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Helper\Profiler;
+use Xibo\Helper\Status;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Layout
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Layout implements \JsonSerializable
+{
+ use EntityTrait;
+ use TagLinkTrait;
+
+ /**
+ * @SWG\Property(
+ * description="The layoutId"
+ * )
+ * @var int
+ */
+ public $layoutId;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The userId of the Layout Owner"
+ * )
+ */
+ public $ownerId;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The id of the Layout's dedicated Campaign"
+ * )
+ */
+ public $campaignId;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The parentId, if this Layout has a draft"
+ * )
+ */
+ public $parentId;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The Status Id"
+ * )
+ */
+ public $publishedStatusId = 1;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The Published Status (Published, Draft or Pending Approval"
+ * )
+ */
+ public $publishedStatus;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The Published Date"
+ * )
+ */
+ public $publishedDate;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The id of the image media set as the background"
+ * )
+ */
+ public $backgroundImageId;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The XLF schema version"
+ * )
+ */
+ public $schemaVersion;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The name of the Layout"
+ * )
+ */
+ public $layout;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The description of the Layout"
+ * )
+ */
+ public $description;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="A HEX string representing the Layout background color"
+ * )
+ */
+ public $backgroundColor;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime the Layout was created"
+ * )
+ */
+ public $createdDt;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime the Layout was last modified"
+ * )
+ */
+ public $modifiedDt;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="Flag indicating the Layout status"
+ * )
+ */
+ public $status;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="Flag indicating whether the Layout is retired"
+ * )
+ */
+ public $retired;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="The Layer that the background should occupy"
+ * )
+ */
+ public $backgroundzIndex;
+
+ /**
+ * @var double
+ * @SWG\Property(
+ * description="The Layout Width"
+ * )
+ */
+ public $width;
+
+ /**
+ * @var double
+ * @SWG\Property(
+ * description="The Layout Height"
+ * )
+ */
+ public $height;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The Layout Orientation"
+ * )
+ */
+ public $orientation;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="If this Layout has been requested by Campaign, then this is the display order of the Layout within the Campaign"
+ * )
+ */
+ public $displayOrder;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="A read-only estimate of this Layout's total duration in seconds. This is equal to the longest region duration and is valid when the layout status is 1 or 2."
+ * )
+ */
+ public $duration;
+
+ /**
+ * @var string
+ * @SWG\Property(description="A status message detailing any errors with the layout")
+ */
+ public $statusMessage;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="Flag indicating whether the Layout stat is enabled"
+ * )
+ */
+ public $enableStat;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="Flag indicating whether the default transitions should be applied to this Layout"
+ * )
+ */
+ public $autoApplyTransitions;
+
+ /**
+ * @var string
+ * @SWG\Property(description="Code identifier for this Layout")
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="Is this layout locked by another user?")
+ * @var bool
+ */
+ public $isLocked;
+
+ // Child items
+ /**
+ * @SWG\Property(description="An array of Regions belonging to this Layout")
+ * @var Region[]
+ */
+ public $regions = [];
+
+ /**
+ * @SWG\Property(description="Tags associated with this Layout, array of TagLink objects")
+ * @var TagLink[]
+ */
+ public $tags = [];
+
+ /** @var Region[] */
+ public $drawers = [];
+
+ /** @var Action[] */
+ public $actions = [];
+
+ /** @var \Xibo\Entity\Permission[] */
+ public $permissions = [];
+
+ /** @var \Xibo\Entity\Campaign[] */
+ public $campaigns = [];
+
+ // Read only properties
+ public $owner;
+ public $groupsWithPermissions;
+
+ /**
+ * @SWG\Property(description="The id of the Folder this Layout belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Layout")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ // Private
+ /** @var TagLink[] */
+ private $unlinkTags = [];
+ /** @var TagLink[] */
+ private $linkTags = [];
+
+ // Handle empty regions
+ private $hasEmptyRegion = false;
+
+ // Flag to indicate we've not built this layout this session.
+ private $hasBuilt = false;
+
+ public static $loadOptionsMinimum = [
+ 'loadPlaylists' => false,
+ 'loadTags' => false,
+ 'loadPermissions' => false,
+ 'loadCampaigns' => false
+ ];
+
+ public static $saveOptionsMinimum = [
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => true,
+ 'validate' => false,
+ 'audit' => false,
+ 'notify' => false
+ ];
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var RegionFactory
+ */
+ private $regionFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var ModuleFactory
+ */
+ private $moduleFactory;
+
+ /**
+ * @var ModuleTemplateFactory
+ */
+ private $moduleTemplateFactory;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var ActionFactory */
+ private $actionFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+ /**
+ * @var FontFactory
+ */
+ private $fontFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param PermissionFactory $permissionFactory
+ * @param RegionFactory $regionFactory
+ * @param TagFactory $tagFactory
+ * @param CampaignFactory $campaignFactory
+ * @param LayoutFactory $layoutFactory
+ * @param MediaFactory $mediaFactory
+ * @param ModuleFactory $moduleFactory
+ * @param ModuleTemplateFactory $moduleTemplateFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param ActionFactory $actionFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ $config,
+ $permissionFactory,
+ $regionFactory,
+ $tagFactory,
+ $campaignFactory,
+ $layoutFactory,
+ $mediaFactory,
+ $moduleFactory,
+ $moduleTemplateFactory,
+ $playlistFactory,
+ $actionFactory,
+ $folderFactory,
+ FontFactory $fontFactory
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->setPermissionsClass('Xibo\Entity\Campaign');
+ $this->config = $config;
+ $this->permissionFactory = $permissionFactory;
+ $this->regionFactory = $regionFactory;
+ $this->tagFactory = $tagFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->actionFactory = $actionFactory;
+ $this->folderFactory = $folderFactory;
+ $this->fontFactory = $fontFactory;
+ }
+
+ public function __clone()
+ {
+ // Clear the layout id
+ $this->layoutId = null;
+ $this->campaignId = null;
+ $this->code = null;
+ $this->hash = null;
+ $this->permissions = [];
+ $this->tags = [];
+ $this->linkTags = [];
+
+ // A normal clone (for copy) will set this to Published, so that the copy is published.
+ $this->publishedStatusId = 1;
+
+ // Clone the regions
+ $this->regions = array_map(function ($object) { return clone $object; }, $this->regions);
+ // Clone drawers
+ $this->drawers = array_map(function ($object) { return clone $object; }, $this->drawers);
+ // Clone actions
+ $this->actions = array_map(function ($object) { return clone $object; }, $this->actions);
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ $countRegions = is_array($this->regions) ? count($this->regions) : 0;
+ $countTags = is_array($this->tags) ? count($this->tags) : 0;
+ $countDrawers = is_array($this->drawers) ? count($this->drawers) : 0;
+
+ $statusMessages = $this->getStatusMessage();
+ $countMessages = is_array($statusMessages) ? count($statusMessages) : 0;
+
+ return sprintf('Layout %s - %d x %d. Regions = %d, Drawers = %d, Tags = %d. layoutId = %d. Status = %d, messages %d', $this->layout, $this->width, $this->height, $countRegions, $countDrawers, $countTags, $this->layoutId, $this->status, $countMessages);
+ }
+
+ /**
+ * @return string
+ */
+ private function hash()
+ {
+ return md5($this->layoutId . $this->ownerId . $this->campaignId . $this->backgroundImageId . $this->backgroundColor . $this->width . $this->height . $this->status . $this->description . json_encode($this->statusMessage) . $this->publishedStatusId . json_encode($this->actions));
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->campaignId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Sets the Owner of the Layout (including children)
+ * @param int $ownerId
+ * @param bool $cascade Cascade ownership change down to Playlist records
+ * @throws GeneralException
+ * @throws NotFoundException
+ */
+ public function setOwner($ownerId, $cascade = false)
+ {
+ $this->getLog()->debug('setOwner: layoutId=' . $this->layoutId . ', ownerId=' . $ownerId);
+
+ $this->load();
+ $this->ownerId = $ownerId;
+
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+ $region->setOwner($ownerId, $cascade);
+ }
+ }
+
+ /**
+ * @return bool if this Layout has an empty Region.
+ */
+ public function hasEmptyRegion()
+ {
+ return $this->hasEmptyRegion;
+ }
+
+ /**
+ * Helper function that checks if Layout has an empty Region
+ * without building it.
+ */
+ public function checkForEmptyRegion()
+ {
+ $this->load();
+
+ foreach ($this->regions as $region) {
+ $widgets = $region->getPlaylist()->setModuleFactory($this->moduleFactory)->expandWidgets();
+ $countWidgets = count($widgets);
+
+ if ($countWidgets <= 0) {
+ $this->hasEmptyRegion = true;
+ break;
+ }
+ }
+
+ return $this->hasEmptyRegion;
+ }
+
+ /**
+ * Set the status of this layout to indicate a build is required
+ */
+ private function setBuildRequired()
+ {
+ $this->status = 3;
+ }
+
+ /**
+ * Load Regions from a Layout
+ * @param int $regionId
+ * @return Region
+ * @throws NotFoundException
+ */
+ public function getRegion($regionId)
+ {
+ foreach ($this->regions as $region) {
+ /* @var Region $region */
+ if ($region->regionId == $regionId) {
+ return $region;
+ }
+ }
+
+ throw new NotFoundException(__('Cannot find region'));
+ }
+
+ /**
+ * Load Drawers from a Layout
+ * @param int $regionId
+ * @return Region
+ * @throws NotFoundException
+ */
+ public function getDrawer($regionId)
+ {
+ foreach ($this->drawers as $drawer) {
+ /* @var Region $drawer */
+ if ($drawer->regionId == $regionId) {
+ return $drawer;
+ }
+ }
+
+ throw new NotFoundException(__('Cannot find drawer region'));
+ }
+
+ /**
+ * Load both Regions and Drawers from a Layout
+ * @param int $regionId
+ * @return Region
+ * @throws NotFoundException
+ */
+ public function getRegionOrDrawer($regionId)
+ {
+ /** @var Region[] $allRegions */
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+ if ($region->regionId == $regionId) {
+ return $region;
+ }
+ }
+
+ throw new NotFoundException(__('Cannot find Region or Drawer'));
+ }
+
+ /**
+ * Get All Widgets assigned to this Layout
+ * @return Widget[]
+ * @throws NotFoundException
+ */
+ public function getAllWidgets()
+ {
+ $widgets = [];
+
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ /** @var Region $region */
+ foreach ($allRegions as $region) {
+ $widgets = array_merge($region->getPlaylist()->widgets, $widgets);
+ }
+
+ return $widgets;
+ }
+
+ /**
+ * Get Region Widgets assigned to this Layout
+ * @return Widget[]
+ * @throws NotFoundException
+ */
+ public function getRegionWidgets()
+ {
+ $widgets = [];
+
+ foreach ($this->regions as $region) {
+ $widgets = array_merge($region->getPlaylist()->widgets, $widgets);
+ }
+
+ return $widgets;
+ }
+
+ /**
+ * Get Drawer Widgets assigned to this Layout
+ * @return Widget[]
+ * @throws NotFoundException
+ */
+ public function getDrawerWidgets()
+ {
+ $widgets = [];
+
+ foreach ($this->drawers as $drawer) {
+ $widgets = array_merge($drawer->getPlaylist()->widgets, $widgets);
+ }
+
+ return $widgets;
+ }
+
+ /**
+ * Is this Layout Editable - i.e. are we in a draft state or not.
+ * @return bool true if this layout is editable
+ */
+ public function isEditable()
+ {
+ return ($this->publishedStatusId === 2); // Draft
+ }
+
+ /**
+ * Is this Layout a Child?
+ * @return bool
+ */
+ public function isChild()
+ {
+ return ($this->parentId !== null);
+ }
+
+ /**
+ * @return bool true if this layout has a draft
+ */
+ public function hasDraft(): bool
+ {
+ return $this->isEditable() && !$this->isChild();
+ }
+
+ /**
+ * Is this Layout a Template?
+ * @return bool
+ */
+ public function isTemplate(): bool
+ {
+ return $this->hasTag('template');
+ }
+
+ /**
+ * @return \Xibo\Entity\TagLink[]
+ */
+ public function getTags(): array
+ {
+ return $this->tags;
+ }
+
+ /**
+ * @return array
+ */
+ public function getStatusMessage()
+ {
+ if ($this->statusMessage === null || empty($this->statusMessage)) {
+ return [];
+ }
+
+ if (is_array($this->statusMessage)) {
+ return $this->statusMessage;
+ }
+
+ $this->statusMessage = json_decode($this->statusMessage, true);
+
+ return $this->statusMessage;
+ }
+
+ /**
+ * Push a new message
+ * @param $message
+ */
+ public function pushStatusMessage($message)
+ {
+ $this->getStatusMessage();
+
+ $this->statusMessage[] = $message;
+ }
+
+ /**
+ * Clear status message
+ */
+ private function clearStatusMessage()
+ {
+ $this->statusMessage = null;
+ }
+
+ /**
+ * Load this Layout
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadPlaylists' => true,
+ 'loadPermissions' => true,
+ 'loadCampaigns' => true,
+ 'loadActions' => true,
+ ], $options);
+
+ if ($this->loaded || $this->layoutId == 0) {
+ return;
+ }
+
+ $this->getLog()->debug(sprintf('Loading Layout %d with options %s', $this->layoutId, json_encode($options)));
+
+ // Load permissions
+ if ($options['loadPermissions']) {
+ $this->permissions = $this->permissionFactory->getByObjectId('Xibo\\Entity\\Campaign', $this->campaignId);
+ }
+
+ // Load all regions
+ $this->regions = $this->regionFactory->getByLayoutId($this->layoutId);
+
+ // load all drawers
+ $this->drawers = $this->regionFactory->getDrawersByLayoutId($this->layoutId);
+
+ if ($options['loadPlaylists']) {
+ $this->loadPlaylists($options);
+ }
+
+ // Load Campaigns
+ if ($options['loadCampaigns']) {
+ $this->campaigns = $this->campaignFactory->getByLayoutId($this->layoutId);
+ }
+
+ // Load Actions
+ if ($options['loadActions']) {
+ $this->actions = $this->actionFactory->getBySourceAndSourceId('layout', $this->layoutId);
+ }
+
+ // Set the hash
+ $this->hash = $this->hash();
+ $this->loaded = true;
+
+ $this->getLog()->debug('Loaded ' . $this->layoutId . ' with hash ' . $this->hash . ', status ' . $this->status);
+ }
+
+ /**
+ * Load All Playlists
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function loadPlaylists($options = [])
+ {
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+ $region->load($options);
+ }
+ }
+
+ /**
+ * Load Region Playlists
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function loadDrawerPlaylists($options = [])
+ {
+ foreach ($this->drawers as $drawer) {
+ /* @var Region $region */
+ $drawer->load($options);
+ }
+ }
+
+ /**
+ * Load Drawer Playlists
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function loadRegionPlaylists($options = [])
+ {
+ foreach ($this->regions as $region) {
+ /* @var Region $region */
+ $region->load($options);
+ }
+ }
+
+ /**
+ * Get this Layout's Campaign
+ * @return \Xibo\Entity\Campaign
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getCampaign()
+ {
+ return $this->campaignFactory->getById($this->campaignId);
+ }
+
+ /**
+ * Save this Layout
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function save($options = [])
+ {
+ // Default options
+ $options = array_merge([
+ 'saveLayout' => true,
+ 'saveRegions' => true,
+ 'saveTags' => true,
+ 'setBuildRequired' => true,
+ 'validate' => true,
+ 'notify' => true,
+ 'audit' => true,
+ 'import' => false,
+ 'appendCountOnDuplicate' => false,
+ 'setModifiedDt' => true,
+ 'auditMessage' => 'Saved',
+ 'type' => null
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate($options);
+ }
+
+ if ($options['setBuildRequired']) {
+ $this->setBuildRequired();
+ }
+
+ $this->getLog()->debug('Saving ' . $this . ' with options ' . json_encode($options, JSON_PRETTY_PRINT));
+
+ // New or existing layout
+ if ($this->layoutId == null || $this->layoutId == 0) {
+ $this->add();
+
+ if ($options['audit']) {
+ if ($this->parentId === null) {
+ $this->audit(
+ $this->layoutId,
+ $options['auditMessage'] ?? 'Added',
+ [
+ 'layoutId' => $this->layoutId,
+ 'layout' => $this->layout,
+ 'campaignId' => $this->campaignId,
+ ]
+ );
+ } else {
+ $this->audit(
+ $this->layoutId,
+ $options['auditMessage'] ?? 'Checked out',
+ [
+ 'layoutId' => $this->parentId,
+ 'layout' => $this->layout,
+ 'campaignId' => $this->campaignId,
+ ]
+ );
+ }
+ }
+ } else if (($this->hash() != $this->hash && $options['saveLayout']) || $options['setBuildRequired']) {
+ $this->update($options);
+
+ if ($options['audit'] && count($this->getChangedProperties()) > 0) {
+ $change = $this->getChangedProperties();
+ $change['campaignId'][] = $this->campaignId;
+
+ if ($this->parentId === null) {
+ $this->audit($this->layoutId, $options['auditMessage'] ?? 'Updated', $change);
+ } else {
+ $this->audit($this->layoutId, $options['auditMessage'] ?? 'Updated Draft', $change);
+ }
+ }
+ } else {
+ $this->getLog()->info('Save layout properties unchanged for layoutId ' . $this->layoutId
+ . ', status = ' . $this->status);
+ }
+
+ if ($options['saveRegions']) {
+ $this->getLog()->debug('Saving Regions on ' . $this);
+
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ // Update all regions
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+
+ // Assert the Layout Id
+ $region->layoutId = $this->layoutId;
+ $region->save($options);
+ }
+ }
+
+ if ($options['saveTags']) {
+ $this->getLog()->debug('Saving tags on ' . $this);
+
+ // Remove unwanted ones
+ if (is_array($this->unlinkTags)) {
+ foreach ($this->unlinkTags as $tag) {
+ $this->unlinkTagFromEntity('lktaglayout', 'layoutId', $this->layoutId, $tag->tagId);
+ }
+ }
+
+ // Save the tags
+ if (is_array($this->linkTags)) {
+ foreach ($this->linkTags as $tag) {
+ $this->linkTagToEntity('lktaglayout', 'layoutId', $this->layoutId, $tag->tagId, $tag->value);
+ }
+ }
+ }
+
+ $this->getLog()->debug('Save finished for ' . $this);
+ }
+
+ /**
+ * Delete Layout
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function delete($options = [])
+ {
+ // We must ensure everything is loaded before we delete
+ if (!$this->loaded) {
+ $this->load();
+ }
+
+ $this->getLog()->debug('Deleting ' . $this);
+
+ // We cannot delete the default default
+ if ($this->layoutId == $this->config->getSetting('DEFAULT_LAYOUT')) {
+ throw new InvalidArgumentException(__('This layout is used as the global default and cannot be deleted'), 'layoutId');
+ }
+
+ // Delete our draft if we have one
+ // this is recursive, so be careful!
+ if ($this->parentId === null && $this->publishedStatusId === 2) {
+ try {
+ $draft = $this->layoutFactory->getByParentId($this->layoutId);
+ $draft->delete(['notify' => false]);
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->info('No draft to delete for a Layout in the Draft state, odd!');
+ }
+ }
+
+ // Unassign all Tags
+ $this->unlinkAllTagsFromEntity('lktaglayout', 'layoutId', $this->layoutId);
+
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ // Delete Regions
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+ $region->delete($options);
+ }
+
+ // If we are the top level parent we also delete objects that sit on the top-level
+ if ($this->parentId === null) {
+
+ // Delete Permissions
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->deleteAll();
+ }
+
+ // Delete widget history
+ $this->getStore()->update('DELETE FROM `widgethistory` WHERE layoutHistoryId IN (SELECT layoutHistoryId FROM `layouthistory` WHERE campaignId = :campaignId)', ['campaignId' => $this->campaignId]);
+
+ // Delete layout history
+ $this->getStore()->update('DELETE FROM `layouthistory` WHERE campaignId = :campaignId', ['campaignId' => $this->campaignId]);
+
+ // Unassign from all Campaigns
+ foreach ($this->campaigns as $campaign) {
+ /* @var Campaign $campaign */
+ $campaign->layouts = $this->layoutFactory->getByCampaignId($campaign->campaignId, false);
+ // Passing this layoutId without a display order will remove all occurrences.
+ // https://github.com/xibosignage/xibo/issues/1960
+ $campaign->unassignLayout($this->layoutId);
+ $campaign->save(['validate' => false]);
+ }
+
+ // Delete our own Campaign
+ $campaign = $this->campaignFactory->getById($this->campaignId);
+ $campaign->delete();
+
+ // Remove the Layout from any display defaults
+ $this->getStore()->update('UPDATE `display` SET defaultlayoutid = :defaultLayoutId WHERE defaultlayoutid = :layoutId', [
+ 'layoutId' => $this->layoutId,
+ 'defaultLayoutId' => $this->config->getSetting('DEFAULT_LAYOUT')
+ ]);
+
+ // Remove any display group links
+ $this->getStore()->update('DELETE FROM `lklayoutdisplaygroup` WHERE layoutId = :layoutId', ['layoutId' => $this->layoutId]);
+
+ // Remove any display group links
+ $this->getStore()->update('DELETE FROM `schedule_sync` WHERE layoutId = :layoutId', ['layoutId' => $this->layoutId]);
+ } else {
+ // Remove the draft from any Campaign assignments
+ $this->getStore()->update('DELETE FROM `lkcampaignlayout` WHERE layoutId = :layoutId', ['layoutId' => $this->layoutId]);
+ }
+
+ foreach ($this->actions as $action) {
+ $action->delete();
+ }
+
+ // Remove the Layout (now it is orphaned it can be deleted safely)
+ $this->getStore()->update('DELETE FROM `layout` WHERE layoutid = :layoutId', array('layoutId' => $this->layoutId));
+
+ $this->getLog()->audit('Layout', $this->layoutId, 'Layout Deleted', ['layoutId' => $this->layoutId]);
+
+ // Delete the cached file (if there is one)
+ $this->deleteFiles();
+
+ // Audit the Delete
+ $this->audit($this->layoutId, 'Deleted' . (($this->parentId !== null) ? ' draft for ' . $this->parentId : ''));
+ }
+
+ /**
+ * Validate this layout
+ * @throws GeneralException
+ */
+ public function validate($options)
+ {
+ // We must provide either a template or a resolution
+ if ($this->width == 0 || $this->height == 0) {
+ throw new InvalidArgumentException(__('The layout dimensions cannot be empty'), 'width/height');
+ }
+
+ // Validation
+ // Layout created from media follows the media character limit
+ if (empty($this->layout) || strlen($this->layout) > 100 || strlen($this->layout) < 1) {
+ throw new InvalidArgumentException(
+ __('Layout Name must be between 1 and 100 characters'),
+ 'name'
+ );
+ }
+
+ if (!empty($this->description) && strlen($this->description) > 254) {
+ throw new InvalidArgumentException(
+ __('Description can not be longer than 254 characters'),
+ 'description'
+ );
+ }
+
+ // Check for duplicates
+ // exclude our own duplicate (if we're a draft)
+ $duplicates = $this->layoutFactory->query(null, [
+ 'userId' => $this->ownerId,
+ 'layoutExact' => $this->layout,
+ 'notLayoutId' => ($this->parentId !== null) ? $this->parentId : $this->layoutId,
+ 'disableUserCheck' => 1,
+ 'excludeTemplates' => -1
+ ]);
+
+ $duplicateCount = count($duplicates);
+ if ($duplicateCount > 0) {
+ if ($options['appendCountOnDuplicate']) {
+ $this->layout = $this->layout . ' #' . ($duplicateCount + 1);
+ } else {
+ throw new DuplicateEntityException(sprintf(
+ __("You already own a Layout called '%s'. Please choose another name."),
+ $this->layout
+ ));
+ }
+ }
+
+ // Check zindex is positive
+ if ($this->backgroundzIndex < 0) {
+ throw new InvalidArgumentException(__('Layer must be 0 or a positive number'), 'backgroundzIndex');
+ }
+
+ if ($this->code != null) {
+
+ if (!v::alnum('_')->validate($this->code)) {
+ throw new InvalidArgumentException(__('Please use only alphanumeric characters in Layout Code identifier', 'code'));
+ }
+
+ $duplicateCode = $this->layoutFactory->query(null, [
+ 'notLayoutId' => ($this->parentId !== null) ? $this->parentId : $this->layoutId,
+ 'disableUserCheck' => 1,
+ 'excludeTemplates' => -1,
+ 'retired' => -1,
+ 'code' => $this->code
+ ]);
+
+ if (count($duplicateCode) > 0) {
+ throw new DuplicateEntityException(__("Layout with provided code already exists"));
+ }
+ }
+ }
+
+ /**
+ * Add layout history
+ * this is called when a new Layout is added, and when a Draft Layout is published
+ * we can therefore expect to always have a Layout History record for a Layout
+ */
+ private function addLayoutHistory()
+ {
+ $this->getLog()->debug('Adding Layout History record for ' . $this->layoutId);
+
+ // Add a record in layout history when a layout is added or published
+ $this->getStore()->insert('
+ INSERT INTO `layouthistory` (campaignId, layoutId, publishedDate)
+ VALUES (:campaignId, :layoutId, :publishedDate)
+ ', [
+ 'campaignId' => $this->campaignId,
+ 'layoutId' => $this->layoutId,
+ 'publishedDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+
+ /**
+ * Add Widget History
+ * this should be called when the contents of a Draft Layout are destroyed during the publish process
+ * it preserves the current state of widgets before they are removed from the database
+ * that can then be used for proof of play stats, to get back to the original widget name/type and mediaId
+ * @param \Xibo\Entity\Layout $parent
+ * @throws NotFoundException
+ */
+ private function addWidgetHistory($parent)
+ {
+ // Get the most recent layout history record
+ $layoutHistoryId = $this->getStore()->select('
+ SELECT layoutHistoryId FROM `layouthistory` WHERE layoutId = :layoutId
+ ', [
+ 'layoutId' => $parent->layoutId
+ ]);
+
+ if (count($layoutHistoryId) <= 0) {
+ // We are missing the parent layout history record, which isn't good.
+ // I think all we can do at this stage is log it
+ $this->getLog()->alert('Missing Layout History for layoutId ' . $parent->layoutId . ' which is on campaignId ' . $parent->campaignId);
+ return;
+ }
+
+ $layoutHistoryId = intval($layoutHistoryId[0]['layoutHistoryId']);
+
+ // Add records in the widget history table representing all widgets on this Layout
+ foreach ($parent->getAllWidgets() as $widget) {
+
+ // Does this widget have a mediaId
+ $mediaId = null;
+ try {
+ $mediaId = $widget->getPrimaryMediaId();
+ } catch (NotFoundException $notFoundException) {
+ // this is fine
+ }
+
+ $this->getStore()->insert('
+ INSERT INTO `widgethistory` (layoutHistoryId, widgetId, mediaId, type, name)
+ VALUES (:layoutHistoryId, :widgetId, :mediaId, :type, :name);
+ ', [
+ 'layoutHistoryId' => $layoutHistoryId,
+ 'widgetId' => $widget->widgetId,
+ 'mediaId' => $mediaId,
+ 'type' => $widget->type,
+ 'name' => $widget->getOptionValue('name', null),
+ ]);
+ }
+ }
+
+ /**
+ * Export the Layout as its XLF
+ * @return string
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function toXlf()
+ {
+ Profiler::start('Layout::toXlf', $this->getLog());
+ $this->getLog()->debug('Layout toXLF for Layout ' . $this->layout . ' - ' . $this->layoutId);
+
+ $this->load(['loadPlaylists' => true]);
+
+ // Keep track of whether this layout has an empty region
+ $this->hasEmptyRegion = false;
+ $layoutCountRegionsWithDuration = 0;
+
+ $document = new \DOMDocument();
+ $layoutNode = $document->createElement('layout');
+ $layoutNode->setAttribute('width', $this->width);
+ $layoutNode->setAttribute('height', $this->height);
+ $layoutNode->setAttribute('bgcolor', $this->backgroundColor);
+ $layoutNode->setAttribute('schemaVersion', $this->schemaVersion);
+
+ // add Layout code only if code identifier is set on the Layout.
+ if ($this->code != null) {
+ $layoutNode->setAttribute('code', $this->code);
+ }
+
+ // Layout stat collection flag
+ if (is_null($this->enableStat)) {
+ $layoutEnableStat = $this->config->getSetting('LAYOUT_STATS_ENABLED_DEFAULT');
+ $this->getLog()->debug('Layout enableStat is empty. Get the default setting.');
+ } else {
+ $layoutEnableStat = $this->enableStat;
+ }
+ $layoutNode->setAttribute('enableStat', $layoutEnableStat);
+
+ // Only set the z-index if present
+ if ($this->backgroundzIndex != 0) {
+ $layoutNode->setAttribute('zindex', $this->backgroundzIndex);
+ }
+
+ if ($this->backgroundImageId != 0) {
+ // Get stored as
+ $media = $this->mediaFactory->getById($this->backgroundImageId);
+ if ($media->released === 0) {
+ $this->pushStatusMessage(sprintf(
+ __('%s set as the Layout background image is pending conversion'),
+ $media->name
+ ));
+ $this->status = Status::$STATUS_PLAYER;
+ } else if ($media->released === 2) {
+ $resizeLimit = $this->config->getSetting('DEFAULT_RESIZE_LIMIT');
+ $this->status = Status::$STATUS_INVALID;
+ throw new InvalidArgumentException(sprintf(
+ __('%s set as the Layout background image is too large. Please ensure that none of the images in your layout are larger than %s pixels on their longest edge. Please check the allowed Resize Limit in Administration -> Settings'),//@phpcs:ignore
+ $media->name,
+ $resizeLimit
+ ), 'backgroundImageId');
+ }
+ $layoutNode->setAttribute('background', $media->storedAs);
+ }
+
+ $document->appendChild($layoutNode);
+
+ // Track module status within the layout
+ $status = 0;
+ $this->clearStatusMessage();
+
+ $layoutActionNode = null;
+ if (is_array($this->actions) && count($this->actions) > 0) {
+ // actions on Layout
+ foreach ($this->actions as $action) {
+ $layoutActionNode = $document->createElement('action');
+ $this->decorateActionXmlNode($layoutActionNode, $action);
+ $layoutNode->appendChild($layoutActionNode);
+ }
+ }
+
+ // merge regions and drawers into one array and go through it.
+ $allRegions = array_merge($this->regions, $this->drawers);
+
+ foreach ($allRegions as $region) {
+ /* @var Region $region */
+
+ // drawer
+ if ($region->isDrawer === 1) {
+ $regionNode = $document->createElement('drawer');
+ // normal region
+ } else {
+ $regionNode = $document->createElement('region');
+ }
+
+ $regionNode->setAttribute('id', $region->regionId);
+ $regionNode->setAttribute('width', $region->width);
+ $regionNode->setAttribute('height', $region->height);
+ $regionNode->setAttribute('top', $region->top);
+ $regionNode->setAttribute('left', $region->left);
+ $regionNode->setAttribute('syncKey', $region->syncKey ?? '');
+
+ // Only set the zIndex if present
+ if ($region->zIndex != 0) {
+ $regionNode->setAttribute('zindex', $region->zIndex);
+ }
+
+ $regionActionNode = null;
+
+ foreach ($region->actions as $action) {
+ $regionActionNode = $document->createElement('action');
+ $this->decorateActionXmlNode($regionActionNode, $action);
+ $regionNode->appendChild($regionActionNode);
+ }
+
+ $layoutNode->appendChild($regionNode);
+
+ // Region Duration
+ $region->duration = 0;
+
+ // Region Options
+ $regionOptionsNode = $document->createElement('options');
+
+ foreach ($region->regionOptions as $regionOption) {
+ $regionOptionNode = $document->createElement($regionOption->option, $regionOption->value ?? '');
+ $regionOptionsNode->appendChild($regionOptionNode);
+ }
+
+ $regionNode->appendChild($regionOptionsNode);
+
+ // Store region look to work out duration calc
+ $regionLoop = $region->getOptionValue('loop', 0);
+
+ // Canvas Regions
+ // --------------
+ // These are special regions containing multiple widgets which are all rendered by the same HTML.
+ // we should get the "global" widget inside this region and only add that to the XLF.
+ if ($region->type === 'canvas') {
+ $widget = null;
+ $widgetDuration = 0;
+
+ foreach ($region->getPlaylist()->setModuleFactory($this->moduleFactory)->widgets as $item) {
+ // Pull out the global widget, if we have one (we should)
+ if ($item->type === 'global') {
+ $widget = $item;
+ }
+
+ // Get the highest duration.
+ $widgetDuration = max($widgetDuration, $item->calculatedDuration);
+
+ // Validate all canvas widget properties.
+ $this->assessWidgetStatus($this->moduleFactory->getByType($item->type), $item, $status);
+ }
+
+ // If we don't have a global widget then we fail with an empty region
+ if ($widget === null) {
+ $widgets = [];
+ } else {
+ // Force use duration and pick the highest duration from inside.
+ $widget->useDuration = 1;
+ $widget->duration = $widgetDuration;
+ $widget->calculatedDuration = $widgetDuration;
+
+ // Add this widget only
+ $widgets = [$widget];
+ }
+ } else {
+ $widgets = $region->getPlaylist()->setModuleFactory($this->moduleFactory)->expandWidgets();
+ }
+
+ // Get a count of widgets in this region
+ $countWidgets = count($widgets);
+
+ // Check for empty Region, exclude Drawers from this check.
+ if ($countWidgets <= 0 && $region->isDrawer == 0) {
+ $this->getLog()->info('Layout has empty region - ' . $countWidgets . ' widgets. playlistId = '
+ . $region->getPlaylist()->getId());
+ $this->hasEmptyRegion = true;
+ }
+
+ // Work out if we have any "lead regions", those are Widgets with a duration
+ $maxWidgetDurationInLayout = 1;
+
+ foreach ($widgets as $widget) {
+ if (($widget->useDuration == 1 && $widget->type !== 'global')
+ || $countWidgets > 1
+ || $regionLoop == 1
+ || $widget->type == 'video'
+ ) {
+ $layoutCountRegionsWithDuration++;
+ }
+
+ $maxWidgetDurationInLayout = Max(
+ ($widget->useDuration == 1 ? $widget->duration : $widget->calculatedDuration),
+ $maxWidgetDurationInLayout
+ );
+ }
+
+ foreach ($widgets as $widget) {
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // Set the Layout Status
+ $this->assessWidgetStatus($module, $widget, $status);
+
+ // Determine the duration of this widget
+ // the calculated duration contains the best guess at this duration from the playlist's perspective
+ // the only time we want to override this, is if we want it set to the Minimum Duration for the XLF
+ $widgetDuration = $widget->calculatedDuration;
+
+ // Is this Widget one that does not have a duration of its own?
+ // Assuming we have at least 1 region with a set duration, then we ought to
+ // Reset to the minimum duration
+ // do not do that if we are in the drawer Region!
+ if ($widget->useDuration == 0
+ && $countWidgets <= 1
+ && $regionLoop == 0
+ && $widget->type != 'video'
+ && $widget->type != 'videoin'
+ && $layoutCountRegionsWithDuration >= 1
+ && $region->isDrawer === 0
+ ) {
+ // Make sure this Widget expires immediately so that the other Regions can be the leaders when
+ // it comes to expiring the Layout
+ // Only do this when the widget's default duration is not the max duration in layout
+ if ($widgetDuration < $maxWidgetDurationInLayout) {
+ $widgetDuration = Widget::$widgetMinDuration;
+ }
+ }
+
+ if ($region->isDrawer === 0) {
+ // Region duration
+ // If we have a cycle playback duration, we use that, otherwise we use the normal calculated
+ // duration.
+ $tempCyclePlaybackAverageDuration = $widget->getUnmatchedProperty(
+ 'tempCyclePlaybackAverageDuration',
+ 0
+ );
+ if ($tempCyclePlaybackAverageDuration) {
+ $region->duration = $region->duration + $tempCyclePlaybackAverageDuration;
+ } else {
+ $region->duration = $region->duration + $widgetDuration;
+ }
+
+ // We also want to add any transition OUT duration
+ // only the OUT duration because IN durations do not get added to the widget duration by the player
+ // https://github.com/xibosignage/xibo/issues/705
+ if ($widget->getOptionValue('transOut', '') != '') {
+ // Transition durations are in milliseconds
+ $region->duration = $region->duration + ($widget->getOptionValue('transOutDuration', 0) / 1000);
+ }
+ }
+
+ // Create media xml node for XLF.
+ $renderAs = $module->renderAs;
+ $mediaNode = $document->createElement('media');
+ $mediaNode->setAttribute('id', $widget->widgetId);
+ $mediaNode->setAttribute('schemaVersion', $widget->schemaVersion);
+ $mediaNode->setAttribute('type', $widget->type);
+ $mediaNode->setAttribute('render', ($renderAs == '') ? 'native' : $renderAs);
+
+ // to make the xml cleaner, add those nodes only on Widgets that were grouped in a subPlaylist Widget.
+ if (!empty($widget->tempId) && $widget->tempId != $widget->widgetId) {
+ $mediaNode->setAttribute('playlist', $widget->playlist);
+ $mediaNode->setAttribute('displayOrder', $widget->displayOrder);
+ // parentWidgetId is the Sub-playlist WidgetId,
+ // which is used to group all Widgets belonging to the same Sub-playlist
+ $mediaNode->setAttribute('parentWidgetId', $widget->tempId);
+
+ // These three attributes relate to cycle based playback
+ $mediaNode->setAttribute('isRandom', $widget->getOptionValue('isRandom', 0));
+ $mediaNode->setAttribute('playCount', $widget->getOptionValue('playCount', 0));
+ $mediaNode->setAttribute('cyclePlayback', $widget->getOptionValue('cyclePlayback', 0));
+ }
+
+ // Set the duration according to whether we are using widget duration or not
+ $isEndDetectVideoWidget = (
+ ($widget->type === 'video' || $widget->type === 'audio')
+ && $widget->useDuration === 0
+ );
+
+ $mediaNode->setAttribute('duration', ($isEndDetectVideoWidget ? 0 : $widgetDuration));
+ $mediaNode->setAttribute('useDuration', $widget->useDuration);
+ $widgetActionNode = null;
+
+ foreach ($widget->actions as $action) {
+ $widgetActionNode = $document->createElement('action');
+ $this->decorateActionXmlNode($widgetActionNode, $action);
+ $mediaNode->appendChild($widgetActionNode);
+ }
+
+ // Set a from/to date
+ if ($widget->fromDt != null || $widget->fromDt === Widget::$DATE_MIN) {
+ $mediaNode->setAttribute(
+ 'fromDt',
+ Carbon::createFromTimestamp($widget->fromDt)->format(DateFormatHelper::getSystemFormat())
+ );
+ }
+
+ if ($widget->toDt != null || $widget->toDt === Widget::$DATE_MAX) {
+ $mediaNode->setAttribute(
+ 'toDt',
+ Carbon::createFromTimestamp($widget->toDt)->format(DateFormatHelper::getSystemFormat())
+ );
+ }
+
+ //
+ // Logic Table
+ // -----------
+ // Widget With Media
+ // LAYOUT MEDIA WIDGET Media stats collected?
+ // ON ON ON YES Widget takes precedence // Match - 1
+ // ON OFF ON YES Widget takes precedence // Match - 1
+ // ON INHERIT ON YES Widget takes precedence // Match - 1
+ //
+ // OFF ON ON YES Widget takes precedence // Match - 1
+ // OFF OFF ON YES Widget takes precedence // Match - 1
+ // OFF INHERIT ON YES Widget takes precedence // Match - 1
+ //
+ // ON ON OFF NO Widget takes precedence // Match - 2
+ // ON OFF OFF NO Widget takes precedence // Match - 2
+ // ON INHERIT OFF NO Widget takes precedence // Match - 2
+ //
+ // OFF ON OFF NO Widget takes precedence // Match - 2
+ // OFF OFF OFF NO Widget takes precedence // Match - 2
+ // OFF INHERIT OFF NO Widget takes precedence // Match - 2
+ //
+ // ON ON INHERIT YES Media takes precedence // Match - 3
+ // ON OFF INHERIT NO Media takes precedence // Match - 4
+ // ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+ //
+ // OFF ON INHERIT YES Media takes precedence // Match - 3
+ // OFF OFF INHERIT NO Media takes precedence // Match - 4
+ // OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+ //
+ // Widget Without Media
+ // LAYOUT WIDGET Widget stats collected?
+ // ON ON YES Widget takes precedence // Match - 1
+ // ON OFF NO Widget takes precedence // Match - 2
+ // ON INHERIT YES Inherited from Layout // Match - 7
+ // OFF ON YES Widget takes precedence // Match - 1
+ // OFF OFF NO Widget takes precedence // Match - 2
+ // OFF INHERIT NO Inherited from Layout // Match - 8
+
+ // Widget stat collection flag
+ $widgetEnableStat = $widget->getOptionValue(
+ 'enableStat',
+ $this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT')
+ );
+
+ if ($widgetEnableStat === null || $widgetEnableStat === '') {
+ $widgetEnableStat = $this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT');
+ }
+
+ $enableStat = 0; // Match - 0
+
+ if ($widgetEnableStat == 'On') {
+ $enableStat = 1; // Match - 1
+ $this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
+ . (($layoutEnableStat == 1) ? 'On': 'Off') . ' Widget '.$widgetEnableStat
+ . '. Media node output '. $enableStat);
+ } else if ($widgetEnableStat == 'Off') {
+ $enableStat = 0; // Match - 2
+ $this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
+ . (($layoutEnableStat == 1) ? 'On': 'Off') . ' Widget ' . $widgetEnableStat
+ . '. Media node output '. $enableStat);
+ } else if ($widgetEnableStat == 'Inherit') {
+ try {
+ // Media enable stat flag - WIDGET WITH MEDIA
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+
+ if (empty($media->enableStat)) {
+ $mediaEnableStat = $this->config->getSetting('MEDIA_STATS_ENABLED_DEFAULT');
+ $this->getLog()->debug('Media enableStat is empty. Get the default setting.');
+ } else {
+ $mediaEnableStat = $media->enableStat;
+ }
+
+ if ($mediaEnableStat == 'On') {
+ $enableStat = 1; // Match - 3
+ } else if ($mediaEnableStat == 'Off') {
+ $enableStat = 0; // Match - 4
+ } else if ($mediaEnableStat == 'Inherit') {
+ $enableStat = $layoutEnableStat; // Match - 5 and 6
+ }
+
+ $this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
+ . (($layoutEnableStat == 1) ? 'On': 'Off')
+ . ((isset($mediaEnableStat)) ? (' Media ' . $mediaEnableStat) : '')
+ . ' Widget '.$widgetEnableStat
+ . '. Media node output '. $enableStat);
+
+ } catch (\Exception $e) { // - WIDGET WITHOUT MEDIA
+ $this->getLog()->debug($widget->widgetId
+ . ' is not a library media and does not have a media id.');
+ $enableStat = $layoutEnableStat; // Match - 7 and 8
+
+ $this->getLog()->debug('For ' . $widget->widgetId . ': Layout '
+ . (($layoutEnableStat == 1) ? 'On': 'Off')
+ . ' Widget ' . $widgetEnableStat
+ . '. Media node output '. $enableStat);
+ }
+ }
+
+ // Set enable stat collection flag
+ $mediaNode->setAttribute('enableStat', $enableStat);
+ //
+
+ // automatically set the transitions on the layout xml, we are not saving widgets here to avoid
+ // deadlock issues.
+ if ($this->autoApplyTransitions == 1) {
+ $widgetTransIn = $widget->getOptionValue(
+ 'transIn',
+ $this->config->getSetting('DEFAULT_TRANSITION_IN')
+ );
+ $widgetTransOut = $widget->getOptionValue(
+ 'transOut',
+ $this->config->getSetting('DEFAULT_TRANSITION_OUT')
+ );
+ $widgetTransInDuration = $widget->getOptionValue(
+ 'transInDuration',
+ $this->config->getSetting('DEFAULT_TRANSITION_DURATION')
+ );
+ $widgetTransOutDuration = $widget->getOptionValue(
+ 'transOutDuration',
+ $this->config->getSetting('DEFAULT_TRANSITION_DURATION')
+ );
+
+ $widget->setOptionValue('transIn', 'attrib', $widgetTransIn);
+ $widget->setOptionValue('transInDuration', 'attrib', $widgetTransInDuration);
+ $widget->setOptionValue('transOut', 'attrib', $widgetTransOut);
+ $widget->setOptionValue('transOutDuration', 'attrib', $widgetTransOutDuration);
+ }
+
+ // Create options nodes
+ $optionsNode = $document->createElement('options');
+ $rawNode = $document->createElement('raw');
+
+ $mediaNode->appendChild($optionsNode);
+ $mediaNode->appendChild($rawNode);
+
+ // Inject the URI
+ $uriInjected = false;
+ if ($module->regionSpecific == 0) {
+ $media = $this->mediaFactory->getById($widget->getPrimaryMediaId());
+ $optionNode = $document->createElement('uri', $media->storedAs);
+ $optionsNode->appendChild($optionNode);
+ $uriInjected = true;
+
+ // Add the fileId attribute to the media element
+ $mediaNode->setAttribute('fileId', $media->mediaId);
+ }
+
+ // Track whether we have an updateInterval configured.
+ $hasUpdatedInterval = false;
+
+ // Output all properties belonging to the module (we are not interested in templates because they
+ // are all HTML rendered)
+ $module->decorateProperties($widget, true, false);
+
+ foreach ($module->properties as $property) {
+ // We only output certain properties
+ if ($property->includeInXlf) {
+ if (($uriInjected && $property->id == 'uri') || empty($property->id)) {
+ // Skip any property named "uri" if we've already injected a special node for that.
+ // Skip properties without an id
+ continue;
+ }
+
+ // We have something to output
+ $optionNode = $document->createElement($property->id);
+
+ if ($property->isCData() && $property->value) {
+ $cdata = $document->createCDATASection($property->value);
+ $optionNode->appendChild($cdata);
+
+ // Add to the raw node
+ $rawNode->appendChild($optionNode);
+ } else {
+ $optionNode->nodeValue = $property->value ?? '';
+
+ // Add to the options node
+ $optionsNode->appendChild($optionNode);
+ }
+ }
+
+ if ($property->id === 'updateInterval') {
+ $hasUpdatedInterval = true;
+ }
+ }
+
+ // Handle common properties which are stored as options.
+ $this->getLog()->debug('toXlf: adding transtions to option nodes, widgetId: ' . $widget->widgetId);
+
+ $transIn = $widget->getOptionValue('transIn', null);
+ if (!empty($transIn)) {
+ $optionsNode->appendChild($document->createElement('transIn', $transIn));
+ $optionsNode->appendChild($document->createElement(
+ 'transInDuration',
+ $widget->getOptionValue(
+ 'transInDuration',
+ $this->config->getSetting('DEFAULT_TRANSITION_DURATION')
+ )
+ ));
+ $optionsNode->appendChild($document->createElement(
+ 'transInDirection',
+ $widget->getOptionValue('transInDirection', 'E')
+ ));
+ }
+
+ $transOut = $widget->getOptionValue('transOut', null);
+ if (!empty($transOut)) {
+ $optionsNode->appendChild($document->createElement('transOut', $transOut));
+ $optionsNode->appendChild($document->createElement(
+ 'transOutDuration',
+ $widget->getOptionValue(
+ 'transOutDuration',
+ $this->config->getSetting('DEFAULT_TRANSITION_DURATION')
+ )
+ ));
+ $optionsNode->appendChild($document->createElement(
+ 'transOutDirection',
+ $widget->getOptionValue('transOutDirection', 'E')
+ ));
+ }
+
+ // If we do not have an update interval, should we set a default one?
+ // https://github.com/xibosignage/xibo/issues/2319
+ if (!$hasUpdatedInterval && $module->regionSpecific == 1) {
+ // Modules/Widgets without an update interval update very infrequently
+ $optionsNode->appendChild(
+ $document->createElement('updateInterval', 1440 * 30)
+ );
+ }
+
+ // Handle associated audio
+ $audioNodes = null;
+ foreach ($widget->audio as $audio) {
+ /** @var WidgetAudio $audio */
+ if ($audioNodes == null) {
+ $audioNodes = $document->createElement('audio');
+ }
+
+ // Get the full media node for this audio element
+ $audioMedia = $this->mediaFactory->getById($audio->mediaId);
+
+ $audioNode = $document->createElement('uri', $audioMedia->storedAs);
+ $audioNode->setAttribute('volume', $audio->volume);
+ $audioNode->setAttribute('loop', $audio->loop);
+ $audioNode->setAttribute('mediaId', $audio->mediaId);
+ $audioNodes->appendChild($audioNode);
+ }
+
+ if ($audioNodes != null) {
+ $mediaNode->appendChild($audioNodes);
+ }
+
+ $regionNode->appendChild($mediaNode);
+ }
+
+ $this->getLog()->debug('Region duration on layout ' . $this->layoutId . ' is ' . $region->duration
+ . '. Comparing to ' . $this->duration);
+
+ // Track the max duration within the layout
+ // Test this duration against the layout duration
+ if ($this->duration < $region->duration) {
+ $this->duration = $region->duration;
+ }
+
+ $event = new LayoutBuildRegionEvent($region->regionId, $regionNode);
+ $this->getDispatcher()->dispatch($event, $event::NAME);
+ // End of region loop.
+ }
+
+ $this->getLog()->debug('Setting Layout Duration to ' . $this->duration);
+
+ $tagsNode = $document->createElement('tags');
+
+ foreach ($this->tags as $tag) {
+ /* @var Tag $tag */
+ $tagNode = $document->createElement('tag', $tag->tag . (!empty($tag->value) ? '|' . $tag->value : ''));
+
+ $tagsNode->appendChild($tagNode);
+ }
+
+ $layoutNode->appendChild($tagsNode);
+
+ // Update the layout status / duration accordingly
+ $this->status = ($status < $this->status) ? $status : $this->status;
+
+ // Fire a layout.build event, passing the layout and the generated document.
+ $event = new LayoutBuildEvent($this, $document);
+ $this->getDispatcher()->dispatch($event, $event::NAME);
+
+ Profiler::end('Layout::toXlf', $this->getLog());
+ return $document->saveXML();
+ }
+
+ /**
+ * Assess the status of the provided widget
+ * @param Module $module
+ * @param Widget $widget
+ * @param int $status
+ * @return void
+ */
+ public function assessWidgetStatus(Module $module, Widget $widget, int &$status): void
+ {
+ $moduleStatus = Status::$STATUS_VALID;
+ try {
+ // Validate the module
+ $module
+ ->decorateProperties($widget, true)
+ ->validateProperties('status');
+
+ // Also validate the module template
+ $templateId = $widget->getOptionValue('templateId', null);
+ if ($templateId !== null && $templateId !== 'elements') {
+ $template = $this->moduleTemplateFactory->getByDataTypeAndId($module->dataType, $templateId);
+ $template
+ ->decorateProperties($widget)
+ ->validateProperties('status');
+ }
+
+ // If we have validator interfaces, then use it now
+ foreach ($module->getWidgetValidators() as $widgetValidator) {
+ $widgetValidator->validate($module, $widget, 'status');
+ }
+
+ // We need to make sure that all media in the widget have a valid release status
+ // Get all primary media IDs for this widget (audio IDs are excluded)
+ $mediaIds = $widget->getPrimaryMedia();
+
+ // Only validate if we actually have media IDs
+ if (!empty($mediaIds)) {
+ // Inspect each media item individually to validate its released status
+ foreach ($mediaIds as $mediaId) {
+ $media = $this->mediaFactory->getById($mediaId);
+ if ($media->released == 0) {
+ throw new GeneralException(sprintf(
+ __('%s is pending conversion'),
+ $media->name
+ ));
+ } else if ($media->released == 2) {
+ if ($media->mediaType === 'image') {
+ throw new GeneralException(sprintf(
+ __('%s is too large. Please ensure that none of the images in your layout are larger than your Resize Limit on their longest edge.'),//phpcs:ignore
+ $media->name
+ ));
+ } else {
+ throw new GeneralException(sprintf(
+ __('%s failed validation and cannot be published.'),
+ $media->name
+ ));
+ }
+ }
+ }
+ }
+
+ // Is this a sub-playlist?
+ if ($module->type === 'subplaylist') {
+ $event = new SubPlaylistValidityEvent($widget);
+ $this->getDispatcher()->dispatch($event);
+
+ if (!$event->isValid()) {
+ throw new InvalidArgumentException(__('Misconfigured Playlist'), 'playlistId');
+ }
+ }
+ } catch (GeneralException $xiboException) {
+ $this->getLog()->debug('assessWidgetStatus: ' . $module->moduleId . ' invalid, e: '
+ . $xiboException->getMessage());
+
+ $moduleStatus = Status::$STATUS_INVALID;
+
+ // Include the exception on
+ $this->pushStatusMessage($xiboException->getMessage());
+ }
+
+ $status = ($moduleStatus > $status) ? $moduleStatus : $status;
+ }
+
+ /**
+ * @param \DOMElement $node
+ * @param Action $action
+ * @return void
+ */
+ private function decorateActionXmlNode(\DOMElement $node, Action $action): void
+ {
+ $node->setAttribute('layoutCode', $action->layoutCode ?? '');
+ $node->setAttribute('target', $action->target ?? '');
+ $node->setAttribute('source', $action->source ?? '');
+ $node->setAttribute('actionType', $action->actionType ?? '');
+ $node->setAttribute('triggerType', $action->triggerType ?? '');
+ $node->setAttribute('triggerCode', $action->triggerCode ?? '');
+ $node->setAttribute('id', $action->actionId);
+
+ if (!empty($action->widgetId)) {
+ $node->setAttribute('widgetId', $action->widgetId);
+ }
+
+ if (!empty($action->targetId)) {
+ $node->setAttribute('targetId', $action->targetId);
+ }
+
+ if (!empty($action->sourceId)) {
+ $node->setAttribute('sourceId', $action->sourceId);
+ }
+ }
+
+ /**
+ * Export the Layout as a ZipArchive
+ * @param DataSetFactory $dataSetFactory
+ * @param \Xibo\Factory\WidgetDataFactory $widgetDataFactory
+ * @param string $fileName
+ * @param array $options
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function toZip(
+ DataSetFactory $dataSetFactory,
+ WidgetDataFactory $widgetDataFactory,
+ string $fileName,
+ array $options = []
+ ): void {
+ $options = array_merge([
+ 'includeData' => false,
+ 'includeFallback' => false,
+ ], $options);
+
+ // Load the complete layout
+ $this->load();
+
+ // We export to a ZIP file
+ $zip = new \ZipArchive();
+ $result = $zip->open($fileName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+ if ($result !== true) {
+ throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: ' . $result), 'fileName');
+ }
+
+ // Add a mapping file for the region names
+ $regionMapping = [];
+ foreach ($this->regions as $region) {
+ /** @var Region $region */
+ $regionMapping[$region->regionId] = $region->name;
+ }
+
+ // Add a mapping file for the drawer region names
+ $drawerMapping = [];
+ foreach ($this->drawers as $drawer) {
+ /** @var Region $region */
+ $drawerMapping[$drawer->regionId] = $drawer->name;
+ }
+
+ // Add layout information to the ZIP
+ $zip->addFromString('layout.json', json_encode([
+ 'layout' => $this->layout,
+ 'description' => $this->description,
+ 'regions' => $regionMapping,
+ 'drawers' => $drawerMapping,
+ 'layoutDefinitions' => $this
+ ]));
+
+ // Add all media
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ $mappings = [];
+
+ foreach ($this->mediaFactory->getByLayoutId($this->layoutId, 1, 1) as $media) {
+ /* @var Media $media */
+ $zip->addFile($libraryLocation . $media->storedAs, 'library/' . $media->fileName);
+ $media->load();
+
+ $mappings[] = [
+ 'file' => $media->fileName,
+ 'mediaid' => $media->mediaId,
+ 'name' => $media->name,
+ 'type' => $media->mediaType,
+ 'duration' => $media->duration,
+ 'background' => 0,
+ 'font' => 0,
+ 'tags' => $media->tags
+ ];
+ }
+
+ // Add the background image
+ if ($this->backgroundImageId != 0) {
+ $media = $this->mediaFactory->getById($this->backgroundImageId);
+ $zip->addFile($libraryLocation . $media->storedAs, 'library/' . $media->fileName);
+ $media->load();
+
+ $mappings[] = [
+ 'file' => $media->fileName,
+ 'mediaid' => $media->mediaId,
+ 'name' => $media->name,
+ 'type' => $media->mediaType,
+ 'duration' => $media->duration,
+ 'background' => 1,
+ 'font' => 0,
+ 'tags' => $media->tags
+ ];
+ }
+
+ if (file_exists($this->getThumbnailUri())) {
+ $zip->addFile($this->getThumbnailUri(), 'library/thumbs/campaign_thumb.png');
+ }
+
+ // Add any fonts
+ // Parse cdata/raw Widget Options (raw html, css, js etc)
+ // Get fonts assigned to elements
+ // lookup font files in db by name and add them to the zip
+ $fonts = [];
+ $nonElementsFonts = null;
+
+ foreach ($this->getAllWidgets() as $widget) {
+ foreach ($widget->widgetOptions as $option) {
+ if ($option->type === 'cdata' || $option->type === 'raw' && $option->option !== 'elements') {
+ preg_match_all('/font-family:(.*?);/', $option->value, $nonElementsFonts);
+ if (!empty($nonElementsFonts[1])) {
+ foreach ($nonElementsFonts[1] as $nonElementsFont) {
+ if (!in_array(trim($nonElementsFont), $fonts)) {
+ $fonts[] = trim($nonElementsFont);
+ }
+ }
+ }
+ } else if ($option->option === 'elements') {
+ $widgetElements = $widget->getOptionValue('elements', null);
+ // Elements will be JSON
+ $widgetElements = json_decode($widgetElements, true);
+
+ // go through the arrays to get properties array inside of elements
+ // find fontFamily property, add it to fonts array if we do not already have it there
+ foreach (($widgetElements ?? []) as $widgetElement) {
+ foreach (($widgetElement['elements'] ?? []) as $element) {
+ foreach ($element['properties'] as $property) {
+ if ($property['id'] === 'fontFamily' && !in_array($property['value'], $fonts)) {
+ $fonts[] = $property['value'];
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!empty($fonts)) {
+ $this->getLog()->debug(sprintf('Matched fonts: %s', json_encode($fonts)));
+
+ foreach ($fonts as $font) {
+ $matches = $this->fontFactory->getByName($font);
+
+ if (count($matches) <= 0) {
+ $this->getLog()->info(sprintf('Unmatched font during export: %s', $font));
+ continue;
+ }
+
+ $fontFile = $matches[0];
+
+ $zip->addFile($libraryLocation . 'fonts/'. $fontFile->fileName, 'library/' . $fontFile->fileName);
+
+ $mappings[] = [
+ 'file' => $fontFile->fileName,
+ 'fontId' => $fontFile->id,
+ 'name' => $fontFile->name,
+ 'type' => 'font',
+ 'background' => 0,
+ 'font' => 1
+ ];
+ }
+ }
+
+ // Add the mappings file to the ZIP
+ $zip->addFromString('mapping.json', json_encode($mappings));
+
+ // Handle any DataSet structures
+ $dataSetIds = [];
+ $dataSets = [];
+
+ // Handle any Widget Data
+ $widgetData = [];
+
+ // Playlists
+ $playlistMappings = [];
+ $playlistDefinitions = [];
+ $nestedPlaylistDefinitions = [];
+
+ foreach ($this->getAllWidgets() as $widget) {
+ if ($widget->type == 'dataset') {
+ $dataSetId = $widget->getOptionValue('dataSetId', 0);
+
+ if ($dataSetId != 0) {
+ if (in_array($dataSetId, $dataSetIds)) {
+ continue;
+ }
+
+ // Export the structure for this dataSet
+ $dataSet = $dataSetFactory->getById($dataSetId);
+ $dataSet->load();
+
+ // Are we also looking to export the data?
+ if ($options['includeData']) {
+ $dataSet->data = $dataSet->getData([], ['includeFormulaColumns' => false]);
+ }
+
+ $dataSetIds[] = $dataSet->dataSetId;
+ $dataSets[] = $dataSet;
+ }
+ } else if ($widget->type == 'subplaylist') {
+ $playlistItems = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
+ foreach ($playlistItems as $playlistItem) {
+ $count = 1;
+ $playlist = $this->playlistFactory->getById($playlistItem['playlistId']);
+ // include Widgets only for non dynamic Playlists #2392
+ $playlist->load(['loadWidgets' => !$playlist->isDynamic]);
+ if ($playlist->isDynamic === 0) {
+ $playlist->expandWidgets(0, false);
+ }
+
+ $playlistDefinitions[$playlist->playlistId] = $playlist;
+
+ // this is a recursive function, we are adding Playlist definitions,
+ // Playlist mappings and DataSets existing on the nested Playlist.
+ $playlist->generatePlaylistMapping(
+ $playlist->widgets,
+ $playlist->playlistId,
+ $playlistMappings,
+ $count,
+ $nestedPlaylistDefinitions,
+ $dataSetIds,
+ $dataSets,
+ $dataSetFactory,
+ $options['includeData']
+ );
+ }
+ }
+
+ // Handle fallback data?
+ if ($options['includeFallback'] == 1) {
+ $fallback = $widgetDataFactory->getByWidgetId($widget->widgetId);
+ if (count($fallback) > 0) {
+ $widgetData[$widget->widgetId] = $fallback;
+ }
+ }
+ }
+
+ // Add the mappings file to the ZIP
+ if ($dataSets != []) {
+ $zip->addFromString('dataSet.json', json_encode($dataSets, JSON_PRETTY_PRINT));
+ }
+
+ // Add widget data
+ if ($options['includeFallback'] == 1 && $widgetData != []) {
+ $zip->addFromString('fallback.json', json_encode($widgetData, JSON_PRETTY_PRINT));
+ }
+
+ // Add the Playlist definitions to the ZIP
+ if ($playlistDefinitions != []) {
+ $zip->addFromString('playlist.json', json_encode($playlistDefinitions, JSON_PRETTY_PRINT));
+ }
+
+ // Add the nested Playlist definitions to the ZIP
+ if ($nestedPlaylistDefinitions != []) {
+ $zip->addFromString('nestedPlaylist.json', json_encode($nestedPlaylistDefinitions, JSON_PRETTY_PRINT));
+ }
+
+ // Add Playlist mappings file to the ZIP
+ if ($playlistMappings != []) {
+ $zip->addFromString('playlistMappings.json', json_encode($playlistMappings, JSON_PRETTY_PRINT));
+ }
+
+ $zip->close();
+ }
+
+ /**
+ * Is a build of this layout required?
+ * @return bool
+ */
+ public function isBuildRequired(): bool
+ {
+ return $this->status == 3 || !file_exists($this->getCachePath());
+ }
+
+ /**
+ * Has this Layout built this session?
+ * @return bool
+ */
+ public function hasBuilt(): bool
+ {
+ return $this->hasBuilt;
+ }
+
+ /**
+ * Save the XLF to disk if necessary
+ * @param array $options
+ * @return string the path
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function xlfToDisk($options = [])
+ {
+ $options = array_merge([
+ 'notify' => true,
+ 'collectNow' => true,
+ 'exceptionOnError' => false,
+ 'exceptionOnEmptyRegion' => true,
+ 'publishing' => false
+ ], $options);
+
+ Profiler::start('Layout::xlfToDisk', $this->getLog());
+
+ $path = $this->getCachePath();
+
+ if ($this->status == 3 || !file_exists($path)) {
+ $this->getLog()->debug('XLF needs building for Layout ' . $this->layoutId);
+
+ $this->load(['loadPlaylists' => true]);
+
+ // Layout auto Publish
+ if ($this->config->getSetting('DEFAULT_LAYOUT_AUTO_PUBLISH_CHECKB') == 1 && $this->isChild()) {
+ // we are editing a draft layout, the published date is set on the original layout, therefore we
+ // need our parent.
+ $parent = $this->layoutFactory->loadById($this->parentId);
+
+ $layoutCurrentPublishedDate = Carbon::createFromTimestamp($parent->publishedDate);
+ $newPublishDateString = Carbon::now()->addMinutes(30)->format(DateFormatHelper::getSystemFormat());
+ $newPublishDate = Carbon::createFromTimeString($newPublishDateString);
+
+ if ($layoutCurrentPublishedDate > $newPublishDate) {
+ // Layout is set to Publish manually on a date further than 30 min from now, we don't touch it in
+ // this case.
+ $this->getLog()->debug('Layout is set to Publish manually on a date further than 30 min'
+ . ' from now, do not update');
+ } else if ($parent->publishedDate != null
+ && $layoutCurrentPublishedDate < Carbon::now()->subMinutes(5)
+ ) {
+ // Layout is set to Publish manually at least 5 min in the past at the moment, we expect the
+ // Regular Maintenance to build it before that happens
+ $this->getLog()->debug('Layout should be built by Regular Maintenance');
+ } else {
+ $parent->setPublishedDate($newPublishDateString);
+ $this->getLog()->debug('Layout set to automatically Publish on ' . $newPublishDateString);
+ }
+ }
+
+ // Assume error
+ $this->status = Status::$STATUS_INVALID;
+
+ // Reset duration
+ $this->duration = 0;
+
+ // Save the resulting XLF
+ try {
+ file_put_contents($path, $this->toXlf());
+ } catch (\Exception $e) {
+ $this->getLog()->error('Cannot build Layout ' . $this->layoutId . '. error: ' . $e->getMessage());
+
+ // Will continue and save the status as 4
+ $this->status = Status::$STATUS_INVALID;
+
+ if ($e->getMessage() != '') {
+ $this->pushStatusMessage($e->getMessage());
+ } else {
+ $this->pushStatusMessage('Unexpected Error');
+ }
+ // No need to notify on an errored build
+ $options['notify'] = false;
+ }
+
+ if ($options['exceptionOnError']) {
+ // Handle exception cases
+ if ($this->status === Status::$STATUS_INVALID
+ || ($options['exceptionOnEmptyRegion'] && $this->hasEmptyRegion())
+ ) {
+ $this->getLog()->debug('xlfToDisk: publish failed for layoutId ' . $this->layoutId
+ . ', status is ' . $this->status);
+
+ $this->audit($this->layoutId, 'Publish layout failed, rollback', ['layoutId' => $this->layoutId]);
+
+ throw new InvalidArgumentException(
+ sprintf(
+ __('There is an error with this Layout: %s'),
+ implode(',', $this->getStatusMessage())
+ ),
+ 'status'
+ );
+ }
+ }
+
+ // If we have an empty region, and we've not exceptioned, then we need to record that in our status
+ if ($this->hasEmptyRegion()) {
+ $this->status = Status::$STATUS_INVALID;
+ $this->pushStatusMessage(__('Empty Region'));
+ }
+
+ $this->save([
+ 'saveRegions' => true,
+ 'saveRegionOptions' => false,
+ 'manageRegionAssignments' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => false,
+ 'audit' => false,
+ 'validate' => false,
+ 'notify' => $options['notify'],
+ 'collectNow' => $options['collectNow'],
+ 'setModifiedDt' => false,
+ ]);
+
+ $this->hasBuilt = true;
+ } else {
+ $this->getLog()->debug('xlfToDisk: no build required for layoutId: ' . $this->layoutId);
+ $this->hasBuilt = false;
+ }
+
+ Profiler::end('Layout::xlfToDisk', $this->getLog());
+ return $path;
+ }
+
+ /**
+ * @return string
+ */
+ private function getCachePath()
+ {
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ return $libraryLocation . $this->layoutId . '.xlf';
+ }
+
+ /**
+ * Delete any cached files for this Layout.
+ */
+ private function deleteFiles()
+ {
+ if (file_exists($this->getCachePath())) {
+ @unlink($this->getCachePath());
+ }
+
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+
+ // Delete any thumbs
+ if (file_exists($libraryLocation . 'thumbs/' . $this->getId() . '_layout_thumb.png')) {
+ @unlink($libraryLocation . 'thumbs/' . $this->getId() . '_layout_thumb.png');
+ }
+
+ if (file_exists($libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png')) {
+ @unlink($libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png');
+ }
+ }
+
+ /**
+ * Publish the Draft
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function publishDraft()
+ {
+ $this->getLog()->debug('publish: publishing draft layoutId: ' . $this->layoutId . ', status: ' . $this->status);
+
+ // We are the draft - make sure we have a parent
+ if (!$this->isChild()) {
+ throw new InvalidArgumentException(__('Not a Draft'), 'statusId');
+ }
+
+ // Get my parent for later
+ $parent = $this->layoutFactory->loadById($this->parentId);
+
+ // I am the draft, so I clear my parentId, and set the parentId of my parent, to myself (swapping us)
+ // Make me the parent.
+ $this->getStore()->update('UPDATE `layout` SET parentId = NULL WHERE layoutId = :layoutId', [
+ 'layoutId' => $this->layoutId
+ ]);
+
+ // Set my parent, to be my child.
+ $this->getStore()->update('UPDATE `layout` SET parentId = :parentId WHERE layoutId = :layoutId', [
+ 'parentId' => $this->layoutId,
+ 'layoutId' => $this->parentId
+ ]);
+
+ // clear publishedDate
+ $this->getStore()->update('UPDATE `layout` SET publishedDate = null WHERE layoutId = :layoutId', [
+ 'layoutId' => $this->layoutId
+ ]);
+
+ // Update any campaign links
+ $this->getStore()->update('
+ UPDATE `lkcampaignlayout`
+ SET layoutId = :layoutId
+ WHERE layoutId = :parentId
+ AND campaignId IN (SELECT campaignId FROM campaign WHERE isLayoutSpecific = 0)
+ ', [
+ 'parentId' => $this->parentId,
+ 'layoutId' => $this->layoutId
+ ]);
+
+ // Persist things that might have changed
+ // NOTE: permissions are managed on the campaign, so we do not need to worry.
+ $this->layout = $parent->layout;
+ $this->description = $parent->description;
+ $this->retired = $parent->retired;
+ $this->enableStat = $parent->enableStat;
+ $this->code = $parent->code;
+ $this->folderId = $parent->folderId;
+
+ // Swap all tags over, any changes we've made to the parents tags should be moved to the child.
+ $this->getStore()->update('UPDATE `lktaglayout` SET layoutId = :layoutId WHERE layoutId = :parentId', [
+ 'parentId' => $parent->layoutId,
+ 'layoutId' => $this->layoutId
+ ]);
+
+ // Update any Displays which use this as their default Layout
+ $this->getStore()->update('UPDATE `display` SET defaultLayoutId = :layoutId WHERE defaultLayoutId = :parentId', [
+ 'parentId' => $parent->layoutId,
+ 'layoutId' => $this->layoutId
+ ]);
+
+ // Swap any display group links
+ $this->getStore()->update('UPDATE `lklayoutdisplaygroup` SET layoutId = :layoutId WHERE layoutId = :parentId', [
+ 'layoutId' => $this->layoutId,
+ 'parentId' => $parent->layoutId
+ ]);
+
+ // swap any schedule_sync links
+ $this->getStore()->update('UPDATE `schedule_sync` SET layoutId = :layoutId WHERE layoutId = :parentId', [
+ 'layoutId' => $this->layoutId,
+ 'parentId' => $parent->layoutId
+ ]);
+
+ // If this is the global default layout, then add some special handling to make sure we swap the default over
+ // to the incoming draft
+ if ($this->parentId == $this->config->getSetting('DEFAULT_LAYOUT')) {
+ // Change it over to me.
+ $this->config->changeSetting('DEFAULT_LAYOUT', $this->layoutId);
+ }
+
+ // Preserve the widget information
+ $this->addWidgetHistory($parent);
+
+ // Publish thumbnails.
+ $this->publishThumbnail();
+
+ // Delete the parent (make sure we set the parent to be a child of us, otherwise we will delete the linked
+ // campaign
+ $parent->parentId = $this->layoutId;
+ $parent->tags = []; // Clear the tags so we don't attempt a delete.
+ $parent->permissions = []; // Clear the permissions so we don't attempt a delete
+ $parent->delete();
+
+ // Set my statusId to published
+ // we do not want to notify here as we should wait for the build to happen
+ $this->publishedStatusId = 1;
+ $this->save([
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => false,
+ 'validate' => false,
+ 'audit' => true,
+ 'notify' => false
+ ]);
+
+ // Nullify my parentId (I no longer have a parent)
+ $this->parentId = null;
+
+ // Add a layout history
+ $this->addLayoutHistory();
+
+ // Always rebuild for a publish
+ $this->status = 3;
+ }
+
+ public function setPublishedDate($publishedDate)
+ {
+ $this->publishedDate = $publishedDate;
+
+ $this->getStore()->update('UPDATE `layout` SET publishedDate = :publishedDate WHERE layoutId = :layoutId', [
+ 'layoutId' => $this->layoutId,
+ 'publishedDate' => $this->publishedDate
+ ]);
+ }
+
+ /**
+ * Discard the Draft
+ * @throws GeneralException
+ */
+ public function discardDraft(bool $isShouldUpdateParent = true)
+ {
+ // We are the draft - make sure we have a parent
+ if (!$this->isChild()) {
+ $this->getLog()->debug('Cant discard draft ' . $this->layoutId . '. publishedStatusId = ' . $this->publishedStatusId . ', parentId = ' . $this->parentId);
+ throw new InvalidArgumentException(__('Not a Draft'), 'statusId');
+ }
+
+ // We just need to delete ourselves really
+ $this->delete();
+
+ // We also need to update the parent so that it is no longer draft
+ if ($isShouldUpdateParent) {
+ $parent = $this->layoutFactory->getById($this->parentId);
+ $parent->publishedStatusId = 1;
+ $parent->save([
+ self::$saveOptionsMinimum
+ ]);
+ }
+ }
+
+ //
+ // Add / Update
+ //
+
+ /**
+ * Add
+ * @throws GeneralException
+ */
+ private function add()
+ {
+ $this->getLog()->debug('Adding Layout ' . $this->layout);
+
+ $sql = 'INSERT INTO layout (layout, description, userID, createdDT, modifiedDT, publishedStatusId, status, width, height, schemaVersion, backgroundImageId, backgroundColor, backgroundzIndex, parentId, enableStat, retired, duration, autoApplyTransitions, code)
+ VALUES (:layout, :description, :userid, :createddt, :modifieddt, :publishedStatusId, :status, :width, :height, :schemaVersion, :backgroundImageId, :backgroundColor, :backgroundzIndex, :parentId, :enableStat, 0, 0, :autoApplyTransitions, :code)';
+
+ $time = Carbon::now()->format(DateFormatHelper::getSystemFormat());
+
+ $this->layoutId = $this->getStore()->insert($sql, array(
+ 'layout' => $this->layout,
+ 'description' => $this->description,
+ 'userid' => $this->ownerId,
+ 'createddt' => $time,
+ 'modifieddt' => $time,
+ 'publishedStatusId' => $this->publishedStatusId, // Default to 1 (published)
+ 'status' => 3,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'schemaVersion' => Environment::$XLF_VERSION,
+ 'backgroundImageId' => $this->backgroundImageId,
+ 'backgroundColor' => $this->backgroundColor,
+ 'backgroundzIndex' => $this->backgroundzIndex,
+ 'parentId' => ($this->parentId == null) ? null : $this->parentId,
+ 'enableStat' => $this->enableStat,
+ 'autoApplyTransitions' => ($this->autoApplyTransitions == null) ? 0 : $this->autoApplyTransitions,
+ 'code' => ($this->code == null) ? null : $this->code
+ ));
+
+ // Add a Campaign
+ // we do not add a campaign record for draft layouts.
+ if ($this->parentId === null) {
+ $campaign = $this->campaignFactory->create(
+ $this->getUnmatchedProperty('type', 'list'),
+ $this->layout,
+ $this->getOwnerId(),
+ ($this->folderId == null) ? 1 : $this->folderId
+ );
+ $campaign->isLayoutSpecific = 1;
+ $campaign->cyclePlaybackEnabled = 0;
+ $campaign->listPlayOrder = 'round';
+
+ // check that the user has access to the folder we're adding them to
+ $folder = $this->folderFactory->getById($campaign->folderId, 0);
+ $campaign->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ // Assign the layout
+ $campaign->assignLayout($this->layoutId);
+
+ // Ready to save the Campaign
+ // adding a Layout Specific Campaign shouldn't ever notify (it can't hit anything because we've only
+ // just added it)
+ $campaign->save([
+ 'notify' => false
+ ]);
+
+ // Assign the new campaignId to this layout
+ $this->campaignId = $campaign->campaignId;
+
+ // Add a layout history
+ $this->addLayoutHistory();
+ } else if ($this->campaignId == null) {
+ throw new InvalidArgumentException(__('Draft Layouts must have a parent'), 'campaignId');
+ } else {
+ // Add this draft layout as a link to the campaign
+ $campaign = $this->campaignFactory->getById($this->campaignId);
+ $campaign->layouts = $this->layoutFactory->getByCampaignId($campaign->campaignId, false);
+ $campaign->assignLayout($this->layoutId);
+ $campaign->save([
+ 'notify' => false
+ ]);
+ }
+ }
+
+ /**
+ * Update
+ * @param array $options
+ * @throws GeneralException
+ */
+ private function update($options = [])
+ {
+ $options = array_merge([
+ 'notify' => true,
+ 'collectNow' => true,
+ ], $options);
+
+ $this->getLog()->debug('Editing Layout ' . $this->layout . '. Id = ' . $this->layoutId);
+
+ $sql = '
+ UPDATE layout
+ SET layout = :layout,
+ description = :description,
+ duration = :duration,
+ modifiedDT = :modifieddt,
+ retired = :retired,
+ width = :width,
+ height = :height,
+ backgroundImageId = :backgroundImageId,
+ backgroundColor = :backgroundColor,
+ backgroundzIndex = :backgroundzIndex,
+ `status` = :status,
+ publishedStatusId = :publishedStatusId,
+ `userId` = :userId,
+ `schemaVersion` = :schemaVersion,
+ `statusMessage` = :statusMessage,
+ enableStat = :enableStat,
+ autoApplyTransitions = :autoApplyTransitions,
+ code = :code
+ WHERE layoutID = :layoutid
+ ';
+
+ // Only set the modified date if requested.
+ $time = ($options['setModifiedDt'])
+ ? Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ : $this->modifiedDt;
+
+ $this->getStore()->update($sql, array(
+ 'layoutid' => $this->layoutId,
+ 'layout' => $this->layout,
+ 'description' => $this->description,
+ 'duration' => ($this->duration == null) ? 0 : $this->duration,
+ 'modifieddt' => $time,
+ 'retired' => ($this->retired == null) ? 0 : $this->retired,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'backgroundImageId' => ($this->backgroundImageId == null) ? null : $this->backgroundImageId,
+ 'backgroundColor' => $this->backgroundColor,
+ 'backgroundzIndex' => $this->backgroundzIndex,
+ 'status' => $this->status,
+ 'publishedStatusId' => $this->publishedStatusId,
+ 'userId' => $this->ownerId,
+ 'schemaVersion' => ($this->schemaVersion == null) ? Environment::$XLF_VERSION : $this->schemaVersion,
+ 'statusMessage' => (empty($this->statusMessage)) ? null : json_encode($this->statusMessage),
+ 'enableStat' => $this->enableStat,
+ 'autoApplyTransitions' => $this->autoApplyTransitions,
+ 'code' => ($this->code == null) ? null : $this->code
+ ));
+
+ // Update the Campaign
+ if ($this->parentId === null) {
+ $campaign = $this->campaignFactory->getById($this->campaignId);
+ $campaign->campaign = $this->layout;
+ $campaign->ownerId = $this->ownerId;
+ $campaign->folderId = $this->folderId;
+
+ // if user has disabled folder feature, presumably said user also has no permissions to folder
+ // getById would fail here and prevent adding new Layout in web ui
+ try {
+ $folder = $this->folderFactory->getById($campaign->folderId);
+ $campaign->permissionsFolderId = ($folder->getPermissionFolderId() == null) ? $folder->id : $folder->getPermissionFolderId();
+ } catch (NotFoundException $exception) {
+ $campaign->permissionsFolderId = 1;
+ }
+ $campaign->save(['validate' => false, 'notify' => $options['notify'], 'collectNow' => $options['collectNow'], 'layoutCode' => $this->code]);
+ }
+ }
+
+ /**
+ * Handle the Playlist closure table for specified Layout object
+ *
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function managePlaylistClosureTable()
+ {
+ // we only need to set the closure table records for the playlists assigned directly to the regionPlaylist here
+ // all other relations between Playlists themselves are handled on import before layout is created
+ // as the SQL we run here is recursive everything will end up with correct parent/child relation and depth level
+ foreach ($this->getAllWidgets() as $widget) {
+ if ($widget->type == 'subplaylist') {
+ $assignedPlaylistIds = [];
+ $assignedPlaylists = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
+ foreach ($assignedPlaylists as $subPlaylistItem) {
+ if (!in_array($subPlaylistItem['playlistId'], $assignedPlaylistIds)) {
+ $assignedPlaylistIds[] = $subPlaylistItem['playlistId'];
+ }
+ }
+
+ foreach ($this->regions as $region) {
+ $regionPlaylist = $region->regionPlaylist;
+
+ if ($widget->playlistId == $regionPlaylist->playlistId) {
+ $parentId = $regionPlaylist->playlistId;
+ $child = $assignedPlaylistIds;
+ }
+ }
+
+ if (isset($parentId) && isset($child)) {
+ foreach ($child as $childId) {
+ $this->getLog()->debug(
+ 'Manage closure table for parent ' . $parentId . ' and child ' . $childId
+ );
+
+ if ($this->getStore()->exists('SELECT parentId, childId, depth FROM lkplaylistplaylist WHERE childId = :childId AND parentId = :parentId ', [//phpcs:ignore
+ 'parentId' => $parentId,
+ 'childId' => $childId
+ ])) {
+ throw new InvalidArgumentException(
+ __('Cannot add the same SubPlaylist twice.'),
+ 'playlistId'
+ );
+ }
+
+ $this->getStore()->insert('
+ INSERT INTO `lkplaylistplaylist` (parentId, childId, depth)
+ SELECT p.parentId, c.childId, p.depth + c.depth + 1
+ FROM lkplaylistplaylist p, lkplaylistplaylist c
+ WHERE p.childId = :parentId AND c.parentId = :childId
+ ', [
+ 'parentId' => $parentId,
+ 'childId' => $childId
+ ]);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * This function will adjust the Action sourceId and targetId in all relevant objects in our imported Layout
+ * @param bool $validate
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function manageActions(bool $validate = true): void
+ {
+ $oldRegionIds = [];
+ $newRegionIds = [];
+ $newWidgetIds = [];
+ $oldWidgetIds = [];
+
+ // get all regionIds including drawers
+ $allNewRegions = array_merge($this->regions, $this->drawers);
+
+ // create an array of new and old (from import) Region and Widget ids
+ /** @var Region $region */
+ foreach ($allNewRegions as $region) {
+ $newRegionIds[] = $region->regionId;
+ $oldRegionIds[] = $region->tempId;
+
+ /** @var Widget $widget */
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ $newWidgetIds[] = $widget->widgetId;
+ $oldWidgetIds[] = $widget->tempWidgetId;
+ }
+ }
+
+ // combine the arrays into $old=>$new key value arrays
+ $combined = array_combine($oldRegionIds, $newRegionIds);
+ $combinedWidgets = array_combine($oldWidgetIds, $newWidgetIds);
+
+ // get Actions with Layout
+ $layoutActions = $this->actionFactory->query(null, ['source' => 'importLayout']);
+
+ // go through all imported actions on a Layout and replace the source/target Ids with the new ones
+ foreach ($layoutActions as $action) {
+ // If the action targets the old layout ID, update it so the action now targets the new layout ID
+ if ($action->targetId == $action->layoutId) {
+ $action->targetId = $this->layoutId;
+ }
+ $action->source = 'layout';
+ $action->sourceId = $this->layoutId;
+ $action->layoutId = $this->layoutId;
+
+ if ($action->targetId != null) {
+ foreach ($combined as $old => $new) {
+ if ($old == $action->targetId) {
+ $this->getLog()->debug('Layout Import, switching Layout Action target ID from ' . $old . ' to ' . $new);
+ $action->targetId = $new;
+ }
+ }
+ }
+
+ // switch widgetId
+ if ($action->widgetId != null) {
+
+ foreach ($combinedWidgets as $old => $new) {
+ if ($old == $action->widgetId && $action->actionType == 'navWidget') {
+ $this->getLog()->debug('Layout Import, switching Widget Action widget ID from ' . $old . ' to ' . $new);
+ $action->widgetId = $new;
+ }
+ }
+ }
+
+ $action->save(['validate' => $validate]);
+ }
+
+ // Actions with Region
+ $regionActions = $this->actionFactory->query(null, ['source' => 'importRegion']);
+
+ // go through all imported actions on a Region and replace the source/target Ids with the new ones
+ foreach ($regionActions as $action) {
+ // If the action targets the old layout ID, update it so the action now targets the new layout ID
+ if ($action->targetId == $action->layoutId) {
+ $action->targetId = $this->layoutId;
+ }
+ $action->source = 'region';
+ $action->layoutId = $this->layoutId;
+
+ foreach ($combined as $old => $new) {
+ if ($old == $action->targetId) {
+ $this->getLog()->debug('Layout Import, switching Region Action target ID from ' . $old . ' to ' . $new);
+ $action->targetId = $new;
+ }
+
+ if ($action->sourceId === $old) {
+ $this->getLog()->debug('Layout Import, switching Region Action source ID from ' . $old . ' to ' . $new);
+ $action->sourceId = $new;
+ }
+ }
+
+ // switch widgetId
+ if ($action->widgetId != null) {
+
+ foreach ($combinedWidgets as $old => $new) {
+ if ($old == $action->widgetId && $action->actionType == 'navWidget') {
+ $this->getLog()->debug('Layout Import, switching Widget Action widget ID from ' . $old . ' to ' . $new);
+ $action->widgetId = $new;
+ }
+ }
+ }
+
+ $action->save(['validate' => $validate]);
+ }
+
+ // Actions with Widget
+ $widgetActions = $this->actionFactory->query(null, ['source' => 'importWidget']);
+
+ // go through all imported actions on a Widget and replace the source/target Ids with the new ones
+ foreach ($widgetActions as $action) {
+ // If the action targets the old layout ID, update it so the action now targets the new layout ID
+ if ($action->targetId == $action->layoutId) {
+ $action->targetId = $this->layoutId;
+ }
+ $action->source = 'widget';
+ $action->layoutId = $this->layoutId;
+
+ // switch Action source Id and Action widget Id
+ foreach ($combinedWidgets as $old => $new) {
+ if ($action->sourceId == $old) {
+ $this->getLog()->debug('Layout Import, switching Widget Action source ID from ' . $old . ' to ' . $new);
+ $action->sourceId = $new;
+ }
+
+ if ($action->widgetId != null) {
+ if ($old == $action->widgetId && $action->actionType == 'navWidget') {
+ $this->getLog()->debug('Layout Import, switching Widget Action widget ID from ' . $old . ' to ' . $new);
+ $action->widgetId = $new;
+ }
+ }
+ }
+
+ // if we had targetId (regionId) then switch it
+ if ($action->targetId != null) {
+ foreach ($combined as $old => $new) {
+ if ($old == $action->targetId) {
+ $this->getLog()->debug('Layout Import, switching Widget Action target ID from ' . $old . ' to ' . $new);
+ $action->targetId = $new;
+ }
+ }
+ }
+
+ $action->save(['validate' => $validate]);
+ }
+
+ // Make sure we update targetRegionId in Drawer Widgets.
+ foreach ($allNewRegions as $region) {
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ if ($region->isDrawer === 1) {
+ foreach ($combined as $old => $new) {
+ if ($widget->getOptionValue('targetRegionId', null) == $old) {
+ $this->getLog()->debug('Layout Import, switching Widget targetRegionId from ' . $old . ' to ' . $new);
+ $widget->setOptionValue('targetRegionId', 'attrib', $new);
+ $widget->save();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Adjust source and target id in copied Layout (checkout / copy )
+ *
+ * @param Layout $newLayout
+ * @param Layout $originalLayout
+ * @param bool $validate
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function copyActions(Layout $newLayout, Layout $originalLayout, bool $validate = true)
+ {
+ $oldRegionIds = [];
+ $newRegionIds = [];
+ $oldWidgetIds = [];
+ $newWidgetIds = [];
+
+ $this->getLog()->debug('Copy Actions from ' . $originalLayout->layoutId . ' To ' . $newLayout->layoutId);
+
+ /** @var Region[] $allRegions */
+ $allRegions = array_merge($newLayout->regions, $newLayout->drawers);
+
+ // go through all layouts, regions, playlists and their widgets
+ /** @var Region $region */
+ foreach ($allRegions as $region) {
+ // Match our original region id to the id in the parent layout
+ $original = $originalLayout->getRegionOrDrawer($region->getOriginalValue('regionId'));
+
+ $oldRegionIds[] = (int)$original->regionId;
+ $newRegionIds[] = $region->regionId;
+
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ $originalWidget = $original->getPlaylist()->getWidget($widget->getOriginalValue('widgetId'));
+
+ $oldWidgetIds[] = (int)$originalWidget->widgetId;
+ $newWidgetIds[] = $widget->widgetId;
+ }
+ }
+
+ // create $old=>$new arrays of all of them to later update the Actions
+ $combinedRegionIds = array_combine($oldRegionIds, $newRegionIds);
+ $combinedWidgetIds = array_combine($oldWidgetIds, $newWidgetIds);
+
+ $this->getLog()->debug('Region Ids array ' . json_encode($combinedRegionIds));
+ $this->getLog()->debug('Widget Ids array ' . json_encode($combinedWidgetIds));
+
+ // Interactive Actions on Layout
+ foreach ($newLayout->actions as $action) {
+
+ // switch source Id
+ if ($action->sourceId === $originalLayout->layoutId) {
+ $action->sourceId = $newLayout->layoutId;
+ }
+
+ // switch layoutId
+ if ($action->layoutId === $originalLayout->layoutId) {
+ $action->layoutId = $newLayout->layoutId;
+ }
+
+ // if action target (screen) was old layout, update with new id
+ if ($action->targetId === $originalLayout->layoutId && $action->target == 'screen') {
+ $action->targetId = $newLayout->layoutId;
+ }
+
+ // if we had targetId (regionId) then switch it
+ if ($action->targetId != null) {
+ foreach ($combinedRegionIds as $old => $new) {
+ if ($old == $action->targetId) {
+ $action->targetId = $new;
+ }
+ }
+ }
+
+ // switch Action widgetId
+ if ($action->widgetId != null) {
+ foreach ($combinedWidgetIds as $old => $new) {
+ if ($old == $action->widgetId && $action->actionType == 'navWidget') {
+ $action->widgetId = $new;
+ }
+ }
+ }
+
+ $action->save(['validate' => $validate]);
+ }
+
+ // Region Actions
+ foreach ($allRegions as $region) {
+ // Match our original region id to the id in the parent layout
+ $original = $originalLayout->getRegionOrDrawer($region->getOriginalValue('regionId'));
+
+ // Interactive Actions on Region
+ foreach ($region->actions as $action) {
+
+ // switch source Id
+ if ($action->sourceId === $original->regionId) {
+ $action->sourceId = $region->regionId;
+ }
+
+ // switch layoutId
+ if ($action->layoutId === $originalLayout->layoutId) {
+ $action->layoutId = $newLayout->layoutId;
+ }
+
+ // if action target (screen) was old layout, update with new id
+ if ($action->targetId === $originalLayout->layoutId && $action->target == 'screen') {
+ $action->targetId = $newLayout->layoutId;
+ }
+
+ // if we had targetId (regionId) then switch it
+ if ($action->targetId != null) {
+ foreach ($combinedRegionIds as $old => $new) {
+ if ($old == $action->targetId) {
+ $action->targetId = $new;
+ }
+ }
+ }
+
+ // switch Action widgetId
+ if ($action->widgetId != null) {
+
+ foreach ($combinedWidgetIds as $old => $new) {
+ if ($old == $action->widgetId && $action->actionType == 'navWidget') {
+ $action->widgetId = $new;
+ }
+ }
+ }
+
+ $action->save(['validate' => $validate]);
+ }
+
+ // Widget Actions
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ $originalWidget = $original->getPlaylist()->getWidget($widget->getOriginalValue('widgetId'));
+
+ // Make sure we update targetRegionId in Drawer Widgets on checkout.
+ if ($region->isDrawer === 1) {
+ foreach ($combinedRegionIds as $old => $new) {
+ if ($widget->getOptionValue('targetRegionId', null) == $old) {
+ $widget->setOptionValue('targetRegionId', 'attrib', $new);
+ $widget->save();
+ }
+ }
+ }
+ // Interactive Actions on Widget
+ foreach ($widget->actions as $action) {
+ // switch source Id
+ if ($action->sourceId === $originalWidget->widgetId) {
+ $action->sourceId = $widget->widgetId;
+ }
+
+ // switch layoutId
+ if ($action->layoutId === $originalLayout->layoutId) {
+ $action->layoutId = $newLayout->layoutId;
+ }
+
+ // if action target (screen) was old layout, update with new id
+ if ($action->targetId === $originalLayout->layoutId && $action->target == 'screen') {
+ $action->targetId = $newLayout->layoutId;
+ }
+
+ // if we had targetId (regionId) then switch it
+ if ($action->targetId != null) {
+ foreach ($combinedRegionIds as $old => $new) {
+ if ($old == $action->targetId) {
+ $action->targetId = $new;
+ }
+ }
+ }
+
+ // switch Action widgetId
+ if ($action->widgetId != null) {
+ foreach ($combinedWidgetIds as $old => $new) {
+ if ($old == $action->widgetId && $action->actionType == 'navWidget') {
+ $action->widgetId = $new;
+ }
+ }
+ }
+
+ $action->save(['validate' => $validate]);
+ }
+ }
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getThumbnailUri(): string
+ {
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ if ($this->isChild()) {
+ return $libraryLocation . 'thumbs/' . $this->campaignId . '_layout_thumb.png';
+ } else {
+ return $libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png';
+ }
+ }
+
+ /**
+ * Publish the Layout thumbnail if it exists.
+ */
+ private function publishThumbnail()
+ {
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ if (file_exists($libraryLocation . 'thumbs/' . $this->campaignId . '_layout_thumb.png')) {
+ copy(
+ $libraryLocation . 'thumbs/' . $this->campaignId . '_layout_thumb.png',
+ $libraryLocation . 'thumbs/' . $this->campaignId . '_campaign_thumb.png'
+ );
+ }
+ }
+}
diff --git a/lib/Entity/LayoutOnCampaign.php b/lib/Entity/LayoutOnCampaign.php
new file mode 100644
index 0000000..8fdfc8a
--- /dev/null
+++ b/lib/Entity/LayoutOnCampaign.php
@@ -0,0 +1,70 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+/**
+ * @SWG\Definition("Layout linked to a Campaign")
+ */
+class LayoutOnCampaign implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public $lkCampaignLayoutId;
+ public $campaignId;
+ public $layoutId;
+ public $displayOrder;
+
+ public $dayPartId;
+ public $daysOfWeek;
+ public $geoFence;
+
+ /**
+ * @SWG\Property(description="The Layout name (readonly)")
+ * @var string
+ */
+ public $layout;
+
+ /**
+ * @SWG\Property(description="The Layout campaignId (readonly)")
+ * @var string
+ */
+ public $layoutCampaignId;
+
+ /**
+ * @SWG\Property(description="The owner id (readonly))")
+ * @var integer
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="The duration (readonly))")
+ * @var integer
+ */
+ public $duration;
+
+ /**
+ * @SWG\Property(description="The dayPart (readonly)")
+ * @var string
+ */
+ public $dayPart;
+}
diff --git a/lib/Entity/LogEntry.php b/lib/Entity/LogEntry.php
new file mode 100644
index 0000000..f092236
--- /dev/null
+++ b/lib/Entity/LogEntry.php
@@ -0,0 +1,120 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class LogEntry
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class LogEntry implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Log ID")
+ * @var int
+ */
+ public $logId;
+
+ /**
+ * @SWG\Property(description="A unique run number for a set of Log Messages.")
+ * @var string
+ */
+ public $runNo;
+
+ /**
+ * @SWG\Property(description="A timestamp representing the CMS date this log message occured")
+ * @var int
+ */
+ public $logDate;
+
+ /**
+ * @SWG\Property(description="The Channel that generated this message. WEB/API/MAINT/TEST")
+ * @var string
+ */
+ public $channel;
+
+ /**
+ * @SWG\Property(description="The requested route")
+ * @var string
+ */
+ public $page;
+
+ /**
+ * @SWG\Property(description="The request method, GET/POST/PUT/DELETE")
+ * @var string
+ */
+ public $function;
+
+ /**
+ * @SWG\Property(description="The log message")
+ * @var string
+ */
+ public $message;
+
+ /**
+ * @SWG\Property(description="The display ID this message relates to or NULL for CMS")
+ * @var int
+ */
+ public $displayId;
+
+ /**
+ * @SWG\Property(description="The Log Level")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The display this message relates to or CMS for CMS.")
+ * @var string
+ */
+ public $display;
+
+ /**
+ * @SWG\Property(description="Session history id.")
+ * @var int
+ */
+ public $sessionHistoryId;
+
+ /**
+ * @SWG\Property(description="User id.")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/Media.php b/lib/Entity/Media.php
new file mode 100644
index 0000000..a0f02f6
--- /dev/null
+++ b/lib/Entity/Media.php
@@ -0,0 +1,1040 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Mimey\MimeTypes;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Media
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Media implements \JsonSerializable
+{
+ use EntityTrait;
+ use TagLinkTrait;
+
+ /**
+ * @SWG\Property(description="The Media ID")
+ * @var int
+ */
+ public $mediaId;
+
+ /**
+ * @SWG\Property(description="The ID of the User that owns this Media")
+ * @var int
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="The Parent ID of this Media if it has been revised")
+ * @var int
+ */
+ public $parentId;
+
+ /**
+ * @SWG\Property(description="The Name of this Media")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The module type of this Media")
+ * @var string
+ */
+ public $mediaType;
+
+ /**
+ * @SWG\Property(description="The file name of the media as stored in the library")
+ * @var string
+ */
+ public $storedAs;
+
+ /**
+ * @SWG\Property(description="The original file name as it was uploaded")
+ * @var string
+ */
+ public $fileName;
+
+ // Thing that might be referred to
+ /**
+ * @SWG\Property(description="Tags associated with this Media, array of TagLink objects")
+ * @var TagLink[]
+ */
+ public $tags = [];
+
+ /**
+ * @SWG\Property(description="The file size in bytes")
+ * @var int
+ */
+ public $fileSize;
+
+ /**
+ * @SWG\Property(description="The duration to use when assigning this media to a Layout widget")
+ * @var int
+ */
+ public $duration = 0;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this media is valid.")
+ * @var int
+ */
+ public $valid = 0;
+
+ /**
+ * @SWG\Property(description="DEPRECATED: Flag indicating whether this media is a system file or not")
+ * @var int
+ */
+ public $moduleSystemFile = 0;
+
+ /**
+ * @SWG\Property(description="Timestamp indicating when this media should expire")
+ * @var int
+ */
+ public $expires = 0;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this media is retired")
+ * @var int
+ */
+ public $retired = 0;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this media has been edited and replaced with a newer file")
+ * @var int
+ */
+ public $isEdited = 0;
+
+ /**
+ * @SWG\Property(description="A MD5 checksum of the stored media file")
+ * @var string
+ */
+ public $md5;
+
+ /**
+ * @SWG\Property(description="The username of the User that owns this media")
+ * @var string
+ */
+ public $owner;
+
+ /**
+ * @SWG\Property(description="A comma separated list of groups/users with permissions to this Media")
+ * @var string
+ */
+ public $groupsWithPermissions;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this media has been released")
+ * @var int
+ */
+ public $released = 1;
+
+ /**
+ * @SWG\Property(description="An API reference")
+ * @var string
+ */
+ public $apiRef;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime the Media was created"
+ * )
+ */
+ public $createdDt;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime the Media was last modified"
+ * )
+ */
+ public $modifiedDt;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The option to enable the collection of Media Proof of Play statistics"
+ * )
+ */
+ public $enableStat;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The orientation of the Media file"
+ * )
+ */
+ public $orientation;
+
+ /**
+ * @var int
+ * @SWG\Property(description="The width of the Media file")
+ */
+ public $width;
+
+ /**
+ * @var int
+ * @SWG\Property(description="The height of the Media file")
+ */
+ public $height;
+
+ // Private
+ /** @var TagLink[] */
+ private $unlinkTags = [];
+ /** @var TagLink[] */
+ private $linkTags = [];
+ private $requestOptions = [];
+ private $datesToFormat = ['expires'];
+ // New file revision
+ public $isSaveRequired;
+ public $isRemote;
+
+ public $cloned = false;
+ public $newExpiry;
+ public $alwaysCopy = false;
+
+ /**
+ * @SWG\Property(description="The id of the Folder this Media belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Media")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ public $widgets = [];
+ public $displayGroups = [];
+ public $layoutBackgroundImages = [];
+ private $permissions = [];
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param MediaFactory $mediaFactory
+ * @param PermissionFactory $permissionFactory
+ */
+ public function __construct($store, $log, $dispatcher, $config, $mediaFactory, $permissionFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->config = $config;
+ $this->mediaFactory = $mediaFactory;
+ $this->permissionFactory = $permissionFactory;
+ }
+
+ public function __clone()
+ {
+ // Clear the ID's and all widget/displayGroup assignments
+ $this->mediaId = null;
+ $this->widgets = [];
+ $this->displayGroups = [];
+ $this->layoutBackgroundImages = [];
+ $this->permissions = [];
+ $this->tags = [];
+
+ // We need to do something with the name
+ $this->name = sprintf(
+ __('Copy of %s on %s'),
+ $this->name,
+ Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ );
+
+ // Set so that when we add, we copy the existing file in the library
+ $this->fileName = $this->storedAs;
+ $this->storedAs = null;
+ $this->cloned = true;
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->mediaId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getPermissionFolderId(): int
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * Get Owner Id
+ * @return int
+ */
+ public function getOwnerId(): int
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Get the MIME type for this media
+ * @return string
+ */
+ public function getMimeType(): string
+ {
+ $mimeTypes = new MimeTypes();
+ $ext = explode('.', $this->storedAs);
+ return $mimeTypes->getMimeType($ext[count($ext) - 1]);
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner(int $ownerId)
+ {
+ $this->ownerId = $ownerId;
+ }
+
+ /**
+ * @return int
+ * @throws GeneralException
+ */
+ private function countUsages()
+ {
+ $this->load(['fullInfo' => true]);
+
+ return count($this->widgets) + count($this->displayGroups) + count($this->layoutBackgroundImages);
+ }
+
+ /**
+ * Is this media used
+ * @param int $usages threshold
+ * @return bool
+ * @throws GeneralException
+ */
+ public function isUsed($usages = 0)
+ {
+ return $this->countUsages() > $usages;
+ }
+
+ /**
+ * Validate
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function validate($options)
+ {
+ if (!v::stringType()->notEmpty()->validate($this->mediaType)) {
+ throw new InvalidArgumentException(__('Unknown Media Type'), 'type');
+ }
+
+ if (!v::stringType()->notEmpty()->length(1, 100)->validate($this->name)) {
+ throw new InvalidArgumentException(__('The name must be between 1 and 100 characters'), 'name');
+ }
+
+ // Check the naming of this item to ensure it doesn't conflict
+ $params = [];
+ $checkSQL = 'SELECT `name`, `mediaId`, `apiRef` FROM `media` WHERE `name` = :name AND userid = :userId';
+
+ if ($this->mediaId != 0) {
+ $checkSQL .= ' AND mediaId <> :mediaId AND IsEdited = 0 ';
+ $params['mediaId'] = $this->mediaId;
+ }
+ else if ($options['oldMedia'] != null && $this->name == $options['oldMedia']->name) {
+ $checkSQL .= ' AND IsEdited = 0 ';
+ }
+
+ $params['name'] = $this->name;
+ $params['userId'] = $this->ownerId;
+
+ $result = $this->getStore()->select($checkSQL, $params);
+
+ if (count($result) > 0) {
+ // If the media is imported from a provider (ie Pixabay, etc), use it instead of importing again.
+ if (isset($this->apiRef) && $this->apiRef === $result[0]['apiRef']) {
+ $this->mediaId = intval($result[0]['mediaId']);
+ } else {
+ throw new DuplicateEntityException(__('Media you own already has this name. Please choose another.'));
+ }
+ }
+ }
+
+ /**
+ * Load
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function load($options = [])
+ {
+ if ($this->loaded || $this->mediaId == null) {
+ return;
+ }
+
+ $options = array_merge([
+ 'deleting' => false,
+ 'fullInfo' => false
+ ], $options);
+
+ $this->getLog()->debug(sprintf('Loading Media. Options = %s', json_encode($options)));
+
+ // Are we loading for a delete? If so load the child models, unless we're a module file in which case
+ // we've no need.
+ if ($this->mediaType !== 'module' && ($options['deleting'] || $options['fullInfo'])) {
+ // Permissions
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->mediaId);
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Save this media
+ * @param array $options
+ * @throws ConfigurationException
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws GeneralException
+ */
+ public function save($options = [])
+ {
+ $this->getLog()->debug('Save for mediaId: ' . $this->mediaId);
+
+ $options = array_merge([
+ 'validate' => true,
+ 'oldMedia' => null,
+ 'deferred' => false,
+ 'saveTags' => true,
+ 'audit' => true,
+ ], $options);
+
+ if ($options['validate'] && $this->mediaType !== 'module') {
+ $this->validate($options);
+ }
+
+ // Add or edit
+ if ($this->mediaId == null || $this->mediaId == 0) {
+ $this->add();
+
+ // Always set force to true as we always want to save new files
+ $this->isSaveRequired = true;
+
+ if ($options['audit']) {
+ $this->audit($this->mediaId, 'Added', [
+ 'mediaId' => $this->mediaId,
+ 'name' => $this->name,
+ 'mediaType' => $this->mediaType,
+ 'fileName' => $this->fileName,
+ 'folderId' => $this->folderId
+ ]);
+ }
+ } else {
+ $this->edit();
+
+ // If the media file is invalid, then force an update (only applies to module files)
+ $expires = $this->getOriginalValue('expires');
+ $this->isSaveRequired = $this->isSaveRequired
+ || $this->valid == 0
+ || ($expires > 0 && $expires < Carbon::now()->format('U'))
+ || ($this->mediaType === 'module' && !file_exists($this->downloadSink(false)));
+
+ if ($options['audit']) {
+ $this->audit($this->mediaId, 'Updated', $this->getChangedProperties());
+ }
+ }
+
+ if ($options['deferred']) {
+ $this->getLog()->debug('Media Update deferred until later');
+ } else {
+ $this->getLog()->debug('Media Update happening now');
+
+ // Call save file
+ if ($this->isSaveRequired) {
+ $this->saveFile();
+ }
+ }
+
+ if ($options['saveTags']) {
+ // Remove unwanted ones
+ if (is_array($this->unlinkTags)) {
+ foreach ($this->unlinkTags as $tag) {
+ $this->unlinkTagFromEntity('lktagmedia', 'mediaId', $this->mediaId, $tag->tagId);
+ }
+ }
+
+ // Save the tags
+ if (is_array($this->linkTags)) {
+ foreach ($this->linkTags as $tag) {
+ $this->linkTagToEntity('lktagmedia', 'mediaId', $this->mediaId, $tag->tagId, $tag->value);
+ }
+ }
+ }
+ }
+
+ /**
+ * Save Async
+ * @param array $options
+ * @return $this
+ * @throws ConfigurationException
+ * @throws DuplicateEntityException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ */
+ public function saveAsync($options = [])
+ {
+ $options = array_merge([
+ 'deferred' => true,
+ 'requestOptions' => []
+ ], $options);
+ $this->requestOptions = $options['requestOptions'];
+ $this->save($options);
+
+ return $this;
+ }
+
+ /**
+ * Delete
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function delete($options = [])
+ {
+ $options = array_merge([
+ 'rollback' => false
+ ], $options);
+
+ if ($options['rollback']) {
+ $this->deleteRecord();
+ $this->deleteFile();
+ return;
+ }
+
+ $this->load(['deleting' => true]);
+
+ // Prepare some contexts for auditing
+ $auditMessage = 'Deleted';
+ $auditContext = [
+ 'mediaId' => $this->mediaId,
+ 'name' => $this->name,
+ 'mediaType' => $this->mediaType,
+ 'fileName' => $this->fileName,
+ ];
+
+ // Should we bring back this item's parent?
+ try {
+ $parentMedia = $this->mediaFactory->getParentById($this->mediaId);
+
+ // If the parent media is expired, delete it directly
+ // Or if the current media is expired, delete the parent regardless of its own expiration
+ if ((!empty($parentMedia->expires) && $parentMedia->expires < Carbon::now()->timestamp) ||
+ (!empty($this->expires) && $this->expires < Carbon::now()->timestamp)
+ ) {
+ $parentMedia->delete();
+
+ $auditMessage .= ' and deleted old revision';
+ $auditContext['deletedParentMediaId'] = $parentMedia->mediaId;
+ } else {
+ // Otherwise, revert the parent media
+ $parentMedia->isEdited = 0;
+ $parentMedia->parentId = null;
+ $parentMedia->save(['validate' => false, 'audit' => false]);
+
+ $auditMessage .= ' and reverted old revision';
+ $auditContext['revertedMediaId'] = $parentMedia->mediaId;
+ }
+ } catch (NotFoundException) {
+ // No parent, this is fine.
+ }
+
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->delete();
+ }
+
+ $this->unlinkAllTagsFromEntity('lktagmedia', 'mediaId', $this->mediaId);
+
+ $this->deleteRecord();
+ $this->deleteFile();
+
+ $this->audit($this->mediaId, $auditMessage, $auditContext);
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ // The originalFileName column has limit of 254 characters
+ // if the filename basename that we are about to save is still over the limit, attempt to strip query string
+ // we cannot make any operations directly on $this->fileName, as that might be still needed to processDownloads
+ $fileName = basename($this->fileName);
+ if (strpos(basename($fileName), '?')) {
+ $fileName = substr(basename($fileName), 0, strpos(basename($fileName), '?'));
+ }
+
+ // Sanitize what we have left and make it fit in the space we have.
+ $fileName = substr(htmlspecialchars($fileName), 0, 254);
+
+ $this->mediaId = $this->getStore()->insert('
+ INSERT INTO `media` (`name`, `type`, duration, originalFilename, userID, retired, moduleSystemFile, released, apiRef, valid, `createdDt`, `modifiedDt`, `enableStat`, `folderId`, `permissionsFolderId`, `orientation`, `width`, `height`)
+ VALUES (:name, :type, :duration, :originalFileName, :userId, :retired, :moduleSystemFile, :released, :apiRef, :valid, :createdDt, :modifiedDt, :enableStat, :folderId, :permissionsFolderId, :orientation, :width, :height)
+ ', [
+ 'name' => $this->name,
+ 'type' => $this->mediaType,
+ 'duration' => $this->duration,
+ 'originalFileName' => $fileName,
+ 'userId' => $this->ownerId,
+ 'retired' => $this->retired,
+ 'moduleSystemFile' => (($this->moduleSystemFile) ? 1 : 0),
+ 'released' => $this->released,
+ 'apiRef' => $this->apiRef,
+ 'valid' => $this->valid,
+ 'createdDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'enableStat' => $this->enableStat,
+ 'folderId' => ($this->folderId === null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this->permissionsFolderId,
+ 'orientation' => $this->orientation,
+ 'width' => ($this->width === null) ? null : $this->width,
+ 'height' => ($this->height === null) ? null : $this->height,
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $sql = '
+ UPDATE `media`
+ SET `name` = :name,
+ duration = :duration,
+ retired = :retired,
+ moduleSystemFile = :moduleSystemFile,
+ editedMediaId = :editedMediaId,
+ isEdited = :isEdited,
+ userId = :userId,
+ released = :released,
+ apiRef = :apiRef,
+ modifiedDt = :modifiedDt,
+ `enableStat` = :enableStat,
+ expires = :expires,
+ folderId = :folderId,
+ permissionsFolderId = :permissionsFolderId,
+ orientation = :orientation,
+ width = :width,
+ height = :height
+ WHERE mediaId = :mediaId
+ ';
+
+ $params = [
+ 'name' => $this->name,
+ 'duration' => $this->duration,
+ 'retired' => $this->retired,
+ 'moduleSystemFile' => $this->moduleSystemFile,
+ 'editedMediaId' => $this->parentId,
+ 'isEdited' => $this->isEdited,
+ 'userId' => $this->ownerId,
+ 'released' => $this->released,
+ 'apiRef' => $this->apiRef,
+ 'mediaId' => $this->mediaId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'enableStat' => $this->enableStat,
+ 'expires' => $this->expires,
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId,
+ 'orientation' => $this->orientation,
+ 'width' => ($this->width === null) ? null : $this->width,
+ 'height' => ($this->height === null) ? null : $this->height,
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Delete record
+ */
+ private function deleteRecord()
+ {
+ // Delete direct assignments to displays. This will be module files assigned by widgets
+ // no need to notify the display as the next time it collects is sufficient to delete these
+ $this->getStore()->update('DELETE FROM `display_media` WHERE mediaID = :mediaId', [
+ 'mediaId' => $this->mediaId
+ ]);
+
+ // Delete the media entry itself
+ $this->getStore()->update('DELETE FROM `media` WHERE mediaID = :mediaId', ['mediaId' => $this->mediaId]);
+ }
+
+ /**
+ * Save File to Library
+ * works on files that are already in the File system
+ * @throws ConfigurationException
+ */
+ public function saveFile()
+ {
+ $libraryFolder = $this->config->getSetting('LIBRARY_LOCATION');
+
+ // Work out the extension
+ $lastPeriod = strrchr($this->fileName, '.');
+
+ // Determine the save name
+ if ($lastPeriod === false) {
+ $saveName = $this->mediaId;
+ } else {
+ $saveName = $this->mediaId . '.' . strtolower(substr($lastPeriod, 1));
+ }
+
+ if ($this->getUnmatchedProperty('urlDownload', false) === true) {
+ // for upload via URL, handle cases where URL do not have specified extension in url
+ // we either have a long string after lastPeriod or nothing
+ $extension = $this->getUnmatchedProperty('extension');
+ if (isset($extension) && (strlen($lastPeriod) > 3 || $lastPeriod === false)) {
+ $saveName = $this->mediaId . '.' . $extension;
+ }
+
+ // if needed strip any not needed characters from the storedAs, this should be just .
+ if (strpos(basename($saveName), '?')) {
+ $saveName = substr(basename($saveName), 0, strpos(basename($saveName), '?'));
+ }
+
+ $this->storedAs = $saveName;
+ }
+
+ $this->getLog()->debug('saveFile for "' . $this->name . '" [' . $this->mediaId . '] with storedAs = "'
+ . $this->storedAs . '", fileName = "' . $this->fileName . '" to "' . $saveName . '". Always Copy = "'
+ . $this->alwaysCopy . '", Cloned = "' . $this->cloned . '"');
+
+ // If the storesAs is empty, then set it to be the moved file name
+ if (empty($this->storedAs) && !$this->alwaysCopy) {
+ // We could be a fresh file entirely, or we could be a clone
+ if ($this->cloned) {
+ $this->getLog()->debug('Copying cloned file: ' . $libraryFolder . $this->fileName);
+ // Copy the file into the library
+ if (!@copy($libraryFolder . $this->fileName, $libraryFolder . $saveName)) {
+ throw new ConfigurationException(__('Problem copying file in the Library Folder'));
+ }
+ } else {
+ $this->getLog()->debug('Moving temporary file: ' . $libraryFolder . 'temp/' . $this->fileName);
+ // Move the file into the library
+ if (!$this->moveFile($libraryFolder . 'temp/' . $this->fileName, $libraryFolder . $saveName)) {
+ throw new ConfigurationException(__('Problem moving uploaded file into the Library Folder'));
+ }
+ }
+
+ // Set the storedAs
+ $this->storedAs = $saveName;
+ } else {
+ // We have pre-defined where we want this to be stored
+ if (empty($this->storedAs)) {
+ // Assume we want to set this automatically (i.e. we are set to always copy)
+ $this->storedAs = $saveName;
+ }
+
+ if ($this->isRemote) {
+ $this->getLog()->debug('Moving temporary file: ' . $libraryFolder . 'temp/' . $this->name);
+
+ // Move the file into the library
+ if (!$this->moveFile($libraryFolder . 'temp/' . $this->name, $libraryFolder . $this->storedAs)) {
+ throw new ConfigurationException(__('Problem moving downloaded file into the Library Folder'));
+ }
+ } else {
+ $this->getLog()->debug('Copying specified file: ' . $this->fileName);
+
+ if (!@copy($this->fileName, $libraryFolder . $this->storedAs)) {
+ $this->getLog()->error(sprintf('Cannot copy %s to %s', $this->fileName, $libraryFolder . $this->storedAs));
+ throw new ConfigurationException(__('This media has expired and cannot be replaced.'));
+ }
+ }
+ }
+
+ // Work out the MD5
+ $this->md5 = md5_file($libraryFolder . $this->storedAs);
+ $this->fileSize = filesize($libraryFolder . $this->storedAs);
+
+ // Set to valid
+ $this->valid = 1;
+
+ // Resize image dimensions if threshold exceeds
+ // This also sets orientation
+ $this->assessDimensions();
+
+ // Update the MD5 and storedAs to suit
+ $this->getStore()->update('
+ UPDATE `media`
+ SET md5 = :md5,
+ fileSize = :fileSize,
+ storedAs = :storedAs,
+ expires = :expires,
+ released = :released,
+ orientation = :orientation,
+ width = :width,
+ height = :height,
+ valid = :valid
+ WHERE mediaId = :mediaId
+ ', [
+ 'fileSize' => $this->fileSize,
+ 'md5' => $this->md5,
+ 'storedAs' => $this->storedAs,
+ 'expires' => $this->expires,
+ 'released' => $this->released,
+ 'orientation' => $this->orientation,
+ 'valid' => $this->valid,
+ 'width' => ($this->width === null) ? null : $this->width,
+ 'height' => ($this->height === null) ? null : $this->height,
+ 'mediaId' => $this->mediaId,
+ ]);
+ }
+
+ private function assessDimensions(): void
+ {
+ if ($this->mediaType === 'image' || ($this->mediaType === 'module' && $this->moduleSystemFile === 0)) {
+ $libraryFolder = $this->config->getSetting('LIBRARY_LOCATION');
+ $filePath = $libraryFolder . $this->storedAs;
+ list($imgWidth, $imgHeight) = @getimagesize($filePath);
+
+ $resizeThreshold = $this->config->getSetting('DEFAULT_RESIZE_THRESHOLD');
+ $resizeLimit = $this->config->getSetting('DEFAULT_RESIZE_LIMIT');
+
+ $this->width = $imgWidth;
+ $this->height = $imgHeight;
+ $this->orientation = ($imgWidth >= $imgHeight) ? 'landscape' : 'portrait';
+
+ // Media released set to 0 for large size images
+ // if image size is greater than Resize Limit then we flag that image as too big
+ if ($resizeLimit > 0 && ($imgWidth > $resizeLimit || $imgHeight > $resizeLimit)) {
+ $this->released = 2;
+ $this->getLog()->debug('Image size is too big. MediaId '. $this->mediaId);
+ } elseif ($resizeThreshold > 0) {
+ if ($imgWidth > $imgHeight) { // 'landscape';
+ if ($imgWidth <= $resizeThreshold) {
+ $this->released = 1;
+ } else {
+ if ($resizeThreshold > 0) {
+ $this->released = 0;
+ $this->getLog()->debug('Image exceeded threshold, released set to 0. MediaId '. $this->mediaId);
+ }
+ }
+ } else { // 'portrait';
+ if ($imgHeight <= $resizeThreshold) {
+ $this->released = 1;
+ } else {
+ if ($resizeThreshold > 0) {
+ $this->released = 0;
+ $this->getLog()->debug('Image exceeded threshold, released set to 0. MediaId '. $this->mediaId);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Release an image from image processing
+ * @param $md5
+ * @param $fileSize
+ * @param $height
+ * @param $width
+ */
+ public function release($md5, $fileSize, $height, $width)
+ {
+ // Update the img record
+ $this->getStore()->update('UPDATE `media` SET md5 = :md5, fileSize = :fileSize, released = :released, height = :height, width = :width, modifiedDt = :modifiedDt WHERE mediaId = :mediaId', [
+ 'fileSize' => $fileSize,
+ 'md5' => $md5,
+ 'released' => 1,
+ 'mediaId' => $this->mediaId,
+ 'height' => $height,
+ 'width' => $width,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+
+ /**
+ * Delete a Library File
+ */
+ private function deleteFile()
+ {
+ // Make sure storedAs isn't null
+ if ($this->storedAs == null) {
+ $this->getLog()->error(sprintf('Deleting media [%s] with empty stored as. Skipping library file delete.', $this->name));
+ return;
+ }
+
+ // Library location
+ $libraryLocation = $this->config->getSetting("LIBRARY_LOCATION");
+
+ // 3 things to check for..
+ // the actual file, the thumbnail, the background
+ // video cover image and its thumbnail
+ if (file_exists($libraryLocation . $this->storedAs)) {
+ unlink($libraryLocation . $this->storedAs);
+ }
+
+ if (file_exists($libraryLocation . 'tn_' . $this->storedAs)) {
+ unlink($libraryLocation . 'tn_' . $this->storedAs);
+ }
+
+ if (file_exists($libraryLocation . 'tn_' . $this->mediaId . '_videocover.png')) {
+ unlink($libraryLocation . 'tn_' . $this->mediaId . '_videocover.png');
+ }
+
+ if (file_exists($libraryLocation . $this->mediaId . '_videocover.png')) {
+ unlink($libraryLocation . $this->mediaId . '_videocover.png');
+ }
+ }
+
+ /**
+ * Workaround for moving files across file systems
+ * @param $from
+ * @param $to
+ * @return bool
+ */
+ private function moveFile($from, $to)
+ {
+ // Try to move the file first
+ $moved = rename($from, $to);
+
+ if (!$moved) {
+ $this->getLog()->info('Cannot move file: ' . $from . ' to ' . $to . ', will try and copy/delete instead.');
+
+ // Copy
+ $moved = copy($from, $to);
+
+ // Delete
+ if (!@unlink($from)) {
+ $this->getLog()->error('Cannot delete file: ' . $from . ' after copying to ' . $to);
+ }
+ }
+
+ return $moved;
+ }
+
+ /**
+ * Download URL
+ * @return string
+ */
+ public function downloadUrl()
+ {
+ return $this->fileName;
+ }
+
+ /**
+ * Download Sink
+ * @return string
+ */
+ public function downloadSink($temp = true)
+ {
+ return $this->config->getSetting('LIBRARY_LOCATION')
+ . ($temp ? 'temp' . DIRECTORY_SEPARATOR : '')
+ . $this->name;
+ }
+
+ /**
+ * Get optional options for downloading media files
+ * @return array
+ */
+ public function downloadRequestOptions()
+ {
+ return $this->requestOptions;
+ }
+
+ /**
+ * Update Media duration.
+ * This is called on processDownloads when uploading video/audio from url
+ * Real duration can be determined in determineRealDuration function in MediaFactory
+ * @param int $realDuration
+ * @return Media
+ */
+ public function updateDuration(int $realDuration): Media
+ {
+ $this->getLog()->debug('Updating duration for MediaId '. $this->mediaId);
+
+ $this->getStore()->update('UPDATE `media` SET duration = :duration WHERE mediaId = :mediaId', [
+ 'duration' => $realDuration,
+ 'mediaId' => $this->mediaId
+ ]);
+
+ $this->duration = $realDuration;
+
+ return $this;
+ }
+
+ /**
+ * Update Media orientation.
+ * For videos from Library connectors, update the orientation once we have the cover image saved.
+ * @param int $width
+ * @param int $height
+ * @return Media
+ */
+ public function updateOrientation(int $width, int $height): Media
+ {
+ $this->getLog()->debug('Updating orientation and resolution for MediaId '. $this->mediaId);
+
+ $this->width = $width;
+ $this->height = $height;
+ $this->orientation = ($width >= $height) ? 'landscape' : 'portrait';
+
+ $this->getStore()->update('
+ UPDATE `media` SET orientation = :orientation, width = :width, height = :height
+ WHERE mediaId = :mediaId
+ ', [
+ 'orientation' => $this->orientation,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'mediaId' => $this->mediaId
+ ]);
+
+ return $this;
+ }
+}
diff --git a/lib/Entity/MenuBoard.php b/lib/Entity/MenuBoard.php
new file mode 100644
index 0000000..72cf3ca
--- /dev/null
+++ b/lib/Entity/MenuBoard.php
@@ -0,0 +1,388 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\MenuBoardCategoryFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * @SWG\Definition()
+ */
+class MenuBoard implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Menu Board Id")
+ * @var int
+ */
+ public $menuId;
+
+ /**
+ * @SWG\Property(description="The Menu Board name")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The Menu Board description")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="The Menu Board code identifier")
+ * @var string
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="The Menu Board owner Id")
+ * @var int
+ */
+ public $userId;
+ public $owner;
+
+ /**
+ * @SWG\Property(description="The Menu Board last modified date")
+ * @var int
+ */
+ public $modifiedDt;
+
+ /**
+ * @SWG\Property(description="The Id of the Folder this Menu Board belongs to")
+ * @var string
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Menu Board")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ /**
+ * @SWG\Property(description="A comma separated list of Groups/Users that have permission to this menu Board")
+ * @var string
+ */
+ public $groupsWithPermissions;
+
+ /** @var SanitizerService */
+ private $sanitizerService;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /**
+ * @var Permission[]
+ */
+ private $permissions = [];
+ private $categories;
+
+ /** @var PermissionFactory */
+ private $permissionFactory;
+
+ /** @var MenuBoardCategoryFactory */
+ private $menuBoardCategoryFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ private $datesToFormat = ['modifiedDt'];
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param SanitizerService $sanitizerService
+ * @param PoolInterface $pool
+ * @param ConfigServiceInterface $config
+ * @param PermissionFactory $permissionFactory
+ * @param MenuBoardCategoryFactory $menuBoardCategoryFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ $sanitizerService,
+ $pool,
+ $config,
+ $permissionFactory,
+ $menuBoardCategoryFactory,
+ $displayNotifyService
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->sanitizerService = $sanitizerService;
+ $this->config = $config;
+ $this->pool = $pool;
+ $this->permissionFactory = $permissionFactory;
+ $this->menuBoardCategoryFactory = $menuBoardCategoryFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ return $this->sanitizerService->getSanitizer($array);
+ }
+
+ public function __clone()
+ {
+ $this->menuId = null;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('MenuId %d, Name %s, Description %s, Code %s', $this->menuId, $this->name, $this->description, $this->code);
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->menuId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->userId = $ownerId;
+ }
+
+ /**
+ * @param array $options
+ * @return MenuBoard
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadPermissions' => true,
+ 'loadCategories' => false
+ ], $options);
+
+ // If we are already loaded, then don't do it again
+ if ($this->menuId == null || $this->loaded) {
+ return $this;
+ }
+
+ // Permissions
+ if ($options['loadPermissions']) {
+ $this->permissions = $this->permissionFactory->getByObjectId('MenuBoard', $this->menuId);
+ }
+
+ if ($options['loadCategories']) {
+ $this->categories = $this->menuBoardCategoryFactory->getByMenuId($this->menuId);
+ }
+
+ $this->loaded = true;
+
+ return $this;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->name)) {
+ throw new InvalidArgumentException(__('Name cannot be empty'), 'name');
+ }
+ }
+
+ /**
+ * Save this Menu Board
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'audit' => true
+ ], $options);
+
+ if ($options['audit']) {
+ $this->getLog()->debug('Saving ' . $this);
+ }
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->menuId == null || $this->menuId == 0) {
+ $this->add();
+ $this->loaded = true;
+ } else {
+ $this->update();
+ }
+
+ // We've been touched
+ $this->setActive();
+
+ // Notify Displays?
+ $this->notify();
+ }
+
+ /**
+ * Is this MenuBoard active currently
+ * @return bool
+ */
+ public function isActive()
+ {
+ $cache = $this->pool->getItem('/menuboard/accessed/' . $this->menuId);
+ return $cache->isHit();
+ }
+
+ /**
+ * Indicate that this MenuBoard has been accessed recently
+ * @return $this
+ */
+ public function setActive()
+ {
+ $this->getLog()->debug('Setting ' . $this->menuId . ' as active');
+
+ $cache = $this->pool->getItem('/menuboard/accessed/' . $this->menuId);
+ $cache->set('true');
+ $cache->expiresAfter(intval($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD')) * 1.5);
+ $this->pool->saveDeferred($cache);
+ return $this;
+ }
+
+ /**
+ * Get the Display Notify Service
+ * @return DisplayNotifyServiceInterface
+ */
+ public function getDisplayNotifyService(): DisplayNotifyServiceInterface
+ {
+ return $this->displayNotifyService->init();
+ }
+
+ /**
+ * Notify displays of this campaign change
+ */
+ public function notify()
+ {
+ $this->getLog()->debug('MenuBoard ' . $this->menuId . ' wants to notify');
+
+ $this->getDisplayNotifyService()->collectNow()->notifyByMenuBoardId($this->menuId);
+ }
+
+ private function add()
+ {
+ $this->menuId = $this->getStore()->insert(
+ 'INSERT INTO `menu_board` (name, description, code, userId, modifiedDt, folderId, permissionsFolderId) VALUES (:name, :description, :code, :userId, :modifiedDt, :folderId, :permissionsFolderId)',
+ [
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'code' => $this->code,
+ 'userId' => $this->userId,
+ 'modifiedDt' => Carbon::now()->format('U'),
+ 'folderId' => ($this->folderId == null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this->permissionsFolderId
+ ]
+ );
+ }
+
+ private function update()
+ {
+ $this->getStore()->update(
+ 'UPDATE `menu_board` SET name = :name, description = :description, code = :code, userId = :userId, modifiedDt = :modifiedDt, folderId = :folderId, permissionsFolderId = :permissionsFolderId WHERE menuId = :menuId',
+ [
+ 'menuId' => $this->menuId,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'code' => $this->code,
+ 'userId' => $this->userId,
+ 'modifiedDt' => Carbon::now()->format('U'),
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId
+ ]
+ );
+ }
+
+ /**
+ * Delete Menu Board
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function delete()
+ {
+ $this->load(['loadCategories' => true]);
+
+ // Delete all permissions
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->delete();
+ }
+
+ // Delete all
+ /** @var MenuBoardCategory $category */
+ foreach ($this->categories as $category) {
+ $category->delete();
+ }
+
+ $this->getStore()->update('DELETE FROM `menu_board` WHERE menuId = :menuId', ['menuId' => $this->menuId]);
+ }
+}
diff --git a/lib/Entity/MenuBoardCategory.php b/lib/Entity/MenuBoardCategory.php
new file mode 100644
index 0000000..19b2ef8
--- /dev/null
+++ b/lib/Entity/MenuBoardCategory.php
@@ -0,0 +1,263 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Xibo\Factory\MenuBoardCategoryFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\DataType\ProductCategory;
+
+/**
+ * @SWG\Definition()
+ */
+class MenuBoardCategory implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category Id")
+ * @var int
+ */
+ public $menuCategoryId;
+
+ /**
+ * @SWG\Property(description="The Menu Board Id")
+ * @var int
+ */
+ public $menuId;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category name")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category description")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category code identifier")
+ * @var string
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category associated mediaId")
+ * @var int
+ */
+ public $mediaId;
+
+ private $products;
+
+ /** @var MenuBoardCategoryFactory */
+ private $menuCategoryFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param MenuBoardCategoryFactory $menuCategoryFactory
+ */
+ public function __construct($store, $log, $dispatcher, $menuCategoryFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->menuCategoryFactory = $menuCategoryFactory;
+ }
+
+ public function __clone()
+ {
+ $this->menuCategoryId = null;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf(
+ 'MenuCategoryId %d MenuId %d, Name %s, Media %d, Code %s',
+ $this->menuCategoryId,
+ $this->menuId,
+ $this->name,
+ $this->mediaId,
+ $this->code
+ );
+ }
+
+ /**
+ * Convert this to a product category
+ * @return ProductCategory
+ */
+ public function toProductCategory(): ProductCategory
+ {
+ $productCategory = new ProductCategory();
+ $productCategory->name = $this->name;
+ $productCategory->description = $this->description;
+ $productCategory->image = $this->mediaId;
+ return $productCategory;
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->menuCategoryId;
+ }
+
+ /**
+ * @param array $options
+ * @return MenuBoardCategory
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadProducts' => false
+ ], $options);
+
+ // If we are already loaded, then don't do it again
+ if ($this->menuId == null || $this->loaded) {
+ return $this;
+ }
+
+ if ($options['loadProducts']) {
+ $this->products = $this->getProducts();
+ }
+
+ $this->loaded = true;
+
+ return $this;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->name)) {
+ throw new InvalidArgumentException(__('Name cannot be empty'), 'name');
+ }
+ }
+
+ /**
+ * @param array|null $sort The sort order to be applied
+ * @return MenuBoardProduct[]
+ */
+ public function getProducts($sort = null): array
+ {
+ return $this->menuCategoryFactory->getProductData($sort, [
+ 'menuCategoryId' => $this->menuCategoryId
+ ]);
+ }
+
+ /**
+ * @param array|null $sort The sort order to be applied
+ * @return MenuBoardProduct[]
+ */
+ public function getAvailableProducts($sort = null): array
+ {
+ return $this->menuCategoryFactory->getProductData($sort, [
+ 'menuCategoryId' => $this->menuCategoryId,
+ 'availability' => 1
+ ]);
+ }
+
+ /**
+ * Save this Menu Board Category
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ ], $options);
+
+ $this->getLog()->debug('Saving ' . $this);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->menuCategoryId == null || $this->menuCategoryId == 0) {
+ $this->add();
+ $this->loaded = true;
+ } else {
+ $this->update();
+ }
+ }
+
+ private function add(): void
+ {
+ $this->menuCategoryId = $this->getStore()->insert('
+ INSERT INTO `menu_category` (`name`, `menuId`, `mediaId`, `code`, `description`)
+ VALUES (:name, :menuId, :mediaId, :code, :description)
+ ', [
+ 'name' => $this->name,
+ 'mediaId' => $this->mediaId,
+ 'menuId' => $this->menuId,
+ 'code' => $this->code,
+ 'description' => $this->description,
+ ]);
+ }
+
+ private function update(): void
+ {
+ $this->getStore()->update('
+ UPDATE `menu_category`
+ SET `name` = :name, `mediaId` = :mediaId, `code` = :code, `description` = :description
+ WHERE `menuCategoryId` = :menuCategoryId
+ ', [
+ 'menuCategoryId' => $this->menuCategoryId,
+ 'name' => $this->name,
+ 'mediaId' => $this->mediaId,
+ 'code' => $this->code,
+ 'description' => $this->description,
+ ]);
+ }
+
+ /**
+ * Delete Menu Board
+ * @throws NotFoundException
+ */
+ public function delete()
+ {
+ $this->load(['loadProducts' => true]);
+
+ /** @var MenuBoardProduct $product */
+ foreach ($this->products as $product) {
+ $product->delete();
+ }
+
+ $this->getStore()->update('DELETE FROM `menu_category` WHERE menuCategoryId = :menuCategoryId', ['menuCategoryId' => $this->menuCategoryId]);
+ }
+}
diff --git a/lib/Entity/MenuBoardProduct.php b/lib/Entity/MenuBoardProduct.php
new file mode 100644
index 0000000..e60ff25
--- /dev/null
+++ b/lib/Entity/MenuBoardProduct.php
@@ -0,0 +1,327 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Xibo\Factory\MenuBoardProductOptionFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\DataType\Product;
+
+/**
+ * @SWG\Definition()
+ */
+class MenuBoardProduct implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product Id")
+ * @var int
+ */
+ public $menuProductId;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category Id")
+ * @var int
+ */
+ public $menuCategoryId;
+
+ /**
+ * @SWG\Property(description="The Menu Board Id")
+ * @var int
+ */
+ public $menuId;
+
+ /**
+ * @SWG\Property(description="The Menu Board Category name")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product price")
+ * @var double
+ */
+ public $price;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product description")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product code identifier")
+ * @var string
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product display order, used for sorting")
+ * @var int
+ */
+ public $displayOrder;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product availability")
+ * @var int
+ */
+ public $availability;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product allergy information")
+ * @var string
+ */
+ public $allergyInfo;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product allergy information")
+ * @var int
+ */
+ public $calories;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product associated mediaId")
+ * @var int
+ */
+ public $mediaId;
+
+ /**
+ * @SWG\Property(description="The Menu Board Product array of options", @SWG\Items(type="string"))
+ * @var MenuBoardProductOption[]
+ */
+ public $productOptions;
+
+ /**
+ * @var MenuBoardProductOptionFactory
+ */
+ private $menuBoardProductOptionFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param MenuBoardProductOptionFactory $menuBoardProductOptionFactory
+ */
+ public function __construct($store, $log, $dispatcher, $menuBoardProductOptionFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->menuBoardProductOptionFactory = $menuBoardProductOptionFactory;
+ }
+
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->menuProductId;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate(): void
+ {
+ if (!v::stringType()->notEmpty()->validate($this->name)) {
+ throw new InvalidArgumentException(__('Name cannot be empty'), 'name');
+ }
+
+ if (!empty($this->calories) && !v::intType()->min(0)->max(32767)->validate($this->calories)) {
+ throw new InvalidArgumentException(
+ __('Calories must be a whole number between 0 and 32767'),
+ 'calories'
+ );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf(
+ 'MenuProductId %d, MenuCategoryId %d, MenuId %d, Name %s, Price %s, Media %d, Code %s',
+ $this->menuProductId,
+ $this->menuCategoryId,
+ $this->menuId,
+ $this->name,
+ $this->price,
+ $this->mediaId,
+ $this->code
+ );
+ }
+
+ /**
+ * Convert this to a Product
+ * @return Product
+ */
+ public function toProduct(): Product
+ {
+ $product = new Product();
+ $product->name = $this->name;
+ $product->price = $this->price;
+ $product->description = $this->description;
+ $product->availability = $this->availability;
+ $product->allergyInfo = $this->allergyInfo;
+ $product->calories = $this->calories;
+ $product->image = $this->mediaId;
+ foreach (($this->productOptions ?? []) as $productOption) {
+ $product->productOptions[] = [
+ 'name' => $productOption->option,
+ 'value' => $productOption->value,
+ ];
+ }
+ return $product;
+ }
+
+ /**
+ * Save this Menu Board Product
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ ], $options);
+
+ $this->getLog()->debug('Saving ' . $this);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->menuProductId == null || $this->menuProductId == 0) {
+ $this->add();
+ } else {
+ $this->update();
+ }
+ }
+
+ /**
+ * Add Menu Board Product
+ */
+ private function add(): void
+ {
+ $this->menuProductId = $this->getStore()->insert('
+ INSERT INTO `menu_product` (
+ `menuCategoryId`,
+ `menuId`,
+ `name`,
+ `price`,
+ `description`,
+ `mediaId`,
+ `displayOrder`,
+ `availability`,
+ `allergyInfo`,
+ `calories`,
+ `code`
+ )
+ VALUES (
+ :menuCategoryId,
+ :menuId,
+ :name,
+ :price,
+ :description,
+ :mediaId,
+ :displayOrder,
+ :availability,
+ :allergyInfo,
+ :calories,
+ :code
+ )
+ ', [
+ 'menuCategoryId' => $this->menuCategoryId,
+ 'menuId' => $this->menuId,
+ 'name' => $this->name,
+ 'price' => $this->price,
+ 'description' => $this->description,
+ 'mediaId' => $this->mediaId,
+ 'displayOrder' => $this->displayOrder,
+ 'availability' => $this->availability,
+ 'allergyInfo' => $this->allergyInfo,
+ 'calories' => $this->calories,
+ 'code' => $this->code,
+ ]);
+ }
+
+ /**
+ * Update Menu Board Product
+ */
+ private function update(): void
+ {
+ $this->getStore()->update('
+ UPDATE `menu_product` SET
+ `name` = :name,
+ `price` = :price,
+ `description` = :description,
+ `mediaId` = :mediaId,
+ `displayOrder` = :displayOrder,
+ `availability` = :availability,
+ `allergyInfo` = :allergyInfo,
+ `calories` = :calories,
+ `code` = :code
+ WHERE `menuProductId` = :menuProductId
+ ', [
+ 'name' => $this->name,
+ 'price' => $this->price,
+ 'description' => $this->description,
+ 'mediaId' => $this->mediaId,
+ 'displayOrder' => $this->displayOrder,
+ 'availability' => $this->availability,
+ 'allergyInfo' => $this->allergyInfo,
+ 'calories' => $this->calories,
+ 'code' => $this->code,
+ 'menuProductId' => $this->menuProductId
+ ]);
+ }
+
+ /**
+ * Delete Menu Board Product
+ */
+ public function delete()
+ {
+ $this->removeOptions();
+ $this->getStore()->update('DELETE FROM `menu_product` WHERE menuProductId = :menuProductId', ['menuProductId' => $this->menuProductId]);
+ }
+
+ /**
+ * @return MenuBoardProductOption[]
+ */
+ public function getOptions()
+ {
+ $options = $this->menuBoardProductOptionFactory->getByMenuProductId($this->menuProductId);
+
+ return $options;
+ }
+
+ public function removeOptions()
+ {
+ $this->getStore()->update('DELETE FROM `menu_product_options` WHERE menuProductId = :menuProductId', ['menuProductId' => $this->menuProductId]);
+ }
+}
diff --git a/lib/Entity/MenuBoardProductOption.php b/lib/Entity/MenuBoardProductOption.php
new file mode 100644
index 0000000..e87c087
--- /dev/null
+++ b/lib/Entity/MenuBoardProductOption.php
@@ -0,0 +1,130 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * @SWG\Definition()
+ */
+class MenuBoardProductOption implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Menu Product ID that this Option belongs to")
+ * @var int
+ */
+ public $menuProductId;
+
+ /**
+ * @SWG\Property(description="The option name")
+ * @var string
+ */
+ public $option;
+
+ /**
+ * @SWG\Property(description="The option value")
+ * @var string
+ */
+ public $value;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function __clone()
+ {
+ $this->menuProductId = null;
+ }
+
+ public function __toString()
+ {
+ return sprintf('ProductOption %s with value %s', $this->option, $this->value);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->option)
+ && v::floatType()->notEmpty()->validate($this->value)
+ ) {
+ throw new InvalidArgumentException(__('Each value needs a corresponding option'), 'option');
+ }
+
+ if (!v::floatType()->notEmpty()->validate($this->value)
+ && v::stringType()->notEmpty()->validate($this->option)
+ ) {
+ throw new InvalidArgumentException(__('Each option needs a corresponding value'), 'value');
+ }
+ }
+
+ /**
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ ], $options);
+
+ $this->getLog()->debug('Saving ' . $this);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ $this->getStore()->insert(
+ 'INSERT INTO `menu_product_options` (`menuProductId`, `option`, `value`) VALUES (:menuProductId, :option, :value) ON DUPLICATE KEY UPDATE `value` = :value2',
+ [
+ 'menuProductId' => $this->menuProductId,
+ 'option' => $this->option,
+ 'value' => $this->value,
+ 'value2' => $this->value
+ ]
+ );
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update(
+ 'DELETE FROM `menu_product_options` WHERE `menuProductId` = :menuProductId AND `option` = :option',
+ [
+ 'menuProductId' => $this->menuProductId,
+ 'option' => $this->option
+ ]
+ );
+ }
+}
diff --git a/lib/Entity/Module.php b/lib/Entity/Module.php
new file mode 100644
index 0000000..016ae0b
--- /dev/null
+++ b/lib/Entity/Module.php
@@ -0,0 +1,649 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\Definition\LegacyType;
+use Xibo\Widget\Provider\DataProvider;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetValidatorInterface;
+
+/**
+ * Class Module
+ * @package Xibo\Entity
+ * @SWG\Definition()
+ */
+class Module implements \JsonSerializable
+{
+ use EntityTrait;
+ use ModulePropertyTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Module")
+ * @var int
+ */
+ public $moduleId;
+
+ /**
+ * @SWG\Property(description="Module Name")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="Module Author")
+ * @var string
+ */
+ public $author;
+
+ /**
+ * @SWG\Property(description="Description of the Module")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="An icon to use in the toolbar")
+ * @var string
+ */
+ public $icon;
+
+ /**
+ * @SWG\Property(description="The type code for this module")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="Legacy type codes for this module")
+ * @var LegacyType[]
+ */
+ public $legacyTypes;
+
+ /**
+ * @SWG\Property(description="The data type of the data expected to be returned by this modules data provider")
+ * @var string
+ */
+ public $dataType;
+
+ /**
+ * @SWG\Property(description="The group details for this module")
+ * @var string[]
+ */
+ public $group;
+
+ /**
+ * @SWG\Property(description="The cache key used when requesting data")
+ * @var string
+ */
+ public $dataCacheKey;
+
+ /**
+ * @SWG\Property(description="Is fallback data allowed for this module? Only applicable for a Data Widget")
+ * @var int
+ */
+ public $fallbackData;
+
+ /**
+ * @SWG\Property(description="Is specific to a Layout or can be uploaded to the Library?")
+ * @var int
+ */
+ public $regionSpecific;
+
+ /**
+ * @SWG\Property(description="The schema version of the module")
+ * @var int
+ */
+ public $schemaVersion;
+
+ /**
+ * @SWG\Property(description="The compatibility class of the module")
+ * @var string
+ */
+ public $compatibilityClass = null;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the module should be excluded from the Layout Editor")
+ * @var string
+ */
+ public $showIn = 'both';
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the module is assignable to a Layout")
+ * @var int
+ */
+ public $assignable;
+
+ /**
+ * @SWG\Property(description="Does this module have a thumbnail to render?")
+ * @var int
+ */
+ public $hasThumbnail;
+
+ /**
+ * @SWG\Property(description="This is the location to a module's thumbnail")
+ * @var string
+ */
+ public $thumbnail;
+
+ /** @var int The width of the zone */
+ public $startWidth;
+
+ /** @var int The height of the zone */
+ public $startHeight;
+
+ /**
+ * @SWG\Property(description="Should be rendered natively by the Player or via the CMS (native|html)")
+ * @var string
+ */
+ public $renderAs;
+
+ /**
+ * @SWG\Property(description="Class Name including namespace")
+ * @var string
+ */
+ public $class;
+
+ /**
+ * @SWG\Property(description="Validator class name including namespace")
+ * @var string[]
+ */
+ public $validatorClass = [];
+
+ /** @var \Xibo\Widget\Definition\Stencil|null Stencil for this modules preview */
+ public $preview;
+
+ /** @var \Xibo\Widget\Definition\Stencil|null Stencil for this modules HTML cache */
+ public $stencil;
+
+ /**
+ * @SWG\Property(description="Properties to display in the property panel and supply to stencils")
+ * @var \Xibo\Widget\Definition\Property[]|null
+ */
+ public $properties;
+
+ /** @var \Xibo\Widget\Definition\Asset[]|null */
+ public $assets;
+
+ /**
+ * @SWG\Property(description="JavaScript function run when a module is initialised, before data is returned")
+ * @var string
+ */
+ public $onInitialize;
+
+ /**
+ * @SWG\Property(description="Data Parser run against each data item applicable when a dataType is present")
+ * @var string
+ */
+ public $onParseData;
+
+ /**
+ * @SWG\Property(description="A load function to run when the widget first fetches data")
+ * @var string
+ */
+ public $onDataLoad;
+
+ /**
+ * @SWG\Property(description="JavaScript function run when a module is rendered, after data has been returned")
+ * @var string
+ */
+ public $onRender;
+
+ /**
+ * @SWG\Property(description="JavaScript function run when a module becomes visible")
+ * @var string
+ */
+ public $onVisible;
+
+ /**
+ * @SWG\Property(description="Optional sample data item, only applicable when a dataType is present")
+ * @var string
+ */
+ public $sampleData;
+
+ //
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this module is enabled")
+ * @var int
+ */
+ public $enabled;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the Layout designer should render a preview of this module")
+ * @var int
+ */
+ public $previewEnabled;
+
+ /**
+ * @SWG\Property(
+ * description="The default duration for Widgets of this Module when the user has not set a duration."
+ * )
+ * @var int
+ */
+ public $defaultDuration;
+
+ /**
+ * @SWG\Property(description="An array of additional module specific settings")
+ * @var \Xibo\Widget\Definition\Property[]
+ */
+ public $settings = [];
+
+ /**
+ * @SWG\Property(description="An array of additional module specific group properties")
+ * @var \Xibo\Widget\Definition\PropertyGroup[]
+ */
+ public $propertyGroups = [];
+
+ /**
+ * @SWG\Property(
+ * description="An array of required elements",
+ * type="array",
+ * @SWG\Items(type="string")
+ * )
+ * @var string[]
+ */
+ public $requiredElements = [];
+
+ /**
+ * @SWG\Property()
+ * @var bool $isInstalled Is this module installed?
+ */
+ public $isInstalled;
+
+ /**
+ * @SWG\Property()
+ * @var bool $isError Does this module have any errors?
+ */
+ public $isError;
+
+ /**
+ * @SWG\Property()
+ * @var string[] $errors An array of errors this module has.
+ */
+ public $errors;
+
+ //
+ public $allowPreview;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var WidgetProviderInterface */
+ private $widgetProvider;
+
+ /** @var WidgetCompatibilityInterface */
+ private $widgetCompatibility;
+
+ /** @var WidgetValidatorInterface[] */
+ private $widgetValidators = [];
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param \Xibo\Factory\ModuleFactory $moduleFactory
+ */
+ public function __construct(
+ StorageServiceInterface $store,
+ LogServiceInterface $log,
+ EventDispatcherInterface $dispatcher,
+ ModuleFactory $moduleFactory
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->moduleFactory = $moduleFactory;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('%s - %s', $this->type, $this->name);
+ }
+
+ /**
+ * Is a template expected?
+ * @return bool
+ */
+ public function isTemplateExpected(): bool
+ {
+ return (!empty($this->dataType));
+ }
+
+ /**
+ * Is a template expected?
+ * @return bool
+ */
+ public function isDataProviderExpected(): bool
+ {
+ return (!empty($this->dataType));
+ }
+
+ /**
+ * Does this module have required elements?
+ * @return bool
+ */
+ public function hasRequiredElements(): bool
+ {
+ return count($this->requiredElements) > 0;
+ }
+
+ /**
+ * Get this module's widget provider, or null if there isn't one
+ * @return \Xibo\Widget\Provider\WidgetProviderInterface|null
+ */
+ public function getWidgetProviderOrNull(): ?WidgetProviderInterface
+ {
+ return $this->widgetProvider;
+ }
+
+ /**
+ * @param \Xibo\Entity\Widget $widget
+ * @return \Xibo\Widget\Provider\DataProvider
+ */
+ public function createDataProvider(Widget $widget): DataProvider
+ {
+ return $this->moduleFactory->createDataProvider($this, $widget);
+ }
+
+ /**
+ * Fetch duration of a file.
+ * @param string $file
+ * @return int
+ */
+ public function fetchDurationOrDefaultFromFile(string $file): int
+ {
+ $this->getLog()->debug('fetchDurationOrDefaultFromFile: fetchDuration with file: ' . $file);
+
+ // If we don't have a file name, then we use the default duration of 0 (end-detect)
+ if (empty($file)) {
+ return 0;
+ } else {
+ $info = new \getID3();
+ $file = $info->analyze($file);
+
+ // Log error if duration is missing
+ if (!isset($file['playtime_seconds'])) {
+ $errorMessage = isset($file['error'])
+ ? implode('; ', $file['error'])
+ : 'Unknown';
+ $this->getLog()->error('fetchDurationOrDefaultFromFile; Missing playtime_seconds in analyzed
+ file. Error: ' . $errorMessage);
+ }
+
+ return intval($file['playtime_seconds'] ?? $this->defaultDuration);
+ }
+ }
+
+ /**
+ * Calculate the duration of this Widget.
+ * @param Widget $widget
+ * @return int|null
+ */
+ public function calculateDuration(Widget $widget): ?int
+ {
+ if ($this->widgetProvider === null && $this->regionSpecific === 1) {
+ // Take some default action to cover the majourity of region specific widgets
+ // Duration can depend on the number of items per page for some widgets
+ // this is a legacy way of working, and our preference is to use elements
+ $numItems = $widget->getOptionValue('numItems', 15);
+
+ if ($widget->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) {
+ // If we have paging involved then work out the page count.
+ $itemsPerPage = $widget->getOptionValue('itemsPerPage', 0);
+ if ($itemsPerPage > 0) {
+ $numItems = ceil($numItems / $itemsPerPage);
+ }
+
+ return $widget->calculatedDuration * $numItems;
+ } else {
+ return null;
+ }
+ } else if ($this->widgetProvider === null) {
+ return null;
+ }
+
+ $this->getLog()->debug('calculateDuration: using widget provider');
+
+ $durationProvider = $this->moduleFactory->createDurationProvider($this, $widget);
+ $this->widgetProvider->fetchDuration($durationProvider);
+
+ return $durationProvider->isDurationSet() ? $durationProvider->getDuration() : null;
+ }
+
+ /**
+ * Sets the widget provider for this module
+ * @param \Xibo\Widget\Provider\WidgetProviderInterface $widgetProvider
+ * @return $this
+ */
+ public function setWidgetProvider(WidgetProviderInterface $widgetProvider): Module
+ {
+ $this->widgetProvider = $widgetProvider;
+ $this->widgetProvider
+ ->setLog($this->getLog()->getLoggerInterface())
+ ->setDispatcher($this->getDispatcher());
+ return $this;
+ }
+
+ /**
+ * Is a widget compatibility available
+ * @return bool
+ */
+ public function isWidgetCompatibilityAvailable(): bool
+ {
+ return $this->widgetCompatibility !== null;
+ }
+
+ /**
+ * Get this module's widget compatibility, or null if there isn't one
+ * @return \Xibo\Widget\Provider\WidgetCompatibilityInterface|null
+ */
+ public function getWidgetCompatibilityOrNull(): ?WidgetCompatibilityInterface
+ {
+ return $this->widgetCompatibility;
+ }
+
+ /**
+ * Sets the widget compatibility for this module
+ * @param WidgetCompatibilityInterface $widgetCompatibility
+ * @return $this
+ */
+ public function setWidgetCompatibility(WidgetCompatibilityInterface $widgetCompatibility): Module
+ {
+ $this->widgetCompatibility = $widgetCompatibility;
+ $this->widgetCompatibility->setLog($this->getLog()->getLoggerInterface());
+ return $this;
+ }
+
+ public function addWidgetValidator(WidgetValidatorInterface $widgetValidator): Module
+ {
+ $this->widgetValidators[] = $widgetValidator;
+ return $this;
+ }
+
+ /**
+ * Get this module's widget validators
+ * @return \Xibo\Widget\Provider\WidgetValidatorInterface[]
+ */
+ public function getWidgetValidators(): array
+ {
+ return $this->widgetValidators;
+ }
+
+ /**
+ * Get all properties which allow library references.
+ * @return \Xibo\Widget\Definition\Property[]
+ */
+ public function getPropertiesAllowingLibraryRefs(): array
+ {
+ $props = [];
+ foreach ($this->properties as $property) {
+ if ($property->allowLibraryRefs) {
+ $props[] = $property;
+ }
+ }
+
+ return $props;
+ }
+
+ /**
+ * Get assets
+ * @return \Xibo\Widget\Definition\Asset[]
+ */
+ public function getAssets(): array
+ {
+ return $this->assets;
+ }
+
+ /**
+ * Get a module setting
+ * If the setting does not exist, $default will be returned.
+ * If the setting exists, but is not set, the default value from the setting will be returned
+ * @param string $setting The setting
+ * @param mixed|null $default A default value if the setting does not exist
+ * @return mixed
+ */
+ public function getSetting(string $setting, $default = null)
+ {
+ foreach ($this->settings as $property) {
+ if ($property->id === $setting) {
+ return $property->value ?? $property->default;
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::intType()->validate($this->defaultDuration)) {
+ throw new InvalidArgumentException(__('Default Duration is a required field.'), 'defaultDuration');
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function save()
+ {
+ $this->validate();
+
+ if (!$this->isInstalled) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ private function add()
+ {
+ $this->moduleId = $this->getStore()->insert('
+ INSERT INTO `module` (
+ `moduleId`,
+ `enabled`,
+ `previewEnabled`,
+ `defaultDuration`,
+ `settings`
+ )
+ VALUES (
+ :moduleId,
+ :enabled,
+ :previewEnabled,
+ :defaultDuration,
+ :settings
+ )
+ ', [
+ 'moduleId' => $this->moduleId,
+ 'enabled' => $this->enabled,
+ 'previewEnabled' => $this->previewEnabled,
+ 'defaultDuration' => $this->defaultDuration,
+ 'settings' => $this->getSettingsForSaving()
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `module` SET
+ enabled = :enabled,
+ previewEnabled = :previewEnabled,
+ defaultDuration = :defaultDuration,
+ settings = :settings
+ WHERE moduleid = :moduleId
+ ', [
+ 'moduleId' => $this->moduleId,
+ 'enabled' => $this->enabled,
+ 'previewEnabled' => $this->previewEnabled,
+ 'defaultDuration' => $this->defaultDuration,
+ 'settings' => $this->getSettingsForSaving()
+ ]);
+ }
+
+ /**
+ * @return string
+ */
+ private function getSettingsForSaving(): string
+ {
+ $settings = [];
+ foreach ($this->settings as $setting) {
+ if ($setting->value !== null) {
+ $settings[$setting->id] = $setting->value;
+ }
+ }
+ return count($settings) > 0 ? json_encode($settings) : '[]';
+ }
+
+ /**
+ * @return array
+ */
+ public function getSettingsForOutput(): array
+ {
+ $settings = [];
+ foreach ($this->settings as $setting) {
+ $settings[$setting->id] = $setting->value ?? $setting->default;
+ }
+ return $settings;
+ }
+
+ /**
+ * Delete this module
+ * @return void
+ */
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `module` WHERE moduleId = :id', [
+ 'id' => $this->moduleId
+ ]);
+ }
+}
diff --git a/lib/Entity/ModulePropertyTrait.php b/lib/Entity/ModulePropertyTrait.php
new file mode 100644
index 0000000..e76eeea
--- /dev/null
+++ b/lib/Entity/ModulePropertyTrait.php
@@ -0,0 +1,208 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Helper\DateFormatHelper;
+
+/**
+ * A trait for common functionality in regard to properties on modules/module templates
+ */
+trait ModulePropertyTrait
+{
+ /**
+ * @param Widget $widget
+ * @param bool $includeDefaults
+ * @param bool $reverseFilters Reverse filters?
+ * @return $this
+ */
+ public function decorateProperties(Widget $widget, bool $includeDefaults = false, bool $reverseFilters = true)
+ {
+ foreach ($this->properties as $property) {
+ $property->value = $widget->getOptionValue($property->id, null);
+
+ // Should we include defaults?
+ if ($includeDefaults && $property->value === null) {
+ $property->value = $property->default;
+ }
+
+ if ($property->value !== null) {
+ if ($property->type === 'integer') {
+ $property->value = intval($property->value);
+ } else if ($property->type === 'double' || $property->type === 'number') {
+ $property->value = doubleval($property->value);
+ } else if ($property->type === 'checkbox') {
+ $property->value = intval($property->value);
+ }
+ }
+
+ if ($reverseFilters) {
+ $property->reverseFilters();
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $properties
+ * @param bool $includeDefaults
+ * @return array
+ */
+ public function decoratePropertiesByArray(array $properties, bool $includeDefaults = false): array
+ {
+ // Flatten the properties array so that we can reference it by key.
+ $keyedProperties = [];
+ foreach ($properties as $property) {
+ $keyedProperties[$property['id']] = $property['value'] ?? null;
+ }
+
+ $decoratedProperties = [];
+ foreach ($this->properties as $property) {
+ $decoratedProperty = $keyedProperties[$property->id] ?? null;
+
+ // Should we include defaults?
+ if ($includeDefaults && $decoratedProperty === null) {
+ $decoratedProperty = $property->default;
+ }
+
+ if ($decoratedProperty !== null) {
+ if ($property->type === 'integer') {
+ $decoratedProperty = intval($decoratedProperty);
+ } else if ($property->type === 'double' || $property->type === 'number') {
+ $decoratedProperty = doubleval($decoratedProperty);
+ } else if ($property->type === 'checkbox') {
+ $decoratedProperty = intval($decoratedProperty);
+ }
+ }
+
+ $decoratedProperty = $property->reverseFiltersOnValue($decoratedProperty);
+
+ // Add our decorated property
+ $decoratedProperties[$property->id] = $decoratedProperty;
+ }
+ return $decoratedProperties;
+ }
+
+ /**
+ * @param bool $decorateForOutput true if we should decorate for output to either the preview or player
+ * @param array|null $overrideValues a key/value array of values to use instead the stored property values
+ * @param bool $includeDefaults include default values
+ * @param bool $skipNullProperties skip null properties
+ * @return array
+ */
+ public function getPropertyValues(
+ bool $decorateForOutput = true,
+ ?array $overrideValues = null,
+ bool $includeDefaults = false,
+ bool $skipNullProperties = false,
+ ): array {
+ $properties = [];
+ foreach ($this->properties as $property) {
+ $value = $overrideValues !== null ? ($overrideValues[$property->id] ?? null) : $property->value;
+
+ if ($includeDefaults && $value === null) {
+ $value = $property->default ?? null;
+ }
+
+ if ($skipNullProperties && $value === null) {
+ continue;
+ }
+
+ // TODO: should we cast values to their appropriate field formats.
+ if ($decorateForOutput) {
+ // Does this property have library references?
+ if ($property->allowLibraryRefs && !empty($value)) {
+ // Parse them out and replace for our special syntax.
+ // TODO: Can we improve this regex to ignore things we suspect are JavaScript array access?
+ $matches = [];
+ preg_match_all('/\[(.*?)\]/', $value, $matches);
+ foreach ($matches[1] as $match) {
+ // We ignore non-numbers and zero/negative integers
+ if (is_numeric($match) && intval($match) > 0) {
+ $value = str_replace(
+ '[' . $match . ']',
+ '[[mediaId=' . $match . ']]',
+ $value
+ );
+ }
+ }
+ }
+
+ // Do we need to parse out any translations? We only do this on output.
+ if ($property->parseTranslations && !empty($value)) {
+ $matches = [];
+ preg_match_all('/\|\|.*?\|\|/', $value, $matches);
+
+ foreach ($matches[0] as $sub) {
+ // Parse out the translatable string and substitute
+ $value = str_replace($sub, __(str_replace('||', '', $sub)), $value);
+ }
+ }
+
+ // Date format
+ if ($property->variant === 'dateFormat' && !empty($value)) {
+ $value = DateFormatHelper::convertPhpToMomentFormat($value);
+ }
+
+ // Media selector
+ if ($property->type === 'mediaSelector') {
+ $value = (!$value) ? '' : '[[mediaId=' . $value . ']]';
+ }
+ }
+ $properties[$property->id] = $value;
+ }
+ return $properties;
+ }
+
+ /**
+ * Gets the default value for a property
+ * @param string $id
+ * @return mixed
+ */
+ public function getPropertyDefault(string $id): mixed
+ {
+ foreach ($this->properties as $property) {
+ if ($property->id === $id) {
+ return $property->default;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException|\Xibo\Support\Exception\ValueTooLargeException
+ */
+ public function validateProperties(string $stage, $additionalProperties = []): void
+ {
+ // Go through all of our required properties, and validate that they are as they should be.
+ // provide a key/value state of all current properties
+ $properties = array_merge(
+ $this->getPropertyValues(false, null, true),
+ $additionalProperties,
+ );
+
+ foreach ($this->properties as $property) {
+ $property->validate($properties, $stage);
+ }
+ }
+}
diff --git a/lib/Entity/ModuleTemplate.php b/lib/Entity/ModuleTemplate.php
new file mode 100644
index 0000000..bc1df3c
--- /dev/null
+++ b/lib/Entity/ModuleTemplate.php
@@ -0,0 +1,380 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Factory\ModuleTemplateFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Widget\Definition\Asset;
+
+/**
+ * Represents a module template
+ * @SWG\Definition()
+ */
+class ModuleTemplate implements \JsonSerializable
+{
+ use EntityTrait;
+ use ModulePropertyTrait;
+
+ /** @var int The database ID */
+ public $id;
+
+ /**
+ * @SWG\Property()
+ * @var string The templateId
+ */
+ public $templateId;
+
+ /**
+ * @SWG\Property()
+ * @var string Type of template (static|element|stencil)
+ */
+ public $type;
+
+ /**
+ * @SWG\Property()
+ * @var \Xibo\Widget\Definition\Extend|null If this template extends another
+ */
+ public $extends;
+
+ /**
+ * @SWG\Property()
+ * @var string The datatype of this template
+ */
+ public $dataType;
+
+ /**
+ * @SWG\Property()
+ * @var string The title
+ */
+ public $title;
+
+ /**
+ * @SWG\Property(description="Description of the Module Template")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property()
+ * @var string Icon
+ */
+ public $icon;
+
+ /**
+ * @SWG\Property()
+ * Thumbnail
+ * this is the location to a module template's thumbnail, which should be added to the installation
+ * relative to the module class file.
+ * @var string
+ */
+ public $thumbnail;
+
+ /** @var int The width of the zone */
+ public $startWidth;
+
+ /** @var int The height of the zone */
+ public $startHeight;
+
+ /** @var bool Does this template have dimensions? */
+ public $hasDimensions;
+
+ /** @var bool Can this template be rotated? */
+ public $canRotate;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the template should be excluded from the Layout Editor")
+ * @var string
+ */
+ public $showIn = 'both';
+
+ /**
+ * @SWG\Property()
+ * @var \Xibo\Widget\Definition\Property[]|null Properties
+ */
+ public $properties;
+
+ /**
+ * @SWG\Property()
+ * @var bool Is Visible?
+ */
+ public $isVisible = true;
+
+ /**
+ * @SWG\Property()
+ * @var bool Is Enabled?
+ */
+ public $isEnabled = true;
+
+ /**
+ * @SWG\Property(description="An array of additional module specific group properties")
+ * @var \Xibo\Widget\Definition\PropertyGroup[]
+ */
+ public $propertyGroups = [];
+
+ /**
+ * @SWG\Property()
+ * @var \Xibo\Widget\Definition\Stencil|null A stencil, if needed
+ */
+ public $stencil;
+
+ /**
+ * @SWG\Property()
+ * @var Asset[]
+ */
+ public $assets;
+
+ /** @var string A Renderer to run if custom rendering is required. */
+ public $onTemplateRender;
+
+ /** @var string JavaScript function run when the template becomes visible. */
+ public $onTemplateVisible;
+
+ /** @var string A data parser for elements */
+ public $onElementParseData;
+
+ /** @var bool $isError Does this module have any errors? */
+ public $isError;
+
+ /** @var string[] $errors An array of errors this module has. */
+ public $errors;
+
+ /** @var string $ownership Who owns this file? system|custom|user */
+ public $ownership;
+
+ /** @var int $ownerId User ID of the owner of this template */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="A comma separated list of groups/users with permissions to this template")
+ * @var string
+ */
+ public $groupsWithPermissions;
+ /** @var string $xml The XML used to build this template */
+
+ private $xml;
+
+ /** @var \DOMDocument The DOM Document for this templates XML */
+ private $document;
+
+ /** @var \Xibo\Factory\ModuleTemplateFactory */
+ private $moduleTemplateFactory;
+
+ /**
+ * Entity constructor.
+ * @param \Xibo\Storage\StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param \Xibo\Factory\ModuleTemplateFactory $moduleTemplateFactory
+ * @param string $file The file this template resides in
+ */
+ public function __construct(
+ StorageServiceInterface $store,
+ LogServiceInterface $log,
+ EventDispatcherInterface $dispatcher,
+ ModuleTemplateFactory $moduleTemplateFactory,
+ private readonly string $file
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->setPermissionsClass('Xibo\Entity\ModuleTemplate');
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ }
+
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ public function __clone()
+ {
+ $this->id = null;
+ $this->templateId = null;
+ }
+
+ /**
+ * Get assets
+ * @return \Xibo\Widget\Definition\Asset[]
+ */
+ public function getAssets(): array
+ {
+ return $this->assets;
+ }
+
+ /**
+ * Set XML for this Module Template
+ * @param string $xml
+ * @return void
+ */
+ public function setXml(string $xml): void
+ {
+ $this->xml = $xml;
+ }
+
+ /**
+ * Get XML for this Module Template
+ * @return string
+ */
+ public function getXml(): string
+ {
+ // for system templates
+ if ($this->file !== 'database') {
+ $xml = new \DOMDocument();
+ // load whole file to document
+ $xml->loadXML(file_get_contents($this->file));
+ // go through template tags
+ foreach ($xml->getElementsByTagName('template') as $templateXml) {
+ if ($templateXml instanceof \DOMElement) {
+ foreach ($templateXml->childNodes as $childNode) {
+ if ($childNode instanceof \DOMElement) {
+ // match the template to what was requested
+ // set the xml and return it.
+ if ($childNode->nodeName === 'id' && $childNode->nodeValue == $this->templateId) {
+ $this->setXml($xml->saveXML($templateXml));
+ }
+ }
+ }
+ }
+ }
+ }
+ return $this->xml;
+ }
+
+ /**
+ * Set Document
+ * @param \DOMDocument $document
+ * @return void
+ */
+ public function setDocument(\DOMDocument $document): void
+ {
+ $this->document = $document;
+ }
+
+ /**
+ * Get this templates DOM document
+ * @return \DOMDocument
+ */
+ public function getDocument(): \DOMDocument
+ {
+ if ($this->document === null) {
+ $this->document = new \DOMDocument();
+ $this->document->load($this->getXml());
+ }
+ return $this->document;
+ }
+
+ /**
+ * Save
+ * @return void
+ */
+ public function save(): void
+ {
+ if ($this->file === 'database') {
+ if ($this->id === null) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+ }
+
+ /**
+ * Delete
+ * @return void
+ */
+ public function delete(): void
+ {
+ if ($this->file === 'database') {
+ $this->getStore()->update('DELETE FROM module_templates WHERE id = :id', [
+ 'id' => $this->id
+ ]);
+ }
+ }
+
+ /**
+ * Invalidate this module template for any widgets that use it
+ * @return void
+ */
+ public function invalidate(): void
+ {
+ // TODO: can we improve this via the event mechanism instead?
+ $this->getStore()->update('
+ UPDATE `widget` SET modifiedDt = :now
+ WHERE widgetId IN (
+ SELECT widgetId
+ FROM widgetoption
+ WHERE `option` = \'templateId\'
+ AND `value` = :templateId
+ )
+ ', [
+ 'now' => time(),
+ 'templateId' => $this->templateId,
+ ]);
+ }
+
+ /**
+ * Add
+ * @return void
+ */
+ private function add(): void
+ {
+ $this->id = $this->getStore()->insert('
+ INSERT INTO `module_templates` (`templateId`, `dataType`, `xml`, `ownerId`)
+ VALUES (:templateId, :dataType, :xml, :ownerId)
+ ', [
+ 'templateId' => $this->templateId,
+ 'dataType' => $this->dataType,
+ 'xml' => $this->xml,
+ 'ownerId' => $this->ownerId,
+ ]);
+ }
+
+ /**
+ * Edit
+ * @return void
+ */
+ private function edit(): void
+ {
+ $this->getStore()->update('
+ UPDATE `module_templates` SET
+ `templateId` = :templateId,
+ `dataType`= :dataType,
+ `enabled` = :enabled,
+ `xml` = :xml,
+ `ownerId` = :ownerId
+ WHERE `id` = :id
+ ', [
+ 'templateId' => $this->templateId,
+ 'dataType' => $this->dataType,
+ 'xml' => $this->xml,
+ 'enabled' => $this->isEnabled ? 1 : 0,
+ 'ownerId' => $this->ownerId,
+ 'id' => $this->id,
+ ]);
+ }
+}
diff --git a/lib/Entity/Notification.php b/lib/Entity/Notification.php
new file mode 100644
index 0000000..b138f5f
--- /dev/null
+++ b/lib/Entity/Notification.php
@@ -0,0 +1,533 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Notification
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Notification implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(
+ * description="The Notifcation ID"
+ * )
+ * @var int
+ */
+ public $notificationId;
+
+ /**
+ * @SWG\Property(
+ * description="Create Date as Unix Timestamp"
+ * )
+ * @var int
+ */
+ public $createDt;
+
+ /**
+ * @SWG\Property(
+ * description="Release Date as Unix Timestamp"
+ * )
+ * @var int
+ */
+ public $releaseDt;
+
+ /**
+ * @SWG\Property(
+ * description="The subject line"
+ * )
+ * @var string
+ */
+ public $subject;
+
+ /**
+ * @SWG\Property(
+ * description="The Notification type"
+ * )
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(
+ * description="The HTML body of the notification"
+ * )
+ * @var string
+ */
+ public $body;
+
+ /**
+ * @SWG\Property(
+ * description="Should the notification interrupt the CMS UI on navigate/login"
+ * )
+ * @var int
+ */
+ public $isInterrupt = 0;
+
+ /**
+ * @SWG\Property(
+ * description="Flag for system notification"
+ * )
+ * @var int
+ */
+ public $isSystem = 0;
+
+ /**
+ * @SWG\Property(
+ * description="The Owner User Id"
+ * )
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(
+ * description="Attachment filename"
+ * )
+ * @var string
+ */
+ public $filename;
+
+ /**
+ * @SWG\Property(
+ * description="Attachment originalFileName"
+ * )
+ * @var string
+ */
+ public $originalFileName;
+
+ /**
+ * @SWG\Property(
+ * description="Additional email addresses to which a saved report will be sent"
+ * )
+ * @var string
+ */
+ public $nonusers;
+
+ /**
+ * @SWG\Property(
+ * description="User Group Notifications associated with this notification"
+ * )
+ * @var UserGroup[]
+ */
+ public $userGroups = [];
+
+ /**
+ * @SWG\Property(
+ * description="Display Groups associated with this notification"
+ * )
+ * @var DisplayGroup[]
+ */
+ public $displayGroups = [];
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /**
+ * Command constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param UserGroupFactory $userGroupFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ */
+ public function __construct($store, $log, $dispatcher, $userGroupFactory, $displayGroupFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->userGroupFactory = $userGroupFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->notificationId;
+ }
+
+ /**
+ * Get Owner
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Add User Group Notification
+ * @param UserGroup $userGroup
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function assignUserGroup($userGroup)
+ {
+ $this->load();
+
+ if (!in_array($userGroup, $this->userGroups)) {
+ $this->userGroups[] = $userGroup;
+ }
+ }
+
+ /**
+ * Add Display Group
+ * @param DisplayGroup $displayGroup
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function assignDisplayGroup($displayGroup)
+ {
+ $this->load();
+
+ if (!in_array($displayGroup, $this->displayGroups)) {
+ $this->displayGroups[] = $displayGroup;
+ }
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (empty($this->subject)) {
+ throw new InvalidArgumentException(__('Please provide a subject'), 'subject');
+ }
+
+ if (empty($this->body)) {
+ throw new InvalidArgumentException(__('Please provide a body'), 'body');
+ }
+ }
+
+ /**
+ * Load
+ * @param array $options
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadUserGroups' => true,
+ 'loadDisplayGroups' => true,
+ ], $options);
+
+ if ($this->loaded || $this->notificationId == null) {
+ return;
+ }
+
+ // Load the Display Groups and User Group Notifications
+ if ($options['loadUserGroups']) {
+ $this->userGroups = $this->userGroupFactory->getByNotificationId($this->notificationId);
+ }
+
+ if ($options['loadDisplayGroups']) {
+ $this->displayGroups = $this->displayGroupFactory->getByNotificationId($this->notificationId);
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Save Notification
+ * @throws InvalidArgumentException
+ */
+ public function save(): void
+ {
+ $this->validate();
+
+ $isNewRecord = false;
+ if ($this->notificationId == null) {
+ $isNewRecord = true;
+ $this->add();
+ } else {
+ $this->edit();
+ }
+
+ $this->manageAssignments($isNewRecord);
+ }
+
+ /**
+ * Delete Notification
+ */
+ public function delete()
+ {
+ // Remove all links
+ $this->getStore()->update(
+ 'DELETE FROM `lknotificationuser` WHERE `notificationId` = :notificationId',
+ ['notificationId' => $this->notificationId]
+ );
+
+ $this->getStore()->update(
+ 'DELETE FROM `lknotificationgroup` WHERE `notificationId` = :notificationId',
+ ['notificationId' => $this->notificationId]
+ );
+
+ $this->getStore()->update(
+ 'DELETE FROM `lknotificationdg` WHERE `notificationId` = :notificationId',
+ ['notificationId' => $this->notificationId]
+ );
+
+ // Remove the notification
+ $this->getStore()->update(
+ 'DELETE FROM `notification` WHERE `notificationId` = :notificationId',
+ ['notificationId' => $this->notificationId]
+ );
+ }
+
+ /**
+ * Add to DB
+ */
+ private function add()
+ {
+ $this->notificationId = $this->getStore()->insert('
+ INSERT INTO `notification` (
+ `subject`,
+ `body`,
+ `createDt`,
+ `releaseDt`,
+ `isInterrupt`,
+ `isSystem`,
+ `userId`,
+ `filename`,
+ `originalFileName`,
+ `nonusers`,
+ `type`
+ )
+ VALUES (
+ :subject,
+ :body,
+ :createDt,
+ :releaseDt,
+ :isInterrupt,
+ :isSystem,
+ :userId,
+ :filename,
+ :originalFileName,
+ :nonusers,
+ :type
+ )
+ ', [
+ 'subject' => $this->subject,
+ 'body' => $this->body,
+ 'createDt' => $this->createDt,
+ 'releaseDt' => $this->releaseDt,
+ 'isInterrupt' => $this->isInterrupt,
+ 'isSystem' => $this->isSystem,
+ 'userId' => $this->userId,
+ 'filename' => $this->filename,
+ 'originalFileName' => $this->originalFileName,
+ 'nonusers' => $this->nonusers,
+ 'type' => $this->type ?? 'custom'
+ ]);
+ }
+
+ /**
+ * Update in DB
+ */
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `notification` SET `subject` = :subject,
+ `body` = :body,
+ `createDt` = :createDt,
+ `releaseDt` = :releaseDt,
+ `isInterrupt` = :isInterrupt,
+ `isSystem` = :isSystem,
+ `userId` = :userId,
+ `filename` = :filename,
+ `originalFileName` = :originalFileName,
+ `nonusers` = :nonusers,
+ `type` = :type
+ WHERE `notificationId` = :notificationId
+ ', [
+ 'subject' => $this->subject,
+ 'body' => $this->body,
+ 'createDt' => $this->createDt,
+ 'releaseDt' => $this->releaseDt,
+ 'isInterrupt' => $this->isInterrupt,
+ 'isSystem' => $this->isSystem,
+ 'userId' => $this->userId,
+ 'filename' => $this->filename,
+ 'originalFileName' => $this->originalFileName,
+ 'nonusers' => $this->nonusers,
+ 'type' => $this->type ?? 'custom',
+ 'notificationId' => $this->notificationId
+ ]);
+ }
+
+ /**
+ * Manage assignements in DB
+ */
+ private function manageAssignments(bool $isNewRecord): void
+ {
+ $this->linkUserGroups();
+
+ // Only unlink if we're not new (otherwise there is no point as we can't have any links yet)
+ if (!$isNewRecord) {
+ $this->unlinkUserGroups();
+ }
+
+ $this->linkDisplayGroups();
+
+ if (!$isNewRecord) {
+ $this->unlinkDisplayGroups();
+ }
+
+ $this->manageRealisedUserLinks();
+ }
+
+ /**
+ * Manage the links in the User notification table
+ */
+ private function manageRealisedUserLinks(bool $isNewRecord = false): void
+ {
+ if (!$isNewRecord) {
+ // Delete links that no longer exist
+ $this->getStore()->update('
+ DELETE FROM `lknotificationuser`
+ WHERE `notificationId` = :notificationId AND `userId` NOT IN (
+ SELECT `userId`
+ FROM `lkusergroup`
+ INNER JOIN `lknotificationgroup`
+ ON `lknotificationgroup`.groupId = `lkusergroup`.groupId
+ WHERE `lknotificationgroup`.notificationId = :notificationId2
+ ) AND userId <> 0
+ ', [
+ 'notificationId' => $this->notificationId,
+ 'notificationId2' => $this->notificationId
+ ]);
+ }
+
+ // Pop in new links following from this adjustment
+ $this->getStore()->update('
+ INSERT INTO `lknotificationuser` (`notificationId`, `userId`, `read`, `readDt`, `emailDt`)
+ SELECT DISTINCT :notificationId, `userId`, 0, 0, 0
+ FROM `lkusergroup`
+ INNER JOIN `lknotificationgroup`
+ ON `lknotificationgroup`.groupId = `lkusergroup`.groupId
+ WHERE `lknotificationgroup`.notificationId = :notificationId2
+ ON DUPLICATE KEY UPDATE userId = `lknotificationuser`.userId
+ ', [
+ 'notificationId' => $this->notificationId,
+ 'notificationId2' => $this->notificationId
+ ]);
+
+ if ($this->isSystem) {
+ $this->getStore()->insert('
+ INSERT INTO `lknotificationuser` (`notificationId`, `userId`, `read`, `readDt`, `emailDt`)
+ VALUES (:notificationId, :userId, 0, 0, 0)
+ ON DUPLICATE KEY UPDATE userId = `lknotificationuser`.userId
+ ', [
+ 'notificationId' => $this->notificationId,
+ 'userId' => $this->userId
+ ]);
+ }
+ }
+
+ /**
+ * Link User Groups
+ */
+ private function linkUserGroups()
+ {
+ foreach ($this->userGroups as $userGroup) {
+ /* @var UserGroup $userGroup */
+ $this->getStore()->update('INSERT INTO `lknotificationgroup` (notificationId, groupId) VALUES (:notificationId, :userGroupId) ON DUPLICATE KEY UPDATE groupId = groupId', [
+ 'notificationId' => $this->notificationId,
+ 'userGroupId' => $userGroup->groupId
+ ]);
+ }
+ }
+
+ /**
+ * Unlink User Groups
+ */
+ private function unlinkUserGroups()
+ {
+ // Unlink any userGroup that is NOT in the collection
+ $params = ['notificationId' => $this->notificationId];
+
+ $sql = 'DELETE FROM `lknotificationgroup` WHERE notificationId = :notificationId AND groupId NOT IN (0';
+
+ $i = 0;
+ foreach ($this->userGroups as $userGroup) {
+ /* @var UserGroup $userGroup */
+ $i++;
+ $sql .= ',:userGroupId' . $i;
+ $params['userGroupId' . $i] = $userGroup->groupId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Link Display Groups
+ */
+ private function linkDisplayGroups()
+ {
+ foreach ($this->displayGroups as $displayGroup) {
+ /* @var DisplayGroup $displayGroup */
+ $this->getStore()->update('INSERT INTO `lknotificationdg` (notificationId, displayGroupId) VALUES (:notificationId, :displayGroupId) ON DUPLICATE KEY UPDATE displayGroupId = displayGroupId', [
+ 'notificationId' => $this->notificationId,
+ 'displayGroupId' => $displayGroup->displayGroupId
+ ]);
+ }
+ }
+
+ /**
+ * Unlink Display Groups
+ */
+ private function unlinkDisplayGroups()
+ {
+ // Unlink any displayGroup that is NOT in the collection
+ $params = ['notificationId' => $this->notificationId];
+
+ $sql = 'DELETE FROM `lknotificationdg` WHERE notificationId = :notificationId AND displayGroupId NOT IN (0';
+
+ $i = 0;
+ foreach ($this->displayGroups as $displayGroup) {
+ /* @var DisplayGroup $displayGroup */
+ $i++;
+ $sql .= ',:displayGroupId' . $i;
+ $params['displayGroupId' . $i] = $displayGroup->displayGroupId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+}
diff --git a/lib/Entity/Permission.php b/lib/Entity/Permission.php
new file mode 100644
index 0000000..f87c506
--- /dev/null
+++ b/lib/Entity/Permission.php
@@ -0,0 +1,209 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class Permission
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Permission implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Permission Record")
+ * @var int
+ */
+ public $permissionId;
+
+ /**
+ * @SWG\Property(description="The Entity ID that this Permission refers to")
+ * @var int
+ */
+ public $entityId;
+
+ /**
+ * @SWG\Property(description="The User Group ID that this permission refers to")
+ * @var int
+ */
+ public $groupId;
+
+ /**
+ * @SWG\Property(description="The object ID that this permission refers to")
+ * @var int
+ */
+ public $objectId;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the groupId refers to a user specific group")
+ * @var int
+ */
+ public $isUser;
+
+ /**
+ * @SWG\Property(description="The entity name that this refers to")
+ * @var string
+ */
+ public $entity;
+
+ /**
+ * @SWG\Property(description="Legacy for when the Object ID is a string")
+ * @var string
+ */
+ public $objectIdString;
+
+ /**
+ * @SWG\Property(description="The group name that this refers to")
+ * @var string
+ */
+ public $group;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether view permission is granted")
+ * @var int
+ */
+ public $view;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether edit permission is granted")
+ * @var int
+ */
+ public $edit;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether delete permission is granted")
+ * @var int
+ */
+ public $delete;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether modify permission permission is granted.")
+ * @var int
+ */
+ public $modifyPermissions;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function __clone()
+ {
+ $this->permissionId = null;
+ }
+
+ /**
+ * Save this permission
+ * @return void
+ */
+ public function save(): void
+ {
+ if ($this->permissionId == 0) {
+ // Check there is something to add
+ if ($this->view != 0 || $this->edit != 0 || $this->delete != 0) {
+ $this->getLog()->debug(sprintf(
+ 'save: Adding Permission for %s, %d. GroupId: %d - View = %d, Edit = %d, Delete = %d',
+ $this->entity,
+ $this->objectId,
+ $this->groupId,
+ $this->view,
+ $this->edit,
+ $this->delete,
+ ));
+
+ $this->add();
+ }
+ } else {
+ $this->getLog()->debug(sprintf(
+ 'save: Editing Permission for %s, %d. GroupId: %d - View = %d, Edit = %d, Delete = %d',
+ $this->entity,
+ $this->objectId,
+ $this->groupId,
+ $this->view,
+ $this->edit,
+ $this->delete,
+ ));
+
+ // If all permissions are set to 0, then we delete the record to tidy up
+ if ($this->view == 0 && $this->edit == 0 && $this->delete == 0) {
+ $this->delete();
+ } else if (count($this->getChangedProperties()) > 0) {
+ // Something has changed, so run the update.
+ $this->update();
+ }
+ }
+ }
+
+ private function add()
+ {
+ $this->permissionId = $this->getStore()->insert('INSERT INTO `permission` (`entityId`, `groupId`, `objectId`, `view`, `edit`, `delete`) VALUES (:entityId, :groupId, :objectId, :view, :edit, :delete)', array(
+ 'entityId' => $this->entityId,
+ 'objectId' => $this->objectId,
+ 'groupId' => $this->groupId,
+ 'view' => $this->view,
+ 'edit' => $this->edit,
+ 'delete' => $this->delete,
+ ));
+ }
+
+ private function update()
+ {
+ $this->getStore()->update('UPDATE `permission` SET `view` = :view, `edit` = :edit, `delete` = :delete WHERE `entityId` = :entityId AND `groupId` = :groupId AND `objectId` = :objectId', array(
+ 'entityId' => $this->entityId,
+ 'objectId' => $this->objectId,
+ 'groupId' => $this->groupId,
+ 'view' => $this->view,
+ 'edit' => $this->edit,
+ 'delete' => $this->delete,
+ ));
+ }
+
+ public function delete()
+ {
+ $this->getLog()->debug(sprintf('Deleting Permission for %s, %d', $this->entity, $this->objectId));
+ $this->getStore()->update('DELETE FROM `permission` WHERE entityId = :entityId AND objectId = :objectId AND groupId = :groupId', array(
+ 'entityId' => $this->entityId,
+ 'objectId' => $this->objectId,
+ 'groupId' => $this->groupId
+ ));
+ }
+
+ public function deleteAll()
+ {
+ $this->getStore()->update('DELETE FROM `permission` WHERE entityId = :entityId AND objectId = :objectId', array(
+ 'entityId' => $this->entityId,
+ 'objectId' => $this->objectId,
+ ));
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/PlayerFault.php b/lib/Entity/PlayerFault.php
new file mode 100644
index 0000000..1881c4f
--- /dev/null
+++ b/lib/Entity/PlayerFault.php
@@ -0,0 +1,119 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * @SWG\Definition()
+ */
+class PlayerFault implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Fault Id")
+ * @var int
+ */
+ public $playerFaultId;
+
+ /**
+ * @SWG\Property(description="The Display Id")
+ * @var int
+ */
+ public $displayId;
+
+ /**
+ * @SWG\Property(description="The Date the error occured")
+ * @var string
+ */
+ public $incidentDt;
+
+ /**
+ * @SWG\Property(description="The Date the error expires")
+ * @var string
+ */
+ public $expires;
+
+ /**
+ * @SWG\Property(description="The Code associated with the fault")
+ * @var int
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="The Reason for the fault")
+ * @var string
+ */
+ public $reason;
+
+ /**
+ * @SWG\Property(description="The Layout Id")
+ * @var int
+ */
+ public $layoutId;
+
+ /**
+ * @SWG\Property(description="The Region Id")
+ * @var int
+ */
+ public $regionId;
+
+ /**
+ * @SWG\Property(description="The Schedule Id")
+ * @var int
+ */
+ public $scheduleId;
+
+ /**
+ * @SWG\Property(description="The Widget Id")
+ * @var int
+ */
+ public $widgetId;
+
+ /**
+ * @SWG\Property(description="The Media Id")
+ * @var int
+ */
+ public $mediaId;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('Player Fault Id %d, Code %d, Reason %s, Date %s', $this->playerFaultId, $this->code, $this->reason, $this->incidentDt);
+ }
+}
diff --git a/lib/Entity/PlayerVersion.php b/lib/Entity/PlayerVersion.php
new file mode 100644
index 0000000..1424a80
--- /dev/null
+++ b/lib/Entity/PlayerVersion.php
@@ -0,0 +1,466 @@
+.
+ */
+namespace Xibo\Entity;
+
+
+use Carbon\Carbon;
+use Slim\Http\ServerRequest;
+use Symfony\Component\Filesystem\Filesystem;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class PlayerVersion
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+*/
+class PlayerVersion implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="Version ID")
+ * @var int
+ */
+ public $versionId;
+
+ /**
+ * @SWG\Property(description="Player type")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="Version number")
+ * @var string
+ */
+ public $version;
+
+ /**
+ * @SWG\Property(description="Code number")
+ * @var int
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="Player version to show")
+ * @var string
+ */
+ public $playerShowVersion;
+
+ /**
+ * @SWG\Property(description="The Player Version created date")
+ * @var string
+ */
+ public $createdAt;
+
+ /**
+ * @SWG\Property(description="The Player Version modified date")
+ * @var string
+ */
+ public $modifiedAt;
+
+ /**
+ * @SWG\Property(description="The name of the user that modified this Player Version last")
+ * @var string
+ */
+ public $modifiedBy;
+
+ /**
+ * @SWG\Property(description="The Player Version file name")
+ * @var string
+ */
+ public $fileName;
+
+ /**
+ * @SWG\Property(description="The Player Version file size in bytes")
+ * @var int
+ */
+ public $size;
+
+ /**
+ * @SWG\Property(description="A MD5 checksum of the stored Player Version file")
+ * @var string
+ */
+ public $md5;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var PlayerVersionFactory
+ */
+ private $playerVersionFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param MediaFactory $mediaFactory
+ * @param PlayerVersionFactory $playerVersionFactory
+ */
+ public function __construct($store, $log, $dispatcher, $config, $playerVersionFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->config = $config;
+ $this->playerVersionFactory = $playerVersionFactory;
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->versionId = $this->getStore()->insert('
+ INSERT INTO `player_software` (`player_type`, `player_version`, `player_code`, `playerShowVersion`,`createdAt`, `modifiedAt`, `modifiedBy`, `fileName`, `size`, `md5`)
+ VALUES (:type, :version, :code, :playerShowVersion, :createdAt, :modifiedAt, :modifiedBy, :fileName, :size, :md5)
+ ', [
+ 'type' => $this->type,
+ 'version' => $this->version,
+ 'code' => $this->code,
+ 'playerShowVersion' => $this->playerShowVersion,
+ 'createdAt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedAt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedBy' => $this->modifiedBy,
+ 'fileName' => $this->fileName,
+ 'size' => $this->size,
+ 'md5' => $this->md5
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $sql = '
+ UPDATE `player_software`
+ SET `player_version` = :version,
+ `player_code` = :code,
+ `playerShowVersion` = :playerShowVersion,
+ `modifiedAt` = :modifiedAt,
+ `modifiedBy` = :modifiedBy
+ WHERE versionId = :versionId
+ ';
+
+ $params = [
+ 'version' => $this->version,
+ 'code' => $this->code,
+ 'playerShowVersion' => $this->playerShowVersion,
+ 'modifiedAt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedBy' => $this->modifiedBy,
+ 'versionId' => $this->versionId
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->load();
+
+ // delete record
+ $this->getStore()->update('DELETE FROM `player_software` WHERE `versionId` = :versionId', [
+ 'versionId' => $this->versionId
+ ]);
+
+ // Library location
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+
+ // delete file
+ if (file_exists($libraryLocation . 'playersoftware/' . $this->fileName)) {
+ unlink($libraryLocation . 'playersoftware/' . $this->fileName);
+ }
+
+ // delete unpacked file
+ if (is_dir($libraryLocation . 'playersoftware/chromeos/' . $this->versionId)) {
+ (new Filesystem())->remove($libraryLocation . 'playersoftware/chromeos/' . $this->versionId);
+ }
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function unpack(string $libraryFolder, ServerRequest $request): static
+ {
+ // ChromeOS
+ // Unpack the `.chrome` file as a tar/gz, validate its signature, extract it into the library folder
+ if ($this->type === 'chromeOS') {
+ $this->getLog()->debug('add: handling chromeOS upload');
+
+ $fullFileName = $libraryFolder . 'playersoftware/' . $this->fileName;
+
+ // Check the signature of the file to make sure it comes from a verified source.
+ try {
+ $this->getLog()->debug('unpack: loading gnupg to verify the signature');
+
+ $gpg = new \gnupg();
+ $gpg->seterrormode(\gnupg::ERROR_EXCEPTION);
+ $info = $gpg->verify(
+ file_get_contents($fullFileName),
+ false,
+ );
+
+ if ($info === false
+ || $info[0]['fingerprint'] !== '10415C506BE63E70BAF1D58BC1EF165A0F880F75'
+ || $info[0]['status'] !== 0
+ || $info[0]['summary'] !== 0
+ ) {
+ $this->getLog()->error('unpack: unable to verify GPG. file = ' . $this->fileName);
+ throw new GeneralException();
+ }
+
+ $this->getLog()->debug('unpack: signature verified');
+
+ // Signature verified, move the file, so we can decrypt it.
+ rename($fullFileName, $libraryFolder . 'playersoftware/' . $this->versionId . '.gpg');
+
+ $this->getLog()->debug('unpack: using the shell to decrypt the file');
+
+ // Go to the shell to decrypt it.
+ shell_exec('gpg --decrypt --output ' . $libraryFolder . 'playersoftware/' . $this->versionId
+ . ' ' . $libraryFolder . 'playersoftware/' . $this->versionId . '.gpg');
+
+ // Was this successful?
+ if (!file_exists($libraryFolder . 'playersoftware/' . $this->versionId)) {
+ throw new NotFoundException('Not found after decryption');
+ }
+
+ // Rename the GPG file back to its original name.
+ rename($libraryFolder . 'playersoftware/' . $this->versionId . '.gpg', $fullFileName);
+ } catch (\Exception $e) {
+ $this->getLog()->error('unpack: ' . $e->getMessage());
+ throw new InvalidArgumentException(__('Package file unsupported or invalid'));
+ }
+
+ $zip = new \ZipArchive();
+ if (!$zip->open($libraryFolder . 'playersoftware/' . $this->versionId)) {
+ throw new InvalidArgumentException(__('Unable to open ZIP'));
+ }
+
+ // Make sure the ZIP file contains a manifest.json file.
+ if ($zip->locateName('manifest.json') === false) {
+ throw new InvalidArgumentException(__('Software package does not contain a manifest'));
+ }
+
+ // Make a folder for this
+ $folder = $libraryFolder . 'playersoftware/chromeos/' . $this->versionId;
+ if (is_dir($folder)) {
+ unlink($folder);
+ }
+ mkdir($folder);
+
+ // Extract to that folder
+ $zip->extractTo($folder);
+ $zip->close();
+
+ // Update manifest.json
+ $manifest = json_decode(file_get_contents($folder . '/manifest.json'), true);
+
+ $isXiboThemed = $this->config->getThemeConfig('app_name', 'Xibo') === 'Xibo';
+ if (!$isXiboThemed) {
+ $manifest['id'] = $this->config->getThemeConfig('theme_url');
+ $manifest['name'] = $this->config->getThemeConfig('theme_name');
+ $manifest['description'] = $this->config->getThemeConfig('theme_title');
+ $manifest['short_name'] = $this->config->getThemeConfig('app_name') . '-chromeos';
+ }
+
+ // Start URL if we're running in a sub-folder.
+ $manifest['start_url'] = (new HttpsDetect())->getBaseUrl($request) . '/pwa';
+
+ // Update asset URLs
+ for ($i = 0; $i < count($manifest['icons']); $i++) {
+ if ($manifest['icons'][$i]['sizes'] == '512x512') {
+ $manifest['icons'][$i]['src'] = $this->config->uri('img/512x512.png');
+ } else {
+ $manifest['icons'][$i]['src'] = $this->config->uri('img/192x192.png');
+ }
+ }
+
+ file_put_contents($folder . '/manifest.json', json_encode($manifest));
+
+ // Unlink our decrypted file
+ unlink($libraryFolder . 'playersoftware/' . $this->versionId);
+ }
+
+ return $this;
+ }
+
+ public function setActive(): static
+ {
+ if ($this->type === 'chromeOS') {
+ $this->getLog()->debug('setActive: set this version to be the latest');
+
+ $chromeLocation = $this->config->getSetting('LIBRARY_LOCATION') . 'playersoftware/chromeos';
+ if (is_link($chromeLocation . '/latest')) {
+ unlink($chromeLocation . '/latest');
+ }
+ symlink($chromeLocation . '/' . $this->versionId, $chromeLocation . '/latest');
+ }
+
+ return $this;
+ }
+
+ /**
+ * Load
+ */
+ public function load()
+ {
+ if ($this->loaded || $this->versionId == null)
+ return;
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Save this media
+ * @param array $options
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->versionId == null || $this->versionId == 0) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ public function validate() {
+ // do we already have a file with the same exact name?
+ $params = [];
+ $checkSQL = 'SELECT `fileName` FROM `player_software` WHERE `fileName` = :fileName';
+
+ if ($this->versionId != null) {
+ $checkSQL .= ' AND `versionId` <> :versionId ';
+ $params['versionId'] = $this->versionId;
+ }
+
+ $params['fileName'] = $this->fileName;
+
+ $result = $this->getStore()->select($checkSQL, $params);
+
+ if (count($result) > 0) {
+ throw new DuplicateEntityException(__('You already own Player Version file with this name.'));
+ }
+ }
+
+ /**
+ * @return $this
+ */
+ public function decorateRecord(): static
+ {
+ $version = '';
+ $code = null;
+ $type = '';
+ $explode = explode('_', $this->fileName);
+ $explodeExt = explode('.', $this->fileName);
+ $playerShowVersion = $explodeExt[0];
+
+ // standard releases
+ if (count($explode) === 5) {
+ if (str_contains($explode[4], '.')) {
+ $explodeExtension = explode('.', $explode[4]);
+ $explode[4] = $explodeExtension[0];
+ }
+
+ if (str_contains($explode[3], 'v')) {
+ $version = strtolower(substr(strrchr($explode[3], 'v'), 1, 3)) ;
+ }
+ if (str_contains($explode[4], 'R')) {
+ $code = strtolower(substr(strrchr($explode[4], 'R'), 1, 3)) ;
+ }
+ $playerShowVersion = $version . ' Revision ' . $code;
+ // for DSDevices specific apk
+ } elseif (count($explode) === 6) {
+ if (str_contains($explode[5], '.')) {
+ $explodeExtension = explode('.', $explode[5]);
+ $explode[5] = $explodeExtension[0];
+ }
+ if (str_contains($explode[3], 'v')) {
+ $version = strtolower(substr(strrchr($explode[3], 'v'), 1, 3)) ;
+ }
+ if (str_contains($explode[4], 'R')) {
+ $code = strtolower(substr(strrchr($explode[4], 'R'), 1, 3)) ;
+ }
+ $playerShowVersion = $version . ' Revision ' . $code . ' ' . $explode[5];
+ // for white labels
+ } elseif (count($explode) === 3) {
+ if (str_contains($explode[2], '.')) {
+ $explodeExtension = explode('.', $explode[2]);
+ $explode[2] = $explodeExtension[0];
+ }
+ if (str_contains($explode[1], 'v')) {
+ $version = strtolower(substr(strrchr($explode[1], 'v'), 1, 3)) ;
+ }
+ if (str_contains($explode[2], 'R')) {
+ $code = strtolower(substr(strrchr($explode[2], 'R'), 1, 3)) ;
+ }
+ $playerShowVersion = $version . ' Revision ' . $code . ' ' . $explode[0];
+ }
+
+ $extension = strtolower(substr(strrchr($this->fileName, '.'), 1));
+
+ if ($extension == 'apk') {
+ $type = 'android';
+ } else if ($extension == 'ipk') {
+ $type = 'lg';
+ } else if ($extension == 'wgt') {
+ $type = 'sssp';
+ } else if ($extension == 'chrome') {
+ $type = 'chromeOS';
+ }
+
+ $this->version = $version;
+ $this->code = $code;
+ $this->playerShowVersion = $playerShowVersion;
+ $this->type = $type;
+
+ return $this;
+ }
+}
diff --git a/lib/Entity/Playlist.php b/lib/Entity/Playlist.php
new file mode 100644
index 0000000..5b61f55
--- /dev/null
+++ b/lib/Entity/Playlist.php
@@ -0,0 +1,1190 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Xibo\Event\PlaylistDeleteEvent;
+use Xibo\Event\SubPlaylistDurationEvent;
+use Xibo\Event\SubPlaylistWidgetsEvent;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Profiler;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Playlist
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Playlist implements \JsonSerializable
+{
+ use EntityTrait;
+ use TagLinkTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Playlist")
+ * @var int
+ */
+ public $playlistId;
+
+ /**
+ * @SWG\Property(description="The userId of the User that owns this Playlist")
+ * @var int
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="The Name of the Playlist")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The RegionId if this Playlist is specific to a Region")
+ * @var int
+ */
+ public $regionId;
+
+ /**
+ * @SWG\Property(description="Flag indicating if this is a dynamic Playlist")
+ * @var int
+ */
+ public $isDynamic;
+
+ /**
+ * @SWG\Property(description="Filter Name for a Dynamic Playlist")
+ * @var string
+ */
+ public $filterMediaName;
+
+ /**
+ * @SWG\Property(description="Which logical operator should be used when filtering by multiple Plalust names? OR|AND")
+ * @var string
+ */
+ public $filterMediaNameLogicalOperator;
+
+ /**
+ * @SWG\Property(description="Filter Tags for a Dynamic Playlist")
+ * @var string
+ */
+ public $filterMediaTags;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether to filter by exact Tag match")
+ * @var int
+ */
+ public $filterExactTags;
+
+ /**
+ * @SWG\Property(description="Which logical operator should be used when filtering by multiple Tags? OR|AND")
+ * @var string
+ */
+ public $filterMediaTagsLogicalOperator;
+
+ /**
+ * @SWG\Property(description="The ID of the folder to filter media items by")
+ * @var int
+ */
+ public $filterFolderId;
+
+ /**
+ * @SWG\Property(description="Maximum number of Media items matching dynamic Playlist filters")
+ * @var int
+ */
+ public $maxNumberOfItems;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime this entity was created"
+ * )
+ */
+ public $createdDt;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime this entity was last modified"
+ * )
+ */
+ public $modifiedDt;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="A read-only estimate of this Layout's total duration in seconds. This is equal to the longest region duration and is valid when the layout status is 1 or 2."
+ * )
+ */
+ public $duration = 0;
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="Flag indicating whether this Playlists requires a duration update"
+ * )
+ */
+ public $requiresDurationUpdate;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The option to enable the collection of Playlist Proof of Play statistics"
+ * )
+ */
+ public $enableStat;
+
+ /**
+ * @SWG\Property(description="Tags associated with this Playlist, array of TagLink objects")
+ * @var TagLink[]
+ */
+ public $tags = [];
+
+ /**
+ * @SWG\Property(description="An array of Widgets assigned to this Playlist")
+ * @var Widget[]
+ */
+ public $widgets = [];
+
+ /**
+ * @SWG\Property(description="An array of permissions")
+ * @var Permission[]
+ */
+ public $permissions = [];
+
+ /**
+ * Temporary Id used during import/upgrade
+ * @var string read only string
+ */
+ public $tempId = null;
+
+ // Read only properties
+ public $owner;
+ public $groupsWithPermissions;
+
+ /**
+ * @SWG\Property(description="The id of the Folder this Playlist belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Playlist")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+ /** @var TagLink[] */
+ private $unlinkTags = [];
+ /** @var TagLink[] */
+ private $linkTags = [];
+
+ //
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var WidgetFactory
+ */
+ private $widgetFactory;
+
+ /**
+ * @var PlaylistFactory
+ */
+ private $playlistFactory;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+ //
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param PermissionFactory $permissionFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param WidgetFactory $widgetFactory
+
+ */
+ public function __construct($store, $log, $dispatcher, $config, $permissionFactory, $playlistFactory, $widgetFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->config = $config;
+ $this->permissionFactory = $permissionFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->widgetFactory = $widgetFactory;
+ }
+
+ /**
+ * @param ModuleFactory $moduleFactory
+ * @return $this
+ */
+ public function setModuleFactory($moduleFactory)
+ {
+ $this->moduleFactory = $moduleFactory;
+ return $this;
+ }
+
+ /**
+ * Clone this Playlist
+ */
+ public function __clone()
+ {
+ $this->hash = null;
+ $this->playlistId = null;
+ $this->regionId = null;
+ $this->permissions = [];
+ $this->tags = [];
+
+ $this->widgets = array_map(function ($object) { return clone $object; }, $this->widgets);
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('Playlist %s. Widgets = %d. PlaylistId = %d. RegionId = %d', $this->name, count($this->widgets), $this->playlistId, $this->regionId);
+ }
+
+ /**
+ * @return string
+ */
+ private function hash()
+ {
+ return md5($this->regionId . $this->playlistId . $this->ownerId . $this->name . $this->duration . $this->requiresDurationUpdate . $this->folderId);
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->playlistId;
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ * @throws NotFoundException
+ */
+ public function setOwner($ownerId)
+ {
+ $this->load();
+ $this->ownerId = $ownerId;
+
+ foreach ($this->widgets as $widget) {
+ /* @var Widget $widget */
+ $widget->setOwner($ownerId);
+ }
+ }
+
+ /**
+ * Is this Playlist a Region Playlist (region specific)
+ * @return bool
+ */
+ public function isRegionPlaylist()
+ {
+ return ($this->regionId != null);
+ }
+
+ /**
+ * Validate this playlist
+ * @throws DuplicateEntityException
+ * @throws NotFoundException
+ */
+ public function validate()
+ {
+ // check for duplicates,
+ // we check for empty playlist name due to layouts existing in the CMS before upgrade to v2
+ if ($this->name != '' && !$this->isRegionPlaylist()) {
+ $duplicates = $this->playlistFactory->query(null, [
+ 'userId' => $this->ownerId,
+ 'playlistExact' => $this->name,
+ 'regionSpecific' => 0,
+ 'disableUserCheck' => 1,
+ 'notPlaylistId' => ($this->playlistId == null) ? 0 : $this->playlistId,
+ ]);
+
+ if (count($duplicates) > 0) {
+ throw new DuplicateEntityException(sprintf(__("You already own a Playlist called '%s'. Please choose another name."), $this->name));
+ }
+ }
+
+ if ($this->isDynamic === 1 && $this->maxNumberOfItems > $this->config->getSetting('DEFAULT_DYNAMIC_PLAYLIST_MAXNUMBER_LIMIT')) {
+ throw new InvalidArgumentException(__('Maximum number of items cannot exceed the limit set in CMS Settings'), 'maxNumberOfItems');
+ }
+ }
+
+ /**
+ * Is this Playlist editable.
+ * Are we a standalone playlist OR are we on a draft layout
+ * @return bool
+ */
+ public function isEditable()
+ {
+ if ($this->isRegionPlaylist()) {
+ // Run a lookup to see if we're on a draft layout
+ $this->getLog()->debug('Checking whether we are on a Layout which is in the Draft State');
+
+ $exists = $this->getStore()->exists('
+ SELECT `layout`.layoutId
+ FROM `region`
+ INNER JOIN `layout` ON layout.layoutId = region.layoutId
+ WHERE regionId = :regionId
+ AND parentId IS NOT NULL
+ ', [
+ 'regionId' => $this->regionId
+ ]);
+
+ $this->getLog()->debug('We are ' . (($exists) ? 'editable' : 'not editable'));
+
+ return $exists;
+ } else {
+ $this->getLog()->debug('Non-region Playlist - we\'re always Editable' );
+ return true;
+ }
+ }
+
+ /**
+ * Get Widget at Index
+ * @param int $index
+ * @param Widget[]|null $widgets
+ * @return Widget
+ * @throws NotFoundException
+ */
+ public function getWidgetAt($index, $widgets = null)
+ {
+ if ($widgets === null)
+ $widgets = $this->widgets;
+
+ if ($index <= count($widgets)) {
+ $zeroBased = $index - 1;
+ if (isset($widgets[$zeroBased])) {
+ return $widgets[$zeroBased];
+ }
+ }
+
+ throw new NotFoundException(sprintf(__('Widget not found at index %d'), $index));
+ }
+
+ /**
+ * Get Widget by Id
+ * @param int $widgetId
+ * @param Widget[]|null $widgets
+ * @return Widget
+ * @throws NotFoundException
+ */
+ public function getWidget($widgetId, $widgets = null)
+ {
+ if ($widgets === null)
+ $widgets = $this->widgets;
+
+ foreach ($widgets as $widget) {
+ if ($widget->widgetId == $widgetId) {
+ return $widget;
+ }
+ }
+
+ throw new NotFoundException(sprintf(__('Widget not found with ID %d'), $widgetId));
+ }
+
+ /**
+ * @param Widget $widget
+ * @param int $displayOrder
+ * @throws NotFoundException
+ */
+ public function assignWidget($widget, $displayOrder = null)
+ {
+ $this->load();
+
+ // Has a display order been provided?
+ if ($displayOrder !== null) {
+ // We need to shuffle any existing widget down to make space for this one.
+ foreach ($this->widgets as $existingWidget) {
+ if ($existingWidget->displayOrder < $displayOrder) {
+ // Earlier in the list, so do nothing.
+ continue;
+ } else {
+ // This widget is >= the display order and therefore needs to be moved down one position.
+ $existingWidget->displayOrder = $existingWidget->displayOrder + 1;
+ }
+ }
+ // Set the incoming widget to the requested display order.
+ $widget->displayOrder = $displayOrder;
+ } else {
+ // Take the next available one
+ $widget->displayOrder = count($this->widgets) + 1;
+ }
+ $this->widgets[] = $widget;
+ }
+
+ /**
+ * Delete a Widget
+ * @param Widget $widget
+ * @param array $options Delete Options
+ * @return $this
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function deleteWidget($widget, $options = [])
+ {
+ $this->load();
+
+ if ($widget->playlistId != $this->playlistId) {
+ throw new InvalidArgumentException(__('Cannot delete a Widget that isn\'t assigned to me'), 'playlistId');
+ }
+
+ // Delete
+ $widget->delete($options);
+
+ // Remove the Deleted Widget from our Widgets
+ $this->widgets = array_udiff($this->widgets, [$widget], function($a, $b) {
+ /* @var \Xibo\Entity\Widget $a */
+ /* @var \Xibo\Entity\Widget $b */
+ return $a->widgetId - $b->widgetId;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Load
+ * @param array $loadOptions
+ * @return $this
+ * @throws NotFoundException
+ */
+ public function load($loadOptions = [])
+ {
+ if ($this->playlistId == null || $this->loaded) {
+ return $this;
+ }
+
+ // Options
+ $options = array_merge([
+ 'loadPermissions' => true,
+ 'loadWidgets' => true,
+ 'loadTags' => true,
+ 'loadActions' => true,
+ 'checkDisplayOrder' => false,
+ ], $loadOptions);
+
+ $this->getLog()->debug('Load Playlist with ' . json_encode($options));
+
+ // Load permissions
+ if ($options['loadPermissions']) {
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->playlistId);
+ }
+
+ // Load the widgets
+ if ($options['loadWidgets']) {
+ foreach ($this->widgetFactory->getByPlaylistId($this->playlistId) as $widget) {
+ /* @var Widget $widget */
+ $widget->load($options['loadActions']);
+ $this->widgets[] = $widget;
+ }
+
+ // for dynamic sync task
+ // make sure we have correct displayOrder on all existing Widgets here.
+ if ($this->isDynamic === 1 && $options['checkDisplayOrder']) {
+ // Sort the widgets by their display order
+ usort($this->widgets, function ($a, $b) {
+ /**
+ * @var Widget $a
+ * @var Widget $b
+ */
+ return $a->displayOrder - $b->displayOrder;
+ });
+
+ $i = 0;
+ foreach ($this->widgets as $widget) {
+ /* @var Widget $widget */
+ $i++;
+ // Assert the displayOrder
+ $widget->displayOrder = $i;
+ }
+ }
+ }
+
+ $this->hash = $this->hash();
+ $this->loaded = true;
+
+ return $this;
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function save($options = [])
+ {
+ // Default options
+ $options = array_merge([
+ 'saveTags' => true,
+ 'saveWidgets' => true,
+ 'notify' => true,
+ 'validate' => true,
+ 'auditPlaylist' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ // if we are auditing and editing a regionPlaylist then get layout specific campaignId
+ $campaignId = 0;
+ $layoutId = 0;
+
+ if ($options['auditPlaylist'] && $this->regionId != null) {
+ $sql = 'SELECT campaign.campaignId, layout.layoutId FROM region INNER JOIN layout ON region.layoutId = layout.layoutId INNER JOIN lkcampaignlayout on layout.layoutId = lkcampaignlayout.layoutId INNER JOIN campaign ON campaign.campaignId = lkcampaignlayout.campaignId WHERE campaign.isLayoutSpecific = 1 AND region.regionId = :regionId ;';
+ $params = ['regionId' => $this->regionId];
+ $results = $this->store->select($sql, $params);
+ foreach ($results as $row) {
+ $campaignId = $row['campaignId'];
+ $layoutId = $row['layoutId'];
+ }
+ }
+
+ if ($this->playlistId == null || $this->playlistId == 0) {
+ $this->add();
+ } else if ($this->hash != $this->hash()) {
+ $this->update();
+ } else {
+ // Nothing changed wrt the Playlist itself.
+ $options['auditPlaylist'] = false;
+ }
+
+ // Save the widgets?
+ if ($options['saveWidgets']) {
+ // Sort the widgets by their display order
+ usort($this->widgets, function ($a, $b) {
+ /**
+ * @var Widget $a
+ * @var Widget $b
+ */
+ return $a->displayOrder - $b->displayOrder;
+ });
+
+ // Assert the Playlist on all widgets and apply a display order
+ // this keeps the widgets in numerical order on each playlist
+ $i = 0;
+ foreach ($this->widgets as $widget) {
+ /* @var Widget $widget */
+ $i++;
+
+ // Assert the playlistId
+ $widget->playlistId = $this->playlistId;
+ // Assert the displayOrder
+ $widget->displayOrder = $i;
+ $widget->save($options);
+ }
+ }
+
+ // Save the tags?
+ if ($options['saveTags']) {
+ // Remove unwanted ones
+ if (is_array($this->unlinkTags)) {
+ foreach ($this->unlinkTags as $tag) {
+ $this->unlinkTagFromEntity('lktagplaylist', 'playlistId', $this->playlistId, $tag->tagId);
+ }
+ }
+
+ // Save the tags
+ if (is_array($this->linkTags)) {
+ foreach ($this->linkTags as $tag) {
+ $this->linkTagToEntity('lktagplaylist', 'playlistId', $this->playlistId, $tag->tagId, $tag->value);
+ }
+ }
+ }
+
+ // Audit
+ if ($options['auditPlaylist']) {
+ $change = $this->getChangedProperties();
+
+ // if we are editing a regionPlaylist then add the layout specific campaignId to the audit log.
+ if ($this->regionId != null) {
+ $change['campaignId'][] = $campaignId;
+ $change['layoutId'][] = $layoutId;
+ }
+
+ $this->audit($this->playlistId, 'Saved', $change);
+ }
+ }
+
+ /**
+ * Delete
+ * @param array $options
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function delete($options = [])
+ {
+ $options = array_merge([
+ 'regionDelete' => false
+ ], $options);
+
+ // We must ensure everything is loaded before we delete
+ if (!$this->loaded) {
+ $this->load();
+ }
+
+ if (!$options['regionDelete'] && $this->regionId != 0) {
+ throw new InvalidArgumentException(
+ __('This Playlist belongs to a Region, please delete the Region instead.'),
+ 'regionId'
+ );
+ }
+
+ // dispatch Playlist Delete Event, this will delete any full screen Layout linked to this Playlist
+ if (empty($this->regionId)) {
+ $event = new PlaylistDeleteEvent($this);
+ $this->getDispatcher()->dispatch($event, $event->getName());
+ }
+
+ // Notify we're going to delete
+ // we do this here, because once we've deleted we lose the references for the storage query
+ $this->notifyLayouts();
+
+ // Delete me from any other Playlists using me as a sub-playlist
+ foreach ($this->playlistFactory->query(null, ['childId' => $this->playlistId, 'depth' => 1]) as $parent) {
+ // $parent is a playlist to which we belong.
+ // find out widget and delete it
+ $this->getLog()->debug('This playlist is a sub-playlist in ' . $parent->name . ' we will need to remove it');
+ $parent->load();
+ foreach ($parent->widgets as $widget) {
+ if ($widget->type === 'subplaylist') {
+ $isWidgetSaveRequired = false;
+ // we get an array with all subplaylists assigned to the parent
+ $subPlaylistItems = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
+ $i = 0;
+ foreach ($subPlaylistItems as $subPlaylistItem) {
+ // find the matching playlistId to the playlistId we want to delete
+ if ($subPlaylistItem['playlistId'] == $this->playlistId) {
+ // if there is only one playlistItem in subPlaylists option then remove the widget
+ if (count($subPlaylistItems) === 1) {
+ $widget->delete(['notify' => false]);
+ } else {
+ // if we have more than one subPlaylist item in subPlaylists option,
+ // we want to just unassign our playlist from it and save the widget,
+ // we don't want to remove the whole widget in this case
+ unset($subPlaylistItems[$i]);
+ $isWidgetSaveRequired = true;
+ }
+ }
+ $i++;
+ }
+
+ if ($isWidgetSaveRequired) {
+ // update row numbers for each element
+ // from getAssignedPlaylists the spots/length will come as null/int
+ // make sure spots and spotLength are saved as string if empty
+ $j = 1;
+ foreach ($subPlaylistItems as $subPlaylistItem) {
+ $subPlaylistItem['rowNo'] = $j;
+ $subPlaylistItem['spots'] = $subPlaylistItem['spots'] ?? '';
+ $subPlaylistItem['spotLength'] = $subPlaylistItem['spotLength'] ?? '';
+ $j++;
+ }
+
+ // update subPlaylists Widget option
+ $widget->setOptionValue('subPlaylists', 'attrib', json_encode(array_values($subPlaylistItems)));
+ $widget->save();
+ }
+ }
+ }
+ }
+
+ // We want to remove all link records from the closure table using the parentId
+ $this->getStore()->update('DELETE FROM `lkplaylistplaylist` WHERE parentId = :playlistId', ['playlistId' => $this->playlistId]);
+
+ // Delete my closure table records
+ $this->getStore()->update('DELETE FROM `lkplaylistplaylist` WHERE childId = :playlistId', ['playlistId' => $this->playlistId]);
+
+ // Unassign tags
+ $this->unlinkAllTagsFromEntity('lktagplaylist', 'playlistId', $this->playlistId);
+
+ // Delete Permissions
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->deleteAll();
+ }
+
+ // Delete widgets
+ foreach ($this->widgets as $widget) {
+ /* @var Widget $widget */
+ // Assert the playlistId
+ $widget->playlistId = $this->playlistId;
+ $widget->delete();
+ }
+
+ // Delete this playlist
+ $this->getStore()->update('DELETE FROM `playlist` WHERE playlistId = :playlistId', array('playlistId' => $this->playlistId));
+
+ // Audit
+ $this->audit($this->playlistId, 'Deleted', ['playlistId' => $this->playlistId, 'regionId' => $this->regionId]);
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->getLog()->debug('Adding Playlist ' . $this->name);
+
+ $time = Carbon::now()->format(DateFormatHelper::getSystemFormat());
+
+ $sql = '
+ INSERT INTO `playlist` (`name`, `ownerId`, `regionId`, `isDynamic`, `filterMediaName`, `filterMediaNameLogicalOperator`, `filterMediaTags`, `filterExactTags`, `filterMediaTagsLogicalOperator`, `filterFolderId`, `maxNumberOfItems`, `createdDt`, `modifiedDt`, `requiresDurationUpdate`, `enableStat`, `folderId`, `permissionsFolderId`)
+ VALUES (:name, :ownerId, :regionId, :isDynamic, :filterMediaName, :filterMediaNameLogicalOperator, :filterMediaTags, :filterExactTags, :filterMediaTagsLogicalOperator, :filterFolderId, :maxNumberOfItems, :createdDt, :modifiedDt, :requiresDurationUpdate, :enableStat, :folderId, :permissionsFolderId)
+ ';
+ $this->playlistId = $this->getStore()->insert($sql, array(
+ 'name' => $this->name,
+ 'ownerId' => $this->ownerId,
+ 'regionId' => $this->regionId == 0 ? null : $this->regionId,
+ 'isDynamic' => $this->isDynamic,
+ 'filterMediaName' => $this->filterMediaName,
+ 'filterMediaNameLogicalOperator' => $this->filterMediaNameLogicalOperator ?? 'OR',
+ 'filterMediaTags' => $this->filterMediaTags,
+ 'filterExactTags' => $this->filterExactTags ?? 0,
+ 'filterMediaTagsLogicalOperator' => $this->filterMediaTagsLogicalOperator ?? 'OR',
+ 'filterFolderId' => $this->filterFolderId,
+ 'maxNumberOfItems' => $this->isDynamic == 0 ? null : $this->maxNumberOfItems,
+ 'createdDt' => $time,
+ 'modifiedDt' => $time,
+ 'requiresDurationUpdate' => ($this->requiresDurationUpdate === null) ? 0 : $this->requiresDurationUpdate,
+ 'enableStat' => $this->enableStat,
+ 'folderId' => ($this->folderId == null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this->permissionsFolderId
+ ));
+
+ // Insert my self link
+ $this->getStore()->insert('INSERT INTO `lkplaylistplaylist` (`parentId`, `childId`, `depth`) VALUES (:parentId, :childId, 0)', [
+ 'parentId' => $this->playlistId,
+ 'childId' => $this->playlistId
+ ]);
+ }
+
+ /**
+ * Update
+ */
+ private function update()
+ {
+ $this->getLog()->debug('Updating Playlist ' . $this->name . '. Id = ' . $this->playlistId);
+
+ $sql = '
+ UPDATE `playlist` SET
+ `name` = :name,
+ `ownerId` = :ownerId,
+ `regionId` = :regionId,
+ `modifiedDt` = :modifiedDt,
+ `duration` = :duration,
+ `isDynamic` = :isDynamic,
+ `filterMediaName` = :filterMediaName,
+ `filterMediaNameLogicalOperator` = :filterMediaNameLogicalOperator,
+ `filterMediaTags` = :filterMediaTags,
+ `filterExactTags` = :filterExactTags,
+ `filterMediaTagsLogicalOperator` = :filterMediaTagsLogicalOperator,
+ `filterFolderId` = :filterFolderId,
+ `maxNumberOfItems` = :maxNumberOfItems,
+ `requiresDurationUpdate` = :requiresDurationUpdate,
+ `enableStat` = :enableStat,
+ `folderId` = :folderId,
+ `permissionsFolderId` = :permissionsFolderId
+ WHERE `playlistId` = :playlistId
+ ';
+
+ $this->getStore()->update($sql, array(
+ 'playlistId' => $this->playlistId,
+ 'name' => $this->name,
+ 'ownerId' => $this->ownerId,
+ 'regionId' => $this->regionId == 0 ? null : $this->regionId,
+ 'duration' => $this->duration,
+ 'isDynamic' => $this->isDynamic,
+ 'filterMediaName' => $this->filterMediaName,
+ 'filterMediaNameLogicalOperator' => $this->filterMediaNameLogicalOperator ?? 'OR',
+ 'filterMediaTags' => $this->filterMediaTags,
+ 'filterExactTags' => $this->filterExactTags ?? 0,
+ 'filterMediaTagsLogicalOperator' => $this->filterMediaTagsLogicalOperator ?? 'OR',
+ 'filterFolderId' => $this->filterFolderId,
+ 'maxNumberOfItems' => $this->maxNumberOfItems,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'requiresDurationUpdate' => $this->requiresDurationUpdate,
+ 'enableStat' => $this->enableStat,
+ 'folderId' => ($this->folderId == null) ? 1 : $this->folderId,
+ 'permissionsFolderId' => ($this->permissionsFolderId == null) ? 1 : $this-> permissionsFolderId
+ ));
+ }
+
+ /**
+ * Notify all Layouts of a change to this playlist
+ * This only sets the Layout Status to require a build and to update the layout modified date
+ * once the build is triggered, either from the UI or maintenance it will assess the layout
+ * and call save() if required.
+ * Layout->save() will ultimately notify the interested display groups.
+ */
+ public function notifyLayouts()
+ {
+ // Notify the Playlist
+ $this->getStore()->update('UPDATE `playlist` SET requiresDurationUpdate = 1, `modifiedDT` = :modifiedDt WHERE playlistId = :playlistId', [
+ 'playlistId' => $this->playlistId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+
+ $this->getStore()->update('
+ UPDATE `layout` SET `status` = 3, `modifiedDT` = :modifiedDt WHERE layoutId IN (
+ SELECT `region`.layoutId
+ FROM `lkplaylistplaylist`
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `lkplaylistplaylist`.parentId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ WHERE `lkplaylistplaylist`.childId = :playlistId
+ )
+ ', [
+ 'playlistId' => $this->playlistId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+
+ /**
+ * Expand this Playlists widgets according to any sub-playlists that are present
+ * @param int $parentWidgetId this tracks the top level widgetId
+ * @param bool $expandSubplaylists
+ * @return Widget[]
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function expandWidgets($parentWidgetId = 0, $expandSubplaylists = true)
+ {
+ Profiler::start('Playlist::expandWidgets:' . $this->playlistId, $this->getLog());
+ $this->load();
+
+ $widgets = [];
+
+ // Start with our own Widgets
+ foreach ($this->widgets as $widget) {
+ // some basic checking on whether this widgets date/time are conductive to it being added to the
+ // list. This is really an "expires" check, because we will rely on the player otherwise
+ if ($widget->isExpired()) {
+ continue;
+ }
+
+ // Persist the parentWidgetId in a temporary variable
+ // if we have a parentWidgetId of 0, then we are top-level, and we should use our widgetId
+ $widget->tempId = $parentWidgetId == 0 ? $widget->widgetId : $parentWidgetId;
+
+ // If we're a standard widget, add right away
+ if ($widget->type !== 'subplaylist') {
+ $widgets[] = $widget;
+ } else {
+ if ($expandSubplaylists === true) {
+ $this->getLog()->debug('expandWidgets: processing sub-playlist ' . $widget->widgetId
+ . ', parentWidgetId is ' . $parentWidgetId);
+
+ // Get the sub-playlist widgets
+ $event = new SubPlaylistWidgetsEvent($widget, $widget->tempId);
+ $this->getDispatcher()->dispatch($event, SubPlaylistWidgetsEvent::$NAME);
+ $subPlaylistWidgets = $event->getWidgets();
+ $countSubPlaylistWidgets = count($subPlaylistWidgets);
+
+ // Are we the top level sub-playlist, and do we have cycle playback enabled?
+ if ($parentWidgetId === 0 && $widget->getOptionValue('cyclePlaybackEnabled', 0) === 1) {
+ $this->getLog()->debug('expandWidgets: cyclePlaybackEnabled on ' . $widget->widgetId);
+
+ // Work out the average duration
+ $totalDuration = 0.0;
+ foreach ($subPlaylistWidgets as $subPlaylistWidget) {
+ $totalDuration += $subPlaylistWidget->calculatedDuration;
+ }
+
+ // We split the average across all widgets so that when we add them up again it works out.
+ // Dividing twice is a little confusing
+ // Assume a playlist with 5 items, and an equal 10 seconds per item
+ // That "spot" with cycle playback enabled should take up 10 seconds in total, but the XLF
+ // still contains all 5 items.
+ // averageDuration = 50 / 5 = 10
+ // cycleDuration = 10 / 5 = 2
+ // When our 5 items are added up to make region duration, it will be 2+2+2+2+2=10
+ $averageDuration = $countSubPlaylistWidgets <= 0
+ ? 0
+ : $totalDuration / $countSubPlaylistWidgets;
+ $cycleDuration = $countSubPlaylistWidgets <= 0
+ ? 0
+ : $averageDuration / $countSubPlaylistWidgets;
+
+ $this->getLog()->debug('expandWidgets: cycleDuration is ' . $cycleDuration
+ . ', averageDuration is ' . $averageDuration
+ . ', totalDuration is ' . $totalDuration);
+
+ foreach ($subPlaylistWidgets as $subPlaylistWidget) {
+ $subPlaylistWidget->setUnmatchedProperty(
+ 'tempCyclePlaybackAverageDuration',
+ $cycleDuration
+ );
+ $widgets[] = $subPlaylistWidget;
+ }
+ } else {
+ // Join the sub playlist widgets to the current list we have
+ $widgets = array_merge($widgets, $subPlaylistWidgets);
+ }
+ }
+ }
+ }
+
+ Profiler::end('Playlist::expandWidgets:' . $this->playlistId, $this->getLog());
+ return $widgets;
+ }
+
+ /**
+ * Update Playlist Duration
+ * this is called by the system maintenance task to keep all Playlists durations updated
+ * we should edit this playlist duration (noting the delta) and then find all Playlists of which this is
+ * a sub-playlist and update their durations also (cascade upward)
+ * @return $this
+ * @throws DuplicateEntityException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function updateDuration()
+ {
+ // Update this Playlists Duration - get a SUM of all widget durations
+ $this->load([
+ 'loadPermissions' => false,
+ 'loadWidgets' => true,
+ 'loadTags' => false
+ ]);
+
+ $duration = 0;
+ $removedWidget = false;
+
+ // What is the next time we need to update this Playlist (0 is never)
+ $nextUpdate = 0;
+
+ foreach ($this->widgets as $widget) {
+ // Is this widget expired?
+ if ($widget->isExpired()) {
+ // Remove this widget.
+ if ($widget->getOptionValue('deleteOnExpiry', 0) == 1) {
+ // Don't notify at all because we're going to do that when we finish updating our duration.
+ $widget->delete([
+ 'notify' => false,
+ 'notifyPlaylists' => false,
+ 'forceNotifyPlaylists' => false,
+ 'notifyDisplays' => false
+ ]);
+
+ $removedWidget = true;
+ }
+
+ // Do not assess it
+ continue;
+ }
+
+ // If we're a standard widget, add right away
+ if ($widget->type !== 'subplaylist') {
+ $duration += $widget->calculatedDuration;
+
+ // Does this expire?
+ // Log this as the new next update
+ if ($widget->hasExpiry() && ($nextUpdate == 0 || $nextUpdate > $widget->toDt)) {
+ $nextUpdate = $widget->toDt;
+ }
+ } else {
+ // Add the sub playlist duration
+ $event = new SubPlaylistDurationEvent($widget);
+ $this->getDispatcher()->dispatch($event, SubPlaylistDurationEvent::$NAME);
+ $duration += $event->getDuration();
+ }
+ }
+
+ // Set our "requires duration"
+ $delta = $duration - $this->duration;
+
+ $this->getLog()->debug('Delta duration after updateDuration ' . $delta);
+
+ $this->duration = $duration;
+ $this->requiresDurationUpdate = $nextUpdate;
+
+ $this->save(['saveTags' => false, 'saveWidgets' => false]);
+
+ if ($removedWidget) {
+ $this->notifyLayouts();
+ }
+
+ if ($delta !== 0) {
+ // Use the closure table to update all parent playlists (including this one).
+ $this->getStore()->update('
+ UPDATE `playlist` SET duration = duration + :delta WHERE playlistId IN (
+ SELECT DISTINCT parentId
+ FROM `lkplaylistplaylist`
+ WHERE childId = :playlistId
+ AND parentId <> :playlistId
+ )
+ ', [
+ 'delta' => $delta,
+ 'playlistId' => $this->playlistId
+ ]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Clone the closure table for a new PlaylistId
+ * usually this is used on Draft creation
+ * @param int $newParentId
+ */
+ public function cloneClosureTable($newParentId)
+ {
+ $this->getStore()->update('
+ INSERT INTO `lkplaylistplaylist` (parentId, childId, depth)
+ SELECT :newParentId, childId, depth
+ FROM lkplaylistplaylist
+ WHERE parentId = :parentId AND depth > 0
+ ', [
+ 'newParentId' => $newParentId,
+ 'parentId' => $this->playlistId
+ ]);
+ }
+
+ /**
+ * Recursive function, that goes through all widgets on nested Playlists.
+ *
+ * generates nestedPlaylistDefinitions with Playlist ID as the key - later saved as nestedPlaylist.json on export
+ * generates playlistMappings which contains all relations between playlists (parent/child) -
+ * later saved as playlistMappings.json on export
+ * Adds dataSets data to $dataSets parameter - later saved as dataSet.json on export
+ *
+ * playlistMappings, nestedPLaylistDefinitions, dataSets and dataSetIds are passed by reference.
+ *
+ *
+ * @param $widgets Widget[] An array of widgets assigned to the Playlist
+ * @param $parentId int Playlist Id of the Playlist that is a parent to our current Playlist
+ * @param $playlistMappings array An array of Playlists with ParentId and PlaylistId as keys
+ * @param $count
+ * @param $nestedPlaylistDefinitions array An array of Playlists including widdgets with playlistId as the key
+ * @param $dataSetIds array Array of dataSetIds
+ * @param $dataSets array Array of dataSets with dataSets from widgets on the layout level and nested Playlists
+ * @param $dataSetFactory
+ * @param $includeData bool Flag indicating whether we should include DataSet data in the export
+ * @return mixed
+ * @throws NotFoundException
+ */
+ public function generatePlaylistMapping($widgets, $parentId, &$playlistMappings, &$count, &$nestedPlaylistDefinitions, &$dataSetIds, &$dataSets, $dataSetFactory, $includeData)
+ {
+ foreach ($widgets as $playlistWidget) {
+ if ($playlistWidget->type == 'subplaylist') {
+ $playlistItems = json_decode($playlistWidget->getOptionValue('subPlaylists', '[]'), true);
+ foreach ($playlistItems as $nestedPlaylistItem) {
+ $nestedPlaylist = $this->playlistFactory->getById($nestedPlaylistItem['playlistId']);
+ // include Widgets only for non dynamic Playlists #2392
+ $nestedPlaylist->load(['loadWidgets' => !$nestedPlaylist->isDynamic]);
+ $this->getLog()->debug('playlist mappings parent id ' . $parentId);
+ $nestedPlaylistDefinitions[$nestedPlaylist->playlistId] = $nestedPlaylist;
+
+ $playlistMappings[$parentId][$nestedPlaylist->playlistId] = [
+ 'parentId' => $parentId,
+ 'playlist' => $nestedPlaylist->name,
+ 'playlistId' => $nestedPlaylist->playlistId
+ ];
+
+ $count++;
+
+ // this is a recursive function, we need to go through all levels of nested Playlists.
+ $this->generatePlaylistMapping(
+ $nestedPlaylist->widgets,
+ $nestedPlaylist->playlistId,
+ $playlistMappings,
+ $count,
+ $nestedPlaylistDefinitions,
+ $dataSetIds,
+ $dataSets,
+ $dataSetFactory,
+ $includeData
+ );
+ }
+ }
+ // if we have any widgets that use DataSets we want the dataSetId and data added
+ if ($playlistWidget->type == 'datasetview' || $playlistWidget->type == 'datasetticker' || $playlistWidget->type == 'chart') {
+ $dataSetId = $playlistWidget->getOptionValue('dataSetId', 0);
+ if ($dataSetId != 0) {
+ if (in_array($dataSetId, $dataSetIds)) {
+ continue;
+ }
+ // Export the structure for this dataSet
+ $dataSet = $dataSetFactory->getById($dataSetId);
+ $dataSet->load();
+
+ // Are we also looking to export the data?
+ if ($includeData) {
+ $dataSet->data = $dataSet->getData([], ['includeFormulaColumns' => false]);
+ }
+
+ $dataSetIds[] = $dataSet->dataSetId;
+ $dataSets[] = $dataSet;
+ }
+ }
+ }
+
+ return $playlistMappings;
+ }
+}
diff --git a/lib/Entity/Region.php b/lib/Entity/Region.php
new file mode 100644
index 0000000..162ca5f
--- /dev/null
+++ b/lib/Entity/Region.php
@@ -0,0 +1,654 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+
+use Carbon\Carbon;
+use Xibo\Factory\ActionFactory;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\RegionOptionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Region
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Region implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this region")
+ * @var int
+ */
+ public $regionId;
+
+ /**
+ * @SWG\Property(description="The Layout ID this region belongs to")
+ * @var int
+ */
+ public $layoutId;
+
+ /**
+ * @SWG\Property(description="The userId of the User that owns this Region")
+ * @var int
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="Region Type, zone, playlist, frame or canvas")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The name of this Region")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="Width of the region")
+ * @var double
+ */
+ public $width;
+
+ /**
+ * @SWG\Property(description="Height of the Region")
+ * @var double
+ */
+ public $height;
+
+ /**
+ * @SWG\Property(description="The top coordinate of the Region")
+ * @var double
+ */
+ public $top;
+
+ /**
+ * @SWG\Property(description="The left coordinate of the Region")
+ * @var double
+ */
+ public $left;
+
+ /**
+ * @SWG\Property(description="The z-index of the Region to control Layering")
+ * @var int
+ */
+ public $zIndex;
+
+ /**
+ * @SWG\Property(description="The syncKey of this Region")
+ * @var string
+ */
+ public $syncKey;
+
+ /**
+ * @SWG\Property(description="An array of Region Options")
+ * @var RegionOption[]
+ */
+ public $regionOptions = [];
+
+ /**
+ * @SWG\Property(description="An array of Permissions")
+ * @var Permission[]
+ */
+ public $permissions = [];
+
+ /**
+ * @var int
+ * @SWG\Property(
+ * description="A read-only estimate of this Regions's total duration in seconds. This is valid when the parent layout status is 1 or 2."
+ * )
+ */
+ public $duration;
+
+ /**
+ * @SWG\Property(description="Flag, whether this region is used as an interactive drawer attached to a layout.")
+ * @var int
+ */
+ public $isDrawer = 0;
+
+ /** @var Action[] */
+ public $actions = [];
+
+ /**
+ * Temporary Id used during import/upgrade
+ * @var string read only string
+ */
+ public $tempId = null;
+
+ /**
+ * @var Playlist|null
+ * @SWG\Property(
+ * description="This Regions Playlist - null if getPlaylist() has not been called."
+ * )
+ */
+ public $regionPlaylist = null;
+
+ //
+
+ /**
+ * @var RegionFactory
+ */
+ private $regionFactory;
+
+ /**
+ * @var RegionOptionFactory
+ */
+ private $regionOptionFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var PlaylistFactory
+ */
+ private $playlistFactory;
+
+ /** @var ActionFactory */
+ private $actionFactory;
+
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ //
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param RegionFactory $regionFactory
+ * @param PermissionFactory $permissionFactory
+ * @param RegionOptionFactory $regionOptionFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param ActionFactory $actionFactory
+ */
+ public function __construct($store, $log, $dispatcher, $regionFactory, $permissionFactory, $regionOptionFactory, $playlistFactory, $actionFactory, $campaignFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->regionFactory = $regionFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->regionOptionFactory = $regionOptionFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->actionFactory = $actionFactory;
+ $this->campaignFactory = $campaignFactory;
+ }
+
+ /**
+ * Clone this object
+ */
+ public function __clone()
+ {
+ // Clear the regionId, clone the Playlist
+ $this->regionId = null;
+ $this->hash = null;
+ $this->permissions = [];
+
+ $this->regionPlaylist = clone $this->regionPlaylist;
+
+ $this->regionOptions = array_map(function ($object) { return clone $object; }, $this->regionOptions);
+ // Clone actions
+ $this->actions = array_map(function ($object) { return clone $object; }, $this->actions);
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('Region %s - %d x %d (%d, %d). RegionId = %d, LayoutId = %d. OwnerId = %d. Duration = %d', $this->name, $this->width, $this->height, $this->top, $this->left, $this->regionId, $this->layoutId, $this->ownerId, $this->duration);
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->getPlaylist()->permissionsFolderId;
+ }
+
+ /**
+ * @return string
+ */
+ private function hash()
+ {
+ return md5($this->name
+ . $this->type
+ . $this->ownerId
+ . $this->width
+ . $this->height
+ . $this->top
+ . $this->left
+ . $this->regionId
+ . $this->zIndex
+ . $this->duration
+ . $this->syncKey
+ . json_encode($this->actions));
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->regionId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ * @param bool $cascade Cascade ownership change down to Playlist records
+ * @throws GeneralException
+ */
+ public function setOwner($ownerId, $cascade = false)
+ {
+ $this->load();
+
+ $this->ownerId = $ownerId;
+
+ if ($cascade) {
+ $playlist = $this->getPlaylist();
+ $playlist->setOwner($ownerId);
+ }
+ }
+
+ /**
+ * Get Option
+ * @param string $option
+ * @return RegionOption
+ * @throws GeneralException
+ */
+ public function getOption($option)
+ {
+ $this->load();
+
+ foreach ($this->regionOptions as $regionOption) {
+ /* @var RegionOption $regionOption */
+ if ($regionOption->option == $option)
+ return $regionOption;
+ }
+
+ $this->getLog()->debug('RegionOption ' . $option . ' not found');
+
+ throw new NotFoundException(__('Region Option not found'));
+ }
+
+ /**
+ * Get Region Option Value
+ * @param string $option
+ * @param mixed $default
+ * @return mixed
+ * @throws GeneralException
+ */
+ public function getOptionValue($option, $default = null)
+ {
+ $this->load();
+
+ try {
+ $regionOption = $this->getOption($option);
+ return $regionOption->value;
+ }
+ catch (NotFoundException $e) {
+ return $default;
+ }
+ }
+
+ /**
+ * Set Region Option Value
+ * @param string $option
+ * @param mixed $value
+ * @throws GeneralException
+ */
+ public function setOptionValue($option, $value)
+ {
+ try {
+ $this->getOption($option)->value = $value;
+ }
+ catch (NotFoundException $e) {
+ $this->regionOptions[] = $this->regionOptionFactory->create($this->regionId, $option, $value);
+ }
+ }
+
+ /**
+ * @param array $options
+ * @return Playlist
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getPlaylist($options = [])
+ {
+ if ($this->regionPlaylist === null) {
+ try {
+ $this->regionPlaylist = $this->playlistFactory->getByRegionId($this->regionId)->load($options);
+ } catch (NotFoundException $exception) {
+ $this->regionPlaylist = $this->playlistFactory->create($this->name, $this->ownerId, $this->regionId);
+ $this->regionPlaylist->save();
+ }
+ }
+
+ return $this->regionPlaylist;
+ }
+
+ /**
+ * Load
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ if ($this->loaded || $this->regionId == 0) {
+ return;
+ }
+
+ $options = array_merge([
+ 'loadPlaylists' => false,
+ 'loadActions' => true
+ ], $options);
+
+ $this->getLog()->debug('Load Region with ' . json_encode($options));
+
+ // Load permissions
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->regionId);
+
+ // Get region options
+ $this->regionOptions = $this->regionOptionFactory->getByRegionId($this->regionId);
+
+ // Get Region Actions?
+ if ($options['loadActions']) {
+ $this->actions = $this->actionFactory->getBySourceAndSourceId('region', $this->regionId);
+ }
+
+ // Load the Playlist?
+ if ($options['loadPlaylists']) {
+ $this->getPlaylist($options);
+ }
+
+ $this->hash = $this->hash();
+ $this->loaded = true;
+ }
+
+ /**
+ * Validate the region
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if ($this->width <= 0 || $this->height <= 0) {
+ throw new InvalidArgumentException(__('The Region dimensions cannot be empty or negative'), 'width/height');
+ }
+
+ // Check zindex is positive
+ if ($this->zIndex < 0) {
+ throw new InvalidArgumentException(__('Layer must be 0 or a positive number'), 'zIndex');
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'saveRegionOptions' => true,
+ 'validate' => true,
+ 'audit' => true,
+ 'notify' => true
+ ], $options);
+
+ $this->getLog()->debug('Saving ' . $this . '. Options = ' . json_encode($options, JSON_PRETTY_PRINT));
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($options['audit']) {
+ // get the layout specific campaignId
+ $campaignId = $this->campaignFactory->getCampaignIdForLayout($this->layoutId);
+ }
+
+ if ($this->regionId == null || $this->regionId == 0) {
+ // We are adding
+ $this->add();
+
+ // Add and save a region specific playlist
+ if ($this->regionPlaylist === null) {
+ $this->regionPlaylist = $this->playlistFactory->create($this->name, $this->ownerId, $this->regionId);
+ } else {
+ // assert the region id
+ $this->regionPlaylist->regionId = $this->regionId;
+ $this->regionPlaylist->setOwner($this->ownerId);
+ }
+
+ // TODO: this is strange, campaignId will only be set if we are configured to Audit.
+ if (isset($campaignId)) {
+ $campaign = $this->campaignFactory->getById($campaignId);
+ $this->regionPlaylist->folderId = $campaign->folderId;
+ $this->regionPlaylist->permissionsFolderId = $campaign->permissionsFolderId;
+ }
+
+ $this->regionPlaylist->save($options);
+
+ // Audit
+ if ($options['audit']) {
+ $this->audit(
+ $this->regionId,
+ 'Added',
+ [
+ 'regionId' => $this->regionId,
+ 'campaignId' => $campaignId,
+ 'details' => (string)$this,
+ ]
+ );
+ }
+ } else if ($this->hash != $this->hash()) {
+ $this->update();
+
+ // There are 3 cases that we need to consider
+ // 1 - Saving direct edit of region properties, $this->regionPlaylist will be null, as such we load it from database.
+ // 2 - Saving whole Layout without changing ownership, $this->regionPlaylist will be populated including widgets property, we do not need to save widgets, load from database,
+ // 3 - Saving whole Layout and changing the ownership (reassignAll or setOwner on Layout), in this case, we need to save widgets to cascade the ownerId change, don't load from database
+ // case 3 due to - https://github.com/xibosignage/xibo/issues/2061
+ $regionPlaylist = $this->playlistFactory->getByRegionId($this->regionId);
+
+ if ($this->regionPlaylist == null || $this->ownerId == $regionPlaylist->ownerId) {
+ $this->regionPlaylist = $regionPlaylist;
+ }
+
+ $this->regionPlaylist->name = $this->name;
+
+ if (isset($campaignId)) {
+ $campaign = $this->campaignFactory->getById($campaignId);
+ $this->regionPlaylist->folderId = $campaign->folderId;
+ $this->regionPlaylist->permissionsFolderId = $campaign->permissionsFolderId;
+ }
+
+ $this->regionPlaylist->save($options);
+
+ if ($options['audit'] && count($this->getChangedProperties()) > 0) {
+ $change = $this->getChangedProperties();
+ $change['campaignId'][] = $campaignId;
+ $this->audit($this->regionId, 'Saved', $change);
+ }
+ }
+
+ if ($options['saveRegionOptions']) {
+ // Save all Options
+ foreach ($this->regionOptions as $regionOption) {
+ /* @var RegionOption $regionOption */
+ // Assert the regionId
+ $regionOption->regionId = $this->regionId;
+ $regionOption->save();
+ }
+ }
+ }
+
+ /**
+ * Delete Region
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function delete($options = [])
+ {
+ $options = array_merge([
+ 'notify' => true
+ ], $options);
+
+ // We must ensure everything is loaded before we delete
+ if ($this->hash == null) {
+ $this->load();
+ }
+
+ $this->getLog()->debug('Deleting ' . $this);
+
+ // Delete Permissions
+ foreach ($this->permissions as $permission) {
+ /* @var Permission $permission */
+ $permission->deleteAll();
+ }
+
+ // Delete all region options
+ foreach ($this->regionOptions as $regionOption) {
+ /* @var RegionOption $regionOption */
+ $regionOption->delete();
+ }
+
+ foreach ($this->actions as $action) {
+ $action->delete();
+ }
+
+ // Delete any actions that had this Region id as targetId, to avoid orphaned records in action table.
+ $this->getStore()->update('DELETE FROM `action` WHERE targetId = :targetId', ['targetId' => $this->regionId]);
+
+ // Delete the region specific playlist
+ $this->getPlaylist()->delete(['regionDelete' => true]);
+
+ // Delete this region
+ $this->getStore()->update('DELETE FROM `region` WHERE regionId = :regionId', array('regionId' => $this->regionId));
+
+ $this->getLog()->audit('Region', $this->regionId, 'Region Deleted', ['regionId' => $this->regionId, 'layoutId' => $this->layoutId]);
+
+ // Notify Layout
+ if ($options['notify'])
+ $this->notifyLayout();
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->getLog()->debug('Adding region to LayoutId ' . $this->layoutId);
+
+ $sql = '
+ INSERT INTO `region` (`layoutId`, `ownerId`, `name`, `width`, `height`, `top`, `left`, `zIndex`, `isDrawer`, `type`, `syncKey`)
+ VALUES (:layoutId, :ownerId, :name, :width, :height, :top, :left, :zIndex, :isDrawer, :type, :syncKey)
+ ';
+
+ $this->regionId = $this->getStore()->insert($sql, array(
+ 'layoutId' => $this->layoutId,
+ 'ownerId' => $this->ownerId,
+ 'name' => $this->name,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'top' => $this->top,
+ 'left' => $this->left,
+ 'zIndex' => $this->zIndex,
+ 'isDrawer' => $this->isDrawer,
+ 'type' => $this->type,
+ 'syncKey' => $this->syncKey
+ ));
+ }
+
+ /**
+ * Update
+ */
+ private function update()
+ {
+ $this->getLog()->debug('Editing ' . $this);
+
+ $sql = '
+ UPDATE `region` SET
+ `ownerId` = :ownerId,
+ `name` = :name,
+ `width` = :width,
+ `height` = :height,
+ `top` = :top,
+ `left` = :left,
+ `zIndex` = :zIndex,
+ `duration` = :duration,
+ `isDrawer` = :isDrawer,
+ `type` = :type,
+ `syncKey` = :syncKey
+ WHERE `regionId` = :regionId
+ ';
+
+ $this->getStore()->update($sql, array(
+ 'ownerId' => $this->ownerId,
+ 'name' => $this->name,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'top' => $this->top,
+ 'left' => $this->left,
+ 'zIndex' => $this->zIndex,
+ 'duration' => $this->duration,
+ 'isDrawer' => $this->isDrawer,
+ 'type' => $this->type,
+ 'syncKey' => $this->syncKey,
+ 'regionId' => $this->regionId
+ ));
+ }
+
+ /**
+ * Notify the Layout (set to building)
+ */
+ public function notifyLayout()
+ {
+ $this->getStore()->update('
+ UPDATE `layout` SET `status` = 3, `modifiedDT` = :modifiedDt WHERE layoutId = :layoutId
+ ', [
+ 'layoutId' => $this->layoutId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+}
diff --git a/lib/Entity/RegionOption.php b/lib/Entity/RegionOption.php
new file mode 100644
index 0000000..6d1b652
--- /dev/null
+++ b/lib/Entity/RegionOption.php
@@ -0,0 +1,91 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class RegionOption
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class RegionOption implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The regionId that this Option applies to")
+ * @var int
+ */
+ public $regionId;
+
+ /**
+ * @SWG\Property(description="The option name")
+ * @var string
+ */
+ public $option;
+
+ /**
+ * @SWG\Property(description="The option value")
+ * @var string
+ */
+ public $value;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Clone
+ */
+ public function __clone()
+ {
+ $this->regionId = null;
+ }
+
+ public function save()
+ {
+ $sql = 'INSERT INTO `regionoption` (`regionId`, `option`, `value`) VALUES (:regionId, :option, :value) ON DUPLICATE KEY UPDATE `value` = :value2';
+ $this->getStore()->insert($sql, array(
+ 'regionId' => $this->regionId,
+ 'option' => $this->option,
+ 'value' => $this->value,
+ 'value2' => $this->value,
+ ));
+ }
+
+ public function delete()
+ {
+ $sql = 'DELETE FROM `regionoption` WHERE `regionId` = :regionId AND `option` = :option';
+ $this->getStore()->update($sql, array('regionId' => $this->regionId, 'option' => $this->option));
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/ReportForm.php b/lib/Entity/ReportForm.php
new file mode 100644
index 0000000..d85e1e1
--- /dev/null
+++ b/lib/Entity/ReportForm.php
@@ -0,0 +1,85 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+/**
+* Class ReportForm
+* @package Xibo\Entity
+*
+*/
+class ReportForm
+{
+ /**
+ * On demand report form template
+ * @var string
+ */
+ public $template;
+
+ /**
+ * Report name is the string that is defined in .report file
+ * @var string
+ */
+ public $reportName;
+
+ /**
+ * Report category is the string that is defined in .report file
+ * @var string|null
+ */
+ public $reportCategory;
+
+ /**
+ * The defaults that is used in report form twig file
+ * @var array
+ */
+ public $defaults;
+
+ /**
+ * The string that is displayed when we popover the Schedule button
+ * @var string
+ */
+ public $reportAddBtnTitle;
+
+ /**
+ * ReportForm constructor.
+ * @param string $template
+ * @param string $reportName
+ * @param string $reportCategory
+ * @param array $defaults
+ * @param string|null $reportAddBtnTitle
+ */
+ public function __construct(
+ string $template,
+ string $reportName,
+ string $reportCategory,
+ array $defaults = [],
+ string $reportAddBtnTitle = 'Schedule'
+ ) {
+ $this->template = $template;
+ $this->reportName = $reportName;
+ $this->reportCategory = $reportCategory;
+ $this->defaults = $defaults;
+ $this->reportAddBtnTitle = $reportAddBtnTitle;
+
+ return $this;
+ }
+}
diff --git a/lib/Entity/ReportResult.php b/lib/Entity/ReportResult.php
new file mode 100644
index 0000000..8c09012
--- /dev/null
+++ b/lib/Entity/ReportResult.php
@@ -0,0 +1,111 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+/**
+ * Class ReportResult
+ * @package Xibo\Entity
+ *
+ */
+class ReportResult implements \JsonSerializable
+{
+ /**
+ * Number of total records
+ * @var int
+ */
+ public $recordsTotal;
+
+ /**
+ * Chart data points
+ * @var array|null
+ */
+ public $chart;
+
+ /**
+ * Error message
+ * @var null|string
+ */
+ public $error;
+
+ /**
+ * Metadata that is used in the report preview or in the email template
+ * @var array
+ */
+ public $metadata;
+
+ /**
+ * Datatable Records
+ * @var array
+ */
+ public $table;
+
+ /**
+ * ReportResult constructor.
+ * @param array $metadata
+ * @param array $table
+ * @param int $recordsTotal
+ * @param array $chart
+ * @param null|string $error
+ */
+ public function __construct(
+ array $metadata = [],
+ array $table = [],
+ int $recordsTotal = 0,
+ array $chart = [],
+ ?string $error = null
+ ) {
+ $this->metadata = $metadata;
+ $this->table = $table;
+ $this->recordsTotal = $recordsTotal;
+ $this->chart = $chart;
+ $this->error = $error;
+
+ return $this;
+ }
+
+ public function getMetaData(): array
+ {
+ return $this->metadata;
+ }
+
+ public function getRows(): array
+ {
+ return $this->table;
+ }
+
+ public function countLast(): int
+ {
+ return $this->recordsTotal;
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'metadata' => $this->metadata,
+ 'table' => $this->table,
+ 'recordsTotal' => $this->recordsTotal,
+ 'chart' => $this->chart,
+ 'error' => $this->error
+ ];
+ }
+}
diff --git a/lib/Entity/ReportSchedule.php b/lib/Entity/ReportSchedule.php
new file mode 100644
index 0000000..3be5be5
--- /dev/null
+++ b/lib/Entity/ReportSchedule.php
@@ -0,0 +1,211 @@
+.
+ */
+
+namespace Xibo\Entity;
+use Respect\Validation\Validator as v;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class ReportSchedule
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class ReportSchedule implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public static $SCHEDULE_DAILY = '0 0 * * *';
+ public static $SCHEDULE_WEEKLY = '0 0 * * 1';
+ public static $SCHEDULE_MONTHLY = '0 0 1 * *';
+ public static $SCHEDULE_YEARLY = '0 0 1 1 *';
+
+ public $reportScheduleId;
+ public $lastSavedReportId;
+ public $name;
+ public $reportName;
+ public $filterCriteria;
+ public $schedule;
+ public $lastRunDt = 0;
+ public $previousRunDt;
+ public $createdDt;
+ public $isActive = 1;
+ public $fromDt = 0;
+ public $toDt = 0;
+
+ public $message;
+
+ /**
+ * @SWG\Property(description="The username of the User that owns this report schedule")
+ * @var string
+ */
+ public $owner;
+
+ /**
+ * @SWG\Property(description="The ID of the User that owns this report schedule")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * Command constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true
+ ], $options);
+
+ if ($options['validate'])
+ $this->validate();
+
+ if ($this->reportScheduleId == null) {
+ $this->add();
+ $this->getLog()->debug('Adding report schedule');
+ }
+ else
+ {
+ $this->edit();
+ $this->getLog()->debug('Editing a report schedule');
+ }
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->name))
+ throw new InvalidArgumentException(__('Missing name'), 'name');
+ }
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `reportschedule` WHERE `reportScheduleId` = :reportScheduleId', ['reportScheduleId' => $this->reportScheduleId]);
+ }
+
+ private function add()
+ {
+ $this->reportScheduleId = $this->getStore()->insert('
+ INSERT INTO `reportschedule` (`name`, `lastSavedReportId`, `reportName`, `schedule`, `lastRunDt`, `previousRunDt`, `filterCriteria`, `userId`, `isActive`, `fromDt`, `toDt`, `message`, `createdDt`) VALUES
+ (:name, :lastSavedReportId, :reportName, :schedule, :lastRunDt, :previousRunDt, :filterCriteria, :userId, :isActive, :fromDt, :toDt, :message, :createdDt)
+ ', [
+ 'name' => $this->name,
+ 'lastSavedReportId' => $this->lastSavedReportId,
+ 'reportName' => $this->reportName,
+ 'schedule' => $this->schedule,
+ 'lastRunDt' => $this->lastRunDt,
+ 'previousRunDt' => $this->previousRunDt,
+ 'filterCriteria' => $this->filterCriteria,
+ 'userId' => $this->userId,
+ 'isActive' => $this->isActive,
+ 'fromDt' => $this->fromDt,
+ 'toDt' => $this->toDt,
+ 'message' => $this->message,
+ 'createdDt' => $this->createdDt,
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `reportschedule`
+ SET `name` = :name,
+ `lastSavedReportId` = :lastSavedReportId,
+ `reportName` = :reportName,
+ `schedule` = :schedule,
+ `lastRunDt` = :lastRunDt,
+ `previousRunDt` = :previousRunDt,
+ `filterCriteria` = :filterCriteria,
+ `userId` = :userId,
+ `isActive` = :isActive,
+ `fromDt` = :fromDt,
+ `toDt` = :toDt,
+ `message` = :message,
+ `createdDt` = :createdDt
+ WHERE reportScheduleId = :reportScheduleId', [
+ 'reportScheduleId' => $this->reportScheduleId,
+ 'lastSavedReportId' => $this->lastSavedReportId,
+ 'name' => $this->name,
+ 'reportName' => $this->reportName,
+ 'schedule' => $this->schedule,
+ 'lastRunDt' => $this->lastRunDt,
+ 'previousRunDt' => $this->previousRunDt,
+ 'filterCriteria' => $this->filterCriteria,
+ 'userId' => $this->userId,
+ 'isActive' => $this->isActive,
+ 'fromDt' => $this->fromDt,
+ 'toDt' => $this->toDt,
+ 'message' => $this->message,
+ 'createdDt' => $this->createdDt
+ ]);
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->reportScheduleId;
+ }
+
+ /**
+ * Get Owner Id
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Returns the last saved report id
+ * @return integer
+ */
+ public function getLastSavedReportId()
+ {
+ return $this->lastSavedReportId;
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/RequiredFile.php b/lib/Entity/RequiredFile.php
new file mode 100644
index 0000000..7cf28ec
--- /dev/null
+++ b/lib/Entity/RequiredFile.php
@@ -0,0 +1,127 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DeadlockException;
+
+/**
+ * Class RequiredFile
+ * @package Xibo\Entity
+ */
+class RequiredFile implements \JsonSerializable
+{
+ public static $TYPE_DEPENDENCY = 'P';
+ public static $TYPE_LAYOUT = 'L';
+ public static $TYPE_MEDIA = 'M';
+ public static $TYPE_WIDGET_DATA = 'D';
+
+ use EntityTrait;
+ public $rfId;
+ public $displayId;
+ public $type;
+ public $itemId;
+ public $size = 0;
+ public $path;
+ public $bytesRequested = 0;
+ public $complete = 0;
+ public $released = 1;
+ public $fileType;
+
+ /** @var string The realId of a dependency which we will use to resolve it */
+ public $realId;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Save
+ * @return $this
+ */
+ public function save($options = [])
+ {
+ if ($this->rfId == null) {
+ $this->add();
+ } else if ($this->hasPropertyChanged('bytesRequested') || $this->hasPropertyChanged('complete')) {
+ $this->edit($options);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->rfId = $this->store->insert('
+ INSERT INTO `requiredfile` (`displayId`, `type`, `itemId`, `bytesRequested`, `complete`, `size`, `path`, `released`, `fileType`, `realId`)
+ VALUES (:displayId, :type, :itemId, :bytesRequested, :complete, :size, :path, :released, :fileType, :realId)
+ ', [
+ 'displayId' => $this->displayId,
+ 'type' => $this->type,
+ 'itemId' => $this->itemId,
+ 'bytesRequested' => $this->bytesRequested,
+ 'complete' => $this->complete,
+ 'size' => $this->size,
+ 'path' => $this->path,
+ 'released' => $this->released,
+ 'fileType' => $this->fileType,
+ 'realId' => $this->realId,
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit($options)
+ {
+ $options = array_merge([
+ 'connection' => 'default',
+ 'useTransaction' => true,
+ ], $options);
+
+ try {
+ $this->store->updateWithDeadlockLoop('
+ UPDATE `requiredfile` SET complete = :complete, bytesRequested = :bytesRequested
+ WHERE rfId = :rfId
+ ', [
+ 'rfId' => $this->rfId,
+ 'bytesRequested' => $this->bytesRequested,
+ 'complete' => $this->complete
+ ], $options['connection'], $options['useTransaction']);
+ } catch (DeadlockException $deadlockException) {
+ $this->getLog()->error('Failed to update bytes requested on ' . $this->rfId . ' due to deadlock');
+ }
+ }
+}
diff --git a/lib/Entity/Resolution.php b/lib/Entity/Resolution.php
new file mode 100644
index 0000000..6fa28e0
--- /dev/null
+++ b/lib/Entity/Resolution.php
@@ -0,0 +1,206 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Respect\Validation\Validator as v;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class Resolution
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Resolution implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this Resolution")
+ * @var int
+ */
+ public $resolutionId;
+
+ /**
+ * @SWG\Property(description="The resolution name")
+ * @var string
+ */
+ public $resolution;
+
+ /**
+ * @SWG\Property(description="The display width of the resolution")
+ * @var double
+ */
+ public $width;
+
+ /**
+ * @SWG\Property(description="The display height of the resolution")
+ * @var double
+ */
+ public $height;
+
+ /**
+ * @SWG\Property(description="The designer width of the resolution")
+ * @var double
+ */
+ public $designerWidth;
+
+ /**
+ * @SWG\Property(description="The designer height of the resolution")
+ * @var double
+ */
+ public $designerHeight;
+
+ /**
+ * @SWG\Property(description="The layout schema version")
+ * @var int
+ */
+ public $version = 2;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this resolution is enabled or not")
+ * @var int
+ */
+ public $enabled = 1;
+
+ /**
+ * @SWG\Property(description="The userId who owns this Resolution")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->resolutionId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ // No owner
+ return $this->userId;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ if (!v::stringType()->notEmpty()->validate($this->resolution)) {
+ throw new InvalidArgumentException(__('Please provide a name'), 'name');
+ }
+
+ if (!v::intType()->notEmpty()->min(1)->validate($this->width)) {
+ throw new InvalidArgumentException(__('Please provide a width'), 'width');
+ }
+
+ if (!v::intType()->notEmpty()->min(1)->validate($this->height)) {
+ throw new InvalidArgumentException(__('Please provide a height'), 'height');
+ }
+
+ // Set the designer width and height
+ $factor = min (800 / $this->width, 800 / $this->height);
+
+ $this->designerWidth = round($this->width * $factor);
+ $this->designerHeight = round($this->height * $factor);
+ }
+
+ /**
+ * Save
+ * @param bool|true $validate
+ * @throws InvalidArgumentException
+ */
+ public function save($validate = true)
+ {
+ if ($validate)
+ $this->validate();
+
+ if ($this->resolutionId == null || $this->resolutionId == 0)
+ $this->add();
+ else
+ $this->edit();
+
+ $this->getLog()->audit('Resolution', $this->resolutionId, 'Saving', $this->getChangedProperties());
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM resolution WHERE resolutionID = :resolutionId', ['resolutionId' => $this->resolutionId]);
+ }
+
+ private function add()
+ {
+ $this->resolutionId = $this->getStore()->insert('
+ INSERT INTO `resolution` (resolution, width, height, intended_width, intended_height, version, enabled, `userId`)
+ VALUES (:resolution, :width, :height, :intended_width, :intended_height, :version, :enabled, :userId)
+ ', [
+ 'resolution' => $this->resolution,
+ 'width' => $this->designerWidth,
+ 'height' => $this->designerHeight,
+ 'intended_width' => $this->width,
+ 'intended_height' => $this->height,
+ 'version' => $this->version,
+ 'enabled' => $this->enabled,
+ 'userId' => $this->userId
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE resolution SET resolution = :resolution,
+ width = :width,
+ height = :height,
+ intended_width = :intended_width,
+ intended_height = :intended_height,
+ enabled = :enabled
+ WHERE resolutionID = :resolutionId
+ ', [
+ 'resolutionId' => $this->resolutionId,
+ 'resolution' => $this->resolution,
+ 'width' => $this->designerWidth,
+ 'height' => $this->designerHeight,
+ 'intended_width' => $this->width,
+ 'intended_height' => $this->height,
+ 'enabled' => $this->enabled
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/SavedReport.php b/lib/Entity/SavedReport.php
new file mode 100644
index 0000000..7f92cb9
--- /dev/null
+++ b/lib/Entity/SavedReport.php
@@ -0,0 +1,275 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+* Class SavedReport
+* @package Xibo\Entity
+*
+* @SWG\Definition()
+*/
+class SavedReport implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="Saved report ID")
+ * @var int
+ */
+ public $savedReportId;
+
+ /**
+ * @SWG\Property(description="Saved report name As")
+ * @var string
+ */
+ public $saveAs;
+
+ /**
+ * @SWG\Property(description="Report schedule Id of the saved report")
+ * @var int
+ */
+ public $reportScheduleId;
+
+ /**
+ * @SWG\Property(description="Report schedule name of the saved report")
+ * @var string
+ */
+ public $reportScheduleName;
+
+ /**
+ * @SWG\Property(description="Report name")
+ * @var string
+ */
+ public $reportName;
+
+ /**
+ * @SWG\Property(description="Saved report generated on")
+ * @var string
+ */
+ public $generatedOn;
+
+ /**
+ * @SWG\Property(description="The username of the User that owns this saved report")
+ * @var string
+ */
+ public $owner;
+
+ /**
+ * @SWG\Property(description="The ID of the User that owns this saved report")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="Original name of the saved report media file")
+ * @var string
+ */
+ public $originalFileName;
+
+ /**
+ * @SWG\Property(description="Stored As")
+ * @var string
+ */
+ public $storedAs;
+
+ /**
+ * @SWG\Property(description="Schema Version")
+ * @var int
+ */
+ public $schemaVersion = 2;
+
+ /**
+ * @SWG\Property(description="The Saved Report file name")
+ * @var string
+ */
+ public $fileName;
+
+ /**
+ * @SWG\Property(description="The Saved Report file size in bytes")
+ * @var int
+ */
+ public $size;
+
+ /**
+ * @SWG\Property(description="A MD5 checksum of the stored Saved Report file")
+ * @var string
+ */
+ public $md5;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param MediaFactory $mediaFactory
+ * @param SavedReportFactory $savedReportFactory
+ */
+ public function __construct($store, $log, $dispatcher, $config, $mediaFactory, $savedReportFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->config = $config;
+ $this->mediaFactory = $mediaFactory;
+ $this->savedReportFactory = $savedReportFactory;
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->savedReportId = $this->getStore()->insert('
+ INSERT INTO `saved_report` (`saveAs`, `reportScheduleId`, `generatedOn`, `userId`, `schemaVersion`, `fileName`, `size`, `md5`)
+ VALUES (:saveAs, :reportScheduleId, :generatedOn, :userId, :schemaVersion, :fileName, :size, :md5)
+ ', [
+ 'saveAs' => $this->saveAs,
+ 'reportScheduleId' => $this->reportScheduleId,
+ 'generatedOn' => $this->generatedOn,
+ 'userId' => $this->userId,
+ 'schemaVersion' => $this->schemaVersion,
+ 'fileName' => $this->fileName,
+ 'size' => $this->size,
+ 'md5' => $this->md5
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $sql = '
+ UPDATE `saved_report`
+ SET `saveAs` = :saveAs,
+ `reportScheduleId` = :reportScheduleId,
+ `generatedOn` = :generatedOn,
+ `userId` = :userId,
+ `schemaVersion` = :schemaVersion
+ WHERE savedReportId = :savedReportId
+ ';
+
+ $params = [
+ 'saveAs' => $this->saveAs,
+ 'reportScheduleId' => $this->reportScheduleId,
+ 'generatedOn' => $this->generatedOn,
+ 'userId' => $this->userId,
+ 'schemaVersion' => $this->schemaVersion,
+ 'savedReportId' => $this->savedReportId,
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->load();
+
+ $this->getLog()->debug('Delete saved report: '.$this->saveAs.'. Generated on: '.$this->generatedOn);
+ $this->getStore()->update('DELETE FROM `saved_report` WHERE `savedReportId` = :savedReportId', [
+ 'savedReportId' => $this->savedReportId
+ ]);
+
+ // Update last saved report in report schedule
+ $this->getLog()->debug('Update last saved report in report schedule');
+ $this->getStore()->update('
+ UPDATE `reportschedule` SET lastSavedReportId = ( SELECT IFNULL(MAX(`savedReportId`), 0) FROM `saved_report` WHERE `reportScheduleId`= :reportScheduleId)
+ WHERE `reportScheduleId` = :reportScheduleId',
+ [
+ 'reportScheduleId' => $this->reportScheduleId
+ ]);
+
+ // Library location
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+
+ // delete file
+ if (file_exists($libraryLocation . 'savedreport/'. $this->fileName)) {
+ unlink($libraryLocation . 'savedreport/'. $this->fileName);
+ }
+ }
+
+ /**
+ * Load
+ */
+ public function load()
+ {
+ if ($this->loaded || $this->savedReportId == null)
+ return;
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->savedReportId;
+ }
+
+ /**
+ * Get Owner Id
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Save
+ */
+ public function save()
+ {
+ if ($this->savedReportId == null || $this->savedReportId == 0)
+ $this->add();
+ else
+ $this->edit();
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/Schedule.php b/lib/Entity/Schedule.php
new file mode 100644
index 0000000..f4d9208
--- /dev/null
+++ b/lib/Entity/Schedule.php
@@ -0,0 +1,2190 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\ScheduleCriteriaFactory;
+use Xibo\Factory\ScheduleExclusionFactory;
+use Xibo\Factory\ScheduleReminderFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Translate;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class Schedule
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Schedule implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public static $LAYOUT_EVENT = 1;
+ public static $COMMAND_EVENT = 2;
+ public static $OVERLAY_EVENT = 3;
+ public static $INTERRUPT_EVENT = 4;
+ public static $CAMPAIGN_EVENT = 5;
+ public static $ACTION_EVENT = 6;
+
+ public static $MEDIA_EVENT = 7;
+ public static $PLAYLIST_EVENT = 8;
+ public static $SYNC_EVENT = 9;
+ public static $DATA_CONNECTOR_EVENT = 10;
+ public static $DATE_MIN = 0;
+ public static $DATE_MAX = 2147483647;
+
+ /**
+ * @SWG\Property(
+ * description="The ID of this Event"
+ * )
+ * @var int
+ */
+ public $eventId;
+
+ /**
+ * @SWG\Property(
+ * description="The Event Type ID"
+ * )
+ * @var int
+ */
+ public $eventTypeId;
+
+ /**
+ * @SWG\Property(
+ * description="The CampaignID this event is for"
+ * )
+ * @var int
+ */
+ public $campaignId;
+
+ /**
+ * @SWG\Property(
+ * description="The CommandId this event is for"
+ * )
+ * @var int
+ */
+ public $commandId;
+
+ /**
+ * @SWG\Property(
+ * description="Display Groups assigned to this Scheduled Event.",
+ * type="array",
+ * @SWG\Items(ref="#/definitions/DisplayGroup")
+ * )
+ * @var DisplayGroup[]
+ */
+ public $displayGroups = [];
+
+ /**
+ * @SWG\Property(
+ * description="Schedule Reminders assigned to this Scheduled Event.",
+ * type="array",
+ * @SWG\Items(ref="#/definitions/ScheduleReminder")
+ * )
+ * @var ScheduleReminder[]
+ */
+ public $scheduleReminders = [];
+
+ /**
+ * @SWG\Property(
+ * description="Schedule Criteria assigned to this Scheduled Event.",
+ * type="array",
+ * @SWG\Items(ref="#/definitions/ScheduleCriteria")
+ * )
+ * @var ScheduleCriteria[]
+ */
+ public $criteria = [];
+
+ /**
+ * @SWG\Property(
+ * description="The userId that owns this event."
+ * )
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(
+ * description="A Unix timestamp representing the from date of this event in CMS time."
+ * )
+ * @var int
+ */
+ public $fromDt;
+
+ /**
+ * @SWG\Property(
+ * description="A Unix timestamp representing the to date of this event in CMS time."
+ * )
+ * @var int
+ */
+ public $toDt;
+
+ /**
+ * @SWG\Property(
+ * description="Integer indicating the event priority."
+ * )
+ * @var int
+ */
+ public $isPriority;
+
+ /**
+ * @SWG\Property(
+ * description="The display order for this event."
+ * )
+ * @var int
+ */
+ public $displayOrder;
+
+ /**
+ * @SWG\Property(
+ * description="If this event recurs when what is the recurrence period.",
+ * enum={"None", "Minute", "Hour", "Day", "Week", "Month", "Year"}
+ * )
+ * @var string
+ */
+ public $recurrenceType;
+
+ /**
+ * @SWG\Property(
+ * description="If this event recurs when what is the recurrence frequency.",
+ * )
+ * @var int
+ */
+ public $recurrenceDetail;
+
+ /**
+ * @SWG\Property(
+ * description="A Unix timestamp indicating the end time of the recurring events."
+ * )
+ * @var int
+ */
+ public $recurrenceRange;
+
+ /**
+ * @SWG\Property(description="Recurrence repeats on days - 0 to 7 where 0 is a monday")
+ * @var string
+ */
+ public $recurrenceRepeatsOn;
+
+ /**
+ * @SWG\Property(description="Recurrence monthly repeats on - 0 is day of month, 1 is weekday of week")
+ * @var int
+ */
+ public $recurrenceMonthlyRepeatsOn;
+
+ /**
+ * @SWG\Property(
+ * description="The Campaign/Layout Name",
+ * readOnly=true
+ * )
+ * @var string
+ */
+ public $campaign;
+
+ /**
+ * @SWG\Property(
+ * description="The Command Name",
+ * readOnly=true
+ * )
+ * @var string
+ */
+ public $command;
+
+ /**
+ * @SWG\Property(
+ * description="The Day Part Id"
+ * )
+ * @var int
+ */
+ public $dayPartId;
+
+ /**
+ * @SWG\Property(description="Is this event an always on event?")
+ * @var int
+ */
+ public $isAlways;
+
+ /**
+ * @SWG\Property(description="Does this event have custom from/to date times?")
+ * @var int
+ */
+ public $isCustom;
+
+ /**
+ * Last Recurrence Watermark
+ * @var int
+ */
+ public $lastRecurrenceWatermark;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether the event should be synchronised across displays")
+ * @var int
+ */
+ public $syncEvent = 0;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether the event will sync to the Display timezone")
+ * @var int
+ */
+ public $syncTimezone;
+
+ /**
+ * @SWG\Property(description="Seconds (0-3600) of each full hour that is scheduled that this Layout should occupy")
+ * @var int
+ */
+ public $shareOfVoice;
+
+ /**
+ * @SWG\Property(description="The maximum number of plays per hour per display for this event")
+ * @var int
+ */
+ public $maxPlaysPerHour;
+
+ /**
+ * @SWG\Property(description="Flag (0-1), whether this event is using Geo Location")
+ * @var int
+ */
+ public $isGeoAware;
+
+ /**
+ * @SWG\Property(description="Geo JSON representing the area of this event")
+ * @var string
+ */
+ public $geoLocation;
+
+ /**
+ * @SWG\Property(description="For Action event type, Action trigger code")
+ * @var string
+ */
+ public $actionTriggerCode;
+
+ /**
+ * @SWG\Property(description="For Action event type, the type of the Action (navigate to Layout or Command)")
+ * @var string
+ */
+ public $actionType;
+
+ /**
+ * @SWG\Property(description="For Action event type and navigate to Layout Action type, the Layout code")
+ * @var string
+ */
+ public $actionLayoutCode;
+
+ /**
+ * @SWG\Property(description="If the schedule should be considered part of a larger campaign")
+ * @var int
+ */
+ public $parentCampaignId;
+
+ /**
+ * @SWG\Property(description="For sync events, the id the the sync group")
+ * @var int
+ */
+ public $syncGroupId;
+
+ /**
+ * @SWG\Property(description="For data connector events, the dataSetId")
+ * @var int
+ */
+ public $dataSetId;
+
+ /**
+ * @SWG\Property(description="For data connector events, the data set parameters")
+ * @var int
+ */
+ public $dataSetParams;
+
+ /**
+ * @SWG\Property(description="The userId of the user that last modified this Schedule")
+ * @var int
+ */
+ public $modifiedBy;
+ public $modifiedByName;
+
+ /**
+ * @SWG\Property(description="The Date this Schedule was created on")
+ * @var string
+ */
+ public $createdOn;
+
+ /**
+ * @SWG\Property(description="The Date this Schedule was las updated on")
+ * @var string
+ */
+ public $updatedOn;
+
+ /**
+ * @SWG\Property(description="The name of this Scheduled Event")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="The resolutionId of this Fullscreen Scheduled Event")
+ * @var int
+ */
+ public int $resolutionId;
+
+ /**
+ * @SWG\Property(description="The duration of this Fullscreen Scheduled Event")
+ * @var int
+ */
+ public int $layoutDuration;
+
+ /**
+ * @SWG\Property(description="The background color of this Fullscreen Scheduled Event")
+ * @var string
+ */
+ public string $backgroundColor;
+
+ /**
+ * @var ScheduleEvent[]
+ */
+ private $scheduleEvents = [];
+
+ private $datesToFormat = ['toDt', 'fromDt'];
+
+ private $dayPart = null;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ /** @var ScheduleReminderFactory */
+ private $scheduleReminderFactory;
+
+ /** @var ScheduleExclusionFactory */
+ private $scheduleExclusionFactory;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param PoolInterface $pool
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param DayPartFactory $dayPartFactory
+ * @param UserFactory $userFactory
+ * @param ScheduleReminderFactory $scheduleReminderFactory
+ * @param ScheduleExclusionFactory $scheduleExclusionFactory
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ $config,
+ $pool,
+ $displayGroupFactory,
+ $dayPartFactory,
+ $userFactory,
+ $scheduleReminderFactory,
+ $scheduleExclusionFactory,
+ private readonly ScheduleCriteriaFactory $scheduleCriteriaFactory
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->config = $config;
+ $this->pool = $pool;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ $this->userFactory = $userFactory;
+ $this->scheduleReminderFactory = $scheduleReminderFactory;
+ $this->scheduleExclusionFactory = $scheduleExclusionFactory;
+
+ $this->excludeProperty('lastRecurrenceWatermark');
+ }
+
+ /**
+ * @param CampaignFactory $campaignFactory
+ * @return $this
+ */
+ public function setCampaignFactory($campaignFactory)
+ {
+ $this->campaignFactory = $campaignFactory;
+ return $this;
+ }
+
+ public function __clone()
+ {
+ $this->eventId = null;
+ }
+
+ /**
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ * @return $this
+ */
+ public function setDisplayNotifyService($displayNotifyService)
+ {
+ $this->displayNotifyService = $displayNotifyService;
+ return $this;
+ }
+
+ /**
+ * Get the Display Notify Service
+ * @return DisplayNotifyServiceInterface
+ */
+ public function getDisplayNotifyService(): DisplayNotifyServiceInterface
+ {
+ return $this->displayNotifyService->init();
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->eventId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Sets the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->userId = $ownerId;
+ }
+
+ /**
+ * @param ScheduleCriteria $criteria
+ * @param int|null $id
+ * @return $this
+ */
+ public function addOrUpdateCriteria(ScheduleCriteria $criteria, ?int $id = null): Schedule
+ {
+ // set empty array as the default value if original value is empty/null
+ $originalValue = $this->getOriginalValue('criteria') ?? [];
+
+ // Does this already exist?
+ foreach ($originalValue as $existing) {
+ if ($id !== null && $existing->id === $id) {
+ $this->criteria[] = $criteria;
+ return $this;
+ }
+ }
+
+ // We didn't find it.
+ $this->criteria[] = $criteria;
+ return $this;
+ }
+
+ /**
+ * Are the provided dates within the schedule look ahead
+ * @return bool
+ * @throws GeneralException
+ */
+ private function inScheduleLookAhead(): bool
+ {
+ if ($this->isAlwaysDayPart()) {
+ return true;
+ }
+
+ // From Date and To Date are in UNIX format
+ $currentDate = Carbon::now();
+ $rfLookAhead = clone $currentDate;
+ $rfLookAhead->addSeconds(intval($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD')));
+
+ // Dial current date back to the start of the day
+ $currentDate->startOfDay();
+
+ // Is the event updated to a past date?
+ $isEventUpdatedToPastDate = ($this->toDt <= $this->getOriginalValue('toDt'));
+
+ // Test dates
+ if ($this->recurrenceType != '') {
+ // A recurring event
+ $this->getLog()->debug('Checking look ahead based on recurrence');
+ // we should check whether the event from date is before the lookahead (i.e. the event has recurred once)
+ // we should also check whether the recurrence range is still valid
+ // (i.e. we've not stopped recurring and we don't recur forever)
+ return (
+ $this->fromDt <= $rfLookAhead->format('U')
+ && ($this->recurrenceRange == 0 || $this->recurrenceRange > $currentDate->format('U'))
+ );
+ } else if (!$this->isCustomDayPart() || $this->eventTypeId == self::$COMMAND_EVENT) {
+ // Day parting event (non recurring) or command event
+ // only test the from date.
+ $this->getLog()->debug('Checking look ahead based from date ' . $currentDate->toRssString());
+ return ($this->fromDt >= $currentDate->format('U') && $this->fromDt <= $rfLookAhead->format('U'));
+ } else if ($isEventUpdatedToPastDate) {
+ // Check if the event was updated to a past date
+ // We only need to check the toDt
+ $this->getLog()->debug('Checking look ahead based based on previous event details');
+
+ return ($this->getOriginalValue('toDt') >= $currentDate->format('U'));
+ } else {
+ // Compare the event dates
+ $this->getLog()->debug(
+ 'Checking look ahead based event dates '
+ . $currentDate->toRssString() . ' / ' . $rfLookAhead->toRssString()
+ );
+ return ($this->fromDt <= $rfLookAhead->format('U') && $this->toDt >= $currentDate->format('U'));
+ }
+ }
+
+ /**
+ * Load
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadDisplayGroups' => true,
+ 'loadScheduleReminders' => false,
+ 'loadScheduleCriteria' => true,
+ ], $options);
+
+ // If we are already loaded, then don't do it again
+ if ($this->loaded || $this->eventId == null || $this->eventId == 0) {
+ return;
+ }
+
+ // Load display groups
+ if ($options['loadDisplayGroups']) {
+ $this->displayGroups = $this->displayGroupFactory->getByEventId($this->eventId);
+ }
+
+ // Load schedule reminders
+ if ($options['loadScheduleReminders']) {
+ $this->scheduleReminders = $this->scheduleReminderFactory->query(null, ['eventId'=> $this->eventId]);
+ }
+
+ // Load schedule criteria
+ if ($options['loadScheduleCriteria']) {
+ $this->criteria = $this->scheduleCriteriaFactory->getByEventId($this->eventId);
+ }
+
+ // Set the original values now that we're loaded.
+ $this->setOriginals();
+
+ // We are fully loaded
+ $this->loaded = true;
+ }
+
+ /**
+ * Assign DisplayGroup
+ * @param DisplayGroup $displayGroup
+ * @throws NotFoundException
+ */
+ public function assignDisplayGroup($displayGroup)
+ {
+ $this->load();
+
+ if (!in_array($displayGroup, $this->displayGroups)) {
+ $this->displayGroups[] = $displayGroup;
+ }
+ }
+
+ /**
+ * Unassign DisplayGroup
+ * @param DisplayGroup $displayGroup
+ * @throws NotFoundException
+ */
+ public function unassignDisplayGroup($displayGroup)
+ {
+ $this->load();
+
+ $this->displayGroups = array_udiff($this->displayGroups, [$displayGroup], function ($a, $b) {
+ /**
+ * @var DisplayGroup $a
+ * @var DisplayGroup $b
+ */
+ return $a->getId() - $b->getId();
+ });
+ }
+
+ /**
+ * Validate
+ * @throws GeneralException
+ */
+ public function validate()
+ {
+ if (count($this->displayGroups) <= 0 && $this->eventTypeId !== Schedule::$SYNC_EVENT) {
+ throw new InvalidArgumentException(__('No display groups selected'), 'displayGroups');
+ }
+
+ $this->getLog()->debug('EventTypeId: ' . $this->eventTypeId
+ . '. DayPartId: ' . $this->dayPartId
+ . ', CampaignId: ' . $this->campaignId
+ . ', CommandId: ' . $this->commandId);
+
+ // If we are a custom day part, make sure we don't have a fromDt which is way in the past
+ if ($this->isCustomDayPart() && $this->fromDt < Carbon::now()->subYears(10)->format('U')) {
+ throw new InvalidArgumentException(__('The from date is too far in the past.'), 'fromDt');
+ }
+
+ if (!empty($this->name) && strlen($this->name) > 50) {
+ throw new InvalidArgumentException(
+ __('Name cannot be longer than 50 characters.'),
+ 'name'
+ );
+ }
+
+ if ($this->eventTypeId == Schedule::$LAYOUT_EVENT ||
+ $this->eventTypeId == Schedule::$CAMPAIGN_EVENT ||
+ $this->eventTypeId == Schedule::$OVERLAY_EVENT ||
+ $this->eventTypeId == Schedule::$INTERRUPT_EVENT ||
+ $this->eventTypeId == Schedule::$MEDIA_EVENT ||
+ $this->eventTypeId == Schedule::$PLAYLIST_EVENT
+ ) {
+ // Validate layout
+ if (!v::intType()->notEmpty()->validate($this->campaignId)) {
+ throw new InvalidArgumentException(__('Please select a Campaign/Layout for this event.'), 'campaignId');
+ }
+
+ if ($this->isCustomDayPart()) {
+ // validate the dates
+ if ($this->toDt <= $this->fromDt) {
+ throw new InvalidArgumentException(
+ __('Can not have an end time earlier than your start time'),
+ 'start/end'
+ );
+ }
+ }
+
+ $this->commandId = null;
+
+ // additional validation for Interrupt Layout event type
+ if ($this->eventTypeId == Schedule::$INTERRUPT_EVENT) {
+ if (!v::intType()->notEmpty()->min(0)->max(3600)->validate($this->shareOfVoice)) {
+ throw new InvalidArgumentException(
+ __('Share of Voice must be a whole number between 0 and 3600'),
+ 'shareOfVoice'
+ );
+ }
+ }
+
+ } else if ($this->eventTypeId == Schedule::$COMMAND_EVENT) {
+ // Validate command
+ if (!v::intType()->notEmpty()->validate($this->commandId)) {
+ throw new InvalidArgumentException(__('Please select a Command for this event.'), 'command');
+ }
+ $this->campaignId = null;
+ $this->toDt = null;
+ } elseif ($this->eventTypeId == Schedule::$ACTION_EVENT) {
+ if (!v::stringType()->notEmpty()->validate($this->actionType)) {
+ throw new InvalidArgumentException(__('Please select a Action Type for this event.'), 'actionType');
+ }
+
+ if (!v::stringType()->notEmpty()->validate($this->actionTriggerCode)) {
+ throw new InvalidArgumentException(
+ __('Please select a Action trigger code for this event.'),
+ 'actionTriggerCode'
+ );
+ }
+
+ if ($this->isCustomDayPart()) {
+ // validate the dates
+ if ($this->toDt <= $this->fromDt) {
+ throw new InvalidArgumentException(
+ __('Can not have an end time earlier than your start time'),
+ 'start/end'
+ );
+ }
+ }
+
+ if ($this->actionType === 'command') {
+ if (!v::intType()->notEmpty()->validate($this->commandId)) {
+ throw new InvalidArgumentException(__('Please select a Command for this event.'), 'commandId');
+ }
+ } elseif ($this->actionType === 'navLayout') {
+ if (!v::stringType()->notEmpty()->validate($this->actionLayoutCode)) {
+ throw new InvalidArgumentException(
+ __('Please select a Layout code for this event.'),
+ 'actionLayoutCode'
+ );
+ }
+ $this->commandId = null;
+ }
+ $this->campaignId = null;
+ } else if ($this->eventTypeId === Schedule::$SYNC_EVENT) {
+ if (!v::intType()->notEmpty()->validate($this->syncGroupId)) {
+ throw new InvalidArgumentException(__('Please select a Sync Group for this event.'), 'syncGroupId');
+ }
+
+ if ($this->isCustomDayPart()) {
+ // validate the dates
+ if ($this->toDt <= $this->fromDt) {
+ throw new InvalidArgumentException(
+ __('Can not have an end time earlier than your start time'),
+ 'start/end'
+ );
+ }
+ }
+ } else if ($this->eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) {
+ if (!v::intType()->notEmpty()->validate($this->dataSetId)) {
+ throw new InvalidArgumentException(__('Please select a DataSet for this event.'), 'dataSetId');
+ }
+
+ if ($this->isCustomDayPart()) {
+ // validate the dates
+ if ($this->toDt <= $this->fromDt) {
+ throw new InvalidArgumentException(
+ __('Can not have an end time earlier than your start time'),
+ 'start/end'
+ );
+ }
+ }
+
+ $this->campaignId = null;
+ } else {
+ // No event type selected
+ throw new InvalidArgumentException(__('Please select the Event Type'), 'eventTypeId');
+ }
+
+ // Make sure we have a sensible recurrence setting
+ if (!$this->isCustomDayPart() && ($this->recurrenceType == 'Minute' || $this->recurrenceType == 'Hour')) {
+ throw new InvalidArgumentException(
+ __('Repeats selection is invalid for Always or Daypart events'),
+ 'recurrencyType'
+ );
+ }
+ // Check display order is positive
+ if ($this->displayOrder < 0) {
+ throw new InvalidArgumentException(__('Display Order must be 0 or a positive number'), 'displayOrder');
+ }
+ // Check priority is positive
+ if ($this->isPriority < 0) {
+ throw new InvalidArgumentException(__('Priority must be 0 or a positive number'), 'isPriority');
+ }
+ // Check max plays per hour is positive
+ if ($this->maxPlaysPerHour < 0) {
+ throw new InvalidArgumentException(__('Maximum plays per hour must be 0 or a positive number'), 'maxPlaysPerHour');
+ }
+
+ // Run some additional validation if we have a recurrence type set.
+ if (!empty($this->recurrenceType)) {
+ // Check recurrenceDetail every is positive
+ if ($this->recurrenceDetail === null || $this->recurrenceDetail <= 0) {
+ throw new InvalidArgumentException(__('Repeat every must be a positive number'), 'recurrenceDetail');
+ }
+
+ // Make sure that we don't repeat more frequently than the duration of the event as this is a common
+ // misconfiguration which results in overlapping repeats
+ if ($this->eventTypeId !== Schedule::$COMMAND_EVENT) {
+ $eventDuration = $this->toDt - $this->fromDt;
+
+ // Determine the number of seconds our repeat type/interval represents
+ switch ($this->recurrenceType) {
+ case 'Minute':
+ $repeatDuration = $this->recurrenceDetail * 60;
+ break;
+
+ case 'Hour':
+ $repeatDuration = $this->recurrenceDetail * 3600;
+ break;
+
+ case 'Day':
+ $repeatDuration = $this->recurrenceDetail * 86400;
+ break;
+
+ case 'Week':
+ $repeatDuration = $this->recurrenceDetail * 86400 * 7;
+ break;
+
+ case 'Month':
+ $repeatDuration = $this->recurrenceDetail * 86400 * 30;
+ break;
+
+ case 'Year':
+ $repeatDuration = $this->recurrenceDetail * 86400 * 365;
+ break;
+
+ default:
+ throw new InvalidArgumentException(__('Unknown repeat type'), 'recurrenceType');
+ }
+
+ if ($repeatDuration < $eventDuration) {
+ throw new InvalidArgumentException(
+ __('An event cannot repeat more often than the interval between its start and end date'),
+ 'recurrenceDetail',
+ $eventDuration . ' seconds'
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws GeneralException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'audit' => true,
+ 'deleteOrphaned' => false,
+ 'notify' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ // Handle "always" day parts
+ if ($this->isAlwaysDayPart()) {
+ $this->fromDt = self::$DATE_MIN;
+ $this->toDt = self::$DATE_MAX;
+ }
+
+ if ($this->eventId == null || $this->eventId == 0) {
+ $this->add();
+ $auditMessage = 'Added';
+ $this->loaded = true;
+ $isEdit = false;
+ } else {
+ // If this save action means there aren't any display groups assigned
+ // and if we're set to deleteOrphaned, then delete
+ if ($options['deleteOrphaned'] && count($this->displayGroups) <= 0) {
+ $this->delete();
+ return;
+ } else {
+ $this->edit();
+ $auditMessage = 'Saved';
+ }
+ $isEdit = true;
+ }
+
+ // Manage display assignments
+ if ($this->loaded) {
+ // Manage assignments
+ $this->manageAssignments($isEdit && $options['notify']);
+ }
+
+ // Update schedule criteria
+ $criteriaIds = [];
+ foreach ($this->criteria as $criteria) {
+ $criteria->eventId = $this->eventId;
+ $criteria->save();
+
+ $criteriaIds[] = $criteria->id;
+ }
+
+ // Remove records that no longer exist.
+ if (count($criteriaIds) > 0) {
+ // There are still criteria left
+ $this->getStore()->update('
+ DELETE FROM `schedule_criteria`
+ WHERE `id` NOT IN (' . implode(',', $criteriaIds) . ')
+ AND `eventId` = :eventId
+ ', [
+ 'eventId' => $this->eventId,
+ ]);
+ } else {
+ // No criteria left at all (or never was any)
+ $this->getStore()->update('DELETE FROM `schedule_criteria` WHERE `eventId` = :eventId', [
+ 'eventId' => $this->eventId,
+ ]);
+ }
+
+ // Notify
+ if ($options['notify']) {
+ // Only if the schedule effects the immediate future - i.e. within the RF Look Ahead
+ // Or if the scheduled event was updated to a past date
+ if ($this->inScheduleLookAhead()) {
+ $this->getLog()->debug(
+ 'Schedule changing is within the schedule look ahead, will notify '
+ . count($this->displayGroups) . ' display groups'
+ );
+ foreach ($this->displayGroups as $displayGroup) {
+ /* @var DisplayGroup $displayGroup */
+ $this
+ ->getDisplayNotifyService()
+ ->collectNow()
+ ->notifyByDisplayGroupId($displayGroup->displayGroupId);
+ }
+ } else {
+ $this->getLog()->debug('Schedule changing is not within the schedule look ahead');
+ }
+ }
+
+ if ($options['audit']) {
+ $this->audit($this->getId(), $auditMessage, null, true);
+ }
+
+ // Drop the cache for this event
+ $this->dropEventCache();
+ }
+
+ /**
+ * Delete this Schedule Event
+ */
+ public function delete($options = [])
+ {
+ $this->load();
+
+ $options = array_merge([
+ 'notify' => true
+ ], $options);
+
+ // Notify display groups
+ $notify = $this->displayGroups;
+
+ // Audit
+ $this->audit($this->getId(), 'Deleted', $this->toArray(true));
+
+ // Delete display group assignments
+ $this->displayGroups = [];
+ $this->unlinkDisplayGroups();
+
+ // Delete schedule exclusions
+ $scheduleExclusions = $this->scheduleExclusionFactory->query(null, ['eventId' => $this->eventId]);
+ foreach ($scheduleExclusions as $exclusion) {
+ $exclusion->delete();
+ }
+
+ // Delete schedule reminders
+ if ($this->scheduleReminderFactory !== null) {
+ $scheduleReminders = $this->scheduleReminderFactory->query(null, ['eventId' => $this->eventId]);
+
+ foreach ($scheduleReminders as $reminder) {
+ $reminder->delete();
+ }
+ }
+
+ // Delete schedule criteria
+ $this->getStore()->update('DELETE FROM `schedule_criteria` WHERE `eventId` = :eventId', [
+ 'eventId' => $this->eventId,
+ ]);
+
+ if ($this->eventTypeId === self::$SYNC_EVENT) {
+ $this->getStore()->update('DELETE FROM `schedule_sync` WHERE eventId = :eventId', [
+ 'eventId' => $this->eventId
+ ]);
+ }
+
+ // Delete the event itself
+ $this->getStore()->update('DELETE FROM `schedule` WHERE eventId = :eventId', ['eventId' => $this->eventId]);
+
+ // Notify
+ if ($options['notify']) {
+ // Only if the schedule effects the immediate future - i.e. within the RF Look Ahead
+ if ($this->inScheduleLookAhead() && $this->displayNotifyService !== null) {
+ $this->getLog()->debug(
+ 'Schedule changing is within the schedule look ahead, will notify '
+ . count($notify) . ' display groups'
+ );
+ foreach ($notify as $displayGroup) {
+ /* @var DisplayGroup $displayGroup */
+ $this
+ ->getDisplayNotifyService()
+ ->collectNow()
+ ->notifyByDisplayGroupId($displayGroup->displayGroupId);
+ }
+ } else if ($this->displayNotifyService === null) {
+ $this->getLog()->info('Notify disabled, dependencies not set');
+ }
+ } else {
+ $this->getLog()->debug('Event delete: Notify disabled, option set to false');
+ }
+
+ // Drop the cache for this event
+ $this->dropEventCache();
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->eventId = $this->getStore()->insert('
+ INSERT INTO `schedule` (
+ eventTypeId,
+ CampaignId,
+ commandId,
+ userID,
+ is_priority,
+ FromDT,
+ ToDT,
+ DisplayOrder,
+ recurrence_type,
+ recurrence_detail,
+ recurrence_range,
+ `recurrenceRepeatsOn`,
+ `recurrenceMonthlyRepeatsOn`,
+ `dayPartId`,
+ `syncTimezone`,
+ `syncEvent`,
+ `shareOfVoice`,
+ `isGeoAware`,
+ `geoLocation`,
+ `actionType`,
+ `actionTriggerCode`,
+ `actionLayoutCode`,
+ `maxPlaysPerHour`,
+ `parentCampaignId`,
+ `syncGroupId`,
+ `modifiedBy`,
+ `createdOn`,
+ `updatedOn`,
+ `name`,
+ `dataSetId`,
+ `dataSetParams`
+ )
+ VALUES (
+ :eventTypeId,
+ :campaignId,
+ :commandId,
+ :userId,
+ :isPriority,
+ :fromDt,
+ :toDt,
+ :displayOrder,
+ :recurrenceType,
+ :recurrenceDetail,
+ :recurrenceRange,
+ :recurrenceRepeatsOn,
+ :recurrenceMonthlyRepeatsOn,
+ :dayPartId,
+ :syncTimezone,
+ :syncEvent,
+ :shareOfVoice,
+ :isGeoAware,
+ :geoLocation,
+ :actionType,
+ :actionTriggerCode,
+ :actionLayoutCode,
+ :maxPlaysPerHour,
+ :parentCampaignId,
+ :syncGroupId,
+ :modifiedBy,
+ :createdOn,
+ :updatedOn,
+ :name,
+ :dataSetId,
+ :dataSetParams
+ )
+ ', [
+ 'eventTypeId' => $this->eventTypeId,
+ 'campaignId' => $this->campaignId,
+ 'commandId' => $this->commandId,
+ 'userId' => $this->userId,
+ 'isPriority' => $this->isPriority,
+ 'fromDt' => $this->fromDt,
+ 'toDt' => $this->toDt,
+ 'displayOrder' => $this->displayOrder,
+ 'recurrenceType' => empty($this->recurrenceType) ? null : $this->recurrenceType,
+ 'recurrenceDetail' => $this->recurrenceDetail,
+ 'recurrenceRange' => $this->recurrenceRange,
+ 'recurrenceRepeatsOn' => $this->recurrenceRepeatsOn,
+ 'recurrenceMonthlyRepeatsOn' => ($this->recurrenceMonthlyRepeatsOn == null)
+ ? 0 :
+ $this->recurrenceMonthlyRepeatsOn,
+ 'dayPartId' => $this->dayPartId,
+ 'syncTimezone' => $this->syncTimezone,
+ 'syncEvent' => $this->syncEvent,
+ 'shareOfVoice' => $this->shareOfVoice,
+ 'isGeoAware' => $this->isGeoAware,
+ 'geoLocation' => $this->geoLocation,
+ 'actionType' => $this->actionType,
+ 'actionTriggerCode' => $this->actionTriggerCode,
+ 'actionLayoutCode' => $this->actionLayoutCode,
+ 'maxPlaysPerHour' => $this->maxPlaysPerHour,
+ 'parentCampaignId' => $this->parentCampaignId == 0 ? null : $this->parentCampaignId,
+ 'syncGroupId' => $this->syncGroupId == 0 ? null : $this->syncGroupId,
+ 'modifiedBy' => null,
+ 'createdOn' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'updatedOn' => null,
+ 'name' => !empty($this->name) ? $this->name : null,
+ 'dataSetId' => !empty($this->dataSetId) ? $this->dataSetId : null,
+ 'dataSetParams' => !empty($this->dataSetParams) ? $this->dataSetParams : null,
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `schedule` SET
+ eventTypeId = :eventTypeId,
+ campaignId = :campaignId,
+ commandId = :commandId,
+ is_priority = :isPriority,
+ userId = :userId,
+ fromDt = :fromDt,
+ toDt = :toDt,
+ displayOrder = :displayOrder,
+ recurrence_type = :recurrenceType,
+ recurrence_detail = :recurrenceDetail,
+ recurrence_range = :recurrenceRange,
+ `recurrenceRepeatsOn` = :recurrenceRepeatsOn,
+ `recurrenceMonthlyRepeatsOn` = :recurrenceMonthlyRepeatsOn,
+ `dayPartId` = :dayPartId,
+ `syncTimezone` = :syncTimezone,
+ `syncEvent` = :syncEvent,
+ `shareOfVoice` = :shareOfVoice,
+ `isGeoAware` = :isGeoAware,
+ `geoLocation` = :geoLocation,
+ `actionType` = :actionType,
+ `actionTriggerCode` = :actionTriggerCode,
+ `actionLayoutCode` = :actionLayoutCode,
+ `maxPlaysPerHour` = :maxPlaysPerHour,
+ `parentCampaignId` = :parentCampaignId,
+ `syncGroupId` = :syncGroupId,
+ `modifiedBy` = :modifiedBy,
+ `updatedOn` = :updatedOn,
+ `name` = :name,
+ `dataSetId` = :dataSetId,
+ `dataSetParams` = :dataSetParams
+ WHERE eventId = :eventId
+ ', [
+ 'eventTypeId' => $this->eventTypeId,
+ 'campaignId' => ($this->campaignId !== 0) ? $this->campaignId : null,
+ 'commandId' => $this->commandId,
+ 'userId' => $this->userId,
+ 'isPriority' => $this->isPriority,
+ 'fromDt' => $this->fromDt,
+ 'toDt' => $this->toDt,
+ 'displayOrder' => $this->displayOrder,
+ 'recurrenceType' => empty($this->recurrenceType) ? null : $this->recurrenceType,
+ 'recurrenceDetail' => $this->recurrenceDetail,
+ 'recurrenceRange' => $this->recurrenceRange,
+ 'recurrenceRepeatsOn' => $this->recurrenceRepeatsOn,
+ 'recurrenceMonthlyRepeatsOn' => $this->recurrenceMonthlyRepeatsOn,
+ 'dayPartId' => $this->dayPartId,
+ 'syncTimezone' => $this->syncTimezone,
+ 'syncEvent' => $this->syncEvent,
+ 'shareOfVoice' => $this->shareOfVoice,
+ 'isGeoAware' => $this->isGeoAware,
+ 'geoLocation' => $this->geoLocation,
+ 'actionType' => $this->actionType,
+ 'actionTriggerCode' => $this->actionTriggerCode,
+ 'actionLayoutCode' => $this->actionLayoutCode,
+ 'maxPlaysPerHour' => $this->maxPlaysPerHour,
+ 'parentCampaignId' => $this->parentCampaignId == 0 ? null : $this->parentCampaignId,
+ 'syncGroupId' => $this->syncGroupId == 0 ? null : $this->syncGroupId,
+ 'modifiedBy' => $this->modifiedBy,
+ 'updatedOn' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'name' => $this->name,
+ 'dataSetId' => !empty($this->dataSetId) ? $this->dataSetId : null,
+ 'dataSetParams' => !empty($this->dataSetParams) ? $this->dataSetParams : null,
+ 'eventId' => $this->eventId,
+ ]);
+ }
+
+ /**
+ * Get events between the provided dates.
+ * @param Carbon $fromDt
+ * @param Carbon $toDt
+ * @return ScheduleEvent[]
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function getEvents($fromDt, $toDt)
+ {
+ // Events scheduled "always" will return one event
+ if ($this->isAlwaysDayPart()) {
+ // Create events with min/max dates
+ $this->addDetail(Schedule::$DATE_MIN, Schedule::$DATE_MAX);
+ return $this->scheduleEvents;
+ }
+
+ // Copy the dates as we are going to be operating on them.
+ $fromDt = $fromDt->copy();
+ $toDt = $toDt->copy();
+
+ if ($this->pool == null) {
+ throw new ConfigurationException(__('Cache pool not available'));
+ }
+ if ($this->eventId == null) {
+ throw new InvalidArgumentException(__('Unable to generate schedule, unknown event'), 'eventId');
+ }
+ // What if we are requesting a single point in time?
+ if ($fromDt == $toDt) {
+ $this->log->debug(
+ 'Requesting event for a single point in time: '
+ . $fromDt->format(DateFormatHelper::getSystemFormat())
+ );
+ }
+
+ $events = [];
+ $fromTimeStamp = $fromDt->format('U');
+ $toTimeStamp = $toDt->format('U');
+
+ // Rewind the from date to the start of the month
+ $fromDt->startOfMonth();
+
+ if ($fromDt == $toDt) {
+ $this->log->debug(
+ 'From and To Dates are the same after rewinding 1 month,
+ the date is the 1st of the month, adding a month to toDate.'
+ );
+ $toDt->addMonth();
+ }
+
+ // Load the dates into a date object for parsing
+ $eventStart = Carbon::createFromTimestamp($this->fromDt);
+ $eventEnd = ($this->toDt == null) ? $eventStart->copy() : Carbon::createFromTimestamp($this->toDt);
+
+ // Does the original event go over the month boundary?
+ if ($eventStart->month !== $eventEnd->month) {
+ // We expect some residual events to spill out into the month we are generating
+ // wind back the generate from date
+ $fromDt->subMonth();
+
+ $this->getLog()->debug(
+ 'Expecting events from the prior month to spill over into this one,
+ pulled back the generate from dt to ' . $fromDt->toRssString()
+ );
+ } else {
+ $this->getLog()->debug('The main event has a start and end date within the month, no need to pull it in from the prior month. [eventId:' . $this->eventId . ']');
+ }
+
+ // Keep a cache of schedule exclusions, so we look them up by eventId only one time per event
+ $scheduleExclusions = $this->scheduleExclusionFactory->query(null, ['eventId' => $this->eventId]);
+
+ // Request month cache
+ while ($fromDt < $toDt) {
+ // Empty scheduleEvents as we are looping through each month
+ // we dont want to save previous month events
+ $this->scheduleEvents = [];
+
+ // Events for the month.
+ $this->generateMonth($fromDt, $eventStart, $eventEnd);
+
+ $this->getLog()->debug(
+ 'Filtering Events: ' . json_encode($this->scheduleEvents, JSON_PRETTY_PRINT)
+ . '. fromTimeStamp: ' . $fromTimeStamp . ', toTimeStamp: ' . $toTimeStamp
+ );
+
+ foreach ($this->scheduleEvents as $scheduleEvent) {
+ // Find the excluded recurring events
+ $exclude = false;
+ foreach ($scheduleExclusions as $exclusion) {
+ if ($scheduleEvent->fromDt == $exclusion->fromDt &&
+ $scheduleEvent->toDt == $exclusion->toDt) {
+ $exclude = true;
+ continue;
+ }
+ }
+
+ if ($exclude) {
+ continue;
+ }
+
+ if (in_array($scheduleEvent, $events)) {
+ continue;
+ }
+
+ if ($scheduleEvent->toDt == null) {
+ if ($scheduleEvent->fromDt >= $fromTimeStamp && $scheduleEvent->toDt < $toTimeStamp) {
+ $events[] = $scheduleEvent;
+ }
+ } else {
+ if ($scheduleEvent->fromDt <= $toTimeStamp && $scheduleEvent->toDt > $fromTimeStamp) {
+ $events[] = $scheduleEvent;
+ }
+ }
+ }
+
+ // Move the month forwards
+ $fromDt->addMonth();
+ }
+
+ // Clear our cache of schedule exclusions
+ $scheduleExclusions = null;
+
+ $this->getLog()->debug(
+ 'Filtered ' . count($this->scheduleEvents) . ' to ' . count($events)
+ . ', events: ' . json_encode($events, JSON_PRETTY_PRINT)
+ );
+
+ return $events;
+ }
+
+ /**
+ * Generate Instances
+ * @param Carbon $generateFromDt
+ * @param Carbon $start
+ * @param Carbon $end
+ * @throws GeneralException
+ */
+ private function generateMonth($generateFromDt, $start, $end)
+ {
+ // Operate on copies of the dates passed.
+ $start = $start->copy();
+ $end = $end->copy();
+ $generateFromDt->copy()->startOfMonth();
+ $generateToDt = $generateFromDt->copy()->addMonth();
+
+ $this->getLog()->debug(
+ 'Request for schedule events on eventId ' . $this->eventId
+ . ' from: ' . Carbon::createFromTimestamp($generateFromDt->format(DateFormatHelper::getSystemFormat()))
+ . ' to: ' . Carbon::createFromTimestamp($generateToDt->format(DateFormatHelper::getSystemFormat()))
+ . ' [eventId:' . $this->eventId . ']'
+ );
+
+ // If we are a daypart event, look up the start/end times for the event
+ $this->calculateDayPartTimes($start, $end);
+
+ // Does the original event fall into this window?
+ if ($start <= $generateToDt && $end > $generateFromDt) {
+ // Add the detail for the main event (this is the event that originally triggered the generation)
+ $this->getLog()->debug('Adding original event: ' . $start->toAtomString() . ' - ' . $end->toAtomString());
+ $this->addDetail($start->format('U'), $end->format('U'));
+ }
+
+ // If we don't have any recurrence, we are done
+ if (empty($this->recurrenceType) || empty($this->recurrenceDetail)) {
+ return;
+ }
+
+ // Detect invalid recurrences and quit early
+ if (!$this->isCustomDayPart() && ($this->recurrenceType == 'Minute' || $this->recurrenceType == 'Hour')) {
+ return;
+ }
+
+ // Check the cache
+ $item = $this->pool->getItem('schedule/' . $this->eventId . '/' . $generateFromDt->format('Y-m'));
+
+ if ($item->isHit()) {
+ $this->scheduleEvents = $item->get();
+ $this->getLog()->debug('Returning from cache! [eventId:' . $this->eventId . ']');
+ return;
+ }
+
+ $this->getLog()->debug('Cache miss! [eventId:' . $this->eventId . ']');
+
+ // vv anything below here means that the event window requested is not in the cache vv
+ // WE ARE NOT IN THE CACHE
+ // this means we need to always walk the tree from the last watermark
+ // if the last watermark is after the from window, then we need to walk from the beginning
+
+ // Handle recurrence
+ $originalStart = $start->copy();
+ $lastWatermark = ($this->lastRecurrenceWatermark != 0)
+ ? Carbon::createFromTimestamp($this->lastRecurrenceWatermark)
+ : Carbon::createFromTimestamp(self::$DATE_MIN);
+
+ $this->getLog()->debug(
+ 'Recurrence calculation required - last water mark is set to: ' . $lastWatermark->toRssString()
+ . '. Event dates: ' . $start->toRssString() . ' - '
+ . $end->toRssString() . ' [eventId:' . $this->eventId . ']'
+ );
+
+ // Set the temp starts
+ // the start date should be the latest of the event start date and the last recurrence date
+ if ($lastWatermark > $start && $lastWatermark < $generateFromDt) {
+ $this->getLog()->debug(
+ 'The last watermark is later than the event start date and the generate from dt,
+ using the watermark for forward population [eventId:' . $this->eventId . ']'
+ );
+
+ // Need to set the toDt based on the original event duration and the watermark start date
+ $eventDuration = $start->diffInSeconds($end, true);
+
+ /** @var Carbon $start */
+ $start = $lastWatermark->copy();
+ $end = $start->copy()->addSeconds($eventDuration);
+
+ if ($start <= $generateToDt && $end >= $generateFromDt) {
+ $this->getLog()->debug('The event start/end is inside the month');
+ // If we're a weekly repeat, check that the start date is on a selected day
+ if ($this->recurrenceType !== 'Week'
+ || (!empty($this->recurrenceRepeatsOn)
+ && in_array($start->dayOfWeekIso, explode(',', $this->recurrenceRepeatsOn)))
+ ) {
+ $this->addDetail($start->format('U'), $end->format('U'));
+ }
+ }
+ }
+
+ // range should be the smallest of the recurrence range and the generate window todt
+ // the start/end date should be the the first recurrence in the current window
+ if ($this->recurrenceRange != 0) {
+ $range = Carbon::createFromTimestamp($this->recurrenceRange);
+
+ // Override the range to be within the period we are looking
+ $range = ($range < $generateToDt) ? $range : $generateToDt->copy();
+ } else {
+ $range = $generateToDt->copy();
+ }
+
+ $this->getLog()->debug(
+ '[' . $generateFromDt->toRssString() . ' - ' . $generateToDt->toRssString()
+ . '] Looping from ' . $start->toRssString()
+ . ' to ' . $range->toRssString() . ' [eventId:' . $this->eventId . ']'
+ );
+
+ // loop until we have added the recurring events for the schedule
+ while ($start < $range) {
+ $this->getLog()->debug(
+ 'Loop: ' . $start->toRssString() . ' to ' . $range->toRssString()
+ . ' [eventId:' . $this->eventId . ', end: ' . $end->toRssString() . ']'
+ );
+
+ // add the appropriate time to the start and end
+ switch ($this->recurrenceType) {
+ case 'Minute':
+ $start->minute($start->minute + $this->recurrenceDetail);
+ $end->minute($end->minute + $this->recurrenceDetail);
+ break;
+
+ case 'Hour':
+ $start->hour($start->hour + $this->recurrenceDetail);
+ $end->hour($end->hour + $this->recurrenceDetail);
+ break;
+
+ case 'Day':
+ $start->day($start->day + $this->recurrenceDetail);
+ $end->day($end->day + $this->recurrenceDetail);
+ break;
+
+ case 'Week':
+ // recurrenceRepeatsOn will contain an array we can use to determine which days it should repeat
+ // on. Roll forward 7 days, adding each day we hit
+ // if we go over the start of the week, then jump forward by the recurrence range
+ if (!empty($this->recurrenceRepeatsOn)) {
+ // Parse days selected and add the necessary events
+ $daysSelected = explode(',', $this->recurrenceRepeatsOn);
+
+ // Are we on the start day of this week already?
+ $onStartOfWeek = ($start->copy()->setTimeFromTimeString('00:00:00') == $start->copy()->locale(Translate::GetLocale())->startOfWeek()->setTimeFromTimeString('00:00:00'));
+
+ // What is the end of this week
+ $endOfWeek = $start->copy()->locale(Translate::GetLocale())->endOfWeek();
+
+ $this->getLog()->debug(
+ 'Days selected: ' . $this->recurrenceRepeatsOn . '. End of week = ' . $endOfWeek
+ . ' start date ' . $start . ' [eventId:' . $this->eventId . ']'
+ );
+
+ for ($i = 1; $i <= 7; $i++) {
+ // Add a day to the start dates
+ // after the first pass, we will already be on the first day of the week
+ if ($i > 1 || !$onStartOfWeek) {
+ $start->day($start->day + 1);
+ $end->day($end->day + 1);
+ }
+
+ $this->getLog()->debug('Assessing start date ' . $start->toAtomString()
+ . ', isoDayOfWeek is ' . $start->dayOfWeekIso . ' [eventId:' . $this->eventId . ']');
+
+ // If we go over the recurrence range, stop
+ // if we go over the start of the week, stop
+ if ($start > $range || $start > $endOfWeek) {
+ break;
+ }
+
+ // Is this day set?
+ if (!in_array($start->dayOfWeekIso, $daysSelected)) {
+ continue;
+ }
+
+ if ($end > $generateFromDt && $start < $generateToDt) {
+ $this->getLog()->debug(
+ 'Adding detail for ' . $start->toAtomString() . ' to ' . $end->toAtomString()
+ );
+
+ if ($this->eventTypeId == self::$COMMAND_EVENT) {
+ $this->addDetail($start->format('U'), null);
+ } else {
+ // If we are a daypart event, look up the start/end times for the event
+ $this->calculateDayPartTimes($start, $end);
+
+ $this->addDetail($start->format('U'), $end->format('U'));
+ }
+ } else {
+ $this->getLog()->debug('Event is outside range');
+ }
+ }
+
+ $this->getLog()->debug(
+ 'Finished 7 day roll forward, start date is ' . $start . ' [eventId:' . $this->eventId . ']'
+ );
+
+ // If we haven't passed the end of the week, roll forward
+ if ($start < $endOfWeek) {
+ $start->day($start->day + 1);
+ $end->day($end->day + 1);
+ }
+
+ // Wind back a week and then add our recurrence detail
+ $start->day($start->day - 7);
+ $end->day($end->day - 7);
+
+ $this->getLog()->debug(
+ 'Resetting start date to ' . $start . ' [eventId:' . $this->eventId . ']'
+ );
+ }
+
+ // Jump forward a week from the original start date (when we entered this loop)
+ $start->day($start->day + ($this->recurrenceDetail * 7));
+ $end->day($end->day + ($this->recurrenceDetail * 7));
+
+ break;
+
+ case 'Month':
+ // We use the difference to set the end date
+ $difference = $end->diffInSeconds($start);
+
+ // Are we repeating on the day of the month, or the day of the week
+ if ($this->recurrenceMonthlyRepeatsOn == 1) {
+ // Week day repeat
+ // Work out the position in the month of this day and the ordinal
+ $ordinals = ['first', 'second', 'third', 'fourth', 'last'];
+ $ordinal = $ordinals[ceil($originalStart->day / 7) - 1];
+
+ // Move forwards to the start of the appropriate month
+ for ($i = 0; $i < $this->recurrenceDetail; $i++) {
+ $start->endOfMonth()->addSecond();
+ }
+
+ // Set to the right day
+ $start->modify($ordinal . ' ' . $originalStart->format('l') . ' of ' . $start->format('F Y'));
+ $start->setTimeFrom($originalStart);
+
+ $this->getLog()->debug('Monthly repeats every ' . $this->recurrenceDetail . ' months on '
+ . $ordinal . ' ' . $start->format('l') . ' of ' . $start->format('F Y'));
+ } else {
+ // Day repeat
+ $startTest = $start->copy()->addDays(28 * $this->recurrenceDetail);
+ if ($originalStart->day > intval($startTest->format('t'))) {
+ // The next month has fewer days than the current month
+ $start = $startTest->endOfMonth()->setTimeFrom($originalStart);
+ } else {
+ $start->addMonth()->day($originalStart->day);
+ }
+
+ $this->getLog()->debug('Monthly repeats every ' . $this->recurrenceDetail . ' months '
+ . ' on a specific day ' . $originalStart->day . ' days this month ' . $start->format('t')
+ . ' set to ' . $start->format('Y-m-d'));
+ }
+
+ // Base the end on the start + difference
+ $end = $start->copy()->addSeconds($difference);
+ break;
+
+ case 'Year':
+ $start->year($start->year + $this->recurrenceDetail);
+ $end->year($end->year + $this->recurrenceDetail);
+ break;
+
+ default:
+ throw new InvalidArgumentException(__('Invalid recurrence type'), 'recurrenceType');
+ }
+
+ // after we have added the appropriate amount, are we still valid
+ if ($start > $range) {
+ $this->getLog()->debug(
+ 'Breaking mid loop because we\'ve exceeded the range. Start: ' . $start->toRssString()
+ . ', range: ' . $range->toRssString() . ' [eventId:' . $this->eventId . ']'
+ );
+ break;
+ }
+
+ // Push the watermark
+ $lastWatermark = $start->copy();
+
+ // Don't add if we are weekly recurrency (handles it's own adding)
+ if ($this->recurrenceType == 'Week' && !empty($this->recurrenceRepeatsOn)) {
+ continue;
+ }
+
+ if ($start <= $generateToDt && $end >= $generateFromDt) {
+ if ($this->eventTypeId == self::$COMMAND_EVENT) {
+ $this->addDetail($start->format('U'), null);
+ } else {
+ // If we are a daypart event, look up the start/end times for the event
+ $this->calculateDayPartTimes($start, $end);
+
+ $this->addDetail($start->format('U'), $end->format('U'));
+ }
+ }
+ }
+
+ $this->getLog()->debug(
+ 'Our last recurrence watermark is: ' . $lastWatermark->toRssString()
+ . '[eventId:' . $this->eventId . ']'
+ );
+
+ // Update our schedule with the new last watermark
+ $lastWatermarkTimeStamp = $lastWatermark->format('U');
+
+ if ($lastWatermarkTimeStamp != $this->lastRecurrenceWatermark) {
+ $this->lastRecurrenceWatermark = $lastWatermarkTimeStamp;
+ $this->getStore()->update('UPDATE `schedule` SET lastRecurrenceWatermark = :lastRecurrenceWatermark WHERE eventId = :eventId', [
+ 'eventId' => $this->eventId,
+ 'lastRecurrenceWatermark' => $this->lastRecurrenceWatermark
+ ]);
+ }
+
+ // Update the cache
+ $item->set($this->scheduleEvents);
+ $item->expiresAt(Carbon::now()->addMonths(2));
+
+ $this->pool->saveDeferred($item);
+
+ return;
+ }
+
+ /**
+ * Drop the event cache
+ * @param $key
+ */
+ private function dropEventCache($key = null)
+ {
+ $compKey = 'schedule/' . $this->eventId;
+
+ if ($key !== null) {
+ $compKey .= '/' . $key;
+ }
+
+ $this->pool->deleteItem($compKey);
+ }
+
+ /**
+ * Calculate the DayPart times
+ * @param Carbon $start
+ * @param Carbon $end
+ * @throws GeneralException
+ */
+ private function calculateDayPartTimes($start, $end)
+ {
+ $dayOfWeekLookup = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+ if (!$this->isAlwaysDayPart() && !$this->isCustomDayPart()) {
+ // TODO: replace with $dayPart->adjustForDate()?
+ // End is always based on Start
+ $end->setTimestamp($start->format('U'));
+
+ // Get the day part
+ $dayPart = $this->getDayPart();
+
+ $this->getLog()->debug(
+ 'Start and end time for dayPart is ' . $dayPart->startTime . ' - ' . $dayPart->endTime
+ );
+
+ // What day of the week does this start date represent?
+ // dayOfWeek is 0 for Sunday to 6 for Saturday
+ $found = false;
+ foreach ($dayPart->exceptions as $exception) {
+ // Is there an exception for this day of the week?
+ if ($exception['day'] == $dayOfWeekLookup[$start->dayOfWeek]) {
+ $start->setTimeFromTimeString($exception['start']);
+ $end->setTimeFromTimeString($exception['end']);
+
+ if ($start >= $end) {
+ $end->addDay();
+ }
+
+ $this->getLog()->debug(
+ 'Found exception Start and end time for dayPart exception is '
+ . $exception['start'] . ' - ' . $exception['end']
+ );
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ // Set the time section of our dates based on the daypart date
+ $start->setTimeFromTimeString($dayPart->startTime);
+ $end->setTimeFromTimeString($dayPart->endTime);
+
+ if ($start >= $end) {
+ $this->getLog()->debug('Start is ahead of end - adding a day to the end date');
+ $end->addDay();
+ }
+ }
+ }
+ }
+
+ /**
+ * Add Detail
+ * @param int $fromDt
+ * @param int $toDt
+ */
+ private function addDetail($fromDt, $toDt)
+ {
+ $this->scheduleEvents[] = new ScheduleEvent($fromDt, $toDt);
+ }
+
+ /**
+ * Manage the assignments
+ * @param bool $notify should we notify or not?
+ * @throws \Xibo\Exception\XiboException
+ */
+ private function manageAssignments($notify)
+ {
+ $this->linkDisplayGroups();
+ $this->unlinkDisplayGroups();
+
+ $this->getLog()->debug('manageAssignments: Assessing whether we need to notify');
+ $originalDisplayGroups = $this->getOriginalValue('displayGroups');
+
+ // Get the difference between the original display groups assigned and the new display groups assigned
+ if ($notify && $originalDisplayGroups !== null && $this->inScheduleLookAhead()) {
+ $diff = [];
+ foreach ($originalDisplayGroups as $element) {
+ /** @var \Xibo\Entity\DisplayGroup $element */
+ $diff[$element->getId()] = $element;
+ }
+
+ if (count($diff) > 0) {
+ $this->getLog()->debug(
+ 'manageAssignments: There are ' . count($diff) . ' existing DisplayGroups on this Event'
+ );
+ $ids = array_map(function ($element) {
+ return $element->getId();
+ }, $this->displayGroups);
+
+ $except = array_diff(array_keys($diff), $ids);
+
+ if (count($except) > 0) {
+ foreach ($except as $item) {
+ $this->getLog()->debug(
+ 'manageAssignments: calling notify on displayGroupId '
+ . $diff[$item]->getId()
+ );
+ $this->getDisplayNotifyService()->collectNow()->notifyByDisplayGroupId($diff[$item]->getId());
+ }
+ } else {
+ $this->getLog()->debug('manageAssignments: No need to notify');
+ }
+ } else {
+ $this->getLog()->debug('manageAssignments: No change to DisplayGroup assignments');
+ }
+ } else {
+ $this->getLog()->debug('manageAssignments: Not in look-ahead');
+ }
+ }
+
+ /**
+ * Link Layout
+ */
+ private function linkDisplayGroups()
+ {
+ // TODO: Make this more efficient by storing the prepared SQL statement
+ $sql = 'INSERT INTO `lkscheduledisplaygroup` (eventId, displayGroupId) VALUES (:eventId, :displayGroupId) ON DUPLICATE KEY UPDATE displayGroupId = displayGroupId';
+
+ $i = 0;
+ foreach ($this->displayGroups as $displayGroup) {
+ $i++;
+
+ $this->getStore()->insert($sql, array(
+ 'eventId' => $this->eventId,
+ 'displayGroupId' => $displayGroup->displayGroupId
+ ));
+ }
+ }
+
+ /**
+ * Unlink Layout
+ */
+ private function unlinkDisplayGroups()
+ {
+ // Unlink any layouts that are NOT in the collection
+ $params = ['eventId' => $this->eventId];
+
+ $sql = 'DELETE FROM `lkscheduledisplaygroup` WHERE eventId = :eventId AND displayGroupId NOT IN (0';
+
+ $i = 0;
+ foreach ($this->displayGroups as $displayGroup) {
+ $i++;
+ $sql .= ',:displayGroupId' . $i;
+ $params['displayGroupId' . $i] = $displayGroup->displayGroupId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * @return \Xibo\Entity\DayPart
+ * @throws \Xibo\Exception\NotFoundException
+ */
+ private function getDayPart()
+ {
+ if ($this->dayPart === null) {
+ $this->dayPart = $this->dayPartFactory->getById($this->dayPartId);
+ }
+
+ return $this->dayPart;
+ }
+
+ /**
+ * Is this event an always daypart event
+ * @return bool
+ * @throws NotFoundException
+ */
+ public function isAlwaysDayPart()
+ {
+ return $this->getDayPart()->isAlways === 1;
+ }
+
+ /**
+ * Is this event a custom daypart event
+ * @return bool
+ * @throws NotFoundException
+ */
+ public function isCustomDayPart()
+ {
+ return $this->getDayPart()->isCustom === 1;
+ }
+
+ /**
+ * Get next reminder date
+ * @param Carbon $now
+ * @param ScheduleReminder $reminder
+ * @param int $remindSeconds
+ * @return int|null
+ * @throws ConfigurationException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function getNextReminderDate($now, $reminder, $remindSeconds)
+ {
+ // Determine toDt so that we don't getEvents which never ends
+ // adding the recurrencedetail at the end (minute/hour/week) to make sure we get at least 2 next events
+ $toDt = $now->copy();
+
+ // For a future event we need to forward now to event fromDt
+ $fromDt = Carbon::createFromTimestamp($this->fromDt);
+ if ($fromDt > $toDt) {
+ $toDt = $fromDt;
+ }
+
+ switch ($this->recurrenceType) {
+ case 'Minute':
+ $toDt->minute(($toDt->minute + $this->recurrenceDetail) + $this->recurrenceDetail);
+ break;
+
+ case 'Hour':
+ $toDt->hour(($toDt->hour + $this->recurrenceDetail) + $this->recurrenceDetail);
+ break;
+
+ case 'Day':
+ $toDt->day(($toDt->day + $this->recurrenceDetail) + $this->recurrenceDetail);
+ break;
+
+ case 'Week':
+ $toDt->day(($toDt->day + $this->recurrenceDetail * 7 ) + $this->recurrenceDetail);
+ break;
+
+ case 'Month':
+ $toDt->month(($toDt->month + $this->recurrenceDetail ) + $this->recurrenceDetail);
+ break;
+
+ case 'Year':
+ $toDt->year(($toDt->year + $this->recurrenceDetail ) + $this->recurrenceDetail);
+ break;
+
+ default:
+ throw new InvalidArgumentException(__('Invalid recurrence type'), 'recurrenceType');
+ }
+
+ // toDt is set so that we get two next events from now
+ $scheduleEvents = $this->getEvents($now, $toDt);
+
+ foreach ($scheduleEvents as $event) {
+ if ($reminder->option == ScheduleReminder::$OPTION_BEFORE_START) {
+ $reminderDt = $event->fromDt - $remindSeconds;
+ if ($reminderDt >= $now->format('U')) {
+ return $reminderDt;
+ }
+ } elseif ($reminder->option == ScheduleReminder::$OPTION_AFTER_START) {
+ $reminderDt = $event->fromDt + $remindSeconds;
+ if ($reminderDt >= $now->format('U')) {
+ return $reminderDt;
+ }
+ } elseif ($reminder->option == ScheduleReminder::$OPTION_BEFORE_END) {
+ $reminderDt = $event->toDt - $remindSeconds;
+ if ($reminderDt >= $now->format('U')) {
+ return $reminderDt;
+ }
+ } elseif ($reminder->option == ScheduleReminder::$OPTION_AFTER_END) {
+ $reminderDt = $event->toDt + $remindSeconds;
+ if ($reminderDt >= $now->format('U')) {
+ return $reminderDt;
+ }
+ }
+ }
+
+ // No next event exist
+ throw new NotFoundException(__('reminderDt not found as next event does not exist'));
+ }
+
+ /**
+ * Get event title
+ * @return string
+ * @throws GeneralException
+ */
+ public function getEventTitle()
+ {
+ // Setting for whether we show Layouts with out permissions
+ $showLayoutName = ($this->config->getSetting('SCHEDULE_SHOW_LAYOUT_NAME') == 1);
+
+ // Load the display groups
+ $this->load();
+
+ $displayGroupList = '';
+
+ if (count($this->displayGroups) >= 0) {
+ $array = array_map(function ($object) {
+ return $object->displayGroup;
+ }, $this->displayGroups);
+ $displayGroupList = implode(', ', $array);
+ }
+
+ $user = $this->userFactory->getById($this->userId);
+
+ // Event Title
+ if ($this->campaignId == 0) {
+ // Command
+ $title = __('%s scheduled on %s', $this->command, $displayGroupList);
+ } else {
+ // Should we show the Layout name, or not (depending on permission)
+ // Make sure we only run the below code if we have to, its quite expensive
+ if (!$showLayoutName && !$user->isSuperAdmin()) {
+ // Campaign
+ $campaign = $this->campaignFactory->getById($this->campaignId);
+
+ if (!$user->checkViewable($campaign)) {
+ $this->campaign = __('Private Item');
+ }
+ }
+ $title = __('%s scheduled on %s', $this->campaign, $displayGroupList);
+ }
+
+ return $title;
+ }
+
+ private function toArray($jsonEncodeArrays = false)
+ {
+ $objectAsJson = $this->jsonSerialize();
+
+ foreach ($objectAsJson as $key => $value) {
+ $displayGroups = [];
+ if (is_array($value) && $jsonEncodeArrays) {
+ if ($key === 'displayGroups') {
+ foreach ($value as $index => $displayGroup) {
+ /** @var DisplayGroup $displayGroup */
+ $displayGroups[$index] = $displayGroup->jsonForAudit();
+ }
+
+ $objectAsJson[$key] = json_encode($displayGroups);
+ } else {
+ $objectAsJson[$key] = json_encode($value);
+ }
+ }
+
+ if (in_array($key, $this->datesToFormat)) {
+ $objectAsJson[$key] = !empty($value)
+ ? Carbon::createFromTimestamp($value)->format(DateFormatHelper::getSystemFormat())
+ : $value;
+ }
+
+ if ($key === 'campaignId' && isset($this->campaignFactory)) {
+ $campaign = $this->campaignFactory->getById($value);
+ $objectAsJson['campaign'] = $campaign->campaign;
+ }
+ }
+
+ return $objectAsJson;
+ }
+
+ /**
+ * Get all changed properties for this entity
+ * @param bool $jsonEncodeArrays
+ * @return array
+ * @throws NotFoundException
+ */
+ public function getChangedProperties($jsonEncodeArrays = false)
+ {
+ $changedProperties = [];
+
+ foreach ($this->jsonSerialize() as $key => $value) {
+ if (!is_array($value)
+ && !is_object($value)
+ && $this->propertyOriginallyExisted($key)
+ && $this->hasPropertyChanged($key)
+ ) {
+ if (in_array($key, $this->datesToFormat)) {
+ $original = empty($this->getOriginalValue($key))
+ ? $this->getOriginalValue($key)
+ : Carbon::createFromTimestamp($this->getOriginalValue($key))
+ ->format(DateFormatHelper::getSystemFormat());
+ $new = empty($value)
+ ? $value
+ : Carbon::createFromTimestamp($value)
+ ->format(DateFormatHelper::getSystemFormat());
+ $changedProperties[$key] = $original . ' > ' . $new;
+ } else {
+ $changedProperties[$key] = $this->getOriginalValue($key) . ' > ' . $value;
+
+ if ($key === 'campaignId' && isset($this->campaignFactory)) {
+ $campaign = $this->campaignFactory->getById($value);
+ $changedProperties['campaign'] =
+ $this->getOriginalValue('campaign') . ' > ' . $campaign->campaign;
+ }
+ }
+ }
+
+ if (is_array($value)
+ && $jsonEncodeArrays
+ && $this->propertyOriginallyExisted($key)
+ && $this->hasPropertyChanged($key)
+ ) {
+ if ($key === 'displayGroups') {
+ $displayGroups = [];
+ $originalDisplayGroups = [];
+
+ foreach ($this->getOriginalValue($key) as $index => $displayGroup) {
+ /** @var DisplayGroup $displayGroup */
+ $originalDisplayGroups[$index] = $displayGroup->jsonForAudit();
+ }
+
+ foreach ($value as $index => $displayGroup) {
+ $displayGroups[$index] = $displayGroup->jsonForAudit();
+ }
+
+ $changedProperties[$key] =
+ json_encode($originalDisplayGroups) . ' > ' . json_encode($displayGroups);
+ } else {
+ $changedProperties[$key] =
+ json_encode($this->getOriginalValue($key)) . ' > ' . json_encode($value);
+ }
+ }
+ }
+
+ return $changedProperties;
+ }
+
+ /**
+ * Get an array of event types
+ * @param array $exclude Event type IDs to exclude
+ * @return array
+ */
+ public static function getEventTypes(array $exclude = []): array
+ {
+ $eventTypes = [
+ ['eventTypeId' => self::$LAYOUT_EVENT, 'eventTypeName' => __('Layout')],
+ ['eventTypeId' => self::$COMMAND_EVENT, 'eventTypeName' => __('Command')],
+ ['eventTypeId' => self::$OVERLAY_EVENT, 'eventTypeName' => __('Overlay Layout')],
+ ['eventTypeId' => self::$INTERRUPT_EVENT, 'eventTypeName' => __('Interrupt Layout')],
+ ['eventTypeId' => self::$CAMPAIGN_EVENT, 'eventTypeName' => __('Campaign')],
+ ['eventTypeId' => self::$ACTION_EVENT, 'eventTypeName' => __('Action')],
+ ['eventTypeId' => self::$MEDIA_EVENT, 'eventTypeName' => __('Video/Image')],
+ ['eventTypeId' => self::$PLAYLIST_EVENT, 'eventTypeName' => __('Playlist')],
+ ['eventTypeId' => self::$SYNC_EVENT, 'eventTypeName' => __('Synchronised Event')],
+ ['eventTypeId' => self::$DATA_CONNECTOR_EVENT, 'eventTypeName' => __('Data Connector')],
+ ];
+
+ if (!empty($exclude)) {
+ $eventTypes = array_filter(
+ $eventTypes,
+ fn($type) => !in_array($type['eventTypeId'], $exclude, true)
+ );
+ }
+
+ return array_values($eventTypes);
+ }
+
+ /**
+ * @return string
+ */
+ public function getSyncTypeForEvent(): string
+ {
+ $layouts = $this->getStore()->select(
+ 'SELECT `schedule_sync`.layoutId FROM `schedule_sync` WHERE `schedule_sync`.eventId = :eventId',
+ ['eventId' => $this->eventId]
+ );
+
+ return (count(array_unique($layouts, SORT_REGULAR)) === 1)
+ ? __('Synchronised Mirrored Content')
+ : __('Synchronised Content');
+ }
+
+ /**
+ * @param SyncGroup $syncGroup
+ * @param SanitizerInterface $sanitizer
+ * @return void
+ * @throws NotFoundException
+ */
+ public function updateSyncLinks(SyncGroup $syncGroup, SanitizerInterface $sanitizer): void
+ {
+ foreach ($syncGroup->getSyncGroupMembers() as $display) {
+ $this->getStore()->insert('INSERT INTO `schedule_sync` (`eventId`, `displayId`, `layoutId`)
+ VALUES(:eventId, :displayId, :layoutId) ON DUPLICATE KEY UPDATE layoutId = :layoutId', [
+ 'eventId' => $this->eventId,
+ 'displayId' => $display->displayId,
+ 'layoutId' => $sanitizer->getInt('layoutId_' . $display->displayId)
+ ]);
+ }
+ }
+}
diff --git a/lib/Entity/ScheduleCriteria.php b/lib/Entity/ScheduleCriteria.php
new file mode 100644
index 0000000..770e93f
--- /dev/null
+++ b/lib/Entity/ScheduleCriteria.php
@@ -0,0 +1,144 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Schedule Criteria entity
+ * @SWG\Definition()
+ */
+class ScheduleCriteria implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public int $id;
+ public int $eventId;
+ public string $type;
+ public string $metric;
+ public string $condition;
+ public string $value;
+
+ public function __construct(
+ StorageServiceInterface $store,
+ LogServiceInterface $logService,
+ EventDispatcherInterface $dispatcher
+ ) {
+ $this->setCommonDependencies($store, $logService, $dispatcher);
+ }
+
+ /**
+ * Basic checks to make sure we have all the fields, etc that we need/
+ * @return void
+ * @throws InvalidArgumentException
+ */
+ private function validate(): void
+ {
+ if (empty($this->eventId)) {
+ throw new InvalidArgumentException(__('Criteria must be attached to an event'), 'eventId');
+ }
+
+ if (empty($this->metric)) {
+ throw new InvalidArgumentException(__('Please select a metric'), 'metric');
+ }
+
+ if (!in_array($this->condition, ['set', 'lt', 'lte', 'eq', 'neq', 'gt', 'gte', 'contains', 'ncontains'])) {
+ throw new InvalidArgumentException(__('Please enter a valid condition'), 'condition');
+ }
+ }
+
+ /**
+ * @throws NotFoundException|InvalidArgumentException
+ */
+ public function save(array $options = []): ScheduleCriteria
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'audit' => true,
+ ], $options);
+
+ // Validate?
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if (empty($this->id)) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+
+ if ($options['audit']) {
+ $this->audit($this->id, 'Saved schedule criteria to event', null, true);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Delete this criteria
+ * @return void
+ */
+ public function delete(): void
+ {
+ $this->getStore()->update('DELETE FROM `schedule_criteria` WHERE `id` = :id', ['id' => $this->id]);
+ }
+
+ private function add(): void
+ {
+ $this->id = $this->getStore()->insert('
+ INSERT INTO `schedule_criteria` (`eventId`, `type`, `metric`, `condition`, `value`)
+ VALUES (:eventId, :type, :metric, :condition, :value)
+ ', [
+ 'eventId' => $this->eventId,
+ 'type' => $this->type,
+ 'metric' => $this->metric,
+ 'condition' => $this->condition,
+ 'value' => $this->value,
+ ]);
+ }
+
+ private function edit(): void
+ {
+ $this->getStore()->update('
+ UPDATE `schedule_criteria` SET
+ `eventId` = :eventId,
+ `type` = :type,
+ `metric` = :metric,
+ `condition` = :condition,
+ `value` = :value
+ WHERE `id` = :id
+ ', [
+ 'eventId' => $this->eventId,
+ 'type' => $this->type,
+ 'metric' => $this->metric,
+ 'condition' => $this->condition,
+ 'value' => $this->value,
+ 'id' => $this->id,
+ ]);
+ }
+}
diff --git a/lib/Entity/ScheduleEvent.php b/lib/Entity/ScheduleEvent.php
new file mode 100644
index 0000000..6f3a903
--- /dev/null
+++ b/lib/Entity/ScheduleEvent.php
@@ -0,0 +1,38 @@
+fromDt = $fromDt;
+ $this->toDt = $toDt;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->fromDt . $this->toDt;
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/ScheduleExclusion.php b/lib/Entity/ScheduleExclusion.php
new file mode 100644
index 0000000..f7abf82
--- /dev/null
+++ b/lib/Entity/ScheduleExclusion.php
@@ -0,0 +1,91 @@
+.
+ */
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+
+/**
+ * Class ScheduleExclusion
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class ScheduleExclusion implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="Excluded Schedule ID")
+ * @var int
+ */
+ public $scheduleExclusionId;
+
+ /**
+ * @SWG\Property(description="The eventId that this Excluded Schedule applies to")
+ * @var int
+ */
+ public $eventId;
+
+ /**
+ * @SWG\Property(
+ * description="A Unix timestamp representing the from date of an excluded recurring event in CMS time."
+ * )
+ * @var int
+ */
+ public $fromDt;
+
+ /**
+ * @SWG\Property(
+ * description="A Unix timestamp representing the to date of an excluded recurring event in CMS time."
+ * )
+ * @var int
+ */
+ public $toDt;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function save()
+ {
+ $this->getStore()->insert('INSERT INTO `scheduleexclusions` (`eventId`, `fromDt`, `toDt`) VALUES (:eventId, :fromDt, :toDt)', [
+ 'eventId' => $this->eventId,
+ 'fromDt' => $this->fromDt,
+ 'toDt' => $this->toDt,
+ ]);
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `scheduleexclusions` WHERE `scheduleExclusionId` = :scheduleExclusionId', [
+ 'scheduleExclusionId' => $this->scheduleExclusionId
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/ScheduleReminder.php b/lib/Entity/ScheduleReminder.php
new file mode 100644
index 0000000..c2cf941
--- /dev/null
+++ b/lib/Entity/ScheduleReminder.php
@@ -0,0 +1,237 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+
+use Xibo\Factory\ScheduleReminderFactory;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+* Class ScheduleReminder
+* @package Xibo\Entity
+*
+* @SWG\Definition()
+*/
+class ScheduleReminder implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public static $TYPE_MINUTE = 1;
+ public static $TYPE_HOUR = 2;
+ public static $TYPE_DAY = 3;
+ public static $TYPE_WEEK = 4;
+ public static $TYPE_MONTH = 5;
+
+ public static $OPTION_BEFORE_START = 1;
+ public static $OPTION_AFTER_START = 2;
+ public static $OPTION_BEFORE_END = 3;
+ public static $OPTION_AFTER_END = 4;
+
+ public static $MINUTE = 60;
+ public static $HOUR = 3600;
+ public static $DAY = 86400;
+ public static $WEEK = 604800;
+ public static $MONTH = 30 * 86400;
+
+ /**
+ * @SWG\Property(description="Schedule Reminder ID")
+ * @var int
+ */
+ public $scheduleReminderId;
+
+ /**
+ * @SWG\Property(description="The event ID of the schedule reminder")
+ * @var int
+ */
+ public $eventId;
+
+ /**
+ * @SWG\Property(description="An integer number to define minutes, hours etc.")
+ * @var int
+ */
+ public $value;
+
+ /**
+ * @SWG\Property(description="The type of the reminder (i.e. Minute, Hour, Day, Week, Month)")
+ * @var int
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The options regarding sending a reminder for an event. (i.e., Before start, After start, Before end, After end)")
+ * @var int
+ */
+ public $option;
+
+ /**
+ * @SWG\Property(description="Email flag for schedule reminder")
+ * @var int
+ */
+ public $isEmail;
+
+ /**
+ * @SWG\Property(description="A date that indicates the reminder date")
+ * @var int
+ */
+ public $reminderDt;
+
+ /**
+ * @SWG\Property(description="Last reminder date a reminder was sent")
+ * @var int
+ */
+ public $lastReminderDt = 0;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var ScheduleReminderFactory
+ */
+ private $scheduleReminderFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $config
+ * @param ScheduleReminderFactory $scheduleReminderFactory
+ */
+ public function __construct($store, $log, $dispatcher, $config, $scheduleReminderFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->config = $config;
+ $this->scheduleReminderFactory = $scheduleReminderFactory;
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->scheduleReminderId = $this->getStore()->insert('
+ INSERT INTO `schedulereminder` (`eventId`, `value`, `type`, `option`, `reminderDt`, `isEmail`, `lastReminderDt`)
+ VALUES (:eventId, :value, :type, :option, :reminderDt, :isEmail, :lastReminderDt)
+ ', [
+ 'eventId' => $this->eventId,
+ 'value' => $this->value,
+ 'type' => $this->type,
+ 'option' => $this->option,
+ 'reminderDt' => $this->reminderDt,
+ 'isEmail' => $this->isEmail,
+ 'lastReminderDt' => $this->lastReminderDt,
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $sql = '
+ UPDATE `schedulereminder`
+ SET `eventId` = :eventId,
+ `type` = :type,
+ `value` = :value,
+ `option` = :option,
+ `reminderDt` = :reminderDt,
+ `isEmail` = :isEmail,
+ `lastReminderDt` = :lastReminderDt
+ WHERE scheduleReminderId = :scheduleReminderId
+ ';
+
+ $params = [
+ 'eventId' => $this->eventId,
+ 'type' => $this->type,
+ 'value' => $this->value,
+ 'option' => $this->option,
+ 'reminderDt' => $this->reminderDt,
+ 'isEmail' => $this->isEmail,
+ 'lastReminderDt' => $this->lastReminderDt,
+ 'scheduleReminderId' => $this->scheduleReminderId,
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->load();
+
+ $this->getLog()->debug('Delete schedule reminder: '.$this->scheduleReminderId);
+ $this->getStore()->update('DELETE FROM `schedulereminder` WHERE `scheduleReminderId` = :scheduleReminderId', [
+ 'scheduleReminderId' => $this->scheduleReminderId
+ ]);
+ }
+
+ /**
+ * Load
+ */
+ public function load()
+ {
+ if ($this->loaded || $this->scheduleReminderId == null) {
+ return;
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->scheduleReminderId;
+ }
+
+ /**
+ * Get Reminder Date
+ * @return int
+ */
+ public function getReminderDt()
+ {
+ return $this->reminderDt;
+ }
+
+ /**
+ * Save
+ */
+ public function save()
+ {
+ if ($this->scheduleReminderId == null || $this->scheduleReminderId == 0) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/SearchResult.php b/lib/Entity/SearchResult.php
new file mode 100644
index 0000000..01fd4a7
--- /dev/null
+++ b/lib/Entity/SearchResult.php
@@ -0,0 +1,71 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Xibo\Connector\ProviderDetails;
+
+/**
+ * @SWG\Definition()
+ */
+class SearchResult implements \JsonSerializable
+{
+ public $title;
+ public $description;
+ public $thumbnail;
+ public $source;
+ public $type;
+ public $id;
+ public $download;
+ public $fileSize;
+ public $width;
+ public $height;
+ public $orientation;
+ public $duration;
+ public $videoThumbnailUrl;
+ public $tags = [];
+ public $isFeatured = 0;
+
+ /** @var ProviderDetails */
+ public $provider;
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'source' => $this->source,
+ 'type' => $this->type,
+ 'title' => $this->title,
+ 'description' => $this->description,
+ 'thumbnail' => $this->thumbnail,
+ 'duration' => $this->duration,
+ 'download' => $this->download,
+ 'provider' => $this->provider,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'orientation' => $this->orientation,
+ 'fileSize' => $this->fileSize,
+ 'videoThumbnailUrl' => $this->videoThumbnailUrl,
+ 'tags' => $this->tags,
+ 'isFeatured' => $this->isFeatured
+ ];
+ }
+}
diff --git a/lib/Entity/SearchResults.php b/lib/Entity/SearchResults.php
new file mode 100644
index 0000000..7b75625
--- /dev/null
+++ b/lib/Entity/SearchResults.php
@@ -0,0 +1,37 @@
+.
+ */
+namespace Xibo\Entity;
+
+/**
+ * @SWG\Definition()
+ */
+class SearchResults implements \JsonSerializable
+{
+ public $data = [];
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'data' => $this->data
+ ];
+ }
+}
diff --git a/lib/Entity/Session.php b/lib/Entity/Session.php
new file mode 100644
index 0000000..c1b1c85
--- /dev/null
+++ b/lib/Entity/Session.php
@@ -0,0 +1,71 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class Session
+ * @package Xibo\Entity
+ */
+class Session implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public $sessionId;
+ public $userId;
+ public $userName;
+ public $isExpired;
+ public $lastAccessed;
+ public $remoteAddress;
+ public $userAgent;
+ public $expiresAt;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @return int the userId
+ */
+ public function getId(): int
+ {
+ return $this->userId;
+ }
+
+ /**
+ * @return int the owner UserId (always 1)
+ */
+ public function getOwnerId(): int
+ {
+ return 1;
+ }
+}
diff --git a/lib/Entity/Setting.php b/lib/Entity/Setting.php
new file mode 100644
index 0000000..eae75a7
--- /dev/null
+++ b/lib/Entity/Setting.php
@@ -0,0 +1,21 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\SyncGroupFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * @SWG\Definition()
+ */
+class SyncGroup implements \JsonSerializable
+{
+ use EntityTrait;
+ /**
+ * @SWG\Property(description="The ID of this Entity")
+ * @var int
+ */
+ public $syncGroupId;
+ /**
+ * @SWG\Property(description="The name of this Entity")
+ * @var string
+ */
+ public $name;
+ /**
+ * @SWG\Property(description="The datetime this entity was created")
+ * @var string
+ */
+ public $createdDt;
+ /**
+ * @SWG\Property(description="The datetime this entity was last modified")
+ * @var ?string
+ */
+ public $modifiedDt;
+ /**
+ * @SWG\Property(description="The ID of the user that last modified this sync group")
+ * @var int
+ */
+ public $modifiedBy;
+ /**
+ * @SWG\Property(description="The name of the user that last modified this sync group")
+ * @var string
+ */
+ public $modifiedByName;
+ /**
+ * @SWG\Property(description="The ID of the owner of this sync group")
+ * @var int
+ */
+ public $ownerId;
+ /**
+ * @SWG\Property(description="The name of the owner of this sync group")
+ * @var string
+ */
+ public $owner;
+ /**
+ * @SWG\Property(description="The publisher port number")
+ * @var int
+ */
+ public $syncPublisherPort = 9590;
+ /**
+ * @SWG\Property(description="The delay (in ms) when displaying the changes in content")
+ * @var int
+ */
+ public $syncSwitchDelay = 750;
+ /**
+ * @SWG\Property(description="The delay (in ms) before unpausing the video on start.")
+ * @var int
+ */
+ public $syncVideoPauseDelay = 100;
+ /**
+ * @SWG\Property(description="The ID of the lead Display for this sync group")
+ * @var int
+ */
+ public $leadDisplayId;
+ /**
+ * @SWG\Property(description="The name of the lead Display for this sync group")
+ * @var string
+ */
+ public $leadDisplay;
+ /**
+ * @SWG\Property(description="The id of the Folder this Sync Group belongs to")
+ * @var int
+ */
+ public $folderId;
+
+ /**
+ * @SWG\Property(description="The id of the Folder responsible for providing permissions for this Sync Group")
+ * @var int
+ */
+ public $permissionsFolderId;
+
+
+ private SyncGroupFactory $syncGroupFactory;
+ private DisplayFactory $displayFactory;
+ private PermissionFactory $permissionFactory;
+ private $permissions = [];
+ private ScheduleFactory $scheduleFactory;
+
+ /**
+ * @param $store
+ * @param $log
+ * @param $dispatcher
+ * @param SyncGroupFactory $syncGroupFactory
+ * @param DisplayFactory $displayFactory
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ SyncGroupFactory $syncGroupFactory,
+ DisplayFactory $displayFactory,
+ PermissionFactory $permissionFactory,
+ ScheduleFactory $scheduleFactory
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->setPermissionsClass('Xibo\Entity\SyncGroup');
+ $this->syncGroupFactory = $syncGroupFactory;
+ $this->displayFactory = $displayFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ }
+
+ /**
+ * @return Display[]
+ * @throws NotFoundException
+ */
+ public function getSyncGroupMembers(): array
+ {
+ return $this->displayFactory->getBySyncGroupId($this->syncGroupId);
+ }
+
+ /**
+ * @return array
+ */
+ public function getGroupMembersForForm(): array
+ {
+ return $this->getStore()->select('SELECT `display`.displayId, `display`.display, `display`.syncGroupId, `syncgroup`.leadDisplayId, `displaygroup`.displayGroupId
+ FROM `display`
+ INNER JOIN `syncgroup` ON `syncgroup`.syncGroupId = `display`.syncGroupId
+ INNER JOIN `lkdisplaydg` ON lkdisplaydg.displayid = display.displayId
+ INNER JOIN `displaygroup` ON displaygroup.displaygroupid = lkdisplaydg.displaygroupid AND `displaygroup`.isDisplaySpecific = 1
+ WHERE `display`.syncGroupId = :syncGroupId
+ ORDER BY IF(`syncgroup`.leadDisplayId = `display`.displayId, 0, 1), displayId', [
+ 'syncGroupId' => $this->syncGroupId
+ ]);
+ }
+
+ public function getGroupMembersForEditForm($eventId): array
+ {
+ return $this->getStore()->select('SELECT `display`.displayId, `display`.display, `display`.syncGroupId, `syncgroup`.leadDisplayId, `schedule_sync`.layoutId, `displaygroup`.displayGroupId
+ FROM `display`
+ INNER JOIN `syncgroup` ON `syncgroup`.syncGroupId = `display`.syncGroupId
+ INNER JOIN `schedule_sync` ON `schedule_sync`.displayId = `display`.displayId
+ INNER JOIN `lkdisplaydg` ON lkdisplaydg.displayid = display.displayId
+ INNER JOIN `displaygroup` ON displaygroup.displaygroupid = lkdisplaydg.displaygroupid AND `displaygroup`.isDisplaySpecific = 1
+ WHERE `display`.syncGroupId = :syncGroupId AND `schedule_sync`.eventId = :eventId
+ ORDER BY IF(`syncgroup`.leadDisplayId = `display`.displayId, 0, 1), displayId', [
+ 'syncGroupId' => $this->syncGroupId,
+ 'eventId' => $eventId
+ ]);
+ }
+
+ public function getLayoutIdForDisplay(int $eventId, int $displayId)
+ {
+ $layout = $this->getStore()->select('SELECT `schedule_sync`.layoutId
+ FROM `display`
+ INNER JOIN `schedule_sync` ON `schedule_sync`.displayId = `display`.displayId
+ WHERE `display`.syncGroupId = :syncGroupId AND `schedule_sync`.eventId = :eventId AND `schedule_sync`.displayId = :displayId', [
+ 'eventId' => $eventId,
+ 'displayId' => $displayId,
+ 'syncGroupId' => $this->syncGroupId
+ ]);
+
+ if (count($layout) <= 0) {
+ return null;
+ }
+
+ return $layout[0]['layoutId'];
+ }
+
+ /**
+ * @return int
+ */
+ public function getId(): int
+ {
+ return $this->syncGroupId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getPermissionFolderId(): int
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId(): int
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Set the owner of this group
+ * @param $userId
+ */
+ public function setOwner($userId): void
+ {
+ $this->ownerId = $userId;
+ }
+
+ /**
+ * Load the contents for this display group
+ * @param array $options
+ * @throws NotFoundException
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([], $options);
+
+ if ($this->loaded || $this->syncGroupId == null || $this->syncGroupId == 0) {
+ return;
+ }
+
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->syncGroupId);
+
+ // We are loaded
+ $this->loaded = true;
+ }
+
+ /**
+ * @param $options
+ * @return void
+ * @throws InvalidArgumentException
+ */
+ public function save($options = []): void
+ {
+ $options = array_merge([
+ 'validate' => true,
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if (!isset($this->syncGroupId)) {
+ $this->add();
+ } else {
+ $this->edit();
+ }
+ }
+
+ /**
+ * @return void
+ * @throws InvalidArgumentException
+ */
+ public function validate(): void
+ {
+ if (!v::stringType()->notEmpty()->validate($this->name)) {
+ throw new InvalidArgumentException(__('Name cannot be empty'), 'name');
+ }
+
+ if ($this->syncPublisherPort <= 0 || $this->syncPublisherPort === null) {
+ throw new InvalidArgumentException(__('Sync Publisher Port cannot be empty'), 'syncPublisherPort');
+ }
+
+ if (!isset($this->leadDisplayId) && isset($this->syncGroupId)) {
+ throw new InvalidArgumentException(__('Please select lead Display for this sync group'), 'leadDisplayId');
+ }
+
+ if ($this->syncSwitchDelay < 0) {
+ throw new InvalidArgumentException(__('Switch Delay value cannot be negative'), 'syncSwitchDelay');
+ }
+
+ if ($this->syncVideoPauseDelay < 0) {
+ throw new InvalidArgumentException(__('Video Pause Delay value cannot be negative'), 'syncVideoPauseDelay');
+ }
+ }
+
+ public function validateForSchedule(SanitizerInterface $sanitizer)
+ {
+ foreach ($this->getSyncGroupMembers() as $display) {
+ if (empty($sanitizer->getInt('layoutId_' . $display->displayId))) {
+ $this->getLog()->error('Sync Event : Missing Layout for DisplayID ' . $display->displayId);
+ throw new InvalidArgumentException(
+ __('Please make sure to select a Layout for all Displays in this Sync Group.')
+ );
+ }
+ }
+ }
+
+ private function add(): void
+ {
+ $time = Carbon::now()->format(DateFormatHelper::getSystemFormat());
+
+ $this->syncGroupId = $this->getStore()->insert('
+ INSERT INTO syncgroup (`name`, `createdDt`, `modifiedDt`, `ownerId`, `modifiedBy`, `syncPublisherPort`, `syncSwitchDelay`, `syncVideoPauseDelay`, `folderId`, `permissionsFolderId`)
+ VALUES (:name, :createdDt, :modifiedDt, :ownerId, :modifiedBy, :syncPublisherPort, :syncSwitchDelay, :syncVideoPauseDelay, :folderId, :permissionsFolderId)
+ ', [
+ 'name' => $this->name,
+ 'createdDt' => $time,
+ 'modifiedDt' => null,
+ 'modifiedBy' => $this->modifiedBy,
+ 'ownerId' => $this->ownerId,
+ 'syncPublisherPort' => $this->syncPublisherPort,
+ 'syncSwitchDelay' => $this->syncSwitchDelay,
+ 'syncVideoPauseDelay' => $this->syncVideoPauseDelay,
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId
+ ]);
+ }
+
+ private function edit(): void
+ {
+ $this->getLog()->debug(sprintf('Updating Sync Group. %s, %d', $this->name, $this->syncGroupId));
+ $time = Carbon::now()->format(DateFormatHelper::getSystemFormat());
+
+ $this->getStore()->update('
+ UPDATE syncgroup
+ SET `name` = :name,
+ `modifiedDt` = :modifiedDt,
+ `ownerId` = :ownerId,
+ `modifiedBy` = :modifiedBy,
+ `syncPublisherPort` = :syncPublisherPort,
+ `syncSwitchDelay` = :syncSwitchDelay,
+ `syncVideoPauseDelay` = :syncVideoPauseDelay,
+ `leadDisplayId` = :leadDisplayId,
+ `folderId` = :folderId,
+ `permissionsFolderId` = :permissionsFolderId
+ WHERE syncGroupId = :syncGroupId
+ ', [
+ 'name' => $this->name,
+ 'modifiedDt' => $time,
+ 'ownerId' => $this->ownerId,
+ 'modifiedBy' => $this->modifiedBy,
+ 'syncPublisherPort' => $this->syncPublisherPort,
+ 'syncSwitchDelay' => $this->syncSwitchDelay,
+ 'syncVideoPauseDelay' => $this->syncVideoPauseDelay,
+ 'leadDisplayId' => $this->leadDisplayId == 0 ? null : $this->leadDisplayId,
+ 'folderId' => $this->folderId,
+ 'permissionsFolderId' => $this->permissionsFolderId,
+ 'syncGroupId' => $this->syncGroupId,
+ ]);
+ }
+
+ /**
+ * @return void
+ * @throws NotFoundException
+ */
+ public function delete(): void
+ {
+ // unlink Displays from this syncGroup
+ foreach ($this->getSyncGroupMembers() as $display) {
+ $this->getStore()->update('UPDATE `display` SET `display`.syncGroupId = NULL WHERE `display`.displayId = :displayId', [
+ 'displayId' => $display->displayId
+ ]);
+ }
+
+ // go through events using this syncGroupId and remove them
+ // this will also remove links in schedule_sync table
+ foreach ($this->scheduleFactory->getBySyncGroupId($this->syncGroupId) as $event) {
+ $event->delete();
+ }
+
+ $this->getStore()->update('DELETE FROM `syncgroup` WHERE `syncgroup`.syncGroupId = :syncGroupId', [
+ 'syncGroupId' => $this->syncGroupId
+ ]);
+ }
+
+ /**
+ * @param array $displayIds
+ * @return void
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function setMembers(array $displayIds): void
+ {
+ foreach ($displayIds as $displayId) {
+ $display = $this->displayFactory->getById($displayId);
+
+ if (empty($display->syncGroupId)) {
+ $this->getStore()->update('UPDATE `display` SET `display`.syncGroupId = :syncGroupId WHERE `display`.displayId = :displayId', [
+ 'syncGroupId' => $this->syncGroupId,
+ 'displayId' => $display->displayId
+ ]);
+
+ $display->notify();
+ } else if (!empty($display->syncGroupId) && $display->syncGroupId !== $this->syncGroupId) {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('Display %s already belongs to a different sync group ID %d'),
+ $display->display,
+ $display->syncGroupId
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * @param array $displayIds
+ * @return void
+ * @throws NotFoundException
+ */
+ public function unSetMembers(array $displayIds): void
+ {
+ foreach ($displayIds as $displayId) {
+ $display = $this->displayFactory->getById($displayId);
+
+ if ($display->syncGroupId === $this->syncGroupId) {
+ $this->getStore()->update('UPDATE `display` SET `display`.syncGroupId = NULL WHERE `display`.displayId = :displayId', [
+ 'displayId' => $display->displayId
+ ]);
+
+ $this->getStore()->update(' DELETE FROM `schedule_sync` WHERE `schedule_sync`.displayId = :displayId
+ AND `schedule_sync`.eventId IN (SELECT eventId FROM schedule WHERE schedule.syncGroupId = :syncGroupId)', [
+ 'displayId' => $display->displayId,
+ 'syncGroupId' => $this->syncGroupId
+ ]);
+ }
+
+ $display->notify();
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/Tag.php b/lib/Entity/Tag.php
new file mode 100644
index 0000000..1ddc26a
--- /dev/null
+++ b/lib/Entity/Tag.php
@@ -0,0 +1,236 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Factory\TagFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+
+/**
+ * Class Tag
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Tag implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Tag ID")
+ * @var int
+ */
+ public $tagId;
+
+ /**
+ * @SWG\Property(description="The Tag Name")
+ * @var string
+ */
+ public $tag;
+
+ /**
+ * @SWG\Property(description="Flag, whether the tag is a system tag")
+ * @var int
+ */
+ public $isSystem = 0;
+
+ /**
+ * @SWG\Property(description="Flag, whether the tag requires additional values")
+ * @var int
+ */
+ public $isRequired = 0;
+
+ /**
+ * @SWG\Property(description="An array of options assigned to this Tag")
+ * @var ?string
+ */
+ public $options;
+
+ /** @var TagFactory */
+ private $tagFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param TagFactory $tagFactory
+ */
+ public function __construct($store, $log, $dispatcher, $tagFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->tagFactory = $tagFactory;
+ }
+
+ public function __clone()
+ {
+ $this->tagId = null;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws DuplicateEntityException
+ */
+ public function validate()
+ {
+ // Name Validation
+ if (strlen($this->tag) > 50 || strlen($this->tag) < 1) {
+ throw new InvalidArgumentException(__("Tag must be between 1 and 50 characters"), 'tag');
+ }
+
+ // Check for duplicates
+ $duplicates = $this->tagFactory->query(null, [
+ 'tagExact' => $this->tag,
+ 'notTagId' => $this->tagId,
+ 'disableUserCheck' => 1
+ ]);
+
+ if (count($duplicates) > 0) {
+ throw new DuplicateEntityException(sprintf(__("You already own a Tag called '%s'. Please choose another name."), $this->tag));
+ }
+ }
+
+ /**
+ * Save
+ * @param array $options
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ // Default options
+ $options = array_merge([
+ 'validate' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ // If the tag doesn't exist already - save it
+ if ($this->tagId == null || $this->tagId == 0) {
+ $this->add();
+ } else {
+ $this->update();
+ }
+
+ $this->getLog()->debug('Saving Tag: %s, %d', $this->tag, $this->tagId);
+ }
+
+ /**
+ * Add a tag
+ * @throws \PDOException
+ */
+ private function add()
+ {
+ $this->tagId = $this->getStore()->insert('INSERT INTO `tag` (tag, isRequired, options) VALUES (:tag, :isRequired, :options) ON DUPLICATE KEY UPDATE tag = tag', [
+ 'tag' => $this->tag,
+ 'isRequired' => $this->isRequired,
+ 'options' => ($this->options == null) ? null : $this->options
+ ]);
+ }
+
+ /**
+ * Update a Tag
+ * @throws \PDOException
+ */
+ private function update()
+ {
+ $this->getStore()->update('UPDATE `tag` SET tag = :tag, isRequired = :isRequired, options = :options WHERE tagId = :tagId', [
+ 'tagId' => $this->tagId,
+ 'tag' => $this->tag,
+ 'isRequired' => $this->isRequired,
+ 'options' => ($this->options == null) ? null : $this->options
+ ]);
+ }
+
+ /**
+ * Delete Tag
+ */
+ public function delete()
+ {
+ // Delete the Tag record
+ $this->getStore()->update('DELETE FROM `tag` WHERE tagId = :tagId', ['tagId' => $this->tagId]);
+ }
+
+ /**
+ * Is this tag a system tag?
+ * @return bool
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function isSystemTag()
+ {
+ $tag = $this->tagFactory->getById($this->tagId);
+
+ return $tag->isSystem === 1;
+ }
+
+ /**
+ * Removes Tag value from lktagtables
+ *
+ * @param array $values An Array of values that should be removed from assignment
+ */
+ public function updateTagValues($values)
+ {
+ $this->getLog()->debug('Tag options were changed, the following values need to be removed ' . json_encode($values));
+
+ foreach ($values as $value) {
+ $this->getLog()->debug('removing following value from lktag tables ' . $value);
+
+ $this->getStore()->update('UPDATE `lktagcampaign` SET `value` = null WHERE tagId = :tagId AND value = :value',
+ [
+ 'value' => $value,
+ 'tagId' => $this->tagId
+ ]);
+
+ $this->getStore()->update('UPDATE `lktagdisplaygroup` SET `value` = null WHERE tagId = :tagId AND value = :value',
+ [
+ 'value' => $value,
+ 'tagId' => $this->tagId
+ ]);
+
+ $this->getStore()->update('UPDATE `lktaglayout` SET `value` = null WHERE tagId = :tagId AND value = :value',
+ [
+ 'value' => $value,
+ 'tagId' => $this->tagId
+ ]);
+
+ $this->getStore()->update('UPDATE `lktagmedia` SET `value` = null WHERE tagId = :tagId AND value = :value',
+ [
+ 'value' => $value,
+ 'tagId' => $this->tagId
+ ]);
+
+ $this->getStore()->update('UPDATE `lktagplaylist` SET `value` = null WHERE tagId = :tagId AND value = :value',
+ [
+ 'value' => $value,
+ 'tagId' => $this->tagId
+ ]);
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/TagLink.php b/lib/Entity/TagLink.php
new file mode 100644
index 0000000..f5149ff
--- /dev/null
+++ b/lib/Entity/TagLink.php
@@ -0,0 +1,92 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * @SWG\Definition()
+ */
+class TagLink implements \JsonSerializable
+{
+ use EntityTrait;
+ /**
+ * @SWG\Property(description="The Tag")
+ * @var string
+ */
+ public $tag;
+ /**
+ * @SWG\Property(description="The Tag ID")
+ * @var int
+ */
+ public $tagId;
+ /**
+ * @SWG\Property(description="The Tag Value")
+ * @var string
+ */
+ public $value = null;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function validateOptions(Tag $tag)
+ {
+ if ($tag->options) {
+ if (!is_array($tag->options)) {
+ $tag->options = json_decode($tag->options);
+ }
+
+ if (!empty($this->value) && !in_array($this->value, $tag->options)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('Provided tag value %s, not found in tag %s options, please select the correct value'),
+ $this->value,
+ $this->tag
+ ),
+ 'tagValue'
+ );
+ }
+ }
+
+ if (empty($this->value) && $tag->isRequired === 1) {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('Selected Tag %s requires a value, please enter the Tag in %s|Value format or provide Tag value in the dedicated field.'),
+ $this->tag,
+ $this->tag
+ ),
+ 'tagValue'
+ );
+ }
+ }
+}
diff --git a/lib/Entity/TagLinkTrait.php b/lib/Entity/TagLinkTrait.php
new file mode 100644
index 0000000..4cab330
--- /dev/null
+++ b/lib/Entity/TagLinkTrait.php
@@ -0,0 +1,180 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class TagLinkTrait
+ * @package Xibo\Entity
+ */
+trait TagLinkTrait
+{
+ /**
+ * Does the Entity have the provided tag?
+ * @param $searchTag
+ * @return bool
+ * @throws NotFoundException
+ */
+ public function hasTag($searchTag)
+ {
+ foreach ($this->tags as $tag) {
+ if ($tag->tag == $searchTag) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Assign Tag
+ * @param TagLink $tag
+ * @return $this
+ */
+ public function assignTag(TagLink $tag)
+ {
+ $this->linkTags[] = $tag;
+ $this->tags[] = $tag;
+
+ return $this;
+ }
+
+ /**
+ * Unassign tag
+ * @param TagLink $tag
+ * @return $this
+ */
+ public function unassignTag(TagLink $tag)
+ {
+ $this->unlinkTags[] = $tag;
+
+ foreach ($this->tags as $key => $currentTag) {
+ if ($currentTag->tagId === $tag->tagId) {
+ array_splice($this->tags, $key, 1);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Link
+ */
+ public function linkTagToEntity($table, $column, $entityId, $tagId, $value)
+ {
+ $this->getLog()->debug(sprintf('Linking %s %d, to tagId %d', $column, $entityId, $tagId));
+
+ $this->getStore()->update('INSERT INTO `' . $table .'` (`tagId`, `'.$column.'`, `value`) VALUES (:tagId, :entityId, :value) ON DUPLICATE KEY UPDATE '.$column.' = :entityId, `value` = :value', [
+ 'tagId' => $tagId,
+ 'entityId' => $entityId,
+ 'value' => $value
+ ]);
+ }
+
+ /**
+ * Unlink
+ */
+ public function unlinkTagFromEntity($table, $column, $entityId, $tagId)
+ {
+ $this->getLog()->debug(sprintf('Unlinking %s %d, from tagId %d', $column, $entityId, $tagId));
+
+ $this->getStore()->update('DELETE FROM `'.$table.'` WHERE tagId = :tagId AND `'.$column.'` = :entityId', [
+ 'tagId' => $tagId,
+ 'entityId' => $entityId
+ ]);
+ }
+
+ /**
+ * Unlink all Tags from Entity
+ */
+ public function unlinkAllTagsFromEntity($table, $column, $entityId)
+ {
+ $this->getLog()->debug(sprintf('Unlinking all Tags from %s %d', $column, $entityId));
+
+ $this->getStore()->update('DELETE FROM `'.$table.'` WHERE `'.$column.'` = :entityId', [
+ 'entityId' => $entityId
+ ]);
+ }
+
+ /**
+ * @param TagLink[] $tags
+ */
+ public function updateTagLinks($tags = [])
+ {
+ if ($this->tags != $tags) {
+ $this->unlinkTags = array_udiff($this->tags, $tags, function ($a, $b) {
+ /* @var TagLink $a */
+ /* @var TagLink $b */
+ return $a->tagId - $b->tagId;
+ });
+
+ $this->getLog()->debug(sprintf('Tags to be removed: %s', json_encode($this->unlinkTags)));
+
+ // see what we need to add
+ $this->linkTags = array_udiff($tags, $this->tags, function ($a, $b) {
+ /* @var TagLink $a */
+ /* @var TagLink $b */
+ if ($a->value !== $b->value && $a->tagId === $b->tagId) {
+ return -1;
+ } else {
+ return $a->tagId - $b->tagId;
+ }
+ });
+
+ // Replace the arrays
+ $this->tags = $tags;
+
+ $this->getLog()->debug(sprintf('Tags to be added: %s', json_encode($this->linkTags)));
+
+ $this->getLog()->debug(sprintf('Tags remaining: %s', json_encode($this->tags)));
+ } else {
+ $this->getLog()->debug('Tags were not changed');
+ }
+ }
+
+ /**
+ * Convert TagLink array into a string for use on forms.
+ * @return string
+ */
+ public function getTagString()
+ {
+ $tagsString = '';
+
+ if (empty($this->tags)) {
+ return $tagsString;
+ }
+
+ $i = 1;
+ foreach ($this->tags as $tagLink) {
+ /** @var TagLink $tagLink */
+ if ($i > 1) {
+ $tagsString .= ',';
+ }
+ $tagsString .= $tagLink->tag . (($tagLink->value) ? '|' . $tagLink->value : '');
+ $i++;
+ }
+
+ return $tagsString;
+ }
+}
diff --git a/lib/Entity/Task.php b/lib/Entity/Task.php
new file mode 100644
index 0000000..01a0459
--- /dev/null
+++ b/lib/Entity/Task.php
@@ -0,0 +1,309 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Carbon\Carbon;
+use Cron\CronExpression;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Task
+ * @package Xibo\XTR
+ */
+class Task implements \JsonSerializable
+{
+ use EntityTrait;
+
+ public static $STATUS_RUNNING = 1;
+ public static $STATUS_IDLE = 2;
+ public static $STATUS_ERROR = 3;
+ public static $STATUS_SUCCESS = 4;
+ public static $STATUS_TIMEOUT = 5;
+
+ public $taskId;
+ public $name;
+ public $configFile;
+ public $class;
+ public $status;
+ public $pid = 0;
+ public $options = [];
+ public $schedule;
+ public $lastRunDt = 0;
+ public $lastRunStartDt;
+ public $lastRunMessage;
+ public $lastRunStatus;
+ public $lastRunDuration = 0;
+ public $lastRunExitCode = 0;
+ public $isActive;
+ public $runNow;
+
+ /**
+ * Command constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * @return \DateTime|string
+ * @throws \Exception
+ */
+ public function nextRunDate(): \DateTime|string
+ {
+ try {
+ try {
+ $cron = new CronExpression($this->schedule);
+ } catch (\Exception $e) {
+ // Try and take the first X characters instead.
+ try {
+ $cron = new CronExpression(substr($this->schedule, 0, strlen($this->schedule) - 2));
+ } catch (\Exception) {
+ $this->getLog()->error('nextRunDate: cannot fix CRON syntax error ' . $this->taskId);
+ throw $e;
+ }
+ }
+
+ if ($this->lastRunDt == 0) {
+ return (new \DateTime())->format('U');
+ }
+
+ return $cron->getNextRunDate(\DateTime::createFromFormat('U', $this->lastRunDt))->format('U');
+ } catch (\Exception) {
+ $this->getLog()->error('Invalid CRON expression for TaskId ' . $this->taskId);
+
+ $this->status = self::$STATUS_ERROR;
+ return (new \DateTime())->add(new \DateInterval('P1Y'))->format('U');
+ }
+ }
+
+ /**
+ * Set class and options
+ * @throws NotFoundException
+ */
+ public function setClassAndOptions()
+ {
+ if ($this->configFile == null)
+ throw new NotFoundException(__('No config file recorded for task. Please recreate.'));
+
+ // Get the class and default set of options from the config file.
+ if (!file_exists(PROJECT_ROOT . $this->configFile))
+ throw new NotFoundException(__('Config file not found for Task'));
+
+ $config = json_decode(file_get_contents(PROJECT_ROOT . $this->configFile), true);
+ $this->class = $config['class'];
+ $this->options = array_merge($config['options'], $this->options);
+ }
+
+ /**
+ * Validate
+ * @throws InvalidArgumentException
+ */
+ private function validate(): void
+ {
+ // Test the CRON expression
+ if (empty($this->schedule)) {
+ throw new InvalidArgumentException(__('Please enter a CRON expression in the Schedule'), 'schedule');
+ }
+
+ try {
+ $cron = new CronExpression($this->schedule);
+ $cron->getNextRunDate();
+ } catch (\Exception $e) {
+ $this->getLog()->info('run: CRON syntax error for taskId ' . $this->taskId
+ . ', e: ' . $e->getMessage());
+
+ try {
+ $trimmed = substr($this->schedule, 0, strlen($this->schedule) - 2);
+ $cron = new CronExpression($trimmed);
+ $cron->getNextRunDate();
+ } catch (\Exception) {
+ throw new InvalidArgumentException(__('Invalid CRON expression in the Schedule'), 'schedule');
+ }
+
+ // Swap to the trimmed (and correct) schedule
+ $this->schedule = $trimmed;
+ }
+ }
+
+ /**
+ * Save
+ * @throws InvalidArgumentException
+ */
+ public function save(array $options = []): void
+ {
+ $options = array_merge([
+ 'validate' => true,
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->taskId == null) {
+ $this->add();
+ } else {
+ // If we've transitioned from active to inactive, then reset the task status
+ if ($this->getOriginalValue('isActive') != $this->isActive) {
+ $this->status = Task::$STATUS_IDLE;
+ }
+
+ $this->edit();
+ }
+ }
+
+ /**
+ * Delete
+ */
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `task` WHERE `taskId` = :taskId', ['taskId' => $this->taskId]);
+ }
+
+ private function add()
+ {
+ $this->taskId = $this->getStore()->insert('
+ INSERT INTO `task` (`name`, `status`, `configFile`, `class`, `pid`, `options`, `schedule`,
+ `lastRunDt`, `lastRunMessage`, `lastRunStatus`, `lastRunDuration`, `lastRunExitCode`,
+ `isActive`, `runNow`) VALUES
+ (:name, :status, :configFile, :class, :pid, :options, :schedule,
+ :lastRunDt, :lastRunMessage, :lastRunStatus, :lastRunDuration, :lastRunExitCode,
+ :isActive, :runNow)
+ ', [
+ 'name' => $this->name,
+ 'status' => $this->status,
+ 'pid' => $this->pid,
+ 'configFile' => $this->configFile,
+ 'class' => $this->class,
+ 'options' => json_encode($this->options),
+ 'schedule' => $this->schedule,
+ 'lastRunDt' => $this->lastRunDt,
+ 'lastRunMessage' => $this->lastRunMessage,
+ 'lastRunStatus' => $this->lastRunStatus,
+ 'lastRunDuration' => $this->lastRunDuration,
+ 'lastRunExitCode' => $this->lastRunExitCode,
+ 'isActive' => $this->isActive,
+ 'runNow' => $this->runNow
+ ]);
+ }
+
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `task` SET
+ `name` = :name,
+ `status` = :status,
+ `pid` = :pid,
+ `configFile` = :configFile,
+ `class` = :class,
+ `options` = :options,
+ `schedule` = :schedule,
+ `lastRunDt` = :lastRunDt,
+ `lastRunMessage` = :lastRunMessage,
+ `lastRunStatus` = :lastRunStatus,
+ `lastRunDuration` = :lastRunDuration,
+ `lastRunExitCode` = :lastRunExitCode,
+ `isActive` = :isActive,
+ `runNow` = :runNow
+ WHERE `taskId` = :taskId
+ ', [
+ 'taskId' => $this->taskId,
+ 'name' => $this->name,
+ 'status' => $this->status,
+ 'pid' => $this->pid,
+ 'configFile' => $this->configFile,
+ 'class' => $this->class,
+ 'options' => json_encode($this->options),
+ 'schedule' => $this->schedule,
+ 'lastRunDt' => $this->lastRunDt,
+ 'lastRunMessage' => $this->lastRunMessage,
+ 'lastRunStatus' => $this->lastRunStatus,
+ 'lastRunDuration' => $this->lastRunDuration,
+ 'lastRunExitCode' => $this->lastRunExitCode,
+ 'isActive' => $this->isActive,
+ 'runNow' => $this->runNow
+ ]);
+ }
+
+ /**
+ * Set this task to be started, updating the DB as necessary
+ * @return $this
+ */
+ public function setStarted(): Task
+ {
+ // Set to running
+ $this->status = \Xibo\Entity\Task::$STATUS_RUNNING;
+ $this->lastRunStartDt = Carbon::now()->format('U');
+ $this->pid = getmypid();
+
+ $this->store->update('
+ UPDATE `task` SET `status` = :status, lastRunStartDt = :lastRunStartDt, pid = :pid
+ WHERE taskId = :taskId
+ ', [
+ 'taskId' => $this->taskId,
+ 'status' => $this->status,
+ 'lastRunStartDt' => $this->lastRunStartDt,
+ 'pid' => $this->pid,
+ ], 'xtr', true, false);
+
+ return $this;
+ }
+
+ /**
+ * Set this task to be finished, updating only the fields we might have changed
+ * @return $this
+ */
+ public function setFinished(): Task
+ {
+ $this->getStore()->update('
+ UPDATE `task` SET
+ `status` = :status,
+ `pid` = :pid,
+ `lastRunDt` = :lastRunDt,
+ `lastRunMessage` = :lastRunMessage,
+ `lastRunStatus` = :lastRunStatus,
+ `lastRunDuration` = :lastRunDuration,
+ `lastRunExitCode` = :lastRunExitCode,
+ `runNow` = :runNow
+ WHERE `taskId` = :taskId
+ ', [
+ 'taskId' => $this->taskId,
+ 'status' => $this->status,
+ 'pid' => $this->pid,
+ 'lastRunDt' => $this->lastRunDt,
+ 'lastRunMessage' => $this->lastRunMessage,
+ 'lastRunStatus' => $this->lastRunStatus,
+ 'lastRunDuration' => $this->lastRunDuration,
+ 'lastRunExitCode' => $this->lastRunExitCode,
+ 'runNow' => $this->runNow
+ ], 'xtr', true, false);
+
+ return $this;
+ }
+}
diff --git a/lib/Entity/Transition.php b/lib/Entity/Transition.php
new file mode 100644
index 0000000..fcd6fda
--- /dev/null
+++ b/lib/Entity/Transition.php
@@ -0,0 +1,120 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+
+/**
+ * Class Transition
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Transition
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The transition ID")
+ * @var int
+ */
+ public $transitionId;
+
+ /**
+ * @SWG\Property(description="The transition name")
+ * @var string
+ */
+ public $transition;
+
+ /**
+ * @SWG\Property(description="Code for transition")
+ * @var string
+ */
+ public $code;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this is a directional transition")
+ * @var int
+ */
+ public $hasDirection;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this transition has a duration option")
+ * @var int
+ */
+ public $hasDuration;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this transition should be available for IN assignments")
+ * @var int
+ */
+ public $availableAsIn;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this transition should be available for OUT assignments")
+ * @var int
+ */
+ public $availableAsOut;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function getId()
+ {
+ return $this->transitionId;
+ }
+
+ public function getOwnerId()
+ {
+ return 1;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function save()
+ {
+ if ($this->transitionId == null || $this->transitionId == 0) {
+ throw new InvalidArgumentException();
+ }
+
+ $this->getStore()->update('
+ UPDATE `transition` SET AvailableAsIn = :availableAsIn, AvailableAsOut = :availableAsOut WHERE transitionID = :transitionId
+ ', [
+ 'availableAsIn' => $this->availableAsIn,
+ 'availableAsOut' => $this->availableAsOut,
+ 'transitionId' => $this->transitionId
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/User.php b/lib/Entity/User.php
new file mode 100644
index 0000000..7150e29
--- /dev/null
+++ b/lib/Entity/User.php
@@ -0,0 +1,1577 @@
+.
+ */
+namespace Xibo\Entity;
+
+use League\OAuth2\Server\Entities\UserEntityInterface;
+use Respect\Validation\Validator as v;
+use Xibo\Factory\ApplicationScopeFactory;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\UserOptionFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\Pbkdf2Hash;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\LibraryFullException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class User
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class User implements \JsonSerializable, UserEntityInterface
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The ID of this User")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="The user name")
+ * @var string
+ */
+ public $userName;
+
+ /**
+ * @SWG\Property(description="The user type ID")
+ * @var int
+ */
+ public $userTypeId;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether this user is logged in or not")
+ * @var int
+ */
+ public $loggedIn;
+
+ /**
+ * @SWG\Property(description="Email address of the user used for email alerts")
+ * @var string
+ */
+ public $email;
+
+ /**
+ * @SWG\Property(description="The pageId of the Homepage for this User")
+ * @var int
+ */
+ public $homePageId;
+
+ /**
+ * @SWG\Property(description="This users home folder")
+ * @var int
+ */
+ public $homeFolderId;
+
+ /**
+ * @SWG\Property(description="A timestamp indicating the time the user last logged into the CMS")
+ * @var int
+ */
+ public $lastAccessed;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this user has see the new user wizard")
+ * @var int
+ */
+ public $newUserWizard = 0;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether the user is retired")
+ * @var int
+ */
+ public $retired;
+
+ private $CSPRNG;
+ private $password;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether password change should be forced for this user")
+ * @var int
+ */
+ public $isPasswordChangeRequired = 0;
+
+ /**
+ * @SWG\Property(description="The users user group ID")
+ * @var int
+ */
+ public $groupId;
+
+ /**
+ * @SWG\Property(description="The users group name")
+ * @var int
+ */
+ public $group;
+
+ /**
+ * @SWG\Property(description="The users library quota in bytes")
+ * @var int
+ */
+ public $libraryQuota;
+
+ /**
+ * @SWG\Property(description="First Name")
+ * @var string
+ */
+ public $firstName;
+
+ /**
+ * @SWG\Property(description="Last Name")
+ * @var string
+ */
+ public $lastName;
+
+ /**
+ * @SWG\Property(description="Phone Number")
+ * @var string
+ */
+ public $phone;
+
+ /**
+ * @SWG\Property(description="Reference field 1")
+ * @var string
+ */
+ public $ref1;
+
+ /**
+ * @SWG\Property(description="Reference field 2")
+ * @var string
+ */
+ public $ref2;
+
+ /**
+ * @SWG\Property(description="Reference field 3")
+ * @var string
+ */
+ public $ref3;
+
+ /**
+ * @SWG\Property(description="Reference field 4")
+ * @var string
+ */
+ public $ref4;
+
+ /**
+ * @SWG\Property(description="Reference field 5")
+ * @var string
+ */
+ public $ref5;
+
+ /**
+ * @SWG\Property(description="An array of user groups this user is assigned to")
+ * @var UserGroup[]
+ */
+ public $groups = [];
+
+ /**
+ * @SWG\Property(description="An array of Campaigns for this User")
+ * @var Campaign[]
+ */
+ public $campaigns = [];
+
+ /**
+ * @SWG\Property(description="An array of Layouts for this User")
+ * @var Layout[]
+ */
+ public $layouts = [];
+
+ /**
+ * @SWG\Property(description="An array of Media for this user")
+ * @var Media[]
+ */
+ public $media = [];
+
+ /**
+ * @SWG\Property(description="An array of Scheduled Events for this User")
+ * @var Schedule[]
+ */
+ public $events = [];
+
+ /**
+ * @SWG\Property(description="An array of Playlists owned by this User")
+ * @var Playlist[]
+ */
+ public $playlists = [];
+
+ /**
+ * @SWG\Property(description="An array of Display Groups owned by this User")
+ * @var DisplayGroup[]
+ */
+ public $displayGroups = [];
+
+ /**
+ * @SWG\Property(description="An array of Dayparts owned by this User")
+ * @var DayPart[]
+ */
+ public $dayParts = [];
+
+ /**
+ * @SWG\Property(description="Does this User receive system notifications.")
+ * @var int
+ */
+ public $isSystemNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive system notifications.")
+ * @var int
+ */
+ public $isDisplayNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive DataSet notifications.")
+ * @var int
+ */
+ public $isDataSetNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive Layout notifications.")
+ * @var int
+ */
+ public $isLayoutNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive Library notifications.")
+ * @var int
+ */
+ public $isLibraryNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive Report notifications.")
+ * @var int
+ */
+ public $isReportNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive Schedule notifications.")
+ * @var int
+ */
+ public $isScheduleNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this User receive Custom notifications.")
+ * @var int
+ */
+ public $isCustomNotification = 0;
+
+ /**
+ * @SWG\Property(description="The two factor type id")
+ * @var int
+ */
+ public $twoFactorTypeId;
+
+ /**
+ * @SWG\Property(description="Two Factor authorisation shared secret for this user")
+ * @var string
+ */
+ public $twoFactorSecret;
+
+ /**
+ * @SWG\Property(description="Two Factor authorisation recovery codes", @SWG\Items(type="string"))
+ * @var array
+ */
+ public $twoFactorRecoveryCodes = [];
+
+ /**
+ * @var UserOption[]
+ */
+ private $userOptions = [];
+
+ /**
+ * User options that have been removed
+ * @var \Xibo\Entity\UserOption[]
+ */
+ private $userOptionsRemoved = [];
+
+ /** @var array Resolved Features for the User and their Groups */
+ private $resolvedFeatures = null;
+
+ /**
+ * Cached Permissions
+ * @var array[Permission]
+ */
+ private $permissionCache = array();
+
+ /**
+ * Cached Page Permissions
+ * @var array[Page]
+ */
+ private $pagePermissionCache = null;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $configService;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var UserGroupFactory
+ */
+ private $userGroupFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /**
+ * @var UserOptionFactory
+ */
+ private $userOptionFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var ApplicationScopeFactory */
+ private $applicationScopeFactory;
+
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @var PlayerVersionFactory */
+ private $playerVersionFactory;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param ConfigServiceInterface $configService
+ * @param UserFactory $userFactory
+ * @param PermissionFactory $permissionFactory
+ * @param UserOptionFactory $userOptionFactory
+ * @param ApplicationScopeFactory $applicationScopeFactory
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ $configService,
+ $userFactory,
+ $permissionFactory,
+ $userOptionFactory,
+ $applicationScopeFactory
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->configService = $configService;
+ $this->userFactory = $userFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->userOptionFactory = $userOptionFactory;
+ $this->applicationScopeFactory = $applicationScopeFactory;
+ $this->excludeProperty('twoFactorSecret');
+ $this->excludeProperty('twoFactorRecoveryCodes');
+ }
+
+ /**
+ * Set the user group factory
+ * @param UserGroupFactory $userGroupFactory
+ * @return $this
+ */
+ public function setChildAclDependencies($userGroupFactory)
+ {
+ // Assert myself on these factories
+ $userGroupFactory->setAclDependencies($this, $this->userFactory);
+ $this->userFactory->setAclDependencies($this, $this->userFactory);
+
+ $this->userGroupFactory = $userGroupFactory;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf(
+ 'User %s. userId: %d, UserTypeId: %d, homePageId: %d, email = %s',
+ $this->userName,
+ $this->userId,
+ $this->userTypeId,
+ $this->homePageId,
+ $this->email
+ );
+ }
+
+ /**
+ * @return string
+ */
+ private function hash()
+ {
+ return md5(json_encode($this));
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->getId();
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->userId;
+ }
+
+ /** @inheritDoc */
+ public function getIdentifier()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Get Option
+ * @param string $option
+ * @return UserOption
+ * @throws NotFoundException
+ */
+ public function getOption($option)
+ {
+ $this->load();
+
+ foreach ($this->userOptions as $userOption) {
+ /* @var UserOption $userOption */
+ if ($userOption->option == $option) {
+ return $userOption;
+ }
+ }
+
+ $this->getLog()->debug(sprintf('UserOption %s not found', $option));
+
+ throw new NotFoundException(__('User Option not found'));
+ }
+
+ /**
+ * Remove the provided option
+ * @param \Xibo\Entity\UserOption $option
+ * @return $this
+ */
+ private function removeOption($option)
+ {
+ $this->getLog()->debug('Removing: ' . $option);
+
+ $this->userOptionsRemoved[] = $option;
+ $this->userOptions = array_diff($this->userOptions, [$option]);
+ return $this;
+ }
+
+ /**
+ * Get User Option Value
+ * @param string $option
+ * @param mixed $default
+ * @return mixed
+ * @throws NotFoundException
+ */
+ public function getOptionValue($option, $default)
+ {
+ $this->load();
+
+ try {
+ $userOption = $this->getOption($option);
+ return $userOption->value;
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('Returning the default value: ' . var_export($default, true));
+ return $default;
+ }
+ }
+
+ /**
+ * Set User Option Value
+ * @param string $option
+ * @param mixed $value
+ */
+ public function setOptionValue($option, $value)
+ {
+ try {
+ $option = $this->getOption($option);
+
+ if ($value === null) {
+ $this->removeOption($option);
+ } else {
+ $option->value = $value;
+ }
+ } catch (NotFoundException $e) {
+ $this->userOptions[] = $this->userOptionFactory->create($this->userId, $option, $value);
+ }
+ }
+
+ /**
+ * Remove all user options by a prefix
+ * @param string $optionPrefix The option prefix
+ * @return $this
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function removeOptionByPrefix(string $optionPrefix)
+ {
+ $this->load();
+
+ foreach ($this->userOptions as $userOption) {
+ if (str_starts_with($userOption->option, $optionPrefix)) {
+ $this->removeOption($userOption);
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Set a new password
+ * @param string $password
+ * @param null $oldPassword
+ * @throws GeneralException
+ */
+ public function setNewPassword($password, $oldPassword = null)
+ {
+ // Validate the old password if one is provided
+ if ($oldPassword != null) {
+ $this->checkPassword($oldPassword);
+ }
+
+ // Basic validation
+ if (!v::stringType()->notEmpty()->validate($password)) {
+ throw new InvalidArgumentException(__('Please enter a Password.'), 'password');
+ }
+
+ // Test against a policy if one exists
+ $this->testPasswordAgainstPolicy($password);
+
+ // Set the hash
+ $this->setNewPasswordHash($password);
+ }
+
+ /**
+ * Set a new password and hash
+ * @param string $password
+ */
+ private function setNewPasswordHash($password)
+ {
+ $this->password = password_hash($password, PASSWORD_DEFAULT);
+ $this->CSPRNG = 2;
+ }
+
+ /**
+ * Check password
+ * @param string $password
+ * @throws AccessDeniedException if the passwords don't match
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException if the user has not been loaded
+ */
+ public function checkPassword($password)
+ {
+ if ($this->userId == 0) {
+ throw new NotFoundException(__('User not found'));
+ }
+
+ if ($this->CSPRNG == 0) {
+ // Password is tested using a plain MD5 check
+ if ($this->password != md5($password)) {
+ throw new AccessDeniedException();
+ }
+ } else if ($this->CSPRNG == 1) {
+ // Test with Pbkdf2
+ try {
+ if (!Pbkdf2Hash::verifyPassword($password, $this->password)) {
+ $this->getLog()->debug('Password failed Pbkdf2Hash Check.');
+ throw new AccessDeniedException();
+ }
+ } catch (\InvalidArgumentException $e) {
+ $this->getLog()->warning('Invalid password hash stored for userId ' . $this->userId);
+ $this->getLog()->debug('Hash error: ' . $e->getMessage());
+ }
+ } else {
+ if (!password_verify($password, $this->password)) {
+ $this->getLog()->debug('Password failed Hash Check.');
+ throw new AccessDeniedException();
+ }
+ }
+
+ $this->getLog()->debug('Password checked out OK');
+
+ // Do we need to convert?
+ $this->updateHashIfRequired($password);
+ }
+
+ /**
+ * Update hash if required
+ * @param string $password
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ */
+ private function updateHashIfRequired($password)
+ {
+ if (($this->CSPRNG == 0 || $this->CSPRNG == 1) || ($this->CSPRNG == 2 && password_needs_rehash($this->password, PASSWORD_DEFAULT))) {
+ $this->getLog()->debug('Converting password to use latest hash');
+
+ // Set the hash
+ $this->setNewPasswordHash($password);
+
+ // Save
+ $this->save(['validate' => false, 'passwordUpdate' => true]);
+ }
+ }
+
+ /**
+ * Check to see if a user id is in the session information
+ * @return bool
+ */
+ public function hasIdentity()
+ {
+ $userId = isset($_SESSION['userid']) ? intval($_SESSION['userid']) : 0;
+
+ // Checks for a user ID in the session variable
+ if ($userId == 0) {
+ unset($_SESSION['userid']);
+ return false;
+ }
+ else {
+ $this->userId = $userId;
+ return true;
+ }
+ }
+
+ /**
+ * Load this User
+ * @param bool $all Load everything this user owns
+ * @throws NotFoundException
+ */
+ public function load($all = false)
+ {
+ if ($this->userId == null || $this->loaded)
+ return;
+
+ if ($this->userGroupFactory == null) {
+ throw new \RuntimeException('Cannot load user without first calling setUserGroupFactory');
+ }
+
+ $this->getLog()->debug(sprintf('Loading %d. All Objects = %d', $this->userId, $all));
+
+ $this->groups = $this->userGroupFactory->getByUserId($this->userId);
+ $this->userOptions = $this->userOptionFactory->getByUserId($this->userId);
+
+ // Set the hash
+ $this->hash = $this->hash();
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Validate
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ */
+ public function validate()
+ {
+ $this->getLog()->debug('Validate User');
+
+ if (!v::alnum('_.-')->length(1, 50)->validate($this->userName) && !v::email()->validate($this->userName))
+ throw new InvalidArgumentException(__('User name must be between 1 and 50 characters.'), 'userName');
+
+ if (!empty($this->libraryQuota) && !v::intType()->validate($this->libraryQuota))
+ throw new InvalidArgumentException(__('Library Quota must be a whole number.'), 'libraryQuota');
+
+ if (!empty($this->email) && !v::email()->validate($this->email))
+ throw new InvalidArgumentException(__('Please enter a valid email address or leave it empty.'), 'email');
+
+ try {
+ $user = $this->userFactory->getByName($this->userName);
+
+ if ($this->userId == null || $this->userId != $user->userId)
+ throw new DuplicateEntityException(__('There is already a user with this name. Please choose another.'));
+ } catch (NotFoundException $ignored) {}
+
+ // System User
+ if ($this->userId == $this->configService->getSetting('SYSTEM_USER') && $this->userTypeId != 1) {
+ throw new InvalidArgumentException(__('This User is set as System User and needs to be super admin'), 'userId');
+ }
+
+ if ($this->userId == $this->configService->getSetting('SYSTEM_USER') && $this->retired === 1) {
+ throw new InvalidArgumentException(__('This User is set as System User and cannot be retired'), 'userId');
+ }
+
+ // Library quota
+ if (!empty($this->libraryQuota) && $this->libraryQuota < 0) {
+ throw new InvalidArgumentException(__('Library Quota must be a positive number.'), 'libraryQuota');
+ }
+ }
+
+ /**
+ * Save User
+ * @param array $options
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'passwordUpdate' => false,
+ 'saveUserOptions' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ $this->getLog()->debug('Saving user. ' . $this);
+
+ if ($this->userId == 0) {
+ $this->add();
+ $this->audit($this->userId, 'New user added', ['userName' => $this->userName]);
+ } else if ($options['passwordUpdate']) {
+ $this->updatePassword();
+ $this->audit($this->userId, 'User updated password', false);
+ } else if ($this->hash() != $this->hash
+ || $this->hasPropertyChanged('twoFactorRecoveryCodes')
+ || $this->hasPropertyChanged('password')
+ ) {
+ $this->update();
+ $this->audit($this->userId, 'User updated');
+ }
+
+ // Save user options
+ if ($options['saveUserOptions']) {
+ // Remove any that have been cleared
+ foreach ($this->userOptionsRemoved as $userOption) {
+ $userOption->delete();
+ }
+
+ // Save all Options
+ foreach ($this->userOptions as $userOption) {
+ $userOption->userId = $this->userId;
+ $userOption->save();
+ }
+ }
+ }
+
+ /**
+ * Delete User
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function delete()
+ {
+ $this->getLog()->debug(sprintf('Deleting %d', $this->userId));
+
+ // We must ensure everything is loaded before we delete
+ if ($this->hash == null) {
+ $this->load(true);
+ }
+
+ // Remove the user specific group
+ $group = $this->userGroupFactory->getById($this->groupId);
+ $group->delete();
+
+ // Delete all user options
+ foreach ($this->userOptions as $userOption) {
+ /* @var RegionOption $userOption */
+ $userOption->delete();
+ }
+
+ // Remove any assignments to groups
+ foreach ($this->groups as $group) {
+ $group->unassignUser($this);
+ $group->save(['validate' => false]);
+ }
+
+ $this->getStore()->update('DELETE FROM `user` WHERE userId = :userId', ['userId' => $this->userId]);
+
+ $this->audit($this->userId, 'User deleted', false);
+ }
+
+ /**
+ * Add user
+ */
+ private function add()
+ {
+ $sql = 'INSERT INTO `user` (UserName, UserPassword, isPasswordChangeRequired, usertypeid, newUserWizard, email, homePageId, homeFolderId, CSPRNG, firstName, lastName, phone, ref1, ref2, ref3, ref4, ref5)
+ VALUES (:userName, :password, :isPasswordChangeRequired, :userTypeId, :newUserWizard, :email, :homePageId, :homeFolderId, :CSPRNG, :firstName, :lastName, :phone, :ref1, :ref2, :ref3, :ref4, :ref5)';
+
+ // Get the ID of the record we just inserted
+ $this->userId = $this->getStore()->insert($sql, [
+ 'userName' => $this->userName,
+ 'password' => $this->password,
+ 'isPasswordChangeRequired' => $this->isPasswordChangeRequired,
+ 'userTypeId' => $this->userTypeId,
+ 'newUserWizard' => $this->newUserWizard,
+ 'email' => $this->email,
+ 'homePageId' => $this->homePageId,
+ 'homeFolderId' => $this->homeFolderId,
+ 'CSPRNG' => $this->CSPRNG,
+ 'firstName' => $this->firstName,
+ 'lastName' => $this->lastName,
+ 'phone' => $this->phone,
+ 'ref1' => $this->ref1,
+ 'ref2' => $this->ref2,
+ 'ref3' => $this->ref3,
+ 'ref4' => $this->ref4,
+ 'ref5' => $this->ref5
+ ]);
+
+ // Add the user group
+ $group = $this->userGroupFactory->create($this->userName, $this->libraryQuota);
+ $group->setOwner($this);
+ $group->isSystemNotification = $this->isSystemNotification;
+ $group->isDisplayNotification = $this->isDisplayNotification;
+ $group->isCustomNotification = $this->isCustomNotification;
+ $group->isDataSetNotification = $this->isDataSetNotification;
+ $group->isLayoutNotification = $this->isLayoutNotification;
+ $group->isLibraryNotification = $this->isLibraryNotification;
+ $group->isReportNotification = $this->isReportNotification;
+ $group->isScheduleNotification = $this->isScheduleNotification;
+
+ $group->save();
+
+ // Assert the groupIds on the user (we do this so we have group in the API return)
+ $this->groupId = $group->getId();
+ $this->group = $group->group;
+ }
+
+ /**
+ * Update user
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ private function update()
+ {
+ $this->getLog()->debug('Update userId ' . $this->userId);
+
+ $sql = 'UPDATE `user` SET UserName = :userName,
+ homePageId = :homePageId,
+ homeFolderId = :homeFolderId,
+ Email = :email,
+ Retired = :retired,
+ userTypeId = :userTypeId,
+ newUserWizard = :newUserWizard,
+ CSPRNG = :CSPRNG,
+ `UserPassword` = :password,
+ `isPasswordChangeRequired` = :isPasswordChangeRequired,
+ `twoFactorTypeId` = :twoFactorTypeId,
+ `twoFactorSecret` = :twoFactorSecret,
+ `twoFactorRecoveryCodes` = :twoFactorRecoveryCodes,
+ `firstName` = :firstName,
+ `lastName` = :lastName,
+ `phone` = :phone,
+ `ref1` = :ref1,
+ `ref2` = :ref2,
+ `ref3` = :ref3,
+ `ref4` = :ref4,
+ `ref5` = :ref5
+ WHERE userId = :userId';
+
+ $params = array(
+ 'userName' => $this->userName,
+ 'userTypeId' => $this->userTypeId,
+ 'email' => $this->email,
+ 'homePageId' => $this->homePageId,
+ 'homeFolderId' => $this->homeFolderId,
+ 'retired' => $this->retired,
+ 'newUserWizard' => $this->newUserWizard,
+ 'CSPRNG' => $this->CSPRNG,
+ 'password' => $this->password,
+ 'isPasswordChangeRequired' => $this->isPasswordChangeRequired,
+ 'twoFactorTypeId' => $this->twoFactorTypeId,
+ 'twoFactorSecret' => $this->twoFactorSecret,
+ 'twoFactorRecoveryCodes' => ($this->twoFactorRecoveryCodes == '') ? null : json_encode($this->twoFactorRecoveryCodes),
+ 'firstName' => $this->firstName,
+ 'lastName' => $this->lastName,
+ 'phone' => $this->phone,
+ 'ref1' => $this->ref1,
+ 'ref2' => $this->ref2,
+ 'ref3' => $this->ref3,
+ 'ref4' => $this->ref4,
+ 'ref5' => $this->ref5,
+ 'userId' => $this->userId
+ );
+
+ $this->getStore()->update($sql, $params);
+
+ // Update the group
+ // This is essentially a dirty edit (i.e. we don't touch the group assignments)
+ $group = $this->userGroupFactory->getById($this->groupId);
+ $group->group = $this->userName;
+ $group->isSystemNotification = $this->isSystemNotification;
+ $group->isDisplayNotification = $this->isDisplayNotification;
+ $group->isCustomNotification = $this->isCustomNotification;
+ $group->isDataSetNotification = $this->isDataSetNotification;
+ $group->isLayoutNotification = $this->isLayoutNotification;
+ $group->isLibraryNotification = $this->isLibraryNotification;
+ $group->isReportNotification = $this->isReportNotification;
+ $group->isScheduleNotification = $this->isScheduleNotification;
+
+ // Do not update libraryQuota unless explicitly provided.
+ // This preserves the current value instead of resetting it to null or 0.
+ if (!empty($this->libraryQuota)) {
+ $group->libraryQuota = $this->libraryQuota;
+ }
+
+ $group->save(['linkUsers' => false]);
+ }
+
+ /**
+ * Update user
+ */
+ private function updatePassword()
+ {
+ $this->getLog()->debug('Update user password. %d', $this->userId);
+
+ $sql = 'UPDATE `user` SET CSPRNG = :CSPRNG,
+ `UserPassword` = :password
+ WHERE userId = :userId';
+
+ $params = array(
+ 'CSPRNG' => $this->CSPRNG,
+ 'password' => $this->password,
+ 'userId' => $this->userId
+ );
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Update the Last Accessed date
+ * @param bool $forcePasswordChange
+ */
+ public function touch($forcePasswordChange = false)
+ {
+ $sql = 'UPDATE `user` SET lastAccessed = :time ';
+
+ if ($forcePasswordChange) {
+ $sql .= ' , isPasswordChangeRequired = 1 ';
+ }
+
+ $sql .= ' WHERE userId = :userId';
+
+ // This needs to happen on a separate connection
+ $this->getStore()->update($sql, [
+ 'userId' => $this->userId,
+ 'time' => date("Y-m-d H:i:s")
+ ]);
+ }
+
+ /**
+ * Get all features allowed for this user, including ones from their group
+ * @return array
+ */
+ public function getFeatures()
+ {
+ if ($this->resolvedFeatures === null) {
+ $this->resolvedFeatures = $this->userGroupFactory->getGroupFeaturesForUser($this);
+ }
+
+ return $this->resolvedFeatures;
+ }
+
+ /**
+ * Check whether the requested feature is available.
+ * @param string|array $feature
+ * @param bool $bothRequired
+ * @return bool
+ */
+ public function featureEnabled($feature, $bothRequired = false)
+ {
+ if ($this->isSuperAdmin()) {
+ return true;
+ }
+
+ if (!is_array($feature)) {
+ $feature = [$feature];
+ }
+
+ if ($bothRequired) {
+ return count($feature) === $this->featureEnabledCount($feature);
+ }
+
+ foreach ($feature as $item) {
+ if (in_array($item, $this->getFeatures())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Given an array of features, count the ones that are enabled
+ * @param array $routes
+ * @return int
+ */
+ public function featureEnabledCount(array $routes)
+ {
+ // Shortcut for super admins.
+ if ($this->isSuperAdmin()) {
+ return count($routes);
+ }
+
+ // Test each route
+ $count = 0;
+
+ foreach ($routes as $route) {
+ if ($this->featureEnabled($route)) {
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Is a particular type of notification enabled?
+ * used by the user edit form
+ * @param string $type The type of notification
+ * @param bool $isGroupOnly If true, only return if a user group has the notification type enabled
+ * @return bool
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function isNotificationEnabled(string $type, bool $isGroupOnly = false): bool
+ {
+ $this->load();
+
+ switch ($type) {
+ case 'system':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isSystemNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isSystemNotification;
+ }
+ case 'display':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isDisplayNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isDisplayNotification;
+ }
+ case 'dataset':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isDataSetNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isDataSetNotification;
+ }
+ case 'layout':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isLayoutNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isLayoutNotification;
+ }
+ case 'library':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isLibraryNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isLibraryNotification;
+ }
+ case 'report':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isReportNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isReportNotification;
+ }
+ case 'schedule':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isScheduleNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isScheduleNotification;
+ }
+ case 'custom':
+ foreach ($this->groups as $group) {
+ if ($group->getOriginalValue('isCustomNotification') == 1) {
+ return true;
+ }
+ }
+ if ($isGroupOnly) {
+ return false;
+ } else {
+ return $this->isCustomNotification;
+ }
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Load permissions for a particular entity
+ * @param string $entity
+ * @return \Xibo\Entity\Permission[]
+ */
+ private function loadPermissions(string $entity)
+ {
+ // Check our cache to see if we have permissions for this entity cached already
+ if (!isset($this->permissionCache[$entity])) {
+
+ // Store the results in the cache (default to empty result)
+ $this->permissionCache[$entity] = array();
+
+ // Turn it into an ID keyed array
+ foreach ($this->permissionFactory->getByUserId($entity, $this->userId) as $permission) {
+ // Always take the max
+ if (array_key_exists($permission->objectId, $this->permissionCache[$entity])) {
+ $old = $this->permissionCache[$entity][$permission->objectId];
+ // Create a new permission record with the max of current and new
+ $new = $this->permissionFactory->createEmpty();
+ $new->view = max($permission->view, $old->view);
+ $new->edit = max($permission->edit, $old->edit);
+ $new->delete = max($permission->delete, $old->delete);
+
+ $this->permissionCache[$entity][$permission->objectId] = $new;
+ } else {
+ $this->permissionCache[$entity][$permission->objectId] = $permission;
+ }
+ }
+ }
+
+ return $this->permissionCache[$entity];
+ }
+
+ /**
+ * Check that this object can be used with the permissions sytem
+ * @param object $object
+ * @throws InvalidArgumentException
+ */
+ private function checkObjectCompatibility($object): void
+ {
+ if (!method_exists($object, 'getId')
+ || !method_exists($object, 'getOwnerId')
+ || !method_exists($object, 'permissionsClass')
+ ) {
+ throw new InvalidArgumentException(__('Provided Object not under permission management'), 'object');
+ }
+ }
+
+ /**
+ * Get a permission object
+ * @param object $object
+ * @return \Xibo\Entity\Permission
+ * @throws InvalidArgumentException
+ */
+ public function getPermission($object)
+ {
+ // Check that this object has the necessary methods
+ $this->checkObjectCompatibility($object);
+
+ // Admin users
+ if ($this->isSuperAdmin() || $this->userId == $object->getOwnerId()) {
+ return $this->permissionFactory->getFullPermissions();
+ }
+
+ // Group Admins
+ if ($this->userTypeId == 2 && count(array_intersect($this->groups, $this->userGroupFactory->getByUserId($object->getOwnerId()))))
+ // Group Admin and in the same group as the owner.
+ return $this->permissionFactory->getFullPermissions();
+
+ // Get the permissions for that entity
+ $permissions = $this->loadPermissions($object->permissionsClass());
+
+ // Check to see if our object is in the list
+ if (array_key_exists($object->getId(), $permissions))
+ return $permissions[$object->getId()];
+ else
+ return $this->permissionFactory->createEmpty();
+ }
+
+ /**
+ * Check the given object is viewable
+ * @param object $object
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ public function checkViewable($object)
+ {
+ // Check that this object has the necessary methods
+ $this->checkObjectCompatibility($object);
+
+ // Admin users
+ if ($this->isSuperAdmin() || $this->userId == $object->getOwnerId())
+ return true;
+
+ // Group Admins
+ if ($this->userTypeId == 2 && count(array_intersect($this->groups, $this->userGroupFactory->getByUserId($object->getOwnerId()))))
+ // Group Admin and in the same group as the owner.
+ return true;
+
+ // Get the permissions for that entity
+ $permissions = $this->loadPermissions($object->permissionsClass());
+ $folderPermissions = $this->loadPermissions('Xibo\Entity\Folder');
+
+ // If we are checking for view permissions on a folder, then we always grant those to a users home folder.
+ if ($object->permissionsClass() === 'Xibo\Entity\Folder'
+ && $object->getId() === $this->homeFolderId
+ ) {
+ return true;
+ }
+
+ // Check to see if our object is in the list
+ if (array_key_exists($object->getId(), $permissions)) {
+ return ($permissions[$object->getId()]->view == 1);
+ } else if (method_exists($object, 'getPermissionFolderId') && array_key_exists($object->getPermissionFolderId(), $folderPermissions)) {
+ return ($folderPermissions[$object->getPermissionFolderId()]->view == 1);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Check the given object is editable
+ * @param object $object
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ public function checkEditable($object)
+ {
+ // Check that this object has the necessary methods
+ $this->checkObjectCompatibility($object);
+
+ // Admin users
+ if ($this->isSuperAdmin() || $this->userId == $object->getOwnerId()) {
+ return true;
+ }
+
+ // Group Admins
+ if ($object->permissionsClass() === 'Xibo\Entity\UserGroup') {
+ // userGroup does not have an owner (getOwnerId() returns 0 ), we need to handle it in a different way.
+ if ($this->userTypeId == 2 && count(array_intersect($this->groups, [$object]))) {
+ // Group Admin and group object in the user array of groups
+ return true;
+ }
+ } else {
+ if ($this->userTypeId == 2 && count(array_intersect($this->groups, $this->userGroupFactory->getByUserId($object->getOwnerId())))) {
+ // Group Admin and in the same group as the owner.
+ return true;
+ }
+ }
+
+ // Get the permissions for that entity
+ $permissions = $this->loadPermissions($object->permissionsClass());
+ $folderPermissions = $this->loadPermissions('Xibo\Entity\Folder');
+
+ // Check to see if our object is in the list
+ if (array_key_exists($object->getId(), $permissions)) {
+ return ($permissions[$object->getId()]->edit == 1);
+ } else if (method_exists($object, 'getPermissionFolderId') && array_key_exists($object->getPermissionFolderId(), $folderPermissions)) {
+ return ($folderPermissions[$object->getPermissionFolderId()]->edit == 1);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Check the given object is delete-able
+ * @param object $object
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ public function checkDeleteable($object)
+ {
+ // Check that this object has the necessary methods
+ $this->checkObjectCompatibility($object);
+ // Admin users
+ if ($this->userTypeId == 1 || $this->userId == $object->getOwnerId()) {
+ return true;
+ }
+
+ // Group Admins
+ if ($this->userTypeId == 2 && count(array_intersect($this->groups, $this->userGroupFactory->getByUserId($object->getOwnerId())))) {
+ // Group Admin and in the same group as the owner.
+ return true;
+ }
+
+ // Get the permissions for that entity
+ $permissions = $this->loadPermissions($object->permissionsClass());
+ $folderPermissions = $this->loadPermissions('Xibo\Entity\Folder');
+
+ // Check to see if our object is in the list
+ if (array_key_exists($object->getId(), $permissions)) {
+ return ($permissions[$object->getId()]->delete == 1);
+ } else if (method_exists($object, 'getPermissionFolderId') && array_key_exists($object->getPermissionFolderId(), $folderPermissions)) {
+ return ($folderPermissions[$object->getPermissionFolderId()]->delete == 1);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Check the given objects permissions are modify-able
+ * @param object $object
+ * @return bool
+ * @throws InvalidArgumentException
+ */
+ public function checkPermissionsModifyable($object)
+ {
+ // Check that this object has the necessary methods
+ $this->checkObjectCompatibility($object);
+
+ // Admin users
+ if ($this->userTypeId == 1 || ($this->userId == $object->getOwnerId() && $this->featureEnabled('user.sharing')))
+ return true;
+ // Group Admins
+ else if ($this->userTypeId == 2 && count(array_intersect($this->groups, $this->userGroupFactory->getByUserId($object->getOwnerId()))) && $this->featureEnabled('user.sharing'))
+ // Group Admin and in the same group as the owner.
+ return true;
+ else
+ return false;
+ }
+
+ /**
+ * Returns the usertypeid for this user object.
+ * @return int
+ */
+ public function getUserTypeId()
+ {
+ return $this->userTypeId;
+ }
+
+ /**
+ * Is a super admin
+ * @return bool
+ */
+ public function isSuperAdmin()
+ {
+ return ($this->userTypeId == 1);
+ }
+
+ /**
+ * Is Group Admin
+ * @return bool
+ */
+ public function isGroupAdmin()
+ {
+ return ($this->userTypeId == 2);
+ }
+
+ /**
+ * Is this users library quota full
+ * @param boolean $reconnect
+ * @throws LibraryFullException when the library is full or cannot be determined
+ */
+ public function isQuotaFullByUser($reconnect = false)
+ {
+ $groupId = 0;
+ $userQuota = 0;
+
+ // Get the maximum quota of this users groups and their own quota
+ $rows = $this->getStore()->select('
+ SELECT group.groupId, IFNULL(group.libraryQuota, 0) AS libraryQuota
+ FROM `group`
+ INNER JOIN `lkusergroup`
+ ON group.groupId = lkusergroup.groupId
+ WHERE lkusergroup.userId = :userId
+ ORDER BY `group`.isUserSpecific DESC, IFNULL(group.libraryQuota, 0) DESC
+ ', ['userId' => $this->userId], 'default', $reconnect);
+
+ if (count($rows) <= 0) {
+ throw new LibraryFullException('Problem calculating this users library quota.');
+ }
+
+ foreach ($rows as $row) {
+
+ if ($row['libraryQuota'] > 0) {
+ $groupId = $row['groupId'];
+ $userQuota = intval($row['libraryQuota']);
+ break;
+ }
+ }
+
+ if ($userQuota > 0) {
+ // If there is a quota, then test it against the current library position for this user.
+ // use the groupId that generated the quota in order to calculate the usage
+ $rows = $this->getStore()->select('
+ SELECT IFNULL(SUM(FileSize), 0) AS SumSize
+ FROM `media`
+ INNER JOIN `lkusergroup`
+ ON lkusergroup.userId = media.userId
+ WHERE lkusergroup.groupId = :groupId
+ ', ['groupId' => $groupId], 'default', $reconnect);
+
+ if (count($rows) <= 0) {
+ throw new LibraryFullException("Error Processing Request", 1);
+ }
+
+ $fileSize = intval($rows[0]['SumSize']);
+
+ if (($fileSize / 1024) >= $userQuota) {
+ $this->getLog()->debug('User has exceeded library quota. FileSize: ' . $fileSize . ' bytes, quota is ' . $userQuota * 1024);
+ throw new LibraryFullException(__('You have exceeded your library quota'));
+ }
+ }
+ }
+
+ /**
+ * Tests the supplied password against the password policy
+ * @param string $password
+ * @throws InvalidArgumentException
+ */
+ public function testPasswordAgainstPolicy($password)
+ {
+ // Check password complexity
+ $policy = $this->configService->getSetting('USER_PASSWORD_POLICY');
+
+ if ($policy != '')
+ {
+ $policyError = $this->configService->getSetting('USER_PASSWORD_ERROR');
+ $policyError = ($policyError == '') ? __('Your password does not meet the required complexity') : $policyError;
+
+ if(!preg_match($policy, $password, $matches)) {
+ throw new InvalidArgumentException($policyError);
+ }
+ }
+ }
+
+ /**
+ * @return UserOption[]
+ */
+ public function getUserOptions()
+ {
+ // Don't return anything with Grid in it (these have to be specifically requested).
+ return array_values(array_filter($this->userOptions, function($element) {
+ return !(stripos($element->option, 'Grid'));
+ }));
+ }
+
+ /**
+ * Clear the two factor stored secret and recovery codes
+ */
+ public function clearTwoFactor()
+ {
+ $this->twoFactorTypeId = 0;
+ $this->twoFactorSecret = NULL;
+ $this->twoFactorRecoveryCodes = NULL;
+
+ $sql = 'UPDATE `user` SET twoFactorSecret = :twoFactorSecret,
+ twoFactorTypeId = :twoFactorTypeId,
+ twoFactorRecoveryCodes =:twoFactorRecoveryCodes
+ WHERE userId = :userId';
+
+ $params = [
+ 'userId' => $this->userId,
+ 'twoFactorSecret' => $this->twoFactorSecret,
+ 'twoFactorTypeId' => $this->twoFactorTypeId,
+ 'twoFactorRecoveryCodes' => $this->twoFactorRecoveryCodes
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * @param $recoveryCodes
+ */
+ public function updateRecoveryCodes($recoveryCodes)
+ {
+ $sql = 'UPDATE `user` SET twoFactorRecoveryCodes = :twoFactorRecoveryCodes WHERE userId = :userId';
+
+ $params = [
+ 'userId' => $this->userId,
+ 'twoFactorRecoveryCodes' => $recoveryCodes
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+}
diff --git a/lib/Entity/UserGroup.php b/lib/Entity/UserGroup.php
new file mode 100644
index 0000000..5ca7207
--- /dev/null
+++ b/lib/Entity/UserGroup.php
@@ -0,0 +1,547 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+
+use Respect\Validation\Validator as v;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class UserGroup
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class UserGroup
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Group ID")
+ * @var int
+ */
+ public $groupId;
+
+ /**
+ * @SWG\Property(description="The group name")
+ * @var string
+ */
+ public $group;
+
+ /**
+ * @SWG\Property(description="A flag indicating whether this is a user specific group or not")
+ * @var int
+ */
+ public $isUserSpecific = 0;
+
+ /**
+ * @SWG\Property(description="A flag indicating the special everyone group")
+ * @var int
+ */
+ public $isEveryone = 0;
+
+ /**
+ * @SWG\Property(description="Description of this User Group")
+ * @var string
+ */
+ public $description;
+
+ /**
+ * @SWG\Property(description="This users library quota in bytes. 0 = unlimited")
+ * @var int
+ */
+ public $libraryQuota;
+
+ /**
+ * @SWG\Property(description="Does this Group receive system notifications.")
+ * @var int
+ */
+ public $isSystemNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive display notifications.")
+ * @var int
+ */
+ public $isDisplayNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive DataSet notifications.")
+ * @var int
+ */
+ public $isDataSetNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive Layout notifications.")
+ * @var int
+ */
+ public $isLayoutNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive Library notifications.")
+ * @var int
+ */
+ public $isLibraryNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive Report notifications.")
+ * @var int
+ */
+ public $isReportNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive Schedule notifications.")
+ * @var int
+ */
+ public $isScheduleNotification = 0;
+
+ /**
+ * @SWG\Property(description="Does this Group receive Custom notifications.")
+ * @var int
+ */
+ public $isCustomNotification = 0;
+
+ /**
+ * @SWG\Property(description="Is this Group shown in the list of choices when onboarding a new user")
+ * @var int
+ */
+ public $isShownForAddUser = 0;
+
+ /**
+ * @SWG\Property(description="Default Home page for new users")
+ * @var string
+ */
+ public $defaultHomepageId;
+
+ /**
+ * @SWG\Property(description="Features this User Group has direct access to", @SWG\Items(type="string"))
+ * @var array
+ */
+ public $features = [];
+
+ // Users
+ private $users = [];
+
+ /**
+ * @var UserGroupFactory
+ */
+ private $userGroupFactory;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ private $assignedUserIds = [];
+ private $unassignedUserIds = [];
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param UserGroupFactory $userGroupFactory
+ * @param UserFactory $userFactory
+ */
+ public function __construct($store, $log, $dispatcher, $userGroupFactory, $userFactory)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+
+ $this->userGroupFactory = $userGroupFactory;
+ $this->userFactory = $userFactory;
+ }
+
+ /**
+ *
+ */
+ public function __clone()
+ {
+ // Clear the groupId
+ $this->groupId = null;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('ID = %d, Group = %s, IsUserSpecific = %d', $this->groupId, $this->group, $this->isUserSpecific);
+ }
+
+ /**
+ * Generate a unique hash for this User Group
+ */
+ private function hash()
+ {
+ return md5(json_encode($this));
+ }
+
+ /**
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->groupId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return 0;
+ }
+
+ /**
+ * Set the Owner of this Group
+ * @param User $user
+ */
+ public function setOwner($user)
+ {
+ $this->load();
+
+ $this->isUserSpecific = 1;
+ $this->isEveryone = 0;
+ $this->assignUser($user);
+ }
+
+ /**
+ * Assign User
+ * @param User $user
+ */
+ public function assignUser($user)
+ {
+ $this->load();
+
+ if (!in_array($user, $this->users)) {
+ $this->users[] = $user;
+ $this->assignedUserIds[] = $user->userId;
+ }
+ }
+
+ /**
+ * Unassign User
+ * @param User $user
+ */
+ public function unassignUser($user)
+ {
+ $this->load();
+ $this->unassignedUserIds[] = $user->userId;
+ $this->users = array_udiff($this->users, [$user], function ($a, $b) {
+ /**
+ * @var User $a
+ * @var User $b
+ */
+ return $a->getId() - $b->getId();
+ });
+ }
+
+ /**
+ * Validate
+ */
+ public function validate()
+ {
+ if (!v::stringType()->length(1, 50)->validate($this->group)) {
+ throw new InvalidArgumentException(__('User Group Name cannot be empty.') . $this, 'name');
+ }
+
+ if ($this->libraryQuota !== null && !v::intType()->validate($this->libraryQuota)) {
+ throw new InvalidArgumentException(__('Library Quota must be a whole number.'), 'libraryQuota');
+ }
+
+ try {
+ $group = $this->userGroupFactory->getByName($this->group, $this->isUserSpecific);
+
+ if ($this->groupId == null || $this->groupId != $group->groupId) {
+ throw new DuplicateEntityException(
+ __('There is already a group with this name. Please choose another.')
+ );
+ }
+ } catch (NotFoundException $e) {
+
+ }
+ }
+
+ /**
+ * Load this User Group
+ * @param array $options
+ */
+ public function load($options = [])
+ {
+ $options = array_merge([
+ 'loadUsers' => true
+ ], $options);
+
+ if ($this->loaded || $this->groupId == 0) {
+ return;
+ }
+
+ if ($options['loadUsers']) {
+ // Load all assigned users
+ $this->users = $this->userFactory->getByGroupId($this->groupId);
+ }
+
+ // Set the hash
+ $this->hash = $this->hash();
+ $this->loaded = true;
+ }
+
+ /**
+ * Save the group
+ * @param array $options
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ */
+ public function save($options = [])
+ {
+ $options = array_merge([
+ 'validate' => true,
+ 'linkUsers' => true
+ ], $options);
+
+ if ($options['validate']) {
+ $this->validate();
+ }
+
+ if ($this->groupId == null || $this->groupId == 0) {
+ $this->add();
+ $this->audit($this->groupId, 'User Group added', ['group' => $this->group]);
+ } else if ($this->hash() != $this->hash) {
+ $this->edit();
+ $this->audit($this->groupId, 'User Group edited');
+ }
+
+ if ($options['linkUsers']) {
+ $this->linkUsers();
+ $this->unlinkUsers();
+
+ if (count($this->assignedUserIds) > 0) {
+ $this->audit($this->groupId, 'Users assigned', ['userIds' => implode(',', $this->assignedUserIds)]);
+ }
+
+ if (count($this->unassignedUserIds) > 0) {
+ $this->audit($this->groupId, 'Users unassigned', ['userIds' => implode(',', $this->unassignedUserIds)]);
+ }
+ }
+ }
+
+ /**
+ * Save features
+ * @return $this
+ */
+ public function saveFeatures()
+ {
+ $this->getStore()->update('
+ UPDATE `group` SET features = :features WHERE groupId = :groupId
+ ', [
+ 'groupId' => $this->groupId,
+ 'features' => json_encode($this->features)
+ ]);
+
+ $this->audit($this->groupId, 'User Group feature access modified', [
+ 'features' => json_encode($this->features)
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Delete this Group
+ */
+ public function delete()
+ {
+ // We must ensure everything is loaded before we delete
+ if ($this->hash == null) {
+ $this->load();
+ }
+
+ // Unlink users
+ $this->removeAssignments();
+
+ $this->getStore()->update('DELETE FROM `permission` WHERE groupId = :groupId', ['groupId' => $this->groupId]);
+ $this->getStore()->update('DELETE FROM `group` WHERE groupId = :groupId', ['groupId' => $this->groupId]);
+
+ $this->audit($this->groupId, 'User group deleted.', false);
+ }
+
+ /**
+ * Remove all assignments
+ */
+ private function removeAssignments()
+ {
+ // Delete Notifications
+ // NB: notifications aren't modelled as child objects because there could be many thousands of notifications on each
+ // usergroup. We consider the notification to be the parent here and it manages the assignments.
+ // This does mean that we might end up with an empty notification (not assigned to anything)
+ $this->getStore()->update('DELETE FROM `lknotificationuser` WHERE `userId` IN (SELECT `userId` FROM `lkusergroup` WHERE `groupId` = :groupId) ', ['groupId' => $this->groupId]);
+ $this->getStore()->update('DELETE FROM `lknotificationgroup` WHERE `groupId` = :groupId', ['groupId' => $this->groupId]);
+
+ // Remove user assignments
+ $this->users = [];
+ $this->unlinkUsers();
+ }
+
+ /**
+ * Add
+ */
+ private function add()
+ {
+ $this->groupId = $this->getStore()->insert('
+ INSERT INTO `group` (
+ `group`,
+ `IsUserSpecific`,
+ `description`,
+ `libraryQuota`,
+ `isSystemNotification`,
+ `isDisplayNotification`,
+ `isDataSetNotification`,
+ `isLayoutNotification`,
+ `isLibraryNotification`,
+ `isReportNotification`,
+ `isScheduleNotification`,
+ `isCustomNotification`,
+ `isShownForAddUser`,
+ `defaultHomepageId`
+ )
+ VALUES (
+ :group,
+ :isUserSpecific,
+ :description,
+ :libraryQuota,
+ :isSystemNotification,
+ :isDisplayNotification,
+ :isDataSetNotification,
+ :isLayoutNotification,
+ :isLibraryNotification,
+ :isReportNotification,
+ :isScheduleNotification,
+ :isCustomNotification,
+ :isShownForAddUser,
+ :defaultHomepageId
+ )
+ ', [
+ 'group' => $this->group,
+ 'isUserSpecific' => $this->isUserSpecific,
+ 'description' => $this->description,
+ 'libraryQuota' => $this->libraryQuota,
+ 'isSystemNotification' => $this->isSystemNotification,
+ 'isDisplayNotification' => $this->isDisplayNotification,
+ 'isDataSetNotification' => $this->isDataSetNotification,
+ 'isLayoutNotification' => $this->isLayoutNotification,
+ 'isLibraryNotification' => $this->isLibraryNotification,
+ 'isReportNotification' => $this->isReportNotification,
+ 'isScheduleNotification' => $this->isScheduleNotification,
+ 'isCustomNotification' => $this->isCustomNotification,
+ 'isShownForAddUser' => $this->isShownForAddUser,
+ 'defaultHomepageId' => $this->defaultHomepageId
+ ]);
+ }
+
+ /**
+ * Edit
+ */
+ private function edit()
+ {
+ $this->getStore()->update('
+ UPDATE `group` SET
+ `group` = :group,
+ `description` = :description,
+ libraryQuota = :libraryQuota,
+ `isSystemNotification` = :isSystemNotification,
+ `isDisplayNotification` = :isDisplayNotification,
+ `isDataSetNotification` = :isDataSetNotification,
+ `isLayoutNotification` = :isLayoutNotification,
+ `isLibraryNotification` = :isLibraryNotification,
+ `isReportNotification` = :isReportNotification,
+ `isScheduleNotification` = :isScheduleNotification,
+ `isCustomNotification` = :isCustomNotification,
+ `isShownForAddUser` = :isShownForAddUser,
+ `defaultHomepageId` = :defaultHomepageId
+ WHERE groupId = :groupId
+ ', [
+ 'groupId' => $this->groupId,
+ 'group' => $this->group,
+ 'description' => $this->description,
+ 'libraryQuota' => $this->libraryQuota,
+ 'isSystemNotification' => $this->isSystemNotification,
+ 'isDisplayNotification' => $this->isDisplayNotification,
+ 'isDataSetNotification' => $this->isDataSetNotification,
+ 'isLayoutNotification' => $this->isLayoutNotification,
+ 'isLibraryNotification' => $this->isLibraryNotification,
+ 'isReportNotification' => $this->isReportNotification,
+ 'isScheduleNotification' => $this->isScheduleNotification,
+ 'isCustomNotification' => $this->isCustomNotification,
+ 'isShownForAddUser' => $this->isShownForAddUser,
+ 'defaultHomepageId' => $this->defaultHomepageId
+ ]);
+ }
+
+ /**
+ * Link Users
+ */
+ private function linkUsers()
+ {
+ $insert = $this->getStore()->getConnection()->prepare(
+ 'INSERT INTO `lkusergroup` (groupId, userId)
+ VALUES (:groupId, :userId) ON DUPLICATE KEY UPDATE groupId = groupId'
+ );
+
+ foreach ($this->users as $user) {
+ /* @var User $user */
+ $this->getLog()->debug('Linking %s to %s', $user->userName, $this->group);
+
+ $insert->execute([
+ 'groupId' => $this->groupId,
+ 'userId' => $user->userId
+ ]);
+ }
+ }
+
+ /**
+ * Unlink Users
+ */
+ private function unlinkUsers()
+ {
+ $params = ['groupId' => $this->groupId];
+
+ $sql = 'DELETE FROM `lkusergroup` WHERE groupId = :groupId AND userId NOT IN (0';
+
+ $i = 0;
+ foreach ($this->users as $user) {
+ /* @var User $user */
+ $i++;
+ $sql .= ',:userId' . $i;
+ $params['userId' . $i] = $user->userId;
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+}
diff --git a/lib/Entity/UserNotification.php b/lib/Entity/UserNotification.php
new file mode 100644
index 0000000..32e7579
--- /dev/null
+++ b/lib/Entity/UserNotification.php
@@ -0,0 +1,222 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class UserGroupNotification
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class UserNotification implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(
+ * description="The User Id"
+ * )
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(
+ * description="The Notification Id"
+ * )
+ * @var int
+ */
+ public $notificationId;
+
+ /**
+ * @SWG\Property(
+ * description="Release Date expressed as Unix Timestamp"
+ * )
+ * @var int
+ */
+ public $releaseDt;
+
+ /**
+ * @SWG\Property(
+ * description="Read Date expressed as Unix Timestamp"
+ * )
+ * @var int
+ */
+ public $readDt;
+
+ /**
+ * @SWG\Property(
+ * description="Email Date expressed as Unix Timestamp"
+ * )
+ * @var int
+ */
+ public $emailDt;
+
+ /**
+ * @SWG\Property(
+ * description="A flag indicating whether to show as read or not"
+ * )
+ * @var int
+ */
+ public $read;
+
+ /**
+ * @SWG\Property(
+ * description="The subject"
+ * )
+ * @var string
+ */
+ public $subject;
+
+ /**
+ * @SWG\Property(
+ * description="The body"
+ * )
+ * @var string
+ */
+ public $body;
+
+ /**
+ * @SWG\Property(
+ * description="Should the notification interrupt the CMS UI on navigate/login"
+ * )
+ * @var int
+ */
+ public $isInterrupt;
+
+ /**
+ * @SWG\Property(
+ * description="Flag for system notification"
+ * )
+ * @var int
+ */
+ public $isSystem;
+
+ /**
+ * @var string
+ */
+ public $email;
+
+ /**
+ * @var string
+ */
+ public $filename;
+
+ /**
+ * @var string
+ */
+ public $originalFileName;
+
+ /**
+ * @var string
+ */
+ public $nonusers;
+
+ /**
+ * @var string
+ */
+ public $type;
+
+ /**
+ * Command constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Set Read
+ * @param int $readDt
+ */
+ public function setRead($readDt)
+ {
+ $this->read = 1;
+
+ if ($this->readDt == 0) {
+ $this->readDt = $readDt;
+ }
+ }
+
+ /**
+ * Set unread
+ */
+ public function setUnread()
+ {
+ $this->read = 0;
+ }
+
+ /**
+ * Set Emailed
+ * @param int $emailDt
+ */
+ public function setEmailed($emailDt)
+ {
+ if ($this->emailDt == 0) {
+ $this->emailDt = $emailDt;
+ }
+ }
+
+ /**
+ * Save
+ */
+ public function save()
+ {
+ $this->getStore()->update('
+ UPDATE `lknotificationuser`
+ SET `read` = :read,
+ `readDt` = :readDt,
+ `emailDt` = :emailDt
+ WHERE notificationId = :notificationId
+ AND userId = :userId
+ ', [
+ 'read' => $this->read,
+ 'readDt' => $this->readDt,
+ 'emailDt' => $this->emailDt,
+ 'notificationId' => $this->notificationId,
+ 'userId' => $this->userId
+ ]);
+ }
+
+ /**
+ * @return string
+ */
+ public function getTypeForGroup(): string
+ {
+ return match ($this->type) {
+ 'dataset' => 'isDataSetNotification',
+ 'display' => 'isDisplayNotification',
+ 'layout' => 'isLayoutNotification',
+ 'library' => 'isLibraryNotification',
+ 'report' => 'isReportNotification',
+ 'schedule' => 'isScheduleNotification',
+ default => 'isCustomNotification',
+ };
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/UserOption.php b/lib/Entity/UserOption.php
new file mode 100644
index 0000000..7e58430
--- /dev/null
+++ b/lib/Entity/UserOption.php
@@ -0,0 +1,99 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class UserOption
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class UserOption implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The userId that this Option applies to")
+ * @var int
+ */
+ public $userId;
+
+ /**
+ * @SWG\Property(description="The option name")
+ * @var string
+ */
+ public $option;
+
+ /**
+ * @SWG\Property(description="The option value")
+ * @var string
+ */
+ public $value;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->excludeProperty('userId');
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function __toString()
+ {
+ return $this->userId . '-' . $this->option . '-' . md5($this->value);
+ }
+
+ public function save()
+ {
+ // when the option is not in the database and default is on, switching option value to off
+ // would not insert the record, hence the additional condition here xibosignage/xibo#2975
+ if ($this->hasPropertyChanged('value')
+ || ($this->getOriginalValue('value') === null && $this->value !== null)
+ ) {
+ $this->getStore()->insert('INSERT INTO `useroption` (`userId`, `option`, `value`) VALUES (:userId, :option, :value) ON DUPLICATE KEY UPDATE `value` = :value2', [
+ 'userId' => $this->userId,
+ 'option' => $this->option,
+ 'value' => $this->value,
+ 'value2' => $this->value,
+ ]);
+ }
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `useroption` WHERE `userId` = :userId AND `option` = :option', [
+ 'userId' => $this->userId,
+ 'option' => $this->option
+ ]);
+ }
+}
diff --git a/lib/Entity/UserType.php b/lib/Entity/UserType.php
new file mode 100644
index 0000000..7dbda61
--- /dev/null
+++ b/lib/Entity/UserType.php
@@ -0,0 +1,60 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class UserType
+ * @package Xibo\Entity
+ *
+ */
+class UserType
+{
+ use EntityTrait;
+
+ public $userTypeId;
+ public $userType;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function getId()
+ {
+ return $this->userTypeId;
+ }
+
+ public function getOwnerId()
+ {
+ return 1;
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/Widget.php b/lib/Entity/Widget.php
new file mode 100644
index 0000000..b43dd72
--- /dev/null
+++ b/lib/Entity/Widget.php
@@ -0,0 +1,1353 @@
+.
+ */
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Xibo\Event\SubPlaylistDurationEvent;
+use Xibo\Event\WidgetDeleteEvent;
+use Xibo\Event\WidgetEditEvent;
+use Xibo\Factory\ActionFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Factory\WidgetAudioFactory;
+use Xibo\Factory\WidgetMediaFactory;
+use Xibo\Factory\WidgetOptionFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Definition\Property;
+
+/**
+ * Class Widget
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class Widget implements \JsonSerializable
+{
+ public static $DATE_MIN = 0;
+ public static $DATE_MAX = 2147483647;
+
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Widget ID")
+ * @var int
+ */
+ public $widgetId;
+
+ /**
+ * @SWG\Property(description="The ID of the Playlist this Widget belongs to")
+ * @var int
+ */
+ public $playlistId;
+
+ /**
+ * @SWG\Property(description="The ID of the User that owns this Widget")
+ * @var int
+ */
+ public $ownerId;
+
+ /**
+ * @SWG\Property(description="The Module Type Code")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The duration in seconds this widget should be shown")
+ * @var int
+ */
+ public $duration;
+
+ /**
+ * @SWG\Property(description="The display order of this widget")
+ * @var int
+ */
+ public $displayOrder;
+
+ /**
+ * @SWG\Property(description="Flag indicating if this widget has a duration that should be used")
+ * @var int
+ */
+ public $useDuration;
+
+ /**
+ * @SWG\Property(description="Calculated Duration of this widget after taking into account the useDuration flag")
+ * @var int
+ */
+ public $calculatedDuration = 0;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime the Layout was created"
+ * )
+ */
+ public $createdDt;
+
+ /**
+ * @var string
+ * @SWG\Property(
+ * description="The datetime the Layout was last modified"
+ * )
+ */
+ public $modifiedDt;
+
+ /**
+ * @SWG\Property(description="Widget From Date")
+ * @var int
+ */
+ public $fromDt;
+
+ /**
+ * @SWG\Property(description="Widget To Date")
+ * @var int
+ */
+ public $toDt;
+
+ /**
+ * @SWG\Property(description="Widget Schema Version")
+ * @var int
+ */
+ public $schemaVersion;
+
+ /**
+ * @SWG\Property(description="Transition Type In")
+ * @var int
+ */
+ public $transitionIn;
+
+ /**
+ * @SWG\Property(description="Transition Type out")
+ * @var int
+ */
+ public $transitionOut;
+
+ /**
+ * @SWG\Property(description="Transition duration in")
+ * @var int
+ */
+ public $transitionDurationIn;
+
+ /**
+ * @SWG\Property(description="Transition duration out")
+ * @var int
+ */
+ public $transitionDurationOut;
+
+ /**
+ * @SWG\Property(description="An array of Widget Options")
+ * @var WidgetOption[]
+ */
+ public $widgetOptions = [];
+
+ /**
+ * @SWG\Property(description="An array of MediaIds this widget is linked to")
+ * @var int[]
+ */
+ public $mediaIds = [];
+
+ /**
+ * @SWG\Property(description="An array of Audio MediaIds this widget is linked to")
+ * @var WidgetAudio[]
+ */
+ public $audio = [];
+
+ /**
+ * @SWG\Property(description="An array of permissions for this widget")
+ * @var Permission[]
+ */
+ public $permissions = [];
+
+ /**
+ * @SWG\Property(description="The name of the Playlist this Widget is on")
+ * @var string $playlist
+ */
+ public $playlist;
+
+ /** @var Action[] */
+ public $actions = [];
+
+ /**
+ * Hash Key of Media Assignments
+ * @var string
+ */
+ private $mediaHash = null;
+
+ /**
+ * Temporary media Id used during import/upgrade/sub-playlist ordering
+ * @var string read only string
+ */
+ public $tempId = null;
+
+ /**
+ * Temporary widget Id used during import/upgrade/sub-playlist ordering
+ * @var string read only string
+ */
+ public $tempWidgetId = null;
+
+ /**
+ * Flag to indicate whether the widget is valid
+ * @var bool
+ */
+ public $isValid = false;
+
+ /**
+ * Flag to indicate whether the widget is newly added
+ * @var bool
+ */
+ public $isNew = false;
+
+ public $folderId;
+ public $permissionsFolderId;
+
+ /** @var int[] Original Media IDs */
+ private $originalMediaIds = [];
+
+ /** @var array[WidgetAudio] Original Widget Audio */
+ private $originalAudio = [];
+
+ /** @var \Xibo\Entity\WidgetOption[] Original widget options when this widget was laded */
+ private $originalWidgetOptions = [];
+
+ /**
+ * Minimum duration for widgets
+ * @var int
+ */
+ public static $widgetMinDuration = 1;
+
+ private $datesToFormat = ['toDt', 'fromDt'];
+
+ //
+
+ /**
+ * @var WidgetOptionFactory
+ */
+ private $widgetOptionFactory;
+
+ /**
+ * @var WidgetMediaFactory
+ */
+ private $widgetMediaFactory;
+
+ /** @var WidgetAudioFactory */
+ private $widgetAudioFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /** @var ActionFactory */
+ private $actionFactory;
+ //
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param WidgetOptionFactory $widgetOptionFactory
+ * @param WidgetMediaFactory $widgetMediaFactory
+ * @param WidgetAudioFactory $widgetAudioFactory
+ * @param PermissionFactory $permissionFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ * @param ActionFactory $actionFactory
+ */
+ public function __construct(
+ $store,
+ $log,
+ $dispatcher,
+ $widgetOptionFactory,
+ $widgetMediaFactory,
+ $widgetAudioFactory,
+ $permissionFactory,
+ $displayNotifyService,
+ $actionFactory
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ $this->excludeProperty('module');
+ $this->widgetOptionFactory = $widgetOptionFactory;
+ $this->widgetMediaFactory = $widgetMediaFactory;
+ $this->widgetAudioFactory = $widgetAudioFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ $this->actionFactory = $actionFactory;
+ }
+
+ public function __clone()
+ {
+ $this->hash = null;
+ $this->widgetId = null;
+ $this->widgetOptions = array_map(function ($object) { return clone $object; }, $this->widgetOptions);
+ $this->permissions = [];
+
+ // No need to clone the media, but we should empty the original arrays of ids
+ $this->originalMediaIds = [];
+ $this->originalAudio = [];
+
+ // Clone actions
+ $this->actions = array_map(function ($object) { return clone $object; }, $this->actions);
+ }
+
+ /**
+ * String
+ * @return string
+ */
+ public function __toString()
+ {
+ return sprintf('Widget. %s on playlist %d in position %d. WidgetId = %d', $this->type, $this->playlistId, $this->displayOrder, $this->widgetId);
+ }
+
+ public function getPermissionFolderId()
+ {
+ return $this->permissionsFolderId;
+ }
+
+ /**
+ * Get the Display Notify Service
+ * @return DisplayNotifyServiceInterface
+ */
+ public function getDisplayNotifyService(): DisplayNotifyServiceInterface
+ {
+ return $this->displayNotifyService->init();
+ }
+
+ /**
+ * Unique Hash
+ * @return string
+ */
+ private function hash()
+ {
+ return md5($this->widgetId
+ . $this->playlistId
+ . $this->ownerId
+ . $this->type
+ . $this->duration
+ . $this->displayOrder
+ . $this->useDuration
+ . $this->calculatedDuration
+ . $this->fromDt
+ . $this->toDt
+ . json_encode($this->widgetOptions)
+ . json_encode($this->actions)
+ );
+ }
+
+ /**
+ * Hash of all media id's
+ * @return string
+ */
+ private function mediaHash()
+ {
+ sort($this->mediaIds);
+ return md5(implode(',', $this->mediaIds));
+ }
+
+ /**
+ * Get the Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->widgetId;
+ }
+
+ /**
+ * Get the OwnerId
+ * @return int
+ */
+ public function getOwnerId()
+ {
+ return $this->ownerId;
+ }
+
+ /**
+ * Set the Owner
+ * @param int $ownerId
+ */
+ public function setOwner($ownerId)
+ {
+ $this->ownerId = $ownerId;
+ }
+
+ /**
+ * Get Option
+ * @param string $option
+ * @param bool $originalValue
+ * @return WidgetOption
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getOption(string $option, bool $originalValue = false): WidgetOption
+ {
+ $widgetOptions = $originalValue ? $this->originalWidgetOptions : $this->widgetOptions;
+ foreach ($widgetOptions as $widgetOption) {
+ if (strtolower($widgetOption->option) == strtolower($option)) {
+ return $widgetOption;
+ }
+ }
+
+ throw new NotFoundException(__('Widget Option not found'));
+ }
+
+ /**
+ * Remove an option
+ * @param string $option
+ * @return $this
+ */
+ public function removeOption(string $option): Widget
+ {
+ try {
+ $widgetOption = $this->getOption($option);
+
+ $this->getLog()->debug('removeOption: ' . $option);
+
+ // Unassign
+ foreach ($this->widgetOptions as $key => $value) {
+ if ($value->option === $option) {
+ unset($this->widgetOptions[$key]);
+ }
+ }
+
+ // Delete now
+ $widgetOption->delete();
+ } catch (NotFoundException $exception) {
+ // This is good, notihng to do.
+ }
+ return $this;
+ }
+
+ /**
+ * Change an option
+ * @param string $option
+ * @param string $newOption
+ * @return $this
+ */
+ public function changeOption(string $option, string $newOption): Widget
+ {
+ try {
+ $widgetOption = $this->getOption($option);
+
+ $this->getLog()->debug('changeOption: ' . $option);
+
+ // Unassign
+ foreach ($this->widgetOptions as $key => $value) {
+ if ($value->option === $option) {
+ unset($this->widgetOptions[$key]);
+ }
+ }
+
+ // Change now
+ $widgetOption->delete();
+ $this->widgetOptions[] = $this->widgetOptionFactory->create($this->widgetId, $widgetOption->type, $newOption, $widgetOption->value);
+
+ } catch (NotFoundException $exception) {
+ // This is good, nothing to do.
+ }
+ return $this;
+ }
+
+ /**
+ * Get Widget Option Value
+ * @param string $option
+ * @param mixed $default
+ * @param bool $originalValue
+ * @return mixed
+ */
+ public function getOptionValue(string $option, $default, bool $originalValue = false)
+ {
+ try {
+ $widgetOption = $this->getOption($option, $originalValue);
+ $widgetOption = (($widgetOption->value) === null) ? $default : $widgetOption->value;
+
+ if (is_integer($default)) {
+ $widgetOption = intval($widgetOption);
+ }
+
+ return $widgetOption;
+ } catch (NotFoundException $e) {
+ return $default;
+ }
+ }
+
+ /**
+ * Set Widget Option Value
+ * @param string $option
+ * @param string $type
+ * @param mixed $value
+ */
+ public function setOptionValue(string $option, string $type, $value)
+ {
+ $this->getLog()->debug('setOptionValue: ' . $option . ', ' . $type . '. Value = ' . $value);
+ try {
+ $widgetOption = $this->getOption($option);
+ $widgetOption->type = $type;
+ $widgetOption->value = $value;
+ } catch (NotFoundException $e) {
+ $this->widgetOptions[] = $this->widgetOptionFactory->create($this->widgetId, $type, $option, $value);
+ }
+ }
+
+ /**
+ * Assign File Media
+ * @param int $mediaId
+ */
+ public function assignMedia($mediaId)
+ {
+ $this->load();
+
+ if (!in_array($mediaId, $this->mediaIds))
+ $this->mediaIds[] = $mediaId;
+ }
+
+ /**
+ * Unassign File Media
+ * @param int $mediaId
+ */
+ public function unassignMedia($mediaId)
+ {
+ $this->load();
+
+ $this->mediaIds = array_diff($this->mediaIds, [$mediaId]);
+ }
+
+ /**
+ * Count media
+ * @return int count of media
+ */
+ public function countMedia()
+ {
+ $this->load();
+ return count($this->mediaIds);
+ }
+
+ /**
+ * @return int
+ * @throws NotFoundException
+ */
+ public function getPrimaryMediaId()
+ {
+ $primary = $this->getPrimaryMedia();
+
+ if (count($primary) <= 0)
+ throw new NotFoundException(__('No file to return'));
+
+ return $primary[0];
+ }
+
+ /**
+ * Get Primary Media
+ * @return int[]
+ */
+ public function getPrimaryMedia()
+ {
+ $this->load();
+
+ $this->getLog()->debug('Getting first primary media for Widget: ' . $this->widgetId . ' Media: ' . json_encode($this->mediaIds) . ' audio ' . json_encode($this->getAudioIds()));
+
+ if (count($this->mediaIds) <= 0)
+ return [];
+
+ // Remove the audio media from this array
+ return array_values(array_diff($this->mediaIds, $this->getAudioIds()));
+ }
+
+ /**
+ * Get the temporary path
+ * @return string
+ */
+ public function getLibraryTempPath(): string
+ {
+ return $this->widgetMediaFactory->getLibraryTempPath();
+ }
+
+ /**
+ * Get the path of the primary media
+ * @return string
+ * @throws NotFoundException
+ */
+ public function getPrimaryMediaPath(): string
+ {
+ return $this->widgetMediaFactory->getPathForMediaId($this->getPrimaryMediaId());
+ }
+
+ /**
+ * Assign Audio Media
+ * @param WidgetAudio $audio
+ */
+ public function assignAudio($audio)
+ {
+ $this->load();
+
+ $found = false;
+ foreach ($this->audio as $existingAudio) {
+ if ($existingAudio->mediaId == $audio->mediaId) {
+ $existingAudio->loop = $audio->loop;
+ $existingAudio->volume = $audio->volume;
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found)
+ $this->audio[] = $audio;
+
+ // Assign the media
+ $this->assignMedia($audio->mediaId);
+ }
+
+ /**
+ * Unassign Audio Media
+ * @param int $mediaId
+ */
+ public function assignAudioById($mediaId)
+ {
+ $this->load();
+
+ $widgetAudio = $this->widgetAudioFactory->createEmpty();
+ $widgetAudio->mediaId = $mediaId;
+ $widgetAudio->volume = 100;
+ $widgetAudio->loop = 0;
+
+ $this->assignAudio($widgetAudio);
+ }
+
+ /**
+ * Unassign Audio Media
+ * @param WidgetAudio $audio
+ */
+ public function unassignAudio($audio)
+ {
+ $this->load();
+
+ $this->audio = array_udiff($this->audio, [$audio], function($a, $b) {
+ /**
+ * @var WidgetAudio $a
+ * @var WidgetAudio $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ // Unassign the media
+ $this->unassignMedia($audio->mediaId);
+ }
+
+ /**
+ * Unassign Audio Media
+ * @param int $mediaId
+ */
+ public function unassignAudioById($mediaId)
+ {
+ $this->load();
+
+ foreach ($this->audio as $audio) {
+
+ if ($audio->mediaId == $mediaId)
+ $this->unassignAudio($audio);
+ }
+ }
+
+ /**
+ * Count Audio
+ * @return int
+ */
+ public function countAudio()
+ {
+ $this->load();
+
+ return count($this->audio);
+ }
+
+ /**
+ * Get AudioIds
+ * @return int[]
+ */
+ public function getAudioIds()
+ {
+ $this->load();
+
+ return array_map(function($element) {
+ /** @var WidgetAudio $element */
+ return $element->mediaId;
+ }, $this->audio);
+ }
+
+ /**
+ * Have the media assignments changed.
+ */
+ public function hasMediaChanged()
+ {
+ return ($this->mediaHash != $this->mediaHash());
+ }
+
+ /**
+ * @return bool true if this widget has an expiry date
+ */
+ public function hasExpiry()
+ {
+ return $this->toDt !== self::$DATE_MAX;
+ }
+
+ /**
+ * @return bool true if this widget has expired
+ */
+ public function isExpired()
+ {
+ return ($this->toDt !== self::$DATE_MAX && Carbon::createFromTimestamp($this->toDt)->format('U') < Carbon::now()->format('U'));
+ }
+
+ /**
+ * Calculates the duration of this widget according to some rules
+ * @param \Xibo\Entity\Module $module
+ * @param bool $import
+ * @return $this
+ */
+ public function calculateDuration(
+ Module $module,
+ bool $import = false
+ ): Widget {
+ $this->getLog()->debug('calculateDuration: Calculating for ' . $this->type
+ . ' - existing value is ' . $this->calculatedDuration
+ . ' import is ' . ($import ? 'true' : 'false'));
+
+ // Import
+ // ------
+ // If we are importing a layout we need to adjust the `duration` **before** we pass to any duration
+ // provider, as providers will use the duration set on the widget in their calculations.
+ // $this->duration from xml is `duration * (numItems/itemsPerPage)`
+ if ($import) {
+ $numItems = $this->getOptionValue('numItems', 1);
+ if ($this->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) {
+ // If we have paging involved then work out the page count.
+ $itemsPerPage = $this->getOptionValue('itemsPerPage', 0);
+ if ($itemsPerPage > 0) {
+ $numItems = ceil($numItems / $itemsPerPage);
+ }
+
+ // This is a change to v3
+ // in v3 we only divide by numItems if useDuration = 0, which I think was wrong.
+ $this->duration = ($this->useDuration == 1 ? $this->duration : $module->defaultDuration) / $numItems;
+ }
+ }
+
+ // Start with either the default module duration, or the duration provided
+ if ($this->useDuration == 1) {
+ // Widget duration is as specified
+ $this->calculatedDuration = $this->duration;
+ } else {
+ // Use the default duration
+ $this->calculatedDuration = $module->defaultDuration;
+ }
+
+ // Modify the duration if necessary
+ if ($module->type === 'subplaylist') {
+ // Sub Playlists are a special case and provide their own duration.
+ $this->getLog()->debug('calculateDuration: subplaylist using SubPlaylistDurationEvent');
+
+ $event = new SubPlaylistDurationEvent($this);
+ $this->getDispatcher()->dispatch($event, SubPlaylistDurationEvent::$NAME);
+ $this->calculatedDuration = $event->getDuration();
+ } else {
+ // Our module will calculate the duration for us.
+ $duration = $module->calculateDuration($this);
+ if ($duration !== null) {
+ $this->calculatedDuration = $duration;
+ } else {
+ $this->getLog()->debug('calculateDuration: Duration not set by module');
+ }
+ }
+
+ $this->getLog()->debug('calculateDuration: set to ' . $this->calculatedDuration);
+ return $this;
+ }
+
+ /**
+ * @return int
+ * @throws NotFoundException
+ */
+ public function getDurationForMedia(): int
+ {
+ return $this->widgetMediaFactory->getDurationForMediaId($this->getPrimaryMediaId());
+ }
+
+ /**
+ * Load the Widget
+ * @param bool $loadActions
+ * @return Widget
+ */
+ public function load(bool $loadActions = true): Widget
+ {
+ if ($this->loaded || $this->widgetId == null || $this->widgetId == 0) {
+ return $this;
+ }
+
+ // Load permissions
+ $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->widgetId);
+
+ // Load the widget options
+ $this->widgetOptions = $this->widgetOptionFactory->getByWidgetId($this->widgetId);
+ foreach ($this->widgetOptions as $widgetOption) {
+ $this->originalWidgetOptions[] = clone $widgetOption;
+ }
+
+ // Load any media assignments for this widget
+ $this->mediaIds = $this->widgetMediaFactory->getByWidgetId($this->widgetId);
+ $this->originalMediaIds = $this->mediaIds;
+
+ // Load any widget audio assignments
+ $this->audio = $this->widgetAudioFactory->getByWidgetId($this->widgetId);
+ $this->originalAudio = $this->audio;
+
+ if ($loadActions) {
+ $this->actions = $this->actionFactory->getBySourceAndSourceId('widget', $this->widgetId);
+ }
+
+ $this->hash = $this->hash();
+ $this->mediaHash = $this->mediaHash();
+ $this->loaded = true;
+ return $this;
+ }
+
+ /**
+ * Load the Widget with minimal data i.e., options
+ */
+ public function loadMinimum(): void
+ {
+ if ($this->loaded || $this->widgetId == null || $this->widgetId == 0) {
+ return;
+ }
+
+ // Load the widget options
+ $this->widgetOptions = $this->widgetOptionFactory->getByWidgetId($this->widgetId);
+ foreach ($this->widgetOptions as $widgetOption) {
+ $this->originalWidgetOptions[] = clone $widgetOption;
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * @param Property[] $properties
+ * @return \Xibo\Entity\Widget
+ */
+ public function applyProperties(array $properties): Widget
+ {
+ foreach ($properties as $property) {
+ // Do not save null properties.
+ if ($property->value === null) {
+ $this->removeOption($property->id);
+ } else {
+ // Apply filters
+ $property->applyFilters();
+
+ // Set the property for saving into the widget options
+ $this->setOptionValue($property->id, $property->isCData() ? 'cdata' : 'attrib', $property->value);
+
+ // If this property allows library references to be added, we parse them out here and assign
+ // the matching media to the widget.
+ if ($property->allowLibraryRefs) {
+ // Parse them out and replace for our special syntax.
+ $matches = [];
+ preg_match_all('/\[(.*?)\]/', $property->value, $matches);
+ foreach ($matches[1] as $match) {
+ if (is_numeric($match)) {
+ $this->assignMedia(intval($match));
+ }
+ }
+ }
+
+ // Is this a media selector? and if so should we assign the library media
+ if ($property->type === 'mediaSelector') {
+ if (!empty($value) && is_numeric($value)) {
+ $this->assignMedia(intval($value));
+ }
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Save the widget
+ * @param array $options
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function save($options = [])
+ {
+ // Default options
+ $options = array_merge([
+ 'saveWidgetOptions' => true,
+ 'saveWidgetAudio' => true,
+ 'saveWidgetMedia' => true,
+ 'notify' => true,
+ 'notifyPlaylists' => true,
+ 'notifyDisplays' => false,
+ 'audit' => true,
+ 'auditWidgetOptions' => true,
+ 'auditMessage' => 'Saved',
+ 'alwaysUpdate' => false,
+ 'import' => false,
+ 'upgrade' => false,
+ ], $options);
+
+ $this->getLog()->debug('Saving widgetId ' . $this->getId() . ' with options. '
+ . json_encode($options, JSON_PRETTY_PRINT));
+
+ // if we are auditing get layout specific campaignId
+ $campaignId = 0;
+ $layoutId = 0;
+ if ($options['audit']) {
+ $results = $this->store->select('
+ SELECT `campaign`.campaignId,
+ `layout`.layoutId
+ FROM `playlist`
+ INNER JOIN `region`
+ ON `playlist`.regionId = `region`.regionId
+ INNER JOIN `layout`
+ ON `region`.layoutId = `layout`.layoutId
+ INNER JOIN `lkcampaignlayout`
+ ON `layout`.layoutId = `lkcampaignlayout`.layoutId
+ INNER JOIN `campaign`
+ ON `campaign`.campaignId = `lkcampaignlayout`.campaignId
+ WHERE `campaign`.isLayoutSpecific = 1
+ AND `playlist`.playlistId = :playlistId
+ ', [
+ 'playlistId' => $this->playlistId
+ ]);
+
+ foreach ($results as $row) {
+ $campaignId = intval($row['campaignId']);
+ $layoutId = intval($row['layoutId']);
+ }
+ }
+
+ // Add/Edit
+ $isNew = $this->widgetId == null || $this->widgetId == 0;
+ if ($isNew) {
+ $this->add();
+ } else {
+ // When saving after Widget compatibility upgrade
+ // do not trigger this event, as it will throw an error
+ // this is due to mismatch between playlist closure table (already populated)
+ // and subPlaylists option original values (empty array) - attempt to add the same child will error out.
+ if (!$options['upgrade']) {
+ $this->getDispatcher()->dispatch(new WidgetEditEvent($this), WidgetEditEvent::$NAME);
+ }
+
+ if ($this->hash != $this->hash() || $options['alwaysUpdate']) {
+ $this->update();
+ }
+ }
+
+ // Save the widget options
+ if ($options['saveWidgetOptions']) {
+ foreach ($this->widgetOptions as $widgetOption) {
+ // Assert the widgetId
+ $widgetOption->widgetId = $this->widgetId;
+ $widgetOption->save();
+ }
+ }
+
+ // Save the widget audio
+ if ($options['saveWidgetAudio']) {
+ foreach ($this->audio as $audio) {
+ // Assert the widgetId
+ $audio->widgetId = $this->widgetId;
+ $audio->save();
+ }
+
+ $removedAudio = array_udiff($this->originalAudio, $this->audio, function ($a, $b) {
+ /**
+ * @var WidgetAudio $a
+ * @var WidgetAudio $b
+ */
+ return $a->getId() - $b->getId();
+ });
+
+ foreach ($removedAudio as $audio) {
+ /* @var \Xibo\Entity\WidgetAudio $audio */
+
+ // Assert the widgetId
+ $audio->widgetId = $this->widgetId;
+ $audio->delete();
+ }
+ }
+
+ // Manage the assigned media
+ if ($options['saveWidgetMedia'] || $options['saveWidgetAudio']) {
+ $this->linkMedia();
+ $this->unlinkMedia();
+ }
+
+ // Call notify with the notify options passed in
+ $this->notify($options);
+
+ if ($options['audit']) {
+ if ($isNew) {
+ if ($campaignId != 0 && $layoutId != 0) {
+ $this->audit($this->widgetId, 'Added', [
+ 'widgetId' => $this->widgetId,
+ 'type' => $this->type,
+ 'layoutId' => $layoutId,
+ 'campaignId' => $campaignId
+ ]);
+ }
+ } else {
+ // For elements, do not try to look up changed properties.
+ $changedProperties = $options['auditWidgetOptions'] ? $this->getChangedProperties() : [];
+ $changedItems = [];
+
+ if ($options['auditWidgetOptions']) {
+ foreach ($this->widgetOptions as $widgetOption) {
+ $itemsProperties = $widgetOption->getChangedProperties();
+
+ // for widget options what we get from getChangedProperities is an array with value as key and
+ // changed value as value we want to override the key in the returned array, so that we get a
+ // clear option name that was changed
+ if (array_key_exists('value', $itemsProperties)) {
+ $itemsProperties[$widgetOption->option] = $itemsProperties['value'];
+ unset($itemsProperties['value']);
+ }
+
+ if (count($itemsProperties) > 0) {
+ $changedItems[] = $itemsProperties;
+ }
+ }
+ }
+
+ if (count($changedItems) > 0) {
+ $changedProperties['widgetOptions'] = json_encode($changedItems, JSON_PRETTY_PRINT);
+ }
+
+ // if we are editing a widget assigned to a regionPlaylist add the layout specific campaignId to
+ // the audit log
+ if ($campaignId != 0 && $layoutId != 0) {
+ $changedProperties['campaignId'][] = $campaignId;
+ $changedProperties['layoutId'][] = $layoutId;
+ }
+
+ $this->audit($this->widgetId, $options['auditMessage'], $changedProperties);
+ }
+ }
+ }
+
+ /**
+ * @param array $options
+ */
+ public function delete($options = [])
+ {
+ $options = array_merge([
+ 'notify' => true,
+ 'notifyPlaylists' => true,
+ 'forceNotifyPlaylists' => true,
+ 'notifyDisplays' => false
+ ], $options);
+
+ // We must ensure everything is loaded before we delete
+ $this->load();
+
+ // Widget Delete Event
+ $this->getDispatcher()->dispatch(new WidgetDeleteEvent($this), WidgetDeleteEvent::$NAME);
+
+ // Delete Permissions
+ foreach ($this->permissions as $permission) {
+ $permission->deleteAll();
+ }
+
+ // Delete all Options
+ foreach ($this->widgetOptions as $widgetOption) {
+ // Assert the widgetId
+ $widgetOption->widgetId = $this->widgetId;
+ $widgetOption->delete();
+ }
+
+ // Delete the widget audio
+ foreach ($this->audio as $audio) {
+ // Assert the widgetId
+ $audio->widgetId = $this->widgetId;
+ $audio->delete();
+ }
+
+ foreach ($this->actions as $action) {
+ $action->delete();
+ }
+
+ // Set widgetId to null on any navWidget action that was using this drawer Widget.
+ $this->getStore()->update(
+ 'UPDATE `action` SET `action`.widgetId = NULL
+ WHERE widgetId = :widgetId AND `action`.actionType = \'navWidget\' ',
+ ['widgetId' => $this->widgetId]
+ );
+
+ $this->mediaIds = [];
+
+ // initialize media Ids to unlink
+ $mediaIdsToUnlink = $this->getMediaIdsToUnlink();
+
+ // Unlink Media
+ $this->unlinkMedia();
+
+ // Delete any fallback data
+ $this->getStore()->update('DELETE FROM `widgetdata` WHERE `widgetId` = :widgetId', [
+ 'widgetId' => $this->widgetId,
+ ]);
+
+ // Delete this
+ $this->getStore()->update('DELETE FROM `widget` WHERE `widgetId` = :widgetId', [
+ 'widgetId' => $this->widgetId,
+ ]);
+
+ // Call notify with the notify options passed in
+ $this->notify($options);
+
+ $this->getLog()->debug('Delete Widget Complete');
+
+ // Audit
+ $this->audit($this->widgetId,
+ 'Deleted',
+ array_merge(
+ ['widgetId' => $this->widgetId, 'playlistId' => $this->playlistId],
+ $mediaIdsToUnlink
+ )
+ );
+ }
+
+ /**
+ * Notify
+ * @param $options
+ */
+ private function notify($options)
+ {
+ // By default we do nothing in here, options have to be explicitly enabled.
+ $options = array_merge([
+ 'notify' => false,
+ 'notifyPlaylists' => false,
+ 'forceNotifyPlaylists' => false,
+ 'notifyDisplays' => false
+ ], $options);
+
+ $this->getLog()->debug('Notifying upstream playlist. Notify Layout: ' . $options['notify'] . ' Notify Displays: ' . $options['notifyDisplays']);
+
+ // Should we notify the Playlist
+ // we do this if the duration has changed on this widget.
+ if ($options['forceNotifyPlaylists']|| ($options['notifyPlaylists'] && (
+ $this->hasPropertyChanged('calculatedDuration')
+ || $this->hasPropertyChanged('fromDt')
+ || $this->hasPropertyChanged('toDt')
+ ))) {
+ // Notify the Playlist
+ $this->getStore()->update('UPDATE `playlist` SET requiresDurationUpdate = 1, `modifiedDT` = :modifiedDt WHERE playlistId = :playlistId', [
+ 'playlistId' => $this->playlistId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+
+ // Notify Layout
+ // We do this for draft and published versions of the Layout to keep the Layout Status fresh and the modified
+ // date updated.
+ if ($options['notify']) {
+ // Notify the Layout
+ $this->getStore()->update('
+ UPDATE `layout` SET `status` = 3, `modifiedDT` = :modifiedDt WHERE layoutId IN (
+ SELECT `region`.layoutId
+ FROM `lkplaylistplaylist`
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `lkplaylistplaylist`.parentId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ WHERE `lkplaylistplaylist`.childId = :playlistId
+ )
+ ', [
+ 'playlistId' => $this->playlistId,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+
+ // Notify any displays (clearing their cache)
+ // this is typically done when there has been a dynamic change to the Widget - i.e. the Layout doesn't need
+ // to be rebuilt, but the Widget has some change that will be pushed out through getResource
+ if ($options['notifyDisplays']) {
+ $this->getDisplayNotifyService()->collectNow()->notifyByPlaylistId($this->playlistId);
+ }
+ }
+
+ private function add()
+ {
+ $this->getLog()->debug('Adding Widget ' . $this->type . ' to PlaylistId ' . $this->playlistId);
+
+ $this->isNew = true;
+
+ $sql = '
+ INSERT INTO `widget` (`playlistId`, `ownerId`, `type`, `duration`, `displayOrder`, `useDuration`, `calculatedDuration`, `fromDt`, `toDt`, `createdDt`, `modifiedDt`, `schemaVersion`)
+ VALUES (:playlistId, :ownerId, :type, :duration, :displayOrder, :useDuration, :calculatedDuration, :fromDt, :toDt, :createdDt, :modifiedDt, :schemaVersion)
+ ';
+
+ $this->widgetId = $this->getStore()->insert($sql, array(
+ 'playlistId' => $this->playlistId,
+ 'ownerId' => $this->ownerId,
+ 'type' => $this->type,
+ 'duration' => $this->duration,
+ 'displayOrder' => $this->displayOrder,
+ 'useDuration' => $this->useDuration,
+ 'calculatedDuration' => $this->calculatedDuration,
+ 'fromDt' => ($this->fromDt == null) ? self::$DATE_MIN : $this->fromDt,
+ 'toDt' => ($this->toDt == null) ? self::$DATE_MAX : $this->toDt,
+ 'createdDt' => ($this->createdDt === null) ? Carbon::now()->format('U') : $this->createdDt,
+ 'modifiedDt' => Carbon::now()->format('U'),
+ 'schemaVersion' => $this->schemaVersion
+ ));
+ }
+
+ private function update()
+ {
+ $this->getLog()->debug('Saving Widget ' . $this->type . ' on PlaylistId ' . $this->playlistId . ' WidgetId: ' . $this->widgetId);
+
+ $sql = '
+ UPDATE `widget` SET `playlistId` = :playlistId,
+ `ownerId` = :ownerId,
+ `type` = :type,
+ `duration` = :duration,
+ `displayOrder` = :displayOrder,
+ `useDuration` = :useDuration,
+ `calculatedDuration` = :calculatedDuration,
+ `fromDt` = :fromDt,
+ `toDt` = :toDt,
+ `modifiedDt` = :modifiedDt,
+ `schemaVersion` = :schemaVersion
+ WHERE `widgetId` = :widgetId
+ ';
+
+ $params = [
+ 'playlistId' => $this->playlistId,
+ 'ownerId' => $this->ownerId,
+ 'type' => $this->type,
+ 'duration' => $this->duration,
+ 'widgetId' => $this->widgetId,
+ 'displayOrder' => $this->displayOrder,
+ 'useDuration' => $this->useDuration,
+ 'calculatedDuration' => $this->calculatedDuration,
+ 'fromDt' => ($this->fromDt == null) ? self::$DATE_MIN : $this->fromDt,
+ 'toDt' => ($this->toDt == null) ? self::$DATE_MAX : $this->toDt,
+ 'modifiedDt' => Carbon::now()->format('U'),
+ 'schemaVersion' => $this->schemaVersion
+ ];
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Link Media
+ */
+ private function linkMedia()
+ {
+ // Calculate the difference between the current assignments and the original.
+ $mediaToLink = array_diff($this->mediaIds, $this->originalMediaIds);
+
+ $this->getLog()->debug('Linking %d new media to Widget %d', count($mediaToLink), $this->widgetId);
+
+ // TODO: Make this more efficient by storing the prepared SQL statement
+ $sql = 'INSERT INTO `lkwidgetmedia` (widgetId, mediaId) VALUES (:widgetId, :mediaId) ON DUPLICATE KEY UPDATE mediaId = :mediaId2';
+
+ foreach ($mediaToLink as $mediaId) {
+ $this->getStore()->insert($sql, array(
+ 'widgetId' => $this->widgetId,
+ 'mediaId' => $mediaId,
+ 'mediaId2' => $mediaId
+ ));
+
+ // audit the media being added
+ $this->getLog()->audit('Media', $mediaId, 'Media added to widget', ['mediaId' => $mediaId, 'widgetId' => $this->widgetId]);
+ }
+ }
+
+ /**
+ * Unlink Media
+ */
+ private function unlinkMedia()
+ {
+ // Calculate the difference between the current assignments and the original.
+ $mediaToUnlink = array_diff($this->originalMediaIds, $this->mediaIds);
+
+ $this->getLog()->debug('Unlinking %d old media from Widget %d', count($mediaToUnlink), $this->widgetId);
+
+ if (count($mediaToUnlink) <= 0) {
+ return;
+ }
+
+ // Unlink any media in the collection
+ $params = ['widgetId' => $this->widgetId];
+
+ $sql = 'DELETE FROM `lkwidgetmedia` WHERE widgetId = :widgetId AND mediaId IN (0';
+
+ $i = 0;
+ foreach ($mediaToUnlink as $mediaId) {
+ $i++;
+ $sql .= ',:mediaId' . $i;
+ $params['mediaId' . $i] = $mediaId;
+
+ // audit the media being deleted
+ $this->getLog()->audit('Media', $mediaId, 'Media removed from widget', ['mediaId' => $mediaId, 'widgetId' => $this->widgetId]);
+ }
+
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * Returns an array of MediaIds to unlink
+ *
+ * @return array
+ */
+ private function getMediaIdsToUnlink(): array
+ {
+ // Calculate the difference between the current assignments and the original.
+ $mediaToUnlink = array_diff($this->originalMediaIds, $this->mediaIds);
+
+ if (count($mediaToUnlink) <= 0) {
+ return [];
+ }
+
+ // If there is only one mediaId, add it without a suffix
+ if (count($mediaToUnlink) === 1) {
+ $mediaId = reset($mediaToUnlink);
+ return ['mediaId' => $mediaId];
+ }
+
+ // More than one mediaId, append a suffix to the key
+ $mediaIdsToUnlink = [];
+ $i = 1;
+
+ foreach ($mediaToUnlink as $mediaId) {
+ $mediaIdsToUnlink['mediaId_' . $i] = $mediaId;
+ $i++;
+ }
+
+ return $mediaIdsToUnlink;
+ }
+}
diff --git a/lib/Entity/WidgetAudio.php b/lib/Entity/WidgetAudio.php
new file mode 100644
index 0000000..9fb3691
--- /dev/null
+++ b/lib/Entity/WidgetAudio.php
@@ -0,0 +1,115 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class WidgetAudio
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class WidgetAudio implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Widget Id")
+ * @var int
+ */
+ public $widgetId;
+
+ /**
+ * @SWG\Property(description="The Media Id")
+ * @var int
+ */
+ public $mediaId;
+
+ /**
+ * @SWG\Property(description="The percentage volume")
+ * @var int
+ */
+ public $volume;
+
+ /**
+ * @SWG\Property(description="Flag indicating whether to loop")
+ * @var int
+ */
+ public $loop;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Get Id
+ * @return int
+ */
+ public function getId()
+ {
+ return $this->mediaId;
+ }
+
+ /**
+ * Save this widget audio
+ */
+ public function save()
+ {
+ $sql = '
+ INSERT INTO `lkwidgetaudio` (widgetId, mediaId, `volume`, `loop`)
+ VALUES (:widgetId, :mediaId, :volume, :loop)
+ ON DUPLICATE KEY UPDATE volume = :volume, `loop` = :loop
+ ';
+
+ $this->getStore()->insert($sql, array(
+ 'widgetId' => $this->widgetId,
+ 'mediaId' => $this->mediaId,
+ 'volume' => $this->volume,
+ 'loop' => $this->loop
+ ));
+ }
+
+ /**
+ * Delete this widget audio
+ */
+ public function delete()
+ {
+ $this->getStore()->update('
+ DELETE FROM `lkwidgetaudio`
+ WHERE widgetId = :widgetId AND mediaId = :mediaId
+ ', [
+ 'widgetId' => $this->widgetId,
+ 'mediaId' => $this->mediaId
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/lib/Entity/WidgetData.php b/lib/Entity/WidgetData.php
new file mode 100644
index 0000000..995683f
--- /dev/null
+++ b/lib/Entity/WidgetData.php
@@ -0,0 +1,169 @@
+.
+ */
+
+namespace Xibo\Entity;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class representing widget data
+ * @SWG\Definition()
+ */
+class WidgetData implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(
+ * property="id",
+ * description="The ID"
+ * )
+ * @var int|null
+ */
+ public ?int $id = null;
+
+ /**
+ * @SWG\Property(
+ * property="widgetId",
+ * description="The Widget ID"
+ * )
+ * @var int
+ */
+ public int $widgetId;
+
+ /**
+ * @SWG\Property(
+ * property="data",
+ * description="Array of data properties depending on the widget data type this data is for",
+ * @SWG\Items(type="string")
+ * )
+ * @var array
+ */
+ public array $data;
+
+ /**
+ * @SWG\Property(
+ * property="displayOrder",
+ * description="The Display Order"
+ * )
+ * @var int
+ */
+ public int $displayOrder;
+
+ /**
+ * @SWG\Property(
+ * property="createdDt",
+ * description="The datetime this entity was created"
+ * )
+ * @var ?string
+ */
+ public ?string $createdDt;
+
+ /**
+ * @SWG\Property(
+ * property="modifiedDt",
+ * description="The datetime this entity was last modified"
+ * )
+ * @var ?string
+ */
+ public ?string $modifiedDt;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct(
+ StorageServiceInterface $store,
+ LogServiceInterface $log,
+ EventDispatcherInterface $dispatcher
+ ) {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ /**
+ * Save this widget data
+ * @return $this
+ */
+ public function save(): WidgetData
+ {
+ if ($this->id === null) {
+ $this->add();
+ } else {
+ $this->modifiedDt = Carbon::now()->format(DateFormatHelper::getSystemFormat());
+ $this->edit();
+ }
+ return $this;
+ }
+
+ /**
+ * Delete this widget data
+ * @return void
+ */
+ public function delete(): void
+ {
+ $this->getStore()->update('DELETE FROM `widgetdata` WHERE `id` = :id', ['id' => $this->id]);
+ }
+
+ /**
+ * Add and capture the ID
+ * @return void
+ */
+ private function add(): void
+ {
+ $this->id = $this->getStore()->insert('
+ INSERT INTO `widgetdata` (`widgetId`, `data`, `displayOrder`, `createdDt`, `modifiedDt`)
+ VALUES (:widgetId, :data, :displayOrder, :createdDt, :modifiedDt)
+ ', [
+ 'widgetId' => $this->widgetId,
+ 'data' => $this->data == null ? null : json_encode($this->data),
+ 'displayOrder' => $this->displayOrder,
+ 'createdDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'modifiedDt' => null,
+ ]);
+ }
+
+ /**
+ * Edit
+ * @return void
+ */
+ private function edit(): void
+ {
+ $this->getStore()->update('
+ UPDATE `widgetdata` SET
+ `data` = :data,
+ `displayOrder` = :displayOrder,
+ `modifiedDt` = :modifiedDt
+ WHERE `id` = :id
+ ', [
+ 'id' => $this->id,
+ 'data' => $this->data == null ? null : json_encode($this->data),
+ 'displayOrder' => $this->displayOrder,
+ 'modifiedDt' => $this->modifiedDt,
+ ]);
+ }
+}
diff --git a/lib/Entity/WidgetOption.php b/lib/Entity/WidgetOption.php
new file mode 100644
index 0000000..8165e39
--- /dev/null
+++ b/lib/Entity/WidgetOption.php
@@ -0,0 +1,111 @@
+.
+ */
+
+
+namespace Xibo\Entity;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+
+/**
+ * Class WidgetOption
+ * @package Xibo\Entity
+ *
+ * @SWG\Definition()
+ */
+class WidgetOption implements \JsonSerializable
+{
+ use EntityTrait;
+
+ /**
+ * @SWG\Property(description="The Widget ID that this Option belongs to")
+ * @var int
+ */
+ public $widgetId;
+
+ /**
+ * @SWG\Property(description="The option type, either attrib or raw")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="The option name")
+ * @var string
+ */
+ public $option;
+
+ /**
+ * @SWG\Property(description="The option value")
+ * @var string
+ */
+ public $value;
+
+ /**
+ * Entity constructor.
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct($store, $log, $dispatcher)
+ {
+ $this->setCommonDependencies($store, $log, $dispatcher);
+ }
+
+ public function __clone()
+ {
+ $this->widgetId = null;
+ }
+
+ public function __toString()
+ {
+ if ($this->type == 'cdata') {
+ return sprintf('%s WidgetOption %s', $this->type, $this->option);
+ }
+ else {
+ return sprintf('%s WidgetOption %s with value %s', $this->type, $this->option, $this->value);
+ }
+ }
+
+ public function save()
+ {
+ $this->getLog()->debug('Saving ' . $this);
+
+ $this->getStore()->insert('
+ INSERT INTO `widgetoption` (`widgetId`, `type`, `option`, `value`)
+ VALUES (:widgetId, :type, :option, :value) ON DUPLICATE KEY UPDATE `value` = :value2
+ ', array(
+ 'widgetId' => $this->widgetId,
+ 'type' => $this->type,
+ 'option' => $this->option,
+ 'value' => $this->value,
+ 'value2' => $this->value
+ ));
+ }
+
+ public function delete()
+ {
+ $this->getStore()->update('DELETE FROM `widgetoption` WHERE `widgetId` = :widgetId AND `option` = :option', array(
+ 'widgetId' => $this->widgetId, 'option' => $this->option)
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/Event/CommandDeleteEvent.php b/lib/Event/CommandDeleteEvent.php
new file mode 100644
index 0000000..0e5c6d5
--- /dev/null
+++ b/lib/Event/CommandDeleteEvent.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\Command;
+
+class CommandDeleteEvent extends Event
+{
+ public static $NAME = 'command.delete.event';
+ /**
+ * @var Command
+ */
+ private $command;
+
+ public function __construct(Command $command)
+ {
+ $this->command = $command;
+ }
+
+ public function getCommand(): Command
+ {
+ return $this->command;
+ }
+}
diff --git a/lib/Event/ConnectorDeletingEvent.php b/lib/Event/ConnectorDeletingEvent.php
new file mode 100644
index 0000000..cf32354
--- /dev/null
+++ b/lib/Event/ConnectorDeletingEvent.php
@@ -0,0 +1,33 @@
+connector = $connector;
+ $this->configService = $configService;
+ }
+
+ public function getConnector(): Connector
+ {
+ return $this->connector;
+ }
+
+ public function getConfigService(): ConfigServiceInterface
+ {
+ return $this->configService;
+ }
+}
diff --git a/lib/Event/ConnectorEnabledChangeEvent.php b/lib/Event/ConnectorEnabledChangeEvent.php
new file mode 100644
index 0000000..2d99bbf
--- /dev/null
+++ b/lib/Event/ConnectorEnabledChangeEvent.php
@@ -0,0 +1,33 @@
+connector = $connector;
+ $this->configService = $configService;
+ }
+
+ public function getConnector(): Connector
+ {
+ return $this->connector;
+ }
+
+ public function getConfigService(): ConfigServiceInterface
+ {
+ return $this->configService;
+ }
+}
diff --git a/lib/Event/ConnectorReportEvent.php b/lib/Event/ConnectorReportEvent.php
new file mode 100644
index 0000000..4e56ed1
--- /dev/null
+++ b/lib/Event/ConnectorReportEvent.php
@@ -0,0 +1,45 @@
+.
+ */
+namespace Xibo\Event;
+
+/**
+ * Event used to get list of connector reports
+ */
+class ConnectorReportEvent extends Event
+{
+ public static $NAME = 'connector.report.event';
+
+ /** @var array */
+ private $reports = [];
+
+ public function getReports()
+ {
+ return $this->reports;
+ }
+
+ public function addReports($reports)
+ {
+ $this->reports = array_merge_recursive($this->reports, $reports);
+
+ return $this;
+ }
+}
diff --git a/lib/Event/DashboardDataRequestEvent.php b/lib/Event/DashboardDataRequestEvent.php
new file mode 100644
index 0000000..cba2d63
--- /dev/null
+++ b/lib/Event/DashboardDataRequestEvent.php
@@ -0,0 +1,47 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Widget\Provider\DataProviderInterface;
+
+class DashboardDataRequestEvent extends Event
+{
+ public static $NAME = 'dashboard.data.request.event';
+
+ /** @var DataProviderInterface */
+ private $dataProvider;
+
+ public function __construct(DataProviderInterface $dataProvider)
+ {
+ $this->dataProvider = $dataProvider;
+ }
+
+ /**
+ * The data provider should be updated with data for its Widget.
+ * @return DataProviderInterface
+ */
+ public function getDataProvider(): DataProviderInterface
+ {
+ return $this->dataProvider;
+ }
+}
diff --git a/lib/Event/DataConnectorScriptRequestEvent.php b/lib/Event/DataConnectorScriptRequestEvent.php
new file mode 100644
index 0000000..88b9b87
--- /dev/null
+++ b/lib/Event/DataConnectorScriptRequestEvent.php
@@ -0,0 +1,68 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Connector\DataConnectorScriptProviderInterface;
+use Xibo\Entity\DataSet;
+
+/**
+ * Event triggered to retrieve the Data Connector JavaScript from a connector.
+ */
+class DataConnectorScriptRequestEvent extends Event implements DataConnectorScriptProviderInterface
+{
+ public static $NAME = 'data.connector.script.request';
+
+ /**
+ * @var DataSet
+ */
+ private $dataSet;
+
+ /**
+ * @param DataSet $dataSet
+ */
+ public function __construct(DataSet $dataSet)
+ {
+ $this->dataSet = $dataSet;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getConnectorId(): string
+ {
+ return $this->dataSet->dataConnectorSource;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setScript(string $script): void
+ {
+ if ($this->dataSet->isRealTime == 0) {
+ return;
+ }
+
+ // Save the script.
+ $this->dataSet->saveScript($script);
+ }
+}
diff --git a/lib/Event/DataConnectorSourceRequestEvent.php b/lib/Event/DataConnectorSourceRequestEvent.php
new file mode 100644
index 0000000..d9c514f
--- /dev/null
+++ b/lib/Event/DataConnectorSourceRequestEvent.php
@@ -0,0 +1,80 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use InvalidArgumentException;
+use Xibo\Connector\DataConnectorSourceProviderInterface;
+
+/**
+ * Event triggered to retrieve a list of data connector sources.
+ *
+ * This event collects metadata (names and IDs) of connectors that provides data connector.
+ */
+class DataConnectorSourceRequestEvent extends Event implements DataConnectorSourceProviderInterface
+{
+ public static $NAME = 'data.connector.source.request';
+
+ /**
+ * @var array
+ */
+ private $dataConnectorSources = [];
+
+ /**
+ * Initializes the dataConnectorSources with default value.
+ */
+ public function __construct()
+ {
+ $this->dataConnectorSources[] = [
+ 'id' => 'user_defined',
+ 'name' => __('User-Defined JavaScript')
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addDataConnectorSource(string $id, string $name): void
+ {
+ // ensure that there are no duplicate id or name
+ foreach ($this->dataConnectorSources as $dataConnectorSource) {
+ if ($dataConnectorSource['id'] == $id) {
+ throw new InvalidArgumentException('Duplicate Connector ID found.');
+ }
+ if ($dataConnectorSource['name'] == $name) {
+ throw new InvalidArgumentException('Duplicate Connector Name found.');
+ }
+ }
+
+ $this->dataConnectorSources[] = ['id' => $id, 'name' => $name];
+ }
+
+ /**
+ * Retrieves the list of data connector sources.
+ *
+ * @return array
+ */
+ public function getDataConnectorSources(): array
+ {
+ return $this->dataConnectorSources;
+ }
+}
diff --git a/lib/Event/DataSetDataRequestEvent.php b/lib/Event/DataSetDataRequestEvent.php
new file mode 100644
index 0000000..bda8b0d
--- /dev/null
+++ b/lib/Event/DataSetDataRequestEvent.php
@@ -0,0 +1,49 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * Event raised when a widget requests data.
+ */
+class DataSetDataRequestEvent extends Event
+{
+ public static $NAME = 'dataset.data.request.event';
+
+ /** @var \Xibo\Widget\Provider\DataProviderInterface */
+ private $dataProvider;
+
+ public function __construct(DataProviderInterface $dataProvider)
+ {
+ $this->dataProvider = $dataProvider;
+ }
+
+ /**
+ * The data provider should be updated with data for its widget.
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function getDataProvider(): DataProviderInterface
+ {
+ return $this->dataProvider;
+ }
+}
diff --git a/lib/Event/DataSetDataTypeRequestEvent.php b/lib/Event/DataSetDataTypeRequestEvent.php
new file mode 100644
index 0000000..cd9a6ec
--- /dev/null
+++ b/lib/Event/DataSetDataTypeRequestEvent.php
@@ -0,0 +1,71 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * Event raised when a data set widget requests its datatype.
+ */
+class DataSetDataTypeRequestEvent extends Event
+{
+ public static $NAME = 'dataset.datatype.request.event';
+
+ /** @var int */
+ private $dataSetId;
+
+ /** @var \Xibo\Widget\Definition\DataType */
+ private $dataType;
+
+ public function __construct(int $dataSetId)
+ {
+ $this->dataSetId = $dataSetId;
+ }
+
+ /**
+ * The data provider should be updated with data for its widget.
+ * @return int
+ */
+ public function getDataSetId(): int
+ {
+ return $this->dataSetId;
+ }
+
+ /**
+ * @param \Xibo\Widget\Definition\DataType $dataType
+ * @return $this
+ */
+ public function setDataType(DataType $dataType): DataSetDataTypeRequestEvent
+ {
+ $this->dataType = $dataType;
+ return $this;
+ }
+
+ /**
+ * Return the data type
+ * @return \Xibo\Widget\Definition\DataType
+ */
+ public function getDataType(): ?DataType
+ {
+ return $this->dataType;
+ }
+}
diff --git a/lib/Event/DataSetModifiedDtRequestEvent.php b/lib/Event/DataSetModifiedDtRequestEvent.php
new file mode 100644
index 0000000..e1604d5
--- /dev/null
+++ b/lib/Event/DataSetModifiedDtRequestEvent.php
@@ -0,0 +1,59 @@
+.
+ */
+namespace Xibo\Event;
+
+use Carbon\Carbon;
+
+/**
+ * Event raised when a widget requests data.
+ */
+class DataSetModifiedDtRequestEvent extends Event
+{
+ public static $NAME = 'dataset.modifiedDt.request.event';
+
+ /** @var int */
+ private $dataSetId;
+
+ /** @var Carbon */
+ private $modifiedDt;
+
+ public function __construct(int $dataSetId)
+ {
+ $this->dataSetId = $dataSetId;
+ }
+
+ public function getDataSetId(): int
+ {
+ return $this->dataSetId;
+ }
+
+ public function setModifiedDt(Carbon $modifiedDt): DataSetModifiedDtRequestEvent
+ {
+ $this->modifiedDt = $modifiedDt;
+ return $this;
+ }
+
+ public function getModifiedDt(): ?Carbon
+ {
+ return $this->modifiedDt;
+ }
+}
diff --git a/lib/Event/DayPartDeleteEvent.php b/lib/Event/DayPartDeleteEvent.php
new file mode 100644
index 0000000..1ca91c6
--- /dev/null
+++ b/lib/Event/DayPartDeleteEvent.php
@@ -0,0 +1,41 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\DayPart;
+
+class DayPartDeleteEvent extends Event
+{
+ public static $NAME = 'dayPart.delete.event';
+
+ private $dayPart;
+
+ public function __construct(DayPart $dayPart)
+ {
+ $this->dayPart = $dayPart;
+ }
+
+ public function getDayPart(): DayPart
+ {
+ return $this->dayPart;
+ }
+}
diff --git a/lib/Event/DependencyFileSizeEvent.php b/lib/Event/DependencyFileSizeEvent.php
new file mode 100644
index 0000000..47e3aa6
--- /dev/null
+++ b/lib/Event/DependencyFileSizeEvent.php
@@ -0,0 +1,25 @@
+results = $results;
+ }
+
+ public function addResult($result)
+ {
+ $this->results[] = $result;
+ }
+
+ public function getResults()
+ {
+ return $this->results;
+ }
+}
diff --git a/lib/Event/DisplayGroupLoadEvent.php b/lib/Event/DisplayGroupLoadEvent.php
new file mode 100644
index 0000000..3807d0f
--- /dev/null
+++ b/lib/Event/DisplayGroupLoadEvent.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\DisplayGroup;
+
+class DisplayGroupLoadEvent extends Event
+{
+ public static $NAME = 'display.group.load.event';
+ /**
+ * @var DisplayGroup
+ */
+ private $displayGroup;
+
+ public function __construct(DisplayGroup $displayGroup)
+ {
+ $this->displayGroup = $displayGroup;
+ }
+
+ public function getDisplayGroup(): DisplayGroup
+ {
+ return $this->displayGroup;
+ }
+}
diff --git a/lib/Event/DisplayProfileLoadedEvent.php b/lib/Event/DisplayProfileLoadedEvent.php
new file mode 100644
index 0000000..2c11ced
--- /dev/null
+++ b/lib/Event/DisplayProfileLoadedEvent.php
@@ -0,0 +1,55 @@
+.
+ */
+
+
+namespace Xibo\Event;
+
+use Xibo\Entity\DisplayProfile;
+
+/**
+ * Class DisplayProfileLoadedEvent
+ * @package Xibo\Event
+ */
+class DisplayProfileLoadedEvent extends Event
+{
+ const NAME = 'displayProfile.load';
+
+ /** @var DisplayProfile */
+ protected $displayProfile;
+
+ /**
+ * DisplayProfileLoadedEvent constructor.
+ * @param $displayProfile
+ */
+ public function __construct($displayProfile)
+ {
+ $this->displayProfile = $displayProfile;
+ }
+
+ /**
+ * @return DisplayProfile
+ */
+ public function getDisplayProfile()
+ {
+ return $this->displayProfile;
+ }
+}
diff --git a/lib/Event/Event.php b/lib/Event/Event.php
new file mode 100644
index 0000000..0e03f06
--- /dev/null
+++ b/lib/Event/Event.php
@@ -0,0 +1,39 @@
+.
+ */
+
+namespace Xibo\Event;
+
+/**
+ * An event
+ */
+abstract class Event extends \Symfony\Component\EventDispatcher\Event
+{
+ private static $NAME = 'generic.event';
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this::$NAME;
+ }
+}
diff --git a/lib/Event/FolderMovingEvent.php b/lib/Event/FolderMovingEvent.php
new file mode 100644
index 0000000..48f67bb
--- /dev/null
+++ b/lib/Event/FolderMovingEvent.php
@@ -0,0 +1,44 @@
+folder = $folder;
+ $this->newFolder = $newFolder;
+ $this->merge = $merge;
+ }
+
+ public function getFolder(): Folder
+ {
+ return $this->folder;
+ }
+
+ public function getNewFolder(): Folder
+ {
+ return $this->newFolder;
+ }
+
+ public function getIsMerge(): bool
+ {
+ return $this->merge;
+ }
+}
diff --git a/lib/Event/LayoutBuildEvent.php b/lib/Event/LayoutBuildEvent.php
new file mode 100644
index 0000000..a4c8c59
--- /dev/null
+++ b/lib/Event/LayoutBuildEvent.php
@@ -0,0 +1,58 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Layout;
+
+/**
+ * Class LayoutBuildEvent
+ * @package Xibo\Event
+ */
+class LayoutBuildEvent extends Event
+{
+ const NAME = 'layout.build';
+
+ /** @var Layout */
+ protected $layout;
+
+ /** @var \DOMDocument */
+ protected $document;
+
+ /**
+ * LayoutBuildEvent constructor.
+ * @param $layout
+ * @param $document
+ */
+ public function __construct($layout, $document)
+ {
+ $this->layout = $layout;
+ $this->document = $document;
+ }
+
+ /**
+ * @return \DOMDocument
+ */
+ public function getDocument()
+ {
+ return $this->document;
+ }
+}
diff --git a/lib/Event/LayoutBuildRegionEvent.php b/lib/Event/LayoutBuildRegionEvent.php
new file mode 100644
index 0000000..0d1928e
--- /dev/null
+++ b/lib/Event/LayoutBuildRegionEvent.php
@@ -0,0 +1,56 @@
+.
+ */
+namespace Xibo\Event;
+
+/**
+ * Class LayoutBuildRegionEvent
+ * @package Xibo\Event
+ */
+class LayoutBuildRegionEvent extends Event
+{
+ const NAME = 'layout.build.region';
+
+ /** @var int */
+ protected $regionId;
+
+ /** @var \DOMElement */
+ protected $regionNode;
+
+ /**
+ * LayoutBuildEvent constructor.
+ * @param int $regionId
+ * @param \DOMElement $regionNode
+ */
+ public function __construct($regionId, $regionNode)
+ {
+ $this->regionId = $regionId;
+ $this->regionNode = $regionNode;
+ }
+
+ /**
+ * @return \DOMElement
+ */
+ public function getRegionNode()
+ {
+ return $this->regionNode;
+ }
+}
diff --git a/lib/Event/LayoutOwnerChangeEvent.php b/lib/Event/LayoutOwnerChangeEvent.php
new file mode 100644
index 0000000..0125d23
--- /dev/null
+++ b/lib/Event/LayoutOwnerChangeEvent.php
@@ -0,0 +1,57 @@
+.
+ */
+
+namespace Xibo\Event;
+
+class LayoutOwnerChangeEvent extends Event
+{
+ public static $NAME = 'layout.owner.change.event';
+
+ /** @var int */
+ private $campaignId;
+
+ /** @var int */
+ private $ownerId;
+
+ /**
+ * LayoutOwnerChangeEvent constructor.
+ * @param $campaignId
+ */
+ public function __construct($campaignId, $ownerId)
+ {
+ $this->campaignId = $campaignId;
+ $this->ownerId = $ownerId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getCampaignId() : int
+ {
+ return $this->campaignId;
+ }
+
+ public function getOwnerId() : int
+ {
+ return $this->ownerId;
+ }
+}
diff --git a/lib/Event/LayoutSharingChangeEvent.php b/lib/Event/LayoutSharingChangeEvent.php
new file mode 100644
index 0000000..83c0b5a
--- /dev/null
+++ b/lib/Event/LayoutSharingChangeEvent.php
@@ -0,0 +1,68 @@
+.
+ */
+
+namespace Xibo\Event;
+
+/**
+ * Event raised when a Layout's sharing has been changed.
+ */
+class LayoutSharingChangeEvent extends Event
+{
+ public static string $NAME = 'layout.sharing.change.event';
+
+ /** @var int[] */
+ private array $canvasRegionIds;
+
+ /**
+ * LayoutSharingChangeEvent constructor.
+ * @param int $campaignId
+ */
+ public function __construct(private readonly int $campaignId)
+ {
+ $this->canvasRegionIds = [];
+ }
+
+ /**
+ * @return int
+ */
+ public function getCampaignId(): int
+ {
+ return $this->campaignId;
+ }
+
+ /**
+ * Get the Canvas Region ID
+ * @return int[]
+ */
+ public function getCanvasRegionIds(): array
+ {
+ return $this->canvasRegionIds;
+ }
+
+ /**
+ * Set the Canvas Region ID
+ */
+ public function addCanvasRegionId(int $regionId): void
+ {
+ $this->canvasRegionIds[] = $regionId;
+ }
+}
diff --git a/lib/Event/LibraryProviderEvent.php b/lib/Event/LibraryProviderEvent.php
new file mode 100644
index 0000000..8af84e7
--- /dev/null
+++ b/lib/Event/LibraryProviderEvent.php
@@ -0,0 +1,132 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\SearchResult;
+use Xibo\Entity\SearchResults;
+
+/**
+ * LibraryProviderEvent
+ */
+class LibraryProviderEvent extends Event
+{
+ protected static $NAME = 'connector.provider.library';
+
+ /** @var \Xibo\Entity\SearchResults */
+ private $results;
+
+ /** @var int Record count to start from */
+ private $start;
+
+ /** @var int Number of records to return */
+ private $length;
+
+ /** @var string */
+ private $search;
+
+ /** @var array */
+ private $types;
+
+ /** @var string landspace|portrait or empty */
+ private $orientation;
+ /** @var string provider name */
+ private $provider;
+
+ /**
+ * @param \Xibo\Entity\SearchResults $results
+ * @param $start
+ * @param $length
+ * @param $search
+ * @param $types
+ * @param $orientation
+ * @param $provider
+ */
+ public function __construct(SearchResults $results, $start, $length, $search, $types, $orientation, $provider)
+ {
+ $this->results = $results;
+ $this->start = $start;
+ $this->length = $length;
+ $this->search = $search;
+ $this->types = $types;
+ $this->orientation = $orientation;
+ $this->provider = $provider;
+ }
+
+ public function addResult(SearchResult $result): LibraryProviderEvent
+ {
+ $this->results->data[] = $result;
+ return $this;
+ }
+
+ public function getResults(): SearchResults
+ {
+ return $this->results;
+ }
+
+ /**
+ * Get starting record
+ * @return int
+ */
+ public function getStart(): int
+ {
+ return $this->start;
+ }
+
+ /**
+ * Get number of records to return
+ * @return int
+ */
+ public function getLength(): int
+ {
+ return $this->length;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSearch()
+ {
+ return $this->search;
+ }
+
+ /**
+ * @return array
+ */
+ public function getTypes()
+ {
+ return $this->types;
+ }
+
+ /**
+ * @return string
+ */
+ public function getOrientation()
+ {
+ return $this->orientation;
+ }
+
+ public function getProviderName()
+ {
+ return $this->provider;
+ }
+}
diff --git a/lib/Event/LibraryProviderImportEvent.php b/lib/Event/LibraryProviderImportEvent.php
new file mode 100644
index 0000000..b961349
--- /dev/null
+++ b/lib/Event/LibraryProviderImportEvent.php
@@ -0,0 +1,48 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Connector\ProviderImport;
+
+/**
+ * Event raised when one or more provider search results have been chosen for importing on a layout
+ */
+class LibraryProviderImportEvent extends Event
+{
+ protected static $NAME = 'connector.provider.library.import';
+
+ /** @var ProviderImport[] */
+ private $ids;
+
+ /**
+ * @param ProviderImport[] $ids
+ */
+ public function __construct(array $ids)
+ {
+ $this->ids = $ids;
+ }
+
+ public function getItems(): array
+ {
+ return $this->ids;
+ }
+}
diff --git a/lib/Event/LibraryProviderListEvent.php b/lib/Event/LibraryProviderListEvent.php
new file mode 100644
index 0000000..b9d3a4c
--- /dev/null
+++ b/lib/Event/LibraryProviderListEvent.php
@@ -0,0 +1,57 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Connector\ProviderDetails;
+
+class LibraryProviderListEvent extends Event
+{
+ protected static $NAME = 'connector.provider.library.list';
+ /**
+ * @var array
+ */
+ private mixed $providers;
+
+ public function __construct($providers = [])
+ {
+ $this->providers = $providers;
+ }
+
+ /**
+ * @param ProviderDetails $provider
+ * @return LibraryProviderListEvent
+ */
+ public function addProvider(ProviderDetails $provider): LibraryProviderListEvent
+ {
+ $this->providers[] = $provider;
+ return $this;
+ }
+
+ /**
+ * @return ProviderDetails[]
+ */
+ public function getProviders(): array
+ {
+ return $this->providers;
+ }
+}
diff --git a/lib/Event/LibraryReplaceEvent.php b/lib/Event/LibraryReplaceEvent.php
new file mode 100644
index 0000000..b01bcbc
--- /dev/null
+++ b/lib/Event/LibraryReplaceEvent.php
@@ -0,0 +1,76 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Media;
+use Xibo\Widget\ModuleWidget;
+
+class LibraryReplaceEvent extends Event
+{
+ public static $NAME = 'library.replace.event';
+
+ /** @var ModuleWidget */
+ protected $module;
+
+ /** @var Media */
+ protected $newMedia;
+
+ /** @var Media */
+ protected $oldMedia;
+
+ /**
+ * WidgetEditEvent constructor.
+ * @param ModuleWidget $module
+ * @param Media $newMedia
+ * @param Media $oldMedia
+ */
+ public function __construct($module, $newMedia, $oldMedia)
+ {
+ $this->module = $module;
+ $this->newMedia = $newMedia;
+ $this->oldMedia = $oldMedia;
+ }
+
+ /**
+ * @return ModuleWidget
+ */
+ public function getModule()
+ {
+ return $this->module;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getOldMedia()
+ {
+ return $this->oldMedia;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getNewMedia()
+ {
+ return $this->newMedia;
+ }
+}
diff --git a/lib/Event/LibraryReplaceWidgetEvent.php b/lib/Event/LibraryReplaceWidgetEvent.php
new file mode 100644
index 0000000..2d9eeb6
--- /dev/null
+++ b/lib/Event/LibraryReplaceWidgetEvent.php
@@ -0,0 +1,89 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Media;
+use Xibo\Widget\ModuleWidget;
+
+class LibraryReplaceWidgetEvent extends Event
+{
+ public static $NAME = 'library.replace.widget.event';
+
+ /** @var ModuleWidget */
+ protected $module;
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /** @var Media */
+ protected $newMedia;
+
+ /** @var Media */
+ protected $oldMedia;
+
+ /**
+ * WidgetEditEvent constructor.
+ * @param ModuleWidget $module The Module for the item being uploaded (the replacement)
+ * @param \Xibo\Entity\Widget $widget The Widget - it will already have the new media assigned.
+ * @param Media $newMedia The replacement Media record
+ * @param Media $oldMedia The old Media record
+ */
+ public function __construct($module, $widget, $newMedia, $oldMedia)
+ {
+ $this->module = $module;
+ $this->widget = $widget;
+ $this->newMedia = $newMedia;
+ $this->oldMedia = $oldMedia;
+ }
+
+ /**
+ * @return ModuleWidget
+ */
+ public function getModule()
+ {
+ return $this->module;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getOldMedia()
+ {
+ return $this->oldMedia;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getNewMedia()
+ {
+ return $this->newMedia;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget()
+ {
+ return $this->widget;
+ }
+}
diff --git a/lib/Event/LibraryUploadCompleteEvent.php b/lib/Event/LibraryUploadCompleteEvent.php
new file mode 100644
index 0000000..6c08ed8
--- /dev/null
+++ b/lib/Event/LibraryUploadCompleteEvent.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\Media;
+
+/**
+ * An event fired when library media has completed upload.
+ */
+class LibraryUploadCompleteEvent extends Event
+{
+ public static $NAME = 'library.upload.complete.event';
+
+ /** @var Media */
+ protected $media;
+
+ /**
+ * @param \Xibo\Entity\Media $media
+ */
+ public function __construct(Media $media)
+ {
+ $this->media = $media;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getMedia(): Media
+ {
+ return $this->media;
+ }
+}
diff --git a/lib/Event/MaintenanceDailyEvent.php b/lib/Event/MaintenanceDailyEvent.php
new file mode 100644
index 0000000..4f6d350
--- /dev/null
+++ b/lib/Event/MaintenanceDailyEvent.php
@@ -0,0 +1,28 @@
+.
+ */
+namespace Xibo\Event;
+
+class MaintenanceDailyEvent extends Event
+{
+ public static $NAME = 'maintenance.daily.event';
+ use MaintenanceEventTrait;
+}
diff --git a/lib/Event/MaintenanceEventTrait.php b/lib/Event/MaintenanceEventTrait.php
new file mode 100644
index 0000000..8584022
--- /dev/null
+++ b/lib/Event/MaintenanceEventTrait.php
@@ -0,0 +1,46 @@
+.
+ */
+namespace Xibo\Event;
+
+trait MaintenanceEventTrait
+{
+ private $messages = [];
+
+ /**
+ * Add a message to be recorded in the run log
+ * @param string $message
+ * @return \Xibo\Event\Event|\Xibo\Event\MaintenanceEventTrait
+ */
+ public function addMessage(string $message)
+ {
+ $this->messages[] = $message;
+ return $this;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getMessages(): array
+ {
+ return $this->messages;
+ }
+}
diff --git a/lib/Event/MaintenanceRegularEvent.php b/lib/Event/MaintenanceRegularEvent.php
new file mode 100644
index 0000000..7ef3b44
--- /dev/null
+++ b/lib/Event/MaintenanceRegularEvent.php
@@ -0,0 +1,28 @@
+.
+ */
+namespace Xibo\Event;
+
+class MaintenanceRegularEvent extends Event
+{
+ public static $NAME = 'maintenance.regular.event';
+ use MaintenanceEventTrait;
+}
diff --git a/lib/Event/MediaDeleteEvent.php b/lib/Event/MediaDeleteEvent.php
new file mode 100644
index 0000000..0b81d78
--- /dev/null
+++ b/lib/Event/MediaDeleteEvent.php
@@ -0,0 +1,70 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use phpDocumentor\Reflection\Types\Boolean;
+use Xibo\Entity\Media;
+
+class MediaDeleteEvent extends Event
+{
+ public static $NAME = 'library.media.delete.event';
+
+ /** @var Media */
+ private $media;
+ /**
+ * @var Media|null
+ */
+ private $parentMedia;
+
+ /** @var Boolean */
+ private $purge;
+
+ /**
+ * MediaDeleteEvent constructor.
+ * @param $media
+ */
+ public function __construct($media, $parentMedia = null, $purge = false)
+ {
+ $this->media = $media;
+ $this->parentMedia = $parentMedia;
+ $this->purge = $purge;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getMedia() : Media
+ {
+ return $this->media;
+ }
+
+ public function getParentMedia()
+ {
+ return $this->parentMedia;
+ }
+
+ public function isSetToPurge()
+ {
+ return $this->purge;
+ }
+}
diff --git a/lib/Event/MediaFullLoadEvent.php b/lib/Event/MediaFullLoadEvent.php
new file mode 100644
index 0000000..b0ad69b
--- /dev/null
+++ b/lib/Event/MediaFullLoadEvent.php
@@ -0,0 +1,50 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\Media;
+
+class MediaFullLoadEvent extends Event
+{
+ public static $NAME = 'library.media.full.load.event';
+
+ /** @var Media */
+ private $media;
+
+ /**
+ * MediaDeleteEvent constructor.
+ * @param $media
+ */
+ public function __construct($media)
+ {
+ $this->media = $media;
+ }
+
+ /**
+ * @return Media
+ */
+ public function getMedia() : Media
+ {
+ return $this->media;
+ }
+}
diff --git a/lib/Event/MenuBoardCategoryRequest.php b/lib/Event/MenuBoardCategoryRequest.php
new file mode 100644
index 0000000..93ad4f9
--- /dev/null
+++ b/lib/Event/MenuBoardCategoryRequest.php
@@ -0,0 +1,50 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * Menu Board Category Request.
+ */
+class MenuBoardCategoryRequest extends Event
+{
+ public static $NAME = 'menuboard.category.request.event';
+
+ /** @var \Xibo\Widget\Provider\DataProviderInterface */
+ private DataProviderInterface $dataProvider;
+
+ public function __construct(DataProviderInterface $dataProvider)
+ {
+ $this->dataProvider = $dataProvider;
+ }
+
+ /**
+ * The data provider should be updated with data for its widget.
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function getDataProvider(): DataProviderInterface
+ {
+ return $this->dataProvider;
+ }
+}
diff --git a/lib/Event/MenuBoardModifiedDtRequest.php b/lib/Event/MenuBoardModifiedDtRequest.php
new file mode 100644
index 0000000..c945517
--- /dev/null
+++ b/lib/Event/MenuBoardModifiedDtRequest.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Carbon\Carbon;
+
+/**
+ * Menu Board Product Request.
+ */
+class MenuBoardModifiedDtRequest extends Event
+{
+ public static $NAME = 'menuboard.modifiedDt.request.event';
+
+ /** @var int */
+ private $menuId;
+
+ /** @var Carbon */
+ private $modifiedDt;
+
+ public function __construct(int $menuId)
+ {
+ $this->menuId = $menuId;
+ }
+
+ public function getDataSetId(): int
+ {
+ return $this->menuId;
+ }
+
+ public function setModifiedDt(Carbon $modifiedDt): MenuBoardModifiedDtRequest
+ {
+ $this->modifiedDt = $modifiedDt;
+ return $this;
+ }
+
+ public function getModifiedDt(): ?Carbon
+ {
+ return $this->modifiedDt;
+ }
+}
diff --git a/lib/Event/MenuBoardProductRequest.php b/lib/Event/MenuBoardProductRequest.php
new file mode 100644
index 0000000..429317f
--- /dev/null
+++ b/lib/Event/MenuBoardProductRequest.php
@@ -0,0 +1,50 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * Menu Board Product Request.
+ */
+class MenuBoardProductRequest extends Event
+{
+ public static $NAME = 'menuboard.product.request.event';
+
+ /** @var \Xibo\Widget\Provider\DataProviderInterface */
+ private DataProviderInterface $dataProvider;
+
+ public function __construct(DataProviderInterface $dataProvider)
+ {
+ $this->dataProvider = $dataProvider;
+ }
+
+ /**
+ * The data provider should be updated with data for its widget.
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function getDataProvider(): DataProviderInterface
+ {
+ return $this->dataProvider;
+ }
+}
diff --git a/lib/Event/NotificationDataRequestEvent.php b/lib/Event/NotificationDataRequestEvent.php
new file mode 100644
index 0000000..710b4fe
--- /dev/null
+++ b/lib/Event/NotificationDataRequestEvent.php
@@ -0,0 +1,47 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Widget\Provider\DataProviderInterface;
+
+class NotificationDataRequestEvent extends Event
+{
+ public static $NAME = 'notification.data.request.event';
+
+ /** @var DataProviderInterface */
+ private $dataProvider;
+
+ public function __construct(DataProviderInterface $dataProvider)
+ {
+ $this->dataProvider = $dataProvider;
+ }
+
+ /**
+ * The data provider should be updated with data for its Widget.
+ * @return DataProviderInterface
+ */
+ public function getDataProvider(): DataProviderInterface
+ {
+ return $this->dataProvider;
+ }
+}
diff --git a/lib/Event/NotificationModifiedDtRequestEvent.php b/lib/Event/NotificationModifiedDtRequestEvent.php
new file mode 100644
index 0000000..dfc5eba
--- /dev/null
+++ b/lib/Event/NotificationModifiedDtRequestEvent.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Carbon\Carbon;
+
+/**
+ * Request for the latest released notification.
+ */
+class NotificationModifiedDtRequestEvent extends Event
+{
+ public static $NAME = 'notification.modifiedDt.request.event';
+
+ /** @var int displayId */
+ private $displayId;
+
+ /** @var Carbon */
+ private $modifiedDt;
+
+ public function __construct(int $displayId)
+ {
+ $this->displayId = $displayId;
+ }
+
+ public function getDisplayId(): int
+ {
+ return $this->displayId;
+ }
+
+ public function setModifiedDt(Carbon $modifiedDt): NotificationModifiedDtRequestEvent
+ {
+ $this->modifiedDt = $modifiedDt;
+ return $this;
+ }
+
+ public function getModifiedDt(): ?Carbon
+ {
+ return $this->modifiedDt;
+ }
+}
diff --git a/lib/Event/ParsePermissionEntityEvent.php b/lib/Event/ParsePermissionEntityEvent.php
new file mode 100644
index 0000000..d8212b0
--- /dev/null
+++ b/lib/Event/ParsePermissionEntityEvent.php
@@ -0,0 +1,63 @@
+.
+ */
+
+namespace Xibo\Event;
+
+class ParsePermissionEntityEvent extends Event
+{
+ public static $NAME = 'parse.permission.entity.event.';
+ /**
+ * @var string
+ */
+ private $entity;
+ /**
+ * @var int
+ */
+ private $objectId;
+ private $object;
+
+ public function __construct(string $entity, int $objectId)
+ {
+ $this->entity = $entity;
+ $this->objectId = $objectId;
+ }
+
+ public function getEntity()
+ {
+ return $this->entity;
+ }
+
+ public function getObjectId()
+ {
+ return $this->objectId;
+ }
+
+ public function setObject($object)
+ {
+ $this->object = $object;
+ }
+
+ public function getObject()
+ {
+ return $this->object;
+ }
+}
diff --git a/lib/Event/PlaylistDeleteEvent.php b/lib/Event/PlaylistDeleteEvent.php
new file mode 100644
index 0000000..c6cdcf8
--- /dev/null
+++ b/lib/Event/PlaylistDeleteEvent.php
@@ -0,0 +1,50 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\Playlist;
+
+class PlaylistDeleteEvent extends Event
+{
+ public static $NAME = 'playlist.delete.event';
+
+ /** @var Playlist */
+ private $playlist;
+
+ /**
+ * PlaylistDeleteEvent constructor.
+ * @param $playlist
+ */
+ public function __construct(Playlist $playlist)
+ {
+ $this->playlist = $playlist;
+ }
+
+ /**
+ * @return Playlist
+ */
+ public function getPlaylist() : Playlist
+ {
+ return $this->playlist;
+ }
+}
diff --git a/lib/Event/PlaylistMaxNumberChangedEvent.php b/lib/Event/PlaylistMaxNumberChangedEvent.php
new file mode 100644
index 0000000..7ebae05
--- /dev/null
+++ b/lib/Event/PlaylistMaxNumberChangedEvent.php
@@ -0,0 +1,40 @@
+.
+ */
+
+namespace Xibo\Event;
+
+class PlaylistMaxNumberChangedEvent extends Event
+{
+ public static $NAME = 'playlist.max.item.number.change.event';
+ /** @var int */
+ private $newLimit;
+
+ public function __construct(int $newLimit)
+ {
+ $this->newLimit = $newLimit;
+ }
+
+ public function getNewLimit(): int
+ {
+ return $this->newLimit;
+ }
+}
diff --git a/lib/Event/RegionAddedEvent.php b/lib/Event/RegionAddedEvent.php
new file mode 100644
index 0000000..21f80e7
--- /dev/null
+++ b/lib/Event/RegionAddedEvent.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\Layout;
+use Xibo\Entity\Region;
+
+/**
+ * Event fired when a region is being added (before save)
+ */
+class RegionAddedEvent extends Event
+{
+ public static string $NAME = 'region.added.event';
+
+ /** @var Layout */
+ private Layout $layout;
+
+ /** @var Region */
+ private Region $region;
+
+ public function __construct(Layout $layout, Region $region)
+ {
+ $this->layout = $layout;
+ $this->region = $region;
+ }
+
+ public function getLayout(): Layout
+ {
+ return $this->layout;
+ }
+
+ public function getRegion(): Region
+ {
+ return $this->region;
+ }
+}
diff --git a/lib/Event/ReportDataEvent.php b/lib/Event/ReportDataEvent.php
new file mode 100644
index 0000000..31d9707
--- /dev/null
+++ b/lib/Event/ReportDataEvent.php
@@ -0,0 +1,74 @@
+.
+ */
+namespace Xibo\Event;
+
+/**
+ * Event used to get report results
+ */
+class ReportDataEvent extends Event
+{
+ public static $NAME = 'audience.report.data.event';
+
+ private $type;
+
+ private $params;
+
+ private $results;
+
+ /**
+ * ReportDataEvent constructor.
+ * @param $type
+ */
+ public function __construct($type)
+ {
+ $this->type = $type;
+ }
+
+ public function getReportType()
+ {
+ return $this->type;
+ }
+
+ public function getParams()
+ {
+ return $this->params;
+ }
+
+ public function setParams($params)
+ {
+ $this->params = $params;
+
+ return $this;
+ }
+
+ public function getResults()
+ {
+ return $this->results;
+ }
+
+ public function setResults($results)
+ {
+ $this->results = $results;
+
+ return $this;
+ }
+}
diff --git a/lib/Event/ScheduleCriteriaRequestEvent.php b/lib/Event/ScheduleCriteriaRequestEvent.php
new file mode 100644
index 0000000..71d2a7f
--- /dev/null
+++ b/lib/Event/ScheduleCriteriaRequestEvent.php
@@ -0,0 +1,277 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Support\Exception\ConfigurationException;
+
+/**
+ * This class represents a schedule criteria request event. It is responsible for initializing,
+ * managing, and retrieving schedule criteria. The class provides methods for adding types,
+ * metrics, and their associated conditions and values.
+ */
+class ScheduleCriteriaRequestEvent extends Event implements ScheduleCriteriaRequestInterface
+{
+ public static $NAME = 'schedule.criteria.request';
+ private array $criteria = [];
+ private ?int $currentTypeIndex = null;
+ private array $currentMetric = [];
+ private array $defaultConditions = [];
+
+ public function __construct()
+ {
+ // Initialize default conditions in key-value format
+ $this->defaultConditions = [
+ 'set' => __('Is set'),
+ 'lt' => __('Less than'),
+ 'lte' => __('Less than or equal to'),
+ 'eq' => __('Equal to'),
+ 'neq' => __('Not equal to'),
+ 'gte' => __('Greater than or equal to'),
+ 'gt' => __('Greater than'),
+ 'contains' => __('Contains'),
+ 'ncontains' => __('Not contains'),
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addType(string $id, string $type): self
+ {
+ // Ensure that 'types' key exists
+ if (!isset($this->criteria['types'])) {
+ $this->criteria['types'] = [];
+ }
+
+ // Check if the type already exists
+ foreach ($this->criteria['types'] as $index => $existingType) {
+ if ($existingType['id'] === $id) {
+ // If the type exists, update currentTypeIndex and return
+ $this->currentTypeIndex = $index;
+ return $this;
+ }
+ }
+
+ // If the type doesn't exist, add it in the criteria array
+ $this->criteria['types'][] = [
+ 'id' => $id,
+ 'name' => $type,
+ 'metrics' => []
+ ];
+
+ // Set the current type index for chaining
+ $this->currentTypeIndex = count($this->criteria['types']) - 1;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addMetric(string $id, string $name): self
+ {
+ // Ensure the current type is set
+ if (!isset($this->criteria['types'][$this->currentTypeIndex])) {
+ throw new ConfigurationException(__('Current type is not set.'));
+ }
+
+ // initialize the metric to add
+ $metric = [
+ 'id' => $id,
+ 'name' => $name,
+ 'conditions' => $this->formatConditions($this->defaultConditions),
+ 'isUsingDefaultConditions' => true,
+ 'values' => null
+ ];
+
+ // Reference the current type's metrics
+ $metrics = &$this->criteria['types'][$this->currentTypeIndex]['metrics'];
+
+ // Check if the metric already exists
+ foreach ($metrics as &$existingMetric) {
+ if ($existingMetric['id'] === $id) {
+ // If the metric exists, set currentMetric and return
+ $this->currentMetric = $existingMetric;
+ return $this;
+ }
+ }
+
+ // If the metric doesn't exist, add it to the metrics array
+ $metrics[] = $metric;
+
+ // Set the current metric for chaining
+ $this->currentMetric = $metric;
+
+ return $this;
+ }
+
+
+ /**
+ * @inheritDoc
+ */
+ public function addCondition(array $conditions): self
+ {
+ // Retain default conditions if provided condition array is empty
+ if (empty($conditions)) {
+ return $this;
+ }
+
+ // Ensure current type is set
+ if (!isset($this->criteria['types'][$this->currentTypeIndex])) {
+ throw new ConfigurationException(__('Current type is not set.'));
+ }
+
+ // Validate conditions
+ foreach ($conditions as $id => $name) {
+ if (!array_key_exists($id, $this->defaultConditions)) {
+ throw new ConfigurationException(__('Invalid condition ID: %s', $id));
+ }
+ }
+
+ // Reference the current type's metrics
+ $metrics = &$this->criteria['types'][$this->currentTypeIndex]['metrics'];
+
+ // Find the current metric and handle conditions
+ foreach ($metrics as &$metric) {
+ if ($metric['id'] === $this->currentMetric['id']) {
+ if ($metric['isUsingDefaultConditions']) {
+ // If metric is using default conditions, replace with new ones
+ $metric['conditions'] = $this->formatConditions($conditions);
+ $metric['isUsingDefaultConditions'] = false;
+ } else {
+ // Merge the new conditions with existing ones, avoiding duplicates
+ $existingConditions = $metric['conditions'];
+ $newConditions = $this->formatConditions($conditions);
+
+ // Combine the two condition arrays
+ $mergedConditions = array_merge($existingConditions, $newConditions);
+
+ // Remove duplicates
+ $finalConditions = array_unique($mergedConditions, SORT_REGULAR);
+
+ $metric['conditions'] = array_values($finalConditions);
+ }
+
+ break;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Format conditions from key-value to the required array structure.
+ *
+ * @param array $conditions
+ * @return array
+ */
+ private function formatConditions(array $conditions): array
+ {
+ $formattedConditions = [];
+ foreach ($conditions as $id => $name) {
+ $formattedConditions[] = [
+ 'id' => $id,
+ 'name' => $name,
+ ];
+ }
+
+ return $formattedConditions;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addValues(string $inputType, array $values): self
+ {
+ // Ensure current type is set
+ if (!isset($this->criteria['types'][$this->currentTypeIndex])) {
+ throw new ConfigurationException(__('Current type is not set.'));
+ }
+
+ // Restrict input types to 'dropdown', 'number', 'text' and 'date'
+ $allowedInputTypes = ['dropdown', 'number', 'text', 'date'];
+ if (!in_array($inputType, $allowedInputTypes)) {
+ throw new ConfigurationException(__('Invalid input type.'));
+ }
+
+ // Reference the metrics of the current type
+ $metrics = &$this->criteria['types'][$this->currentTypeIndex]['metrics'];
+
+ // Find the current metric and add or update values
+ foreach ($metrics as &$metric) {
+ if ($metric['id'] === $this->currentMetric['id']) {
+ // Check if the input type matches the existing one (if any)
+ if (isset($metric['values']['inputType']) && $metric['values']['inputType'] !== $inputType) {
+ throw new ConfigurationException(__('Input type does not match.'));
+ }
+
+ // Format the new values
+ $formattedValues = [];
+ foreach ($values as $id => $title) {
+ $formattedValues[] = [
+ 'id' => $id,
+ 'title' => $title
+ ];
+ }
+
+ // Merge new values with existing ones, avoiding duplicates
+ $existingValues = $metric['values']['values'] ?? [];
+
+ // Combine the two value arrays
+ $mergedValues = array_merge($existingValues, $formattedValues);
+
+ // Remove duplicates
+ $uniqueFormattedValues = array_unique($mergedValues, SORT_REGULAR);
+
+ // Update the metric's values
+ $metric['values'] = [
+ 'inputType' => $inputType,
+ 'values' => array_values($uniqueFormattedValues)
+ ];
+
+ break;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the criteria array.
+ *
+ * @return array
+ */
+ public function getCriteria(): array
+ {
+ return $this->criteria;
+ }
+
+ /**
+ * Get the default conditions array.
+ *
+ * @return array
+ */
+ public function getCriteriaDefaultCondition(): array
+ {
+ return $this->formatConditions($this->defaultConditions);
+ }
+}
diff --git a/lib/Event/ScheduleCriteriaRequestInterface.php b/lib/Event/ScheduleCriteriaRequestInterface.php
new file mode 100644
index 0000000..eec618b
--- /dev/null
+++ b/lib/Event/ScheduleCriteriaRequestInterface.php
@@ -0,0 +1,172 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Support\Exception\ConfigurationException;
+
+/**
+ * Interface for managing schedule criteria types, metrics, conditions, and values.
+ *
+ * Allows the addition of types, metrics, conditions, and values in a chained manner:
+ * - Start with `addType()` to add a new type. Call `addType()` multiple times to add multiple types.
+ * - Follow with `addMetric()` to add metrics under the specified type. Call `addMetric()` multiple times to add
+ * multiple metrics to the current type.
+ * - Optionally, call `addCondition()` after `addMetric()` to specify a set of conditions for the metric. If not called,
+ * the system will automatically apply default conditions, which include all supported conditions.
+ * - Conclude with `addValues()` immediately after `addMetric()` or `addCondition()` to specify a set of values for the
+ * metric. Each metric can have one set of values.
+ *
+ * The added criteria are then parsed and displayed in the Schedule Criteria Form, enabling users to configure
+ * scheduling conditions based on the specified parameters.
+ */
+interface ScheduleCriteriaRequestInterface
+{
+ /**
+ * Adds a new type to the criteria.
+ *
+ * **Important Notes:**
+ * - If the type already exists, the existing type is selected to allow method
+ * chaining.
+ *
+ * Example usage:
+ * ```
+ * $event->addType('weather', 'Weather Data')
+ * ->addMetric('temp', 'Temperature')
+ * ->addCondition([
+ * 'eq' => 'Equal to',
+ * 'gt' => 'Greater than'
+ * ])
+ * ->addValues('dropdown', [
+ * 'active' => 'Active',
+ * 'inactive' => 'Inactive'
+ * ]);
+ * ```
+ *
+ * @param string $id Unique identifier for the type.
+ * @param string $type Name of the type.
+ * @return self Returns the current instance for method chaining.
+ */
+ public function addType(string $id, string $type): self;
+
+ /**
+ * Adds a new metric to the current type.
+ *
+ * **Important Notes:**
+ * - If the metric already exists, it sets the metric as the current metric for chaining instead.
+ * - `addType` must be called before this method to define the current type.
+ *
+ * **Example Usage:**
+ * ```
+ * $event->addType('weather', 'Weather Data')
+ * ->addMetric('temp', 'Temperature')
+ * ->addCondition([
+ * 'eq' => 'Equal to',
+ * 'gt' => 'Greater than'
+ * ])
+ * ->addValues('dropdown', [
+ * 'active' => 'Active',
+ * 'inactive' => 'Inactive'
+ * ]);
+ * ```
+ *
+ * @param string $id Unique identifier for the metric.
+ * @param string $name Name of the metric.
+ * @return self Returns the current instance for method chaining.
+ * @throws ConfigurationException If the current type is not set.
+ */
+ public function addMetric(string $id, string $name): self;
+
+ /**
+ * Add conditions to the current metric.
+ *
+ * This method allows you to specify conditions for the current metric.
+ * The list of accepted conditions includes:
+ * - 'set' => 'Is set'
+ * - 'lt' => 'Less than'
+ * - 'lte' => 'Less than or equal to'
+ * - 'eq' => 'Equal to'
+ * - 'neq' => 'Not equal to'
+ * - 'gte' => 'Greater than or equal to'
+ * - 'gt' => 'Greater than'
+ * - 'contains' => 'Contains'
+ * - 'ncontains' => 'Not contains'
+ *
+ * **Important Notes:**
+ * - The `addMetric` method **must** be called before using `addCondition`.
+ * - If this method is **not called** for a metric, the system will automatically
+ * provide the default conditions, which include **all the accepted conditions** listed above.
+ * - New conditions will be merged if they already exist under the same type and metric, avoiding duplicates.
+ *
+ * Example usage:
+ * ```
+ * $event->addType('weather', 'Weather Data')
+ * ->addMetric('temp', 'Temperature')
+ * ->addCondition([
+ * 'eq' => 'Equal to',
+ * 'gt' => 'Greater than'
+ * ])
+ * ->addValues('dropdown', [
+ * 'active' => 'Active',
+ * 'inactive' => 'Inactive'
+ * ]);
+ * ```
+ *
+ * @param array $conditions An associative array of conditions, where the key is the condition ID and the value is
+ * its name.
+ * @return $this Returns the current instance for method chaining.
+ * @throws ConfigurationException If the current metric is not set.
+ */
+ public function addCondition(array $conditions): self;
+
+ /**
+ * Add values to the current metric. The input type must be either "dropdown", "string", "date", or "number".
+ *
+ * For "dropdown" input type, provide an array of values. For other input types ("string", "date", "number"),
+ * the values array should be empty "[]".
+ * The values array should be formatted such that the index is the id and the value is the title/name of the value.
+ *
+ * **Important Notes:**
+ * - The `addMetric` method **must** be called before using `addValues`.
+ * - If values already exist for the same type and metric, new values will be merged, avoiding duplicates.
+ *
+ * Example usage:
+ * ```
+ * $event->addType('weather', 'Weather Data')
+ * ->addMetric('temp', 'Temperature')
+ * ->addCondition([
+ * 'eq' => 'Equal to',
+ * 'gt' => 'Greater than'
+ * ])
+ * ->addValues('dropdown', [
+ * 'active' => 'Active',
+ * 'inactive' => 'Inactive'
+ * ]);
+ * ```
+ *
+ * @param string $inputType Type of input for the values ("dropdown", "string", "date", "number").
+ * @param array $values Array of values, which should be empty for input types other than "dropdown".
+ * @return self Returns the current instance for method chaining.
+ * @throws ConfigurationException If the current type or metric is not set.
+ */
+ public function addValues(string $inputType, array $values): self;
+}
diff --git a/lib/Event/SubPlaylistDurationEvent.php b/lib/Event/SubPlaylistDurationEvent.php
new file mode 100644
index 0000000..4e93d0a
--- /dev/null
+++ b/lib/Event/SubPlaylistDurationEvent.php
@@ -0,0 +1,75 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Edit Event
+ */
+class SubPlaylistDurationEvent extends Event
+{
+ public static $NAME = 'widget.sub-playlist.duration';
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /** @var int */
+ private $duration;
+
+ /**
+ * constructor.
+ * @param \Xibo\Entity\Widget $widget
+ */
+ public function __construct(\Xibo\Entity\Widget $widget)
+ {
+ $this->widget = $widget;
+ $this->duration = 0;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+
+ /**
+ * Get the duration
+ * @return int
+ */
+ public function getDuration(): int
+ {
+ return $this->duration;
+ }
+
+ /**
+ * @param int $duration
+ * @return $this
+ */
+ public function appendDuration(int $duration): SubPlaylistDurationEvent
+ {
+ $this->duration += $duration;
+ return $this;
+ }
+}
diff --git a/lib/Event/SubPlaylistItemsEvent.php b/lib/Event/SubPlaylistItemsEvent.php
new file mode 100644
index 0000000..3f7ba71
--- /dev/null
+++ b/lib/Event/SubPlaylistItemsEvent.php
@@ -0,0 +1,74 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\SubPlaylistItem;
+
+/**
+ * Widget Edit Event
+ */
+class SubPlaylistItemsEvent extends Event
+{
+ public static $NAME = 'widget.sub-playlist.items';
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /** @var SubPlaylistItem[] */
+ private $items = [];
+
+ /**
+ * constructor.
+ * @param \Xibo\Entity\Widget $widget
+ */
+ public function __construct(\Xibo\Entity\Widget $widget)
+ {
+ $this->widget = $widget;
+ }
+
+ /**
+ * @return Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+
+ /**
+ * @return SubPlaylistItem[]
+ */
+ public function getItems(): array
+ {
+ return $this->items;
+ }
+
+ /**
+ * @param array $items
+ * @return $this
+ */
+ public function setItems(array $items): SubPlaylistItemsEvent
+ {
+ $this->items += $items;
+ return $this;
+ }
+}
diff --git a/lib/Event/SubPlaylistValidityEvent.php b/lib/Event/SubPlaylistValidityEvent.php
new file mode 100644
index 0000000..5a4412d
--- /dev/null
+++ b/lib/Event/SubPlaylistValidityEvent.php
@@ -0,0 +1,72 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+
+/**
+ * Sub Playlist Validity Check
+ */
+class SubPlaylistValidityEvent extends Event
+{
+ public static $NAME = 'widget.sub-playlist.validity';
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ private $isValid = true;
+
+ /**
+ * constructor.
+ * @param \Xibo\Entity\Widget $widget
+ */
+ public function __construct(\Xibo\Entity\Widget $widget)
+ {
+ $this->widget = $widget;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+
+ /**
+ * @param bool $isValid
+ * @return $this
+ */
+ public function setIsValid(bool $isValid): SubPlaylistValidityEvent
+ {
+ $this->isValid = $isValid;
+ return $this;
+ }
+
+ /**
+ * @return bool true if valid
+ */
+ public function isValid(): bool
+ {
+ return $this->isValid;
+ }
+}
diff --git a/lib/Event/SubPlaylistWidgetsEvent.php b/lib/Event/SubPlaylistWidgetsEvent.php
new file mode 100644
index 0000000..4f261ed
--- /dev/null
+++ b/lib/Event/SubPlaylistWidgetsEvent.php
@@ -0,0 +1,87 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Edit Event
+ */
+class SubPlaylistWidgetsEvent extends Event
+{
+ public static $NAME = 'widget.sub-playlist.widgets';
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /** @var Widget[] */
+ private $widgets = [];
+
+ /** @var int */
+ private $tempId;
+
+ /**
+ * constructor.
+ * @param \Xibo\Entity\Widget $widget
+ * @param int|null $tempId
+ */
+ public function __construct(\Xibo\Entity\Widget $widget, ?int $tempId)
+ {
+ $this->widget = $widget;
+ $this->tempId = $tempId ?? 0;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+
+ /**
+ * @return int
+ */
+ public function getTempId(): int
+ {
+ return $this->tempId;
+ }
+
+ /**
+ * Get the duration
+ * @return \Xibo\Entity\Widget[]
+ */
+ public function getWidgets(): array
+ {
+ return $this->widgets;
+ }
+
+ /**
+ * @param Widget[] $widgets
+ * @return $this
+ */
+ public function setWidgets(array $widgets): SubPlaylistWidgetsEvent
+ {
+ $this->widgets += $widgets;
+ return $this;
+ }
+}
diff --git a/lib/Event/SystemUserChangedEvent.php b/lib/Event/SystemUserChangedEvent.php
new file mode 100644
index 0000000..f9775e4
--- /dev/null
+++ b/lib/Event/SystemUserChangedEvent.php
@@ -0,0 +1,54 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\User;
+
+class SystemUserChangedEvent extends Event
+{
+ public static $NAME = 'system.user.change.event';
+ /**
+ * @var User
+ */
+ private $oldSystemUser;
+ /**
+ * @var User
+ */
+ private $newSystemUser;
+
+ public function __construct(User $oldSystemUser, User $newSystemUser)
+ {
+ $this->oldSystemUser = $oldSystemUser;
+ $this->newSystemUser = $newSystemUser;
+ }
+
+ public function getOldSystemUser() : User
+ {
+ return $this->oldSystemUser;
+ }
+
+ public function getNewSystemUser() : User
+ {
+ return $this->newSystemUser;
+ }
+}
diff --git a/lib/Event/TagAddEvent.php b/lib/Event/TagAddEvent.php
new file mode 100644
index 0000000..6b7239a
--- /dev/null
+++ b/lib/Event/TagAddEvent.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\Event;
+
+class TagAddEvent extends Event
+{
+ public static $NAME = 'tag.add.event';
+ /**
+ * @var int
+ */
+ private $tagId;
+
+ public function __construct(int $tagId)
+ {
+ $this->tagId = $tagId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getTagId(): int
+ {
+ return $this->tagId;
+ }
+}
diff --git a/lib/Event/TagDeleteEvent.php b/lib/Event/TagDeleteEvent.php
new file mode 100644
index 0000000..a58fcb5
--- /dev/null
+++ b/lib/Event/TagDeleteEvent.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\Event;
+
+class TagDeleteEvent extends Event
+{
+ public static $NAME = 'tag.delete.event';
+ /**
+ * @var int
+ */
+ private $tagId;
+
+ public function __construct(int $tagId)
+ {
+ $this->tagId = $tagId;
+ }
+
+ /**
+ * @return int
+ */
+ public function getTagId(): int
+ {
+ return $this->tagId;
+ }
+}
diff --git a/lib/Event/TagEditEvent.php b/lib/Event/TagEditEvent.php
new file mode 100644
index 0000000..f7d3491
--- /dev/null
+++ b/lib/Event/TagEditEvent.php
@@ -0,0 +1,73 @@
+.
+ */
+
+namespace Xibo\Event;
+
+class TagEditEvent extends Event
+{
+ public static $NAME = 'tag.edit.event';
+ /**
+ * @var int
+ */
+ private $tagId;
+
+ /**
+ * @var string
+ */
+ private $oldTag;
+
+ /**
+ * @var string
+ */
+ private $newTag;
+
+ public function __construct(int $tagId, ?string $oldTag = null, ?string $newTag = null)
+ {
+ $this->tagId = $tagId;
+ $this->oldTag = $oldTag;
+ $this->newTag = $newTag;
+ }
+
+ /**
+ * @return int
+ */
+ public function getTagId(): int
+ {
+ return $this->tagId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getOldTag(): string
+ {
+ return $this->oldTag;
+ }
+
+ /**
+ * @return string
+ */
+ public function getNewTag(): string
+ {
+ return $this->newTag;
+ }
+}
diff --git a/lib/Event/TemplateProviderEvent.php b/lib/Event/TemplateProviderEvent.php
new file mode 100644
index 0000000..4905c70
--- /dev/null
+++ b/lib/Event/TemplateProviderEvent.php
@@ -0,0 +1,119 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\SearchResult;
+use Xibo\Entity\SearchResults;
+
+/**
+ * TemplateProviderEvent
+ */
+class TemplateProviderEvent extends Event
+{
+ protected static $NAME = 'connector.provider.template';
+
+ /** @var \Xibo\Entity\SearchResults */
+ private $results;
+
+ /**
+ * @var int
+ */
+ private $start;
+
+ /**
+ * @var int
+ */
+ private $length;
+
+ /** @var string|null */
+ private $search;
+
+ /** @var string|null */
+ private $orientation;
+
+ /**
+ * @param \Xibo\Entity\SearchResults $results
+ * @param int $start
+ * @param int $length
+ */
+ public function __construct(SearchResults $results, int $start, int $length, ?string $search, ?string $orientation)
+ {
+ $this->results = $results;
+ $this->start = $start;
+ $this->length = $length;
+ $this->search = $search;
+ $this->orientation = $orientation;
+ }
+
+ /**
+ * @param SearchResult $result
+ * @return $this
+ */
+ public function addResult(SearchResult $result): TemplateProviderEvent
+ {
+ $this->results->data[] = $result;
+ return $this;
+ }
+
+ /**
+ * @return SearchResults
+ */
+ public function getResults(): SearchResults
+ {
+ return $this->results;
+ }
+
+ /**
+ * Get starting record
+ * @return int
+ */
+ public function getStart(): int
+ {
+ return $this->start;
+ }
+
+ /**
+ * Get number of records to return
+ * @return int
+ */
+ public function getLength(): int
+ {
+ return $this->length;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getSearch(): ?string
+ {
+ return $this->search;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getOrientation(): ?string
+ {
+ return $this->orientation;
+ }
+}
diff --git a/lib/Event/TemplateProviderImportEvent.php b/lib/Event/TemplateProviderImportEvent.php
new file mode 100644
index 0000000..5aee53b
--- /dev/null
+++ b/lib/Event/TemplateProviderImportEvent.php
@@ -0,0 +1,78 @@
+.
+ */
+
+namespace Xibo\Event;
+
+/**
+ * Event raised when one or more provider search results have been chosen for importing on a layout
+ */
+class TemplateProviderImportEvent extends Event
+{
+ protected static $NAME = 'connector.provider.template.import';
+ /**
+ * @var string
+ */
+ private $downloadUrl;
+ /** @var string */
+ private $libraryLocation;
+ /**
+ * @var string
+ */
+ private $fileName;
+ /** @var string */
+ private $tempFile;
+
+ public function __construct(
+ string $uri,
+ string $fileName,
+ string $libraryLocation
+ ) {
+ $this->downloadUrl = $uri;
+ $this->fileName = $fileName;
+ $this->libraryLocation = $libraryLocation;
+ }
+
+ public function getDownloadUrl(): string
+ {
+ return $this->downloadUrl;
+ }
+
+ public function getFileName(): string
+ {
+ return $this->fileName;
+ }
+
+ public function getLibraryLocation(): string
+ {
+ return $this->libraryLocation;
+ }
+
+ public function setFilePath($tempFile)
+ {
+ $this->tempFile = $tempFile;
+ }
+
+ public function getFilePath()
+ {
+ return $this->tempFile;
+ }
+}
diff --git a/lib/Event/TemplateProviderListEvent.php b/lib/Event/TemplateProviderListEvent.php
new file mode 100644
index 0000000..4dfd71e
--- /dev/null
+++ b/lib/Event/TemplateProviderListEvent.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Connector\ProviderDetails;
+
+/**
+ * Get a list of template providers
+ */
+class TemplateProviderListEvent extends Event
+{
+ protected static $NAME = 'connector.provider.template.list';
+ /**
+ * @var array
+ */
+ private mixed $providers;
+
+ public function __construct($providers = [])
+ {
+ $this->providers = $providers;
+ }
+
+ /**
+ * @param ProviderDetails $provider
+ * @return TemplateProviderListEvent
+ */
+ public function addProvider(ProviderDetails $provider): TemplateProviderListEvent
+ {
+ $this->providers[] = $provider;
+ return $this;
+ }
+
+ /**
+ * @return ProviderDetails[]
+ */
+ public function getProviders(): array
+ {
+ return $this->providers;
+ }
+}
diff --git a/lib/Event/TriggerTaskEvent.php b/lib/Event/TriggerTaskEvent.php
new file mode 100644
index 0000000..18d0965
--- /dev/null
+++ b/lib/Event/TriggerTaskEvent.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Event;
+
+/**
+ * An event which triggers the provided task to Run Now (at the next XTR poll)
+ * optionally clears a cache key to provide further instructions to the task that's running
+ */
+class TriggerTaskEvent extends Event
+{
+ public static string $NAME = 'trigger.task.event';
+
+ /**
+ * @param string $className Class name of the task to be run
+ * @param string $key Cache Key to be dropped
+ */
+ public function __construct(
+ private readonly string $className,
+ private readonly string $key = ''
+ ) {
+ }
+
+ /**
+ * Returns the class name for the task to be run
+ * @return string
+ */
+ public function getClassName(): string
+ {
+ return $this->className;
+ }
+
+ /**
+ * Returns the cache key to be dropped
+ * @return string
+ */
+ public function getKey(): string
+ {
+ return $this->key;
+ }
+}
diff --git a/lib/Event/UserDeleteEvent.php b/lib/Event/UserDeleteEvent.php
new file mode 100644
index 0000000..6e90f13
--- /dev/null
+++ b/lib/Event/UserDeleteEvent.php
@@ -0,0 +1,90 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\User;
+
+class UserDeleteEvent extends Event
+{
+ public static $NAME = 'user.delete.event';
+
+ /** @var User */
+ private $user;
+
+ /** @var User */
+ private $newUser;
+
+ /** @var string */
+ private $function;
+
+ /** @var User */
+ private $systemUser;
+
+ public $returnValue;
+
+ /**
+ * UserDeleteEvent constructor.
+ * @param $user
+ * @param $function
+ */
+ public function __construct($user, $function, $systemUser = null, $newUser = null)
+ {
+ $this->user = $user;
+ $this->newUser = $newUser;
+ $this->systemUser = $systemUser;
+ $this->function = $function;
+ }
+
+ /**
+ * @return User
+ */
+ public function getUser() : User
+ {
+ return $this->user;
+ }
+
+ public function getNewUser()
+ {
+ return $this->newUser;
+ }
+
+ public function getSystemUser() : User
+ {
+ return $this->systemUser;
+ }
+
+ public function getFunction(): string
+ {
+ return $this->function;
+ }
+
+ public function setReturnValue($returnValue)
+ {
+ $this->returnValue = $returnValue;
+ }
+
+ public function getReturnValue()
+ {
+ return $this->returnValue;
+ }
+}
diff --git a/lib/Event/WidgetAddEvent.php b/lib/Event/WidgetAddEvent.php
new file mode 100644
index 0000000..dfa4017
--- /dev/null
+++ b/lib/Event/WidgetAddEvent.php
@@ -0,0 +1,68 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Add
+ * ----------
+ * Call when a new non-file based widget is added to a Layout
+ */
+class WidgetAddEvent extends Event
+{
+ public static $NAME = 'widget.add';
+
+ /** @var \Xibo\Entity\Module */
+ protected $module;
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /**
+ * WidgetEditEvent constructor.
+ * @param \Xibo\Entity\Module $module
+ * @param \Xibo\Entity\Widget $widget
+ */
+ public function __construct(Module $module, Widget $widget)
+ {
+ $this->module = $module;
+ $this->widget = $widget;
+ }
+
+ /**
+ * @return \Xibo\Entity\Module
+ */
+ public function getModule(): Module
+ {
+ return $this->module;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+}
diff --git a/lib/Event/WidgetDataRequestEvent.php b/lib/Event/WidgetDataRequestEvent.php
new file mode 100644
index 0000000..a9840f9
--- /dev/null
+++ b/lib/Event/WidgetDataRequestEvent.php
@@ -0,0 +1,49 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * Event raised when a widget requests data.
+ */
+class WidgetDataRequestEvent extends Event
+{
+ public static $NAME = 'widget.data.request.event';
+
+ /** @var \Xibo\Widget\Provider\DataProviderInterface */
+ private $dataProvider;
+
+ public function __construct(DataProviderInterface $dataProvider)
+ {
+ $this->dataProvider = $dataProvider;
+ }
+
+ /**
+ * The data provider should be updated with data for its widget.
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function getDataProvider(): DataProviderInterface
+ {
+ return $this->dataProvider;
+ }
+}
diff --git a/lib/Event/WidgetDeleteEvent.php b/lib/Event/WidgetDeleteEvent.php
new file mode 100644
index 0000000..241acc5
--- /dev/null
+++ b/lib/Event/WidgetDeleteEvent.php
@@ -0,0 +1,52 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Delete Event
+ */
+class WidgetDeleteEvent extends Event
+{
+ public static $NAME = 'widget.delete';
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /**
+ * constructor.
+ * @param \Xibo\Entity\Widget $widget
+ */
+ public function __construct(\Xibo\Entity\Widget $widget)
+ {
+ $this->widget = $widget;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+}
diff --git a/lib/Event/WidgetEditEvent.php b/lib/Event/WidgetEditEvent.php
new file mode 100644
index 0000000..a1ccc78
--- /dev/null
+++ b/lib/Event/WidgetEditEvent.php
@@ -0,0 +1,52 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Edit Event
+ */
+class WidgetEditEvent extends Event
+{
+ public static $NAME = 'widget.edit';
+
+ /** @var \Xibo\Entity\Widget */
+ protected $widget;
+
+ /**
+ * constructor.
+ * @param \Xibo\Entity\Widget $widget
+ */
+ public function __construct(\Xibo\Entity\Widget $widget)
+ {
+ $this->widget = $widget;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+}
diff --git a/lib/Event/WidgetEditOptionRequestEvent.php b/lib/Event/WidgetEditOptionRequestEvent.php
new file mode 100644
index 0000000..69bfcf9
--- /dev/null
+++ b/lib/Event/WidgetEditOptionRequestEvent.php
@@ -0,0 +1,101 @@
+.
+ *
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\Widget;
+
+/**
+ * An event fired by a widget when presenting its properties
+ * should be used by a connector to provide additional options to a dropdown
+ */
+class WidgetEditOptionRequestEvent extends Event
+{
+ public static $NAME = 'widget.edit.option.event';
+
+ /** @var \Xibo\Entity\Widget */
+ private $widget;
+
+ /** @var string */
+ private $propertyId;
+
+ /** @var mixed */
+ private $propertyValue;
+
+ /** @var array|null */
+ private $options;
+
+ public function __construct(Widget $widget, string $propertyId, $propertyValue)
+ {
+ $this->widget = $widget;
+ $this->propertyId = $propertyId;
+ $this->propertyValue = $propertyValue;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget|null
+ */
+ public function getWidget(): ?Widget
+ {
+ return $this->widget;
+ }
+
+ /**
+ * Which property is making this request?
+ * @return string|null The ID of the property `id=""`
+ */
+ public function getPropertyId(): ?string
+ {
+ return $this->propertyId;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getPropertyValue()
+ {
+ return $this->propertyValue;
+ }
+
+ /**
+ * Get the options array
+ */
+ public function getOptions(): array
+ {
+ if ($this->options === null) {
+ $this->options = [];
+ }
+
+ return $this->options;
+ }
+
+ /**
+ * Set a new options array
+ * @return $this
+ */
+ public function setOptions(array $options): WidgetEditOptionRequestEvent
+ {
+ $this->options = $options;
+ return $this;
+ }
+}
diff --git a/lib/Event/XmdsConnectorFileEvent.php b/lib/Event/XmdsConnectorFileEvent.php
new file mode 100644
index 0000000..5ac107b
--- /dev/null
+++ b/lib/Event/XmdsConnectorFileEvent.php
@@ -0,0 +1,79 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Psr\Http\Message\ResponseInterface;
+use Xibo\Entity\Widget;
+
+class XmdsConnectorFileEvent extends Event
+{
+ public static $NAME = 'connector.xmds.file.event';
+ private $widget;
+ private $response;
+ /**
+ * @var boolean
+ */
+ private $isDebug;
+
+ public function __construct($widget, $isDebug = false)
+ {
+ $this->widget = $widget;
+ $this->isDebug = $isDebug;
+ }
+
+ /**
+ * @return \Xibo\Entity\Widget|null
+ */
+ public function getWidget(): ?Widget
+ {
+ return $this->widget;
+ }
+
+ public function isDebug(): bool
+ {
+ return $this->isDebug;
+ }
+
+ /**
+ * @return \Psr\Http\Message\ResponseInterface
+ */
+ public function getResponse(): ResponseInterface
+ {
+ if ($this->response === null) {
+ $psr17Factory = new Psr17Factory();
+ $this->response = $psr17Factory->createResponse(404, 'Not Found');
+ }
+ return $this->response;
+ }
+
+ /**
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * @return $this
+ */
+ public function setResponse(ResponseInterface $response): XmdsConnectorFileEvent
+ {
+ $this->response = $response;
+ return $this;
+ }
+}
diff --git a/lib/Event/XmdsConnectorTokenEvent.php b/lib/Event/XmdsConnectorTokenEvent.php
new file mode 100644
index 0000000..e4f57fa
--- /dev/null
+++ b/lib/Event/XmdsConnectorTokenEvent.php
@@ -0,0 +1,74 @@
+.
+ */
+
+namespace Xibo\Event;
+
+/**
+ * Event used to generate a token for an XMDS request.
+ */
+class XmdsConnectorTokenEvent extends Event
+{
+ public static $NAME = 'connector.xmds.token.event';
+ private $displayId;
+ private $widgetId;
+ private $ttl;
+ private $token;
+
+ public function setTargets(int $displayId, int $widgetId): XmdsConnectorTokenEvent
+ {
+ $this->displayId = $displayId;
+ $this->widgetId = $widgetId;
+ return $this;
+ }
+
+ public function getDisplayId(): int
+ {
+ return $this->displayId;
+ }
+
+ public function getWidgetId(): ?int
+ {
+ return $this->widgetId;
+ }
+
+ public function setTtl(int $ttl): XmdsConnectorTokenEvent
+ {
+ $this->ttl = $ttl;
+ return $this;
+ }
+
+ public function getTtl(): int
+ {
+ return $this->ttl;
+ }
+
+ public function setToken(string $token): XmdsConnectorTokenEvent
+ {
+ $this->token = $token;
+ return $this;
+ }
+
+ public function getToken(): ?string
+ {
+ return $this->token;
+ }
+}
diff --git a/lib/Event/XmdsDependencyListEvent.php b/lib/Event/XmdsDependencyListEvent.php
new file mode 100644
index 0000000..7fd730d
--- /dev/null
+++ b/lib/Event/XmdsDependencyListEvent.php
@@ -0,0 +1,84 @@
+.
+ */
+namespace Xibo\Event;
+
+use Xibo\Entity\Display;
+use Xibo\Xmds\Entity\Dependency;
+
+/**
+ * A dependency list event
+ */
+class XmdsDependencyListEvent extends Event
+{
+ private static $NAME = 'xmds.dependency.list';
+
+ private $dependencies = [];
+
+ /** @var \Xibo\Entity\Display */
+ private $display;
+
+ public function __construct(Display $display)
+ {
+ $this->display = $display;
+ }
+
+ /**
+ * @return Dependency[]
+ */
+ public function getDependencies(): array
+ {
+ return $this->dependencies;
+ }
+
+ /**
+ * Add a dependency to the list.
+ * @param string $fileType
+ * @param int $id
+ * @param string $path
+ * @param int $size
+ * @param string $md5
+ * @param bool $isAvailableOverHttp
+ * @param int $legacyId
+ * @return $this
+ */
+ public function addDependency(
+ string $fileType,
+ int $id,
+ string $path,
+ int $size,
+ string $md5,
+ bool $isAvailableOverHttp,
+ int $legacyId
+ ): XmdsDependencyListEvent {
+ $this->dependencies[] = new Dependency($fileType, $id, $legacyId, $path, $size, $md5, $isAvailableOverHttp);
+ return $this;
+ }
+
+ /**
+ * Get the display which raised this event
+ * @return \Xibo\Entity\Display
+ */
+ public function getDisplay(): Display
+ {
+ return $this->display;
+ }
+}
diff --git a/lib/Event/XmdsDependencyRequestEvent.php b/lib/Event/XmdsDependencyRequestEvent.php
new file mode 100644
index 0000000..30f2756
--- /dev/null
+++ b/lib/Event/XmdsDependencyRequestEvent.php
@@ -0,0 +1,87 @@
+.
+ */
+
+namespace Xibo\Event;
+
+use Xibo\Entity\RequiredFile;
+
+/**
+ * Event raised when XMDS receives a request for a file.
+ */
+class XmdsDependencyRequestEvent extends Event
+{
+ public static $NAME = 'xmds.dependency.request';
+
+ private $fileType;
+ private $id;
+ private $path;
+ /**
+ * @var string|null
+ */
+ private $realId;
+
+ /**
+ * @param RequiredFile $file
+ */
+ public function __construct(RequiredFile $file)
+ {
+ $this->fileType = $file->fileType;
+ $this->id = $file->itemId;
+ $this->realId = $file->realId;
+ }
+
+ /**
+ * Get the relative path to this dependency, from the library folder forwards.
+ * @param string $path
+ * @return $this
+ */
+ public function setRelativePathToLibrary(string $path): XmdsDependencyRequestEvent
+ {
+ $this->path = $path;
+ return $this;
+ }
+
+ public function getRelativePath(): ?string
+ {
+ return $this->path;
+ }
+
+ public function getFileType(): string
+ {
+ return $this->fileType;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ /**
+ * The Real ID of the dependency request
+ * this will always be the ID of the item (asset/font/etc.) regardless of XMDS schema version being used.
+ * @return string
+ */
+ public function getRealId() : string
+ {
+ return $this->realId;
+ }
+}
diff --git a/lib/Event/XmdsWeatherRequestEvent.php b/lib/Event/XmdsWeatherRequestEvent.php
new file mode 100644
index 0000000..4c172c7
--- /dev/null
+++ b/lib/Event/XmdsWeatherRequestEvent.php
@@ -0,0 +1,92 @@
+.
+ */
+
+namespace Xibo\Event;
+
+/**
+ * Event raised when XMDS receives a request for weather data.
+ */
+class XmdsWeatherRequestEvent extends Event
+{
+ public static $NAME = 'xmds.weather.request';
+
+ /**
+ * @var float
+ */
+ private $latitude;
+
+ /**
+ * @var float
+ */
+ private $longitude;
+
+ /**
+ * @var float
+ */
+ private $weatherData;
+
+ /**
+ * @param float $latitude
+ * @param float $longitude
+ */
+ public function __construct(float $latitude, float $longitude)
+ {
+ $this->latitude = $latitude;
+ $this->longitude = $longitude;
+ }
+
+ /**
+ * @return float
+ */
+ public function getLatitude(): float
+ {
+ return $this->latitude;
+ }
+
+ /**
+ * @return float
+ */
+ public function getLongitude(): float
+ {
+ return $this->longitude;
+ }
+
+ /**
+ * Sets the weather data.
+ *
+ * @param string $weatherData
+ */
+ public function setWeatherData(string $weatherData): void
+ {
+ $this->weatherData = $weatherData;
+ }
+
+ /**
+ * Gets the weather data.
+ *
+ * @return string
+ */
+ public function getWeatherData(): string
+ {
+ return $this->weatherData;
+ }
+}
diff --git a/lib/Factory/ActionFactory.php b/lib/Factory/ActionFactory.php
new file mode 100644
index 0000000..48f6737
--- /dev/null
+++ b/lib/Factory/ActionFactory.php
@@ -0,0 +1,325 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Action;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ActionFactory
+ * @package Xibo\Factory
+ */
+class ActionFactory extends BaseFactory
+{
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ /**
+ * Create Empty
+ * @return Action
+ */
+ public function createEmpty()
+ {
+ return new Action(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher()
+ );
+ }
+
+ /**
+ * Create a new action
+ * @param string|null $triggerType
+ * @param string|null $triggerCode
+ * @param string $actionType
+ * @param string|null $source
+ * @param integer|null $sourceId
+ * @param string $target
+ * @param integer|null $targetId
+ * @param integer|null $widgetId
+ * @param string|null $layoutCode
+ * @param int|null $layoutId
+ * @return Action
+ */
+ public function create(
+ ?string $triggerType,
+ ?string $triggerCode,
+ string $actionType,
+ ?string $source,
+ ?int $sourceId,
+ string $target,
+ ?int $targetId,
+ ?int $widgetId,
+ ?string $layoutCode,
+ ?int $layoutId
+ ) {
+
+ $action = $this->createEmpty();
+ $action->ownerId = $this->getUser()->userId;
+ $action->triggerType = $triggerType;
+ $action->triggerCode = $triggerCode;
+ $action->actionType = $actionType;
+ $action->source = $source;
+ $action->sourceId = $sourceId;
+ $action->target = $target;
+ $action->targetId = $targetId;
+ $action->widgetId = $widgetId;
+ $action->layoutCode = $layoutCode;
+ $action->layoutId = $layoutId;
+
+ return $action;
+ }
+
+ /**
+ * @param int $actionId
+ * @return Action
+ * @throws NotFoundException
+ */
+ public function getById(int $actionId)
+ {
+ $this->getLog()->debug('ActionFactory getById ' . $actionId);
+
+ $actions = $this->query(null, ['disableUserCheck' => 1, 'actionId' => $actionId]);
+
+ if (count($actions) <= 0) {
+ $this->getLog()->debug('Action not found with ID ' . $actionId);
+ throw new NotFoundException(__('Action not found'));
+ }
+
+ // Set our layout
+ return $actions[0];
+ }
+
+ /**
+ * @param string $source
+ * @param int $sourceId
+ * @return Action[]
+ */
+ public function getBySourceAndSourceId(string $source, int $sourceId)
+ {
+ $actions = $this->query(null, ['disableUserCheck' => 1, 'source' => $source, 'sourceId' => $sourceId]);
+
+ return $actions;
+ }
+
+ /**
+ * @param int $ownerId
+ * @return Action[]
+ */
+ public function getByOwnerId(int $ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'ownerId' => $ownerId]);
+ }
+
+ /**
+ * @param int $targetId
+ * @return Action[]
+ * @throws NotFoundException
+ */
+ public function getByTargetId(int $targetId)
+ {
+ $actions = $this->query(null, ['disableUserCheck' => 1, 'targetId' => $targetId]);
+
+ if (count($actions) <= 0) {
+ $this->getLog()->debug('Unable to find target ID ' . $targetId);
+ throw new NotFoundException(__('not found'));
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Check if Touch Action with provided source, sourceId and actionId already exist
+ *
+ * @param string $source
+ * @param int $sourceId
+ * @param string $triggerType
+ * @param null $actionId
+ * @return bool
+ */
+ public function checkIfActionExist(string $source, int $sourceId, string $triggerType, $actionId = null)
+ {
+ // we can have multiple webhook Actions
+ if ($triggerType == 'webhook') {
+ return false;
+ }
+
+ // exclude our Action ID (for edit)
+ $notActionId = ($actionId == null) ? 0 : $actionId;
+
+ $actions = $this->query(null, ['source' => $source, 'sourceId' => $sourceId, 'triggerType' => $triggerType, 'notActionId' => $notActionId]);
+
+ return ( count($actions) >= 1 ) ? true : false;
+ }
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return Action[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['actionId DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT action.actionId,
+ action.ownerId,
+ action.triggerType,
+ action.triggerCode,
+ action.actionType,
+ action.source,
+ action.sourceId,
+ action.target,
+ action.targetId,
+ action.widgetId,
+ action.layoutCode,
+ action.layoutId
+ ';
+
+ $body = ' FROM action
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('actionId') !== null) {
+ $body .= ' AND `action`.actionId = :actionId ';
+ $params['actionId'] = $sanitizedFilter->getInt('actionId');
+ }
+
+ if ($sanitizedFilter->getInt('ownerId') !== null) {
+ $body .= ' AND `action`.ownerId = :ownerId ';
+ $params['ownerId'] = $sanitizedFilter->getInt('ownerId');
+ }
+
+ if ($sanitizedFilter->getString('triggerType') !== null) {
+ $body .= ' AND `action`.triggerType = :triggerType ';
+ $params['triggerType'] = $sanitizedFilter->getString('triggerType');
+ }
+
+ if ($sanitizedFilter->getString('triggerCode') != null) {
+ $body .= ' AND `action`.triggerCode = :triggerCode ';
+ $params['triggerCode'] = $sanitizedFilter->getString('triggerCode');
+ }
+
+ if ($sanitizedFilter->getString('actionType') != null) {
+ $body .= ' AND `action`.actionType = :actionType ';
+ $params['actionType'] = $sanitizedFilter->getInt('actionType');
+ }
+
+ if ($sanitizedFilter->getString('source') != null) {
+ $body .= ' AND `action`.source = :source ';
+ $params['source'] = $sanitizedFilter->getString('source');
+ }
+
+ if ($sanitizedFilter->getInt('sourceId') != null) {
+ $body .= ' AND `action`.sourceId = :sourceId ';
+ $params['sourceId'] = $sanitizedFilter->getInt('sourceId');
+ }
+
+ if ($sanitizedFilter->getString('target') != null) {
+ $body .= ' AND `action`.target = :target ';
+ $params['target'] = $sanitizedFilter->getString('target');
+ }
+
+ if ($sanitizedFilter->getInt('targetId') != null) {
+ $body .= ' AND `action`.targetId = :targetId ';
+ $params['targetId'] = $sanitizedFilter->getInt('targetId');
+ }
+
+ if ($sanitizedFilter->getInt('widgetId') !== null) {
+ $body .= ' AND `action`.widgetId = :widgetId ';
+ $params['objectId'] = $sanitizedFilter->getInt('widgetId');
+ }
+
+ if ($sanitizedFilter->getString('layoutCode') !== null) {
+ $body .= ' AND `action`.layoutCode = :layoutCode ';
+ $params['objectId'] = $sanitizedFilter->getString('layoutCode');
+ }
+
+ if ($sanitizedFilter->getInt('notActionId') !== null) {
+ $body .= ' AND `action`.actionId <> :notActionId ';
+ $params['notActionId'] = $sanitizedFilter->getInt('notActionId');
+ }
+
+ if ($sanitizedFilter->getInt('layoutId') !== null) {
+ // All actions which are attached to this layout in any way.
+ $body .= ' AND `action`.layoutId = :layoutId ';
+ $params['layoutId'] = $sanitizedFilter->getInt('layoutId');
+ }
+
+ if ($sanitizedFilter->getInt('sourceOrTargetId') !== null) {
+ // All actions which are attached to this layout in any way.
+ $body .= ' AND (
+ `action`.sourceId = :sourceOrTargetId
+ OR `action`.targetId = :sourceOrTargetId
+ ) ';
+ $params['sourceOrTargetId'] = $sanitizedFilter->getInt('sourceOrTargetId');
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $action = $this->createEmpty()->hydrate($row);
+ $action->targetId = ($action->targetId === 0) ? null : $action->targetId;
+ $action->widgetId = ($action->widgetId === 0) ? null : $action->widgetId;
+ $action->layoutCode = ($action->widgetId === '') ? null : $action->layoutCode;
+
+ $entries[] = $action;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+
+ }
+}
diff --git a/lib/Factory/ApplicationFactory.php b/lib/Factory/ApplicationFactory.php
new file mode 100644
index 0000000..02a57c0
--- /dev/null
+++ b/lib/Factory/ApplicationFactory.php
@@ -0,0 +1,358 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
+use Xibo\Entity\Application;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ApplicationFactory
+ * @package Xibo\Factory
+ */
+class ApplicationFactory extends BaseFactory implements ClientRepositoryInterface
+{
+ /**
+ * @var ApplicationRedirectUriFactory
+ */
+ private $applicationRedirectUriFactory;
+
+ /** @var ApplicationScopeFactory */
+ private $applicationScopeFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param ApplicationRedirectUriFactory $applicationRedirectUriFactory
+ * @param ApplicationScopeFactory $applicationScopeFactory
+ */
+ public function __construct($user, $applicationRedirectUriFactory, $applicationScopeFactory)
+ {
+ $this->setAclDependencies($user, null);
+
+ $this->applicationRedirectUriFactory = $applicationRedirectUriFactory;
+ $this->applicationScopeFactory = $applicationScopeFactory;
+
+ if ($this->applicationRedirectUriFactory == null) {
+ throw new \RuntimeException('Missing dependency: ApplicationRedirectUriFactory');
+ }
+ }
+
+ /**
+ * @return Application
+ */
+ public function create()
+ {
+ $application = $this->createEmpty();
+ $application->userId = $this->getUser()->userId;
+ return $application;
+ }
+
+ /**
+ * Create an empty application
+ * @return Application
+ */
+ public function createEmpty()
+ {
+ if ($this->applicationRedirectUriFactory == null) {
+ throw new \RuntimeException('Missing dependency: ApplicationRedirectUriFactory');
+ }
+
+ if ($this->applicationScopeFactory == null) {
+ throw new \RuntimeException('Missing dependency: ApplicationScopeFactory');
+ }
+
+ return new Application(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->applicationRedirectUriFactory,
+ $this->applicationScopeFactory
+ );
+ }
+
+ /**
+ * Get by ID
+ * @param $clientId
+ * @return Application
+ * @throws NotFoundException
+ */
+ public function getById($clientId)
+ {
+ $client = $this->query(null, ['clientId' => $clientId]);
+
+ if (count($client) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $client[0];
+ }
+
+ /**
+ * Get by Name
+ * @param $name
+ * @return Application
+ * @throws NotFoundException
+ */
+ public function getByName($name)
+ {
+ $client = $this->query(null, ['name' => $name, 'useRegexForName' => 1]);
+
+ if (count($client) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $client[0];
+ }
+
+ /**
+ * @param int $userId
+ * @return Application[]
+ */
+ public function getByUserId($userId)
+ {
+ return $this->query(null, ['userId' => $userId]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return Application[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $entries = [];
+ $params = [];
+
+ $select = '
+ SELECT `oauth_clients`.id AS `key`,
+ `oauth_clients`.secret,
+ `oauth_clients`.name,
+ `user`.UserName AS owner,
+ `oauth_clients`.authCode,
+ `oauth_clients`.clientCredentials,
+ `oauth_clients`.userId,
+ `oauth_clients`.isConfidential,
+ `oauth_clients`.description,
+ `oauth_clients`.logo,
+ `oauth_clients`.coverImage,
+ `oauth_clients`.companyName,
+ `oauth_clients`.termsUrl,
+ `oauth_clients`.privacyUrl
+ ';
+
+ $body = ' FROM `oauth_clients` ';
+ $body .= ' INNER JOIN `user` ON `user`.userId = `oauth_clients`.userId ';
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getString('clientId') != null) {
+ $body .= ' AND `oauth_clients`.id = :clientId ';
+ $params['clientId'] = $sanitizedFilter->getString('clientId');
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `oauth_clients`.userId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // Filter by Application Name?
+ if ($sanitizedFilter->getString('name') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'oauth_clients',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['isConfidential', 'authCode', 'clientCredentials']
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @inheritDoc
+ * @return Application
+ */
+ public function getClientEntity($clientIdentifier)
+ {
+ $this->getLog()->debug('getClientEntity for clientId: ' . $clientIdentifier);
+
+ try {
+ return $this->getById($clientIdentifier)->load();
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug('getClientEntity: Unable to find ' . $clientIdentifier);
+ return null;
+ }
+ }
+
+ /** @inheritDoc */
+ public function validateClient($clientIdentifier, $clientSecret, $grantType)
+ {
+ $this->getLog()->debug('validateClient for clientId: ' . $clientIdentifier . ' grant is ' . $grantType);
+
+ $client = $this->getClientEntity($clientIdentifier);
+
+ if ($client === null) {
+ $this->getLog()->debug('Client does not exist');
+ return false;
+ }
+
+ if ($client->isConfidential() === true
+ && password_verify($clientSecret, $client->getHash()) === false
+ ) {
+ $this->getLog()->debug('Client secret does not match');
+ return false;
+ }
+
+ $this->getLog()->debug('Grant Type '. $grantType . ' being tested. Client is condifential = ' . $client->isConfidential());
+
+ // Check to see if this grant_type is allowed for this client
+ switch ($grantType) {
+ case 'authorization_code':
+ case 'refresh_token':
+ if ($client->authCode != 1) {
+ return false;
+ }
+
+ break;
+
+ case 'client_credentials':
+ case 'mcaas':
+ if ($client->clientCredentials != 1) {
+ return false;
+ }
+
+ break;
+
+ default:
+ return false;
+ }
+
+ $this->getLog()->debug('Grant Type is allowed.');
+
+ return true;
+ }
+
+ /**
+ * Insert approval record for provided clientId/userId pair with current date and IP address
+ * @param $clientId
+ * @param $userId
+ * @param $approvedDate
+ * @param $approvedIp
+ */
+ public function setApplicationApproved($clientId, $userId, $approvedDate, $approvedIp)
+ {
+ $this->getLog()->debug('Adding approved Access for Application ' . $clientId . ' for User ' . $userId);
+
+ $this->getStore()->insert('
+ INSERT INTO `oauth_lkclientuser` (`clientId`, `userId`, `approvedDate`, `approvedIp`)
+ VALUES (:clientId, :userId, :approvedDate, :approvedIp)
+ ON DUPLICATE KEY UPDATE clientId = clientId, userId = userId, approvedDate = :approvedDate, approvedIp = :approvedIp
+
+ ', [
+ 'clientId' => $clientId,
+ 'userId' => $userId,
+ 'approvedDate' => $approvedDate,
+ 'approvedIp' => $approvedIp
+ ]);
+ }
+
+ /**
+ * Check if provided clientId and userId pair are still authorised
+ * @param $clientId
+ * @param $userId
+ * @return bool
+ */
+ public function checkAuthorised($clientId, $userId): bool
+ {
+ $results = $this->getStore()->select('SELECT clientId, userId FROM `oauth_lkclientuser` WHERE clientId = :clientId AND userId = :userId', [
+ 'userId' => $userId,
+ 'clientId' => $clientId
+ ]);
+
+ if (count($results) <= 0) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get applications authorised by specific user
+ * @param $userId
+ * @return array
+ */
+ public function getAuthorisedByUserId($userId): array
+ {
+ return $this->getStore()->select('SELECT oauth_clients.name, oauth_clients.id, approvedDate, approvedIp FROM `oauth_lkclientuser` INNER JOIN `oauth_clients` on `oauth_lkclientuser`.clientId = `oauth_clients`.id WHERE `oauth_lkclientuser`.userId = :userId', [
+ 'userId' => $userId
+ ]);
+ }
+
+ /**
+ * Remove provided clientId and userId pair from link table
+ * @param $userId
+ * @param $clientId
+ */
+ public function revokeAuthorised($userId, $clientId)
+ {
+ $this->getStore()->update('DELETE FROM `oauth_lkclientuser` WHERE clientId = :clientId AND userId = :userId', [
+ 'userId' => $userId,
+ 'clientId' => $clientId
+ ]);
+ }
+}
diff --git a/lib/Factory/ApplicationRedirectUriFactory.php b/lib/Factory/ApplicationRedirectUriFactory.php
new file mode 100644
index 0000000..93394e4
--- /dev/null
+++ b/lib/Factory/ApplicationRedirectUriFactory.php
@@ -0,0 +1,127 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\ApplicationRedirectUri;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ApplicationRedirectUriFactory
+ * @package Xibo\Factory
+ */
+class ApplicationRedirectUriFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return ApplicationRedirectUri
+ */
+ public function create()
+ {
+ return new ApplicationRedirectUri($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get by ID
+ * @param $id
+ * @return ApplicationRedirectUri
+ * @throws NotFoundException
+ */
+ public function getById($id)
+ {
+ $clientRedirectUri = $this->query(null, ['id' => $id]);
+
+ if (count($clientRedirectUri) <= 0)
+ throw new NotFoundException();
+
+ return $clientRedirectUri[0];
+ }
+
+ /**
+ * Get by Client Id
+ * @param $clientId
+ * @return array[ApplicationRedirectUri]
+ */
+ public function getByClientId($clientId)
+ {
+ return $this->query(null, ['clientId' => $clientId]);
+ }
+
+ /**
+ * Query
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return array
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $select = 'SELECT id, client_id AS clientId, redirect_uri AS redirectUri ';
+
+ $body = ' FROM `oauth_client_redirect_uris` WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getString('clientId') != null) {
+ $body .= ' AND `oauth_client_redirect_uris`.client_id = :clientId ';
+ $params['clientId'] = $sanitizedFilter->getString('clientId');
+ }
+
+ if ($sanitizedFilter->getString('id') != null) {
+ $body .= ' AND `oauth_client_redirect_uris`.client_id = :id ';
+ $params['id'] = $sanitizedFilter->getString('id');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->create()->hydrate($row);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/ApplicationRequestsFactory.php b/lib/Factory/ApplicationRequestsFactory.php
new file mode 100644
index 0000000..2054168
--- /dev/null
+++ b/lib/Factory/ApplicationRequestsFactory.php
@@ -0,0 +1,40 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\ApplicationRequest;
+
+/**
+ * Class ApplicationRequestsFactory
+ * @package Xibo\Factory
+ */
+class ApplicationRequestsFactory extends BaseFactory
+{
+ /**
+ * @return ApplicationRequest
+ */
+ public function createEmpty(): ApplicationRequest
+ {
+ return new ApplicationRequest($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+}
diff --git a/lib/Factory/ApplicationScopeFactory.php b/lib/Factory/ApplicationScopeFactory.php
new file mode 100644
index 0000000..4c7a7db
--- /dev/null
+++ b/lib/Factory/ApplicationScopeFactory.php
@@ -0,0 +1,196 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
+use Xibo\Entity\ApplicationScope;
+use Xibo\OAuth\ScopeEntity;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ApplicationScopeFactory
+ * @package Xibo\Factory
+ */
+class ApplicationScopeFactory extends BaseFactory implements ScopeRepositoryInterface
+{
+ /**
+ * Create Empty
+ * @return ApplicationScope
+ */
+ public function create()
+ {
+ return new ApplicationScope($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get by ID
+ * @param $id
+ * @return ApplicationScope
+ * @throws NotFoundException
+ */
+ public function getById($id)
+ {
+ $scope = $this->query(null, ['id' => $id]);
+
+ if (count($scope) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $scope[0];
+ }
+
+ /**
+ * Get by Client Id
+ * @param $clientId
+ * @return ApplicationScope[]
+ */
+ public function getByClientId($clientId)
+ {
+ return $this->query(null, ['clientId' => $clientId]);
+ }
+
+ /**
+ * Query
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return ApplicationScope[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+
+ $select = 'SELECT `oauth_scopes`.id, `oauth_scopes`.description ';
+ $body = ' FROM `oauth_scopes`';
+
+ if ($sanitizedFilter->getString('clientId') != null) {
+ $body .= ' INNER JOIN `oauth_client_scopes`
+ ON `oauth_client_scopes`.scopeId = `oauth_scopes`.id ';
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getString('clientId') != null) {
+ $body .= ' AND `oauth_client_scopes`.clientId = :clientId ';
+ $params['clientId'] = $sanitizedFilter->getString('clientId');
+ }
+
+ if ($sanitizedFilter->getString('id') != null) {
+ $body .= ' AND `oauth_scopes`.id = :id ';
+ $params['id'] = $sanitizedFilter->getString('id');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+ $limit = '';
+ // Paging
+ if ($filterBy !== null
+ && $sanitizedFilter->getInt('start') !== null
+ && $sanitizedFilter->getInt('length') !== null
+ ) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', '
+ . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->create()->hydrate($row, ['stringProperties' => ['id']]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getScopeEntityByIdentifier($scopeIdentifier)
+ {
+ $this->getLog()->debug('getScopeEntityByIdentifier: ' . $scopeIdentifier);
+
+ try {
+ $applicationScope = $this->getById($scopeIdentifier);
+ $scope = new ScopeEntity();
+ $scope->setIdentifier($applicationScope->getId());
+ return $scope;
+ } catch (NotFoundException $e) {
+ return null;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function finalizeScopes(
+ array $scopes,
+ $grantType,
+ ClientEntityInterface $clientEntity,
+ $userIdentifier = null
+ ): array {
+ /** @var \Xibo\Entity\Application $clientEntity */
+ $countOfScopesRequested = count($scopes);
+ $this->getLog()->debug('finalizeScopes: provided scopes count = ' . $countOfScopesRequested);
+
+ // No scopes have been requested
+ // in this case we should return all scopes configured for the application
+ // this is to maintain backwards compatibility with older implementations which do not
+ // request scopes.
+ if ($countOfScopesRequested <= 0) {
+ return $clientEntity->getScopes();
+ }
+
+ // Scopes have been provided
+ $finalScopes = [];
+
+ // The client entity contains the scopes which are valid for this client
+ foreach ($scopes as $scope) {
+ // See if we can find it
+ $found = false;
+
+ foreach ($clientEntity->getScopes() as $validScope) {
+ if ($validScope->getIdentifier() === $scope->getIdentifier()) {
+ $found = true;
+ break;
+ }
+ }
+
+ if ($found) {
+ $finalScopes[] = $scope;
+ }
+ }
+
+ return $finalScopes;
+ }
+}
diff --git a/lib/Factory/AuditLogFactory.php b/lib/Factory/AuditLogFactory.php
new file mode 100644
index 0000000..636b5d0
--- /dev/null
+++ b/lib/Factory/AuditLogFactory.php
@@ -0,0 +1,185 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\AuditLog;
+
+/**
+ * Class AuditLogFactory
+ * @package Xibo\Factory
+ */
+class AuditLogFactory extends BaseFactory
+{
+ /**
+ * @return AuditLog
+ */
+ public function create()
+ {
+ return new AuditLog($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $this->getLog()->debug(sprintf('AuditLog Factory with filter: %s', var_export($filterBy, true)));
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $entries = [];
+ $params = [];
+
+ $select = '
+ SELECT `logId`,
+ `logDate`,
+ `user`.`userName`,
+ `message`,
+ `objectAfter`,
+ `entity`,
+ `entityId`,
+ `auditlog`.userId,
+ `auditlog`.ipAddress,
+ `auditlog`.sessionHistoryId
+ ';
+
+ $body = '
+ FROM `auditlog`
+ LEFT OUTER JOIN `user`
+ ON `user`.`userId` = `auditlog`.`userId`
+ WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('fromTimeStamp') !== null) {
+ $body .= ' AND `auditlog`.`logDate` >= :fromTimeStamp ';
+ $params['fromTimeStamp'] = $sanitizedFilter->getInt('fromTimeStamp');
+ }
+
+ if ($sanitizedFilter->getInt('toTimeStamp') !== null) {
+ $body .= ' AND `auditlog`.`logDate` < :toTimeStamp ';
+ $params['toTimeStamp'] = $sanitizedFilter->getInt('toTimeStamp');
+ }
+
+ if ($sanitizedFilter->getString('entity') != null) {
+ $body .= ' AND `auditlog`.`entity` LIKE :entity ';
+ $params['entity'] = '%' . $sanitizedFilter->getString('entity') . '%';
+ }
+
+ if ($sanitizedFilter->getString('userName') != null) {
+ $body .= ' AND `user`.`userName` LIKE :userName ';
+ $params['userName'] = '%' . $sanitizedFilter->getString('userName') . '%';
+ }
+
+ if ($sanitizedFilter->getString('message') != null) {
+ $body .= ' AND `auditlog`.`message` LIKE :message ';
+ $params['message'] = '%' . $sanitizedFilter->getString('message') . '%';
+ }
+
+ if ($sanitizedFilter->getString('ipAddress') != null) {
+ $body .= ' AND `auditlog`.`ipAddress` LIKE :ipAddress ';
+ $params['ipAddress'] = '%' . $sanitizedFilter->getString('ipAddress') . '%';
+ }
+
+ if ($sanitizedFilter->getInt('entityId') !== null) {
+ $body .= ' AND ( `auditlog`.`entityId` = :entityId ' ;
+ $params['entityId'] = $sanitizedFilter->getInt('entityId');
+
+ $entity = $sanitizedFilter->getString('entity');
+
+ // if we were supplied with both layout entity and entityId (layoutId), expand the results
+ // we want to get all actions issued on this layout from the moment it was added
+ if (stripos($entity, 'layout') !== false) {
+ $sqlLayoutHistory = '
+ SELECT `campaign`.campaignId
+ FROM `layout`
+ INNER JOIN `lkcampaignlayout`
+ ON `layout`.layoutId = `lkcampaignlayout`.layoutId
+ INNER JOIN `campaign`
+ ON `campaign`.campaignId = `lkcampaignlayout`.campaignId
+ WHERE `campaign`.isLayoutSpecific = 1
+ AND `layout`.layoutId = :layoutId
+ ';
+
+ $results = $this->getStore()->select($sqlLayoutHistory, ['layoutId' => $params['entityId']]);
+ foreach ($results as $row) {
+ $campaignId = $row['campaignId'];
+ }
+
+ if (isset($campaignId)) {
+ $body .= '
+ OR `auditlog`.`entityId` IN (
+ SELECT `layouthistory`.`layoutId`
+ FROM `layouthistory`
+ WHERE `layouthistory`.`campaignId` = :campaignId
+ )) ';
+ $params['campaignId'] = $campaignId;
+ } else {
+ $body .= ' ) ';
+ }
+ } else {
+ $body .= ' ) ';
+ }
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `auditlog`.`userId` = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ if ($sanitizedFilter->getInt('sessionHistoryId') !== null) {
+ $body .= ' AND `auditlog`.`sessionHistoryId` = :sessionHistoryId ';
+ $params['sessionHistoryId'] = $sanitizedFilter->getInt('sessionHistoryId');
+ }
+
+ $order = '';
+ if (is_array($sortOrder) && count($sortOrder) > 0) {
+ $order .= 'ORDER BY ' . implode(', ', $sortOrder) . ' ';
+ }
+
+ // Paging
+ $limit = '';
+ if ($filterBy !== null
+ && $sanitizedFilter->getInt('start') !== null
+ && $sanitizedFilter->getInt('length') !== null
+ ) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0])
+ . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->create()->hydrate($row);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/BandwidthFactory.php b/lib/Factory/BandwidthFactory.php
new file mode 100644
index 0000000..a1bd252
--- /dev/null
+++ b/lib/Factory/BandwidthFactory.php
@@ -0,0 +1,129 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Bandwidth;
+use Xibo\Helper\ByteFormatter;
+
+/**
+ * Class BandwidthFactory
+ * @package Xibo\Factory
+ */
+class BandwidthFactory extends BaseFactory
+{
+ public function __construct(private readonly PoolInterface $pool)
+ {
+ }
+
+ /**
+ * @return Bandwidth
+ */
+ public function createEmpty()
+ {
+ return new Bandwidth($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create and Save Bandwidth record
+ * @param int $type
+ * @param int $displayId
+ * @param int $size
+ * @return Bandwidth
+ */
+ public function createAndSave($type, $displayId, $size)
+ {
+ $bandwidth = $this->createEmpty();
+ $bandwidth->type = $type;
+ $bandwidth->displayId = $displayId;
+ $bandwidth->size = $size;
+ $bandwidth->save();
+
+ return $bandwidth;
+ }
+
+ /**
+ * Is the bandwidth limit exceeded
+ * @param int $limit the bandwidth limit to check against
+ * @param int $usage
+ * @param int $displayId
+ * @return bool
+ */
+ public function isBandwidthExceeded(int $limit, int &$usage = 0, int $displayId = 0): bool
+ {
+ if ($limit <= 0) {
+ return false;
+ }
+
+ // Get from cache.
+ $cache = $this->pool->getItem('bandwidth_' . $displayId);
+ $usage = $cache->get();
+
+ if ($cache->isMiss() || $usage === null) {
+ // Get from the database
+ $usage = $this->getBandwidth($displayId);
+
+ // Save to the cache
+ $cache->set($usage);
+ $cache->setTTL(600);
+ $cache->save();
+ }
+
+ $this->getLog()->debug(sprintf(
+ 'isBandwidthExceeded: Checking bandwidth usage %s against allowance %s',
+ ByteFormatter::format($usage),
+ ByteFormatter::format($limit * 1024)
+ ));
+
+ return ($usage >= ($limit * 1024));
+ }
+
+ private function getBandwidth(int $displayId): int
+ {
+ try {
+ $dbh = $this->getStore()->getConnection();
+
+ // Test bandwidth for the current month
+ $sql = 'SELECT IFNULL(SUM(Size), 0) AS BandwidthUsage FROM `bandwidth` WHERE `Month` = :month';
+ $params = [
+ 'month' => strtotime(date('m') . '/02/' . date('Y') . ' 00:00:00')
+ ];
+
+ // if we are testing the bandwidth usage for specific display, add the information to the query
+ if ($displayId != null) {
+ $sql .= ' AND `displayId` = :displayId';
+ $params['displayId'] = $displayId;
+ }
+
+ $sth = $dbh->prepare($sql);
+ $sth->execute($params);
+
+ return $sth->fetchColumn(0);
+ } catch (\PDOException $e) {
+ $this->getLog()->error('getBandwidth: e = ' . $e->getMessage());
+ return 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/BaseFactory.php b/lib/Factory/BaseFactory.php
new file mode 100644
index 0000000..42ba873
--- /dev/null
+++ b/lib/Factory/BaseFactory.php
@@ -0,0 +1,506 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\BaseDependenciesService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Class BaseFactory
+ * @package Xibo\Factory
+ */
+class BaseFactory
+{
+ /**
+ * Count records last query
+ * @var int
+ */
+ protected $_countLast = 0;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var LogServiceInterface
+ */
+ private $log;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizerService;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+
+ /**
+ * @var BaseDependenciesService
+ */
+ private $baseDependenciesService;
+
+ /**
+ * @param BaseDependenciesService $baseDependenciesService
+ */
+ public function useBaseDependenciesService(BaseDependenciesService $baseDependenciesService)
+ {
+ $this->baseDependenciesService = $baseDependenciesService;
+ }
+
+ /**
+ * Set Acl Dependencies
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @return $this
+ */
+ public function setAclDependencies($user, $userFactory)
+ {
+ $this->user = $user;
+ $this->userFactory = $userFactory;
+ return $this;
+ }
+
+ /**
+ * Get Store
+ * @return StorageServiceInterface
+ */
+ protected function getStore()
+ {
+ return $this->baseDependenciesService->getStore();
+ }
+
+ /**
+ * Get Log
+ * @return LogServiceInterface
+ */
+ protected function getLog()
+ {
+ return $this->baseDependenciesService->getLogger();
+ }
+
+ /**
+ * @return SanitizerService
+ */
+ protected function getSanitizerService()
+ {
+ return $this->baseDependenciesService->getSanitizer();
+ }
+
+ /**
+ * Get Sanitizer
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ return $this->getSanitizerService()->getSanitizer($array);
+ }
+
+ /**
+ * @return \Xibo\Support\Validator\ValidatorInterface
+ */
+ protected function getValidator()
+ {
+ return $this->getSanitizerService()->getValidator();
+ }
+
+ /**
+ * Get User
+ * @return User
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Get User Factory
+ * @return UserFactory
+ */
+ public function getUserFactory()
+ {
+ return $this->userFactory;
+ }
+
+ /**
+ * @return \Symfony\Component\EventDispatcher\EventDispatcherInterface
+ */
+ public function getDispatcher(): EventDispatcherInterface
+ {
+ return $this->baseDependenciesService->getDispatcher();
+ }
+
+ /**
+ * @return \Xibo\Service\ConfigServiceInterface
+ */
+ public function getConfig(): ConfigServiceInterface
+ {
+ return $this->baseDependenciesService->getConfig();
+ }
+
+ /**
+ * Count of records returned for the last query.
+ * @return int
+ */
+ public function countLast()
+ {
+ return $this->_countLast;
+ }
+
+ /**
+ * View Permission SQL
+ * @param $entity
+ * @param $sql
+ * @param $params
+ * @param $idColumn
+ * @param null $ownerColumn
+ * @param array $filterBy
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function viewPermissionSql(
+ $entity,
+ &$sql,
+ &$params,
+ $idColumn,
+ $ownerColumn = null,
+ $filterBy = [],
+ $permissionFolderIdColumn = null
+ ) {
+ $parsedBody = $this->getSanitizer($filterBy);
+ $checkUserId = $parsedBody->getInt('userCheckUserId');
+
+ if ($checkUserId !== null) {
+ $this->getLog()->debug(sprintf('Checking permissions against a specific user: %d', $checkUserId));
+ $user = $this->getUserFactory()->getById($checkUserId);
+ }
+ else {
+ $user = $this->getUser();
+
+ if ($user !== null)
+ $this->getLog()->debug(sprintf('Checking permissions against the logged in user: ID: %d, Name: %s, UserType: %d', $user->userId, $user->userName, $user->userTypeId));
+ }
+
+ $permissionSql = '';
+
+ // Has the user check been disabled? 0 = no it hasn't
+ $performUserCheck = $parsedBody->getCheckbox('disableUserCheck') == 0;
+
+ if ($performUserCheck && !$user->isSuperAdmin()) {
+ $permissionSql .= '
+ AND (' . $idColumn . ' IN (
+ SELECT `permission`.objectId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = `permission`.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ INNER JOIN `lkusergroup`
+ ON `lkusergroup`.groupId = `group`.groupId
+ INNER JOIN `user`
+ ON lkusergroup.UserID = `user`.UserID
+ WHERE `permissionentity`.entity = :permissionEntity
+ AND `user`.userId = :currentUserId
+ AND `permission`.view = 1
+ UNION ALL
+ SELECT `permission`.objectId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = `permission`.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE `permissionentity`.entity = :permissionEntity
+ AND `group`.isEveryone = 1
+ AND `permission`.view = 1
+ )
+ ';
+
+ $params['permissionEntity'] = $entity;
+ $params['currentUserId'] = $user->userId;
+
+ if ($ownerColumn != null) {
+ $permissionSql .= ' OR ' . $ownerColumn . ' = :currentUserId2';
+ $params['currentUserId2'] = $user->userId;
+ }
+
+ // Home folders (only for folder entity)
+ if ($entity === 'Xibo\Entity\Folder') {
+ $permissionSql .= ' OR folder.folderId = :permissionsHomeFolderId';
+ $permissionSql .= ' OR folder.permissionsFolderId = :permissionsHomeFolderId';
+ $params['permissionsHomeFolderId'] = $this->getUser()->homeFolderId;
+ }
+
+ // Group Admin?
+ if ($user->userTypeId == 2 && $ownerColumn != null) {
+ // OR the group admin and the owner of the media are in the same group
+ $permissionSql .= '
+ OR (
+ SELECT COUNT(lkUserGroupId)
+ FROM `lkusergroup`
+ WHERE userId = ' . $ownerColumn . '
+ AND groupId IN (
+ SELECT groupId
+ FROM `lkusergroup`
+ WHERE userId = :currentUserId3
+ )
+ ) > 0
+ ';
+
+ $params['currentUserId3'] = $user->userId;
+ }
+
+ if ($permissionFolderIdColumn != null) {
+ $permissionSql .= '
+ OR ' . $permissionFolderIdColumn . ' IN (
+ SELECT `permission`.objectId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = `permission`.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ INNER JOIN `lkusergroup`
+ ON `lkusergroup`.groupId = `group`.groupId
+ INNER JOIN `user`
+ ON lkusergroup.UserID = `user`.UserID
+ WHERE `permissionentity`.entity = :folderEntity
+ AND `permission`.view = 1
+ AND `user`.userId = :currentUserId
+ UNION ALL
+ SELECT `permission`.objectId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = `permission`.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE `permissionentity`.entity = :folderEntity
+ AND `group`.isEveryone = 1
+ AND `permission`.view = 1
+ )
+ ';
+
+ $params['folderEntity'] = 'Xibo\Entity\Folder';
+ }
+
+ $permissionSql .= ' )';
+
+ //$this->getLog()->debug('Permission SQL = %s', $permissionSql);
+ }
+
+ // Set out params
+ $sql = $sql . $permissionSql;
+ }
+
+ /**
+ * @param $variable
+ * @return array
+ */
+ protected function parseComparisonOperator($variable)
+ {
+ $operator = '=';
+ $allowedOperators = [
+ 'less-than' => '<',
+ 'greater-than' => '>',
+ 'less-than-equal' => '<=',
+ 'greater-than-equal' => '>='
+ ];
+
+ if (stripos($variable, '|') !== false) {
+ $variable = explode('|', $variable);
+
+ if (array_key_exists($variable[0], $allowedOperators)) {
+ $operator = $allowedOperators[$variable[0]];
+ }
+
+ $variable = $variable[1];
+ }
+
+ return [
+ 'operator' => $operator,
+ 'variable' => $variable
+ ];
+ }
+
+ /**
+ * Sets the name filter for all factories to use.
+ *
+ * @param string $tableName Table name
+ * @param string $tableColumn Column with the name
+ * @param array $terms An Array exploded by "," of the search names
+ * @param string $body Current SQL body passed by reference
+ * @param array $params Array of parameters passed by reference
+ * @param bool $useRegex flag to match against a regex pattern
+ */
+ public function nameFilter(
+ $tableName,
+ $tableColumn,
+ $terms,
+ &$body,
+ &$params,
+ $useRegex = false,
+ $logicalOperator = 'OR'
+ ) {
+ $i = 0;
+
+ $tableAndColumn = $tableName . '.' . $tableColumn;
+ // filter empty array elements, in an attempt to better handle spaces after `,`.
+ $filteredNames = array_filter($terms, function ($element) {
+ return is_string($element) && '' !== trim($element);
+ });
+
+ foreach ($filteredNames as $searchName) {
+ // Trim/Sanitise
+ $searchName = trim($searchName);
+
+ // Discard any incompatible
+ if (empty(ltrim($searchName, '-')) || empty($searchName)) {
+ continue;
+ }
+
+ // Validate the logical operator
+ if (!in_array($logicalOperator, ['AND', 'OR'])) {
+ $this->getLog()->error('Invalid logical operator ' . $logicalOperator);
+ return;
+ }
+
+ // increase here, after we expect additional sql to be added.
+ $i++;
+
+ // Not like, or like?
+ if (str_starts_with($searchName, '-')) {
+ if ($i === 1) {
+ $body .= ' AND ( '.$tableAndColumn.' NOT RLIKE (:search'.$i.') ';
+ } else {
+ $body .= ' ' . $logicalOperator . ' '.$tableAndColumn.' NOT RLIKE (:search'.$i.') ';
+ }
+ $params['search' . $i] = $useRegex ? ltrim(($searchName), '-') : preg_quote(ltrim(($searchName), '-'));
+ } else {
+ if ($i === 1) {
+ $body .= ' AND ( '.$tableAndColumn.' RLIKE (:search'.$i.') ';
+ } else {
+ $body .= ' ' . $logicalOperator . ' '.$tableAndColumn.' RLIKE (:search'.$i.') ';
+ }
+ $params['search' . $i] = $useRegex ? $searchName : preg_quote($searchName);
+ }
+ }
+
+ // append closing parenthesis only if we added any sql.
+ if (!empty($filteredNames) && $i > 0) {
+ $body .= ' ) ';
+ }
+ }
+
+ /**
+ * @param array $tags An array of tags
+ * @param string $lkTagTable name of the lktag table
+ * @param string $lkTagTableIdColumn name of the id column in the lktag table
+ * @param string $idColumn name of the id column in main table
+ * @param string $logicalOperator AND or OR logical operator passed from Factory
+ * @param string $operator exactTags passed from factory, determines if the search is LIKE or =
+ * @param string $body Current SQL body passed by reference
+ * @param array $params Array of parameters passed by reference
+ */
+ public function tagFilter(
+ $tags,
+ $lkTagTable,
+ $lkTagTableIdColumn,
+ $idColumn,
+ $logicalOperator,
+ $operator,
+ $notTags,
+ &$body,
+ &$params
+ ) {
+ $i = 0;
+ $paramName = ($notTags) ? 'notTags' : 'tags';
+ $paramValueName = ($notTags) ? 'notValue' : 'value';
+
+ foreach ($tags as $tag) {
+ $i++;
+
+ $tagV = explode('|', $tag);
+
+ // search tag without value
+ if (!isset($tagV[1])) {
+ if ($i == 1) {
+ $body .= ' WHERE `tag` ' . $operator . ' :'. $paramName . $i;
+ } else {
+ $body .= ' OR ' . ' `tag` ' . $operator . ' :' . $paramName . $i;
+ }
+
+ if ($operator === '=') {
+ $params[$paramName . $i] = $tag;
+ } else {
+ $params[$paramName . $i] = '%' . $tag . '%';
+ }
+ // search tag only by value
+ } elseif ($tagV[0] == '') {
+ if ($i == 1) {
+ $body .= ' WHERE `value` ' . $operator . ' :' . $paramValueName . $i;
+ } else {
+ $body .= ' OR ' . ' `value` ' . $operator . ' :' . $paramValueName . $i;
+ }
+
+ if ($operator === '=') {
+ $params[$paramValueName . $i] = $tagV[1];
+ } else {
+ $params[$paramValueName . $i] = '%' . $tagV[1] . '%';
+ }
+ // search tag by both tag and value
+ } else {
+ if ($i == 1) {
+ $body .= ' WHERE `tag` ' . $operator . ' :' . $paramName . $i .
+ ' AND value ' . $operator . ' :' . $paramValueName . $i;
+ } else {
+ $body .= ' OR ' . ' `tag` ' . $operator . ' :' . $paramName . $i .
+ ' AND value ' . $operator . ' :' . $paramValueName . $i;
+ }
+
+ if ($operator === '=') {
+ $params[$paramName . $i] = $tagV[0];
+ $params[$paramValueName . $i] = $tagV[1];
+ } else {
+ $params[$paramName . $i] = '%' . $tagV[0] . '%';
+ $params[$paramValueName . $i] = '%' . $tagV[1] . '%';
+ }
+ }
+ }
+
+ if ($logicalOperator === 'AND' && count($tags) > 1 && !$notTags) {
+ $body .= ' GROUP BY ' . $lkTagTable . '.' . $idColumn . ' HAVING count(' . $lkTagTable .'.'. $lkTagTableIdColumn .') = ' . count($tags);//@phpcs:ignore
+ }
+
+ $body .= ' ) ';
+ }
+}
diff --git a/lib/Factory/CampaignFactory.php b/lib/Factory/CampaignFactory.php
new file mode 100644
index 0000000..3905b60
--- /dev/null
+++ b/lib/Factory/CampaignFactory.php
@@ -0,0 +1,553 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Campaign;
+use Xibo\Entity\LayoutOnCampaign;
+use Xibo\Entity\User;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class CampaignFactory
+ * @package Xibo\Factory
+ */
+class CampaignFactory extends BaseFactory
+{
+ use TagTrait;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param PermissionFactory $permissionFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct($user, $userFactory, $permissionFactory, $scheduleFactory, $displayNotifyService)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ $this->permissionFactory = $permissionFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * @return Campaign
+ */
+ private function createEmpty()
+ {
+ return new Campaign(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this,
+ $this->permissionFactory,
+ $this->scheduleFactory,
+ $this->displayNotifyService
+ );
+ }
+
+ /**
+ * @return \Xibo\Entity\LayoutOnCampaign
+ */
+ public function createEmptyLayoutAssignment(): LayoutOnCampaign
+ {
+ return new LayoutOnCampaign();
+ }
+
+ /**
+ * Create Campaign
+ * @param string $type
+ * @param string $name
+ * @param int $userId
+ * @param int $folderId
+ * @return Campaign
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function create($type, $name, $userId, $folderId)
+ {
+ $campaign = $this->createEmpty();
+ $campaign->type = $type;
+ $campaign->ownerId = $userId;
+ $campaign->campaign = $name;
+ $campaign->folderId = $folderId;
+
+ return $campaign;
+ }
+
+ /**
+ * Get Campaign by ID
+ * @param int $campaignId
+ * @return Campaign
+ * @throws NotFoundException
+ */
+ public function getById($campaignId)
+ {
+ $this->getLog()->debug(sprintf('CampaignFactory getById(%d)', $campaignId));
+
+ $campaigns = $this->query(null, ['disableUserCheck' => 1, 'campaignId' => $campaignId, 'isLayoutSpecific' => -1, 'excludeTemplates' => -1]);
+
+ if (count($campaigns) <= 0) {
+ $this->getLog()->debug(sprintf('Campaign not found with ID %d', $campaignId));
+ throw new NotFoundException(__('Campaign not found'));
+ }
+
+ // Set our layout
+ return $campaigns[0];
+ }
+
+ /**
+ * Get Campaign by Owner Id
+ * @param int $ownerId
+ * @return array[Campaign]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, array('ownerId' => $ownerId, 'excludeTemplates' => -1));
+ }
+
+ /**
+ * Get Campaign by Layout
+ * @param int $layoutId
+ * @return array[Campaign]
+ * @throws NotFoundException
+ */
+ public function getByLayoutId($layoutId)
+ {
+ return $this->query(null, array('disableUserCheck' => 1, 'layoutId' => $layoutId, 'excludeTemplates' => -1));
+ }
+
+ /**
+ * @param $folderId
+ * @return Campaign[]
+ * @throws NotFoundException
+ */
+ public function getByFolderId($folderId, $isLayoutSpecific = -1)
+ {
+ return $this->query(null, [
+ 'disableUserCheck' => 1,
+ 'folderId' => $folderId,
+ 'isLayoutSpecific' => $isLayoutSpecific
+ ]);
+ }
+
+ /**
+ * Query Campaigns
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @param array $options
+ * @return Campaign[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null) {
+ $sortOrder = ['campaign'];
+ }
+
+ $campaigns = [];
+ $params = [];
+
+ $select = '
+ SELECT `campaign`.campaignId,
+ `campaign`.campaign,
+ `campaign`.type,
+ `campaign`.isLayoutSpecific,
+ `campaign`.userId AS ownerId,
+ `campaign`.folderId,
+ `campaign`.permissionsFolderId,
+ `campaign`.cyclePlaybackEnabled,
+ `campaign`.playCount,
+ `campaign`.listPlayOrder,
+ `campaign`.targetType,
+ `campaign`.target,
+ `campaign`.startDt,
+ `campaign`.endDt,
+ `campaign`.plays,
+ `campaign`.spend,
+ `campaign`.impressions,
+ `campaign`.lastPopId,
+ `campaign`.ref1,
+ `campaign`.ref2,
+ `campaign`.ref3,
+ `campaign`.ref4,
+ `campaign`.ref5,
+ `campaign`.createdAt,
+ `campaign`.modifiedAt,
+ `campaign`.modifiedBy,
+ modifiedBy.userName AS modifiedByName,
+ (
+ SELECT COUNT(*)
+ FROM lkcampaignlayout
+ WHERE lkcampaignlayout.campaignId = `campaign`.campaignId
+ ) AS numberLayouts,
+ MAX(CASE WHEN `campaign`.IsLayoutSpecific = 1 THEN `layout`.retired ELSE 0 END) AS retired
+ ';
+
+ $body = '
+ FROM `campaign`
+ LEFT OUTER JOIN `lkcampaignlayout`
+ ON lkcampaignlayout.CampaignID = campaign.CampaignID
+ LEFT OUTER JOIN `layout`
+ ON lkcampaignlayout.LayoutID = layout.LayoutID
+ INNER JOIN `user`
+ ON user.userId = campaign.userId
+ LEFT OUTER JOIN `user` modifiedBy
+ ON modifiedBy.userId = campaign.modifiedBy
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('isLayoutSpecific', ['default' => 0]) != -1) {
+ // Exclude layout specific campaigns
+ $body .= " AND `campaign`.isLayoutSpecific = :isLayoutSpecific ";
+ $params['isLayoutSpecific'] = $sanitizedFilter->getInt('isLayoutSpecific', ['default' => 0]);
+ }
+
+ if ($sanitizedFilter->getInt('campaignId', ['default' => 0]) != 0) {
+ // Join Campaign back onto it again
+ $body .= " AND `campaign`.campaignId = :campaignId ";
+ $params['campaignId'] = $sanitizedFilter->getInt('campaignId', ['default' => 0]);
+ }
+
+ if ($sanitizedFilter->getString('type') != null) {
+ $body .= ' AND campaign.type = :type ';
+ $params['type'] = $sanitizedFilter->getString('type');
+ }
+
+ if ($sanitizedFilter->getInt('ownerId', ['default' => 0]) != 0) {
+ // Join Campaign back onto it again
+ $body .= " AND `campaign`.userId = :ownerId ";
+ $params['ownerId'] = $sanitizedFilter->getInt('ownerId', ['default' => 0]);
+ }
+
+ if ($sanitizedFilter->getInt('layoutId', ['default' => 0]) != 0) {
+ // Filter by Layout
+ $body .= " AND `lkcampaignlayout`.layoutId = :layoutId ";
+ $params['layoutId'] = $sanitizedFilter->getInt('layoutId', ['default' => 0]);
+ }
+
+ if ($sanitizedFilter->getInt('hasLayouts', ['default' => 0]) != 0) {
+
+ $body .= " AND (
+ SELECT COUNT(*)
+ FROM lkcampaignlayout
+ WHERE lkcampaignlayout.campaignId = `campaign`.campaignId
+ )";
+
+ $body .= ($sanitizedFilter->getInt('hasLayouts', ['default' => 0]) == 1) ? " = 0 " : " > 0";
+ }
+
+ // Tags
+ if ($sanitizedFilter->getString('tags') != '') {
+ $tagFilter = $sanitizedFilter->getString('tags');
+
+ if (trim($tagFilter) === '--no-tag') {
+ $body .= ' AND `campaign`.campaignID NOT IN (
+ SELECT `lktagcampaign`.campaignId
+ FROM `tag`
+ INNER JOIN `lktagcampaign`
+ ON `lktagcampaign`.tagId = `tag`.tagId
+ )
+ ';
+ } else {
+ $operator = $sanitizedFilter->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $sanitizedFilter->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $body .= ' AND campaign.campaignID NOT IN (
+ SELECT lktagcampaign.campaignId
+ FROM tag
+ INNER JOIN lktagcampaign
+ ON lktagcampaign.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $notTags,
+ 'lktagcampaign',
+ 'lkTagCampaignId',
+ 'campaignId',
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $body .= ' AND campaign.campaignID IN (
+ SELECT lktagcampaign.campaignId
+ FROM tag
+ INNER JOIN lktagcampaign
+ ON lktagcampaign.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $tags,
+ 'lktagcampaign',
+ 'lkTagCampaignId',
+ 'campaignId',
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+ }
+ }
+ }
+
+ if ($sanitizedFilter->getString('name') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'campaign',
+ 'Campaign',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ // Exclude templates by default
+ if ($sanitizedFilter->getInt('excludeTemplates', ['default' => 1]) != -1) {
+ if ($sanitizedFilter->getInt('excludeTemplates', ['default' => 1]) == 1) {
+ $body .= " AND `campaign`.campaignId NOT IN (SELECT `campaignId` FROM `lkcampaignlayout` WHERE layoutId IN (SELECT layoutId FROM lktaglayout INNER JOIN tag ON lktaglayout.tagId = tag.tagId WHERE tag = 'template')) ";
+ } else {
+ $body .= " AND `campaign`.campaignId IN (SELECT `campaignId` FROM `lkcampaignlayout` WHERE layoutId IN (SELECT layoutId FROM lktaglayout INNER JOIN tag ON lktaglayout.tagId = tag.tagId WHERE tag = 'template')) ";
+ }
+ }
+
+ if ($sanitizedFilter->getInt('folderId') !== null) {
+ $body .= " AND campaign.folderId = :folderId ";
+ $params['folderId'] = $sanitizedFilter->getInt('folderId');
+ }
+
+ // startDt
+ if ($sanitizedFilter->getInt('startDt') !== null) {
+ $body .= ' AND campaign.startDt <= :startDt ';
+ $params['startDt'] = $sanitizedFilter->getInt('startDt');
+ }
+
+ // endDt
+ if ($sanitizedFilter->getInt('endDt') !== null) {
+ $body .= ' AND campaign.endDt > :endDt ';
+ $params['endDt'] = $sanitizedFilter->getInt('endDt');
+ }
+
+ // View Permissions
+ $this->viewPermissionSql('Xibo\Entity\Campaign', $body, $params, '`campaign`.campaignId', '`campaign`.userId', $filterBy, '`campaign`.permissionsFolderId');
+
+ $group = 'GROUP BY `campaign`.CampaignID, Campaign, IsLayoutSpecific, `campaign`.userId ';
+
+ if ($sanitizedFilter->getInt('retired', ['default' => -1]) != -1) {
+ $group .= ' HAVING retired = :retired ';
+ $params['retired'] = $sanitizedFilter->getInt('retired');
+
+ if ($sanitizedFilter->getInt('includeCampaignId') !== null) {
+ $group .= ' OR campaign.campaignId = :includeCampaignId ';
+ $params['includeCampaignId'] = $sanitizedFilter->getInt('includeCampaignId');
+ }
+ }
+
+ if ($sanitizedFilter->getInt('cyclePlaybackEnabled') != null) {
+ $body .= ' AND `campaign`.cyclePlaybackEnabled = :cyclePlaybackEnabled ';
+ $params['cyclePlaybackEnabled'] = $sanitizedFilter->getInt('cyclePlaybackEnabled');
+ }
+
+ if ($sanitizedFilter->getInt('excludeMedia', ['default' => 0]) == 1) {
+ $body .= ' AND `campaign`.type != \'media\' ';
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // Layout durations
+ if ($sanitizedFilter->getInt('totalDuration', ['default' => 0]) != 0) {
+ $select .= ", SUM(`layout`.duration) AS totalDuration";
+ }
+
+ $sql = $select . $body . $group . $order . $limit;
+ $campaignIds = [];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $campaign = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'numberLayouts',
+ 'isLayoutSpecific',
+ 'totalDuration',
+ 'displayOrder',
+ 'cyclePlaybackEnabled',
+ 'playCount',
+ 'target',
+ 'startDt',
+ 'endDt',
+ 'modifiedBy',
+ ],
+ 'stringProperties' => [
+ 'lastPopId'
+ ],
+ 'doubleProperties' => [
+ 'spend',
+ 'impressions',
+ ],
+ ]);
+ $campaignIds[] = $campaign->getId();
+ $campaigns[] = $campaign;
+ }
+
+ // decorate with TagLinks
+ if (count($campaigns) > 0) {
+ $this->decorateWithTagLinks('lktagcampaign', 'campaignId', $campaignIds, $campaigns);
+ }
+
+ // Paging
+ if ($limit != '' && count($campaigns) > 0) {
+ if ($sanitizedFilter->getInt('retired', ['default' => -1]) != -1) {
+ $body .= ' AND layout.retired = :retired ';
+ }
+
+ $results = $this->getStore()->select('SELECT COUNT(DISTINCT campaign.campaignId) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $campaigns;
+ }
+
+ /**
+ * Get layouts linked to the campaignId provided.
+ * @param int $campaignId
+ * @return LayoutOnCampaign[]
+ */
+ public function getLinkedLayouts(int $campaignId): array
+ {
+ $layouts = [];
+ foreach ($this->getStore()->select('
+ SELECT lkcampaignlayout.lkCampaignLayoutId,
+ lkcampaignlayout.displayOrder,
+ lkcampaignlayout.layoutId,
+ lkcampaignlayout.campaignId,
+ lkcampaignlayout.dayPartId,
+ lkcampaignlayout.daysOfWeek,
+ lkcampaignlayout.geoFence,
+ layout.layout,
+ layout.userId AS ownerId,
+ layout.duration,
+ daypart.name AS dayPart,
+ campaign.campaignId AS layoutCampaignId
+ FROM lkcampaignlayout
+ INNER JOIN layout
+ ON layout.layoutId = lkcampaignlayout.layoutId
+ INNER JOIN lkcampaignlayout layoutspecific
+ ON layoutspecific.layoutId = layout.layoutId
+ INNER JOIN campaign
+ ON layoutspecific.campaignId = campaign.campaignId
+ AND campaign.isLayoutSpecific = 1
+ LEFT OUTER JOIN daypart
+ ON daypart.dayPartId = lkcampaignlayout.dayPartId
+ WHERE lkcampaignlayout.campaignId = :campaignId
+ ORDER BY displayOrder
+ ', [
+ 'campaignId' => $campaignId,
+ ]) as $row) {
+ $link = (new LayoutOnCampaign())->hydrate($row, [
+ 'intProperties' => ['displayOrder', 'duration'],
+ ]);
+
+ if (!empty($link->geoFence)) {
+ $link->geoFence = json_decode($link->geoFence, true);
+ }
+
+ $layouts[] = $link;
+ }
+
+ return $layouts;
+ }
+
+ /**
+ * Get the campaignId for a Layout
+ * @param int $layoutId
+ * @return int
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getCampaignIdForLayout(int $layoutId): int
+ {
+ $results = $this->getStore()->select('
+ SELECT campaign.campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout
+ ON layout.layoutId = lkcampaignlayout.layoutId
+ INNER JOIN campaign
+ ON campaign.campaignId = lkcampaignlayout.campaignId
+ WHERE campaign.isLayoutSpecific = 1
+ AND layout.layoutId = :layoutId
+ ', [
+ 'layoutId' => $layoutId
+ ]);
+
+ if (count($results) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return intval($results[0]['campaignId']);
+ }
+}
diff --git a/lib/Factory/CommandFactory.php b/lib/Factory/CommandFactory.php
new file mode 100644
index 0000000..5991e94
--- /dev/null
+++ b/lib/Factory/CommandFactory.php
@@ -0,0 +1,237 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Command;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class CommandFactory
+ * @package Xibo\Factory
+ */
+class CommandFactory extends BaseFactory
+{
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ /**
+ * Create Command
+ * @return Command
+ */
+ public function create()
+ {
+ return new Command($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get by Id
+ * @param $commandId
+ * @return Command
+ * @throws NotFoundException
+ */
+ public function getById($commandId)
+ {
+ $commands = $this->query(null, ['commandId' => $commandId]);
+
+ if (count($commands) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $commands[0];
+ }
+
+ /**
+ * Get by Display Profile Id
+ * @param int $displayProfileId
+ * @param string $type
+ * @return Command[]
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getByDisplayProfileId($displayProfileId, $type)
+ {
+ return $this->query(null, [
+ 'displayProfileId' => $displayProfileId,
+ 'type' => $type
+ ]);
+ }
+
+ /**
+ * @param $ownerId
+ * @return Command[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId): array
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Command[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+
+ if ($sortOrder == null) {
+ $sortOrder = ['command'];
+ }
+
+ $params = [];
+ $select = 'SELECT `command`.commandId,
+ `command`.command,
+ `command`.code,
+ `command`.description,
+ `command`.userId,
+ `command`.availableOn,
+ `command`.commandString,
+ `command`.validationString,
+ `command`.createAlertOn
+ ';
+
+ if ($sanitizedFilter->getInt('displayProfileId') !== null) {
+ $select .= ',
+ :displayProfileId AS displayProfileId,
+ `lkcommanddisplayprofile`.commandString AS commandStringDisplayProfile,
+ `lkcommanddisplayprofile`.validationString AS validationStringDisplayProfile,
+ `lkcommanddisplayprofile`.createAlertOn AS createAlertOnDisplayProfile ';
+ }
+
+ $select .= ' , (SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :permissionEntityForGroup
+ AND objectId = command.commandId
+ AND view = 1
+ ) AS groupsWithPermissions ';
+ $params['permissionEntityForGroup'] = 'Xibo\\Entity\\Command';
+
+ $body = ' FROM `command` ';
+
+ if ($sanitizedFilter->getInt('displayProfileId') !== null) {
+ $body .= '
+ LEFT OUTER JOIN `lkcommanddisplayprofile`
+ ON `lkcommanddisplayprofile`.commandId = `command`.commandId
+ AND `lkcommanddisplayprofile`.displayProfileId = :displayProfileId
+ ';
+
+ $params['displayProfileId'] = $sanitizedFilter->getInt('displayProfileId');
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('commandId') !== null) {
+ $body .= ' AND `command`.commandId = :commandId ';
+ $params['commandId'] = $sanitizedFilter->getInt('commandId');
+ }
+
+ if ($sanitizedFilter->getString('command') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('command'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'command',
+ 'command',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getString('code') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('code'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorCode', ['default' => 'OR']);
+ $this->nameFilter(
+ 'command',
+ 'code',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForCode') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getString('type') != null) {
+ $body .= ' AND (IFNULL(`command`.availableOn, \'\') = \'\' OR `command`.availableOn LIKE :type) ';
+ $params['type'] = '%' . $sanitizedFilter->getString('type') . '%';
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `command`.userId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ $this->viewPermissionSql(
+ 'Xibo\Entity\Command',
+ $body,
+ $params,
+ 'command.commandId',
+ 'command.userId',
+ $filterBy
+ );
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start', $filterBy) !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = (new Command($this->getStore(), $this->getLog(), $this->getDispatcher()))->hydrate($row);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['permissionEntityForGroup']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/ConnectorFactory.php b/lib/Factory/ConnectorFactory.php
new file mode 100644
index 0000000..1e3060e
--- /dev/null
+++ b/lib/Factory/ConnectorFactory.php
@@ -0,0 +1,262 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Illuminate\Support\Str;
+use Psr\Container\ContainerInterface;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Connector\ConnectorInterface;
+use Xibo\Entity\Connector;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\JwtServiceInterface;
+use Xibo\Service\PlayerActionServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Connector Factory
+ */
+class ConnectorFactory extends BaseFactory
+{
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+
+ /** @var \Xibo\Service\ConfigServiceInterface */
+ private $config;
+
+ /** @var \Xibo\Service\JwtServiceInterface */
+ private $jwtService;
+
+ /** @var \Psr\Container\ContainerInterface */
+ private $container;
+
+ /** @var \Xibo\Service\PlayerActionServiceInterface */
+ private $playerActionService;
+
+ /**
+ * @param \Stash\Interfaces\PoolInterface $pool
+ * @param \Xibo\Service\ConfigServiceInterface $config
+ * @param \Xibo\Service\JwtServiceInterface $jwtService
+ * @param \Psr\Container\ContainerInterface $container
+ * @param \Xibo\Service\PlayerActionServiceInterface $playerActionService
+ */
+ public function __construct(
+ PoolInterface $pool,
+ ConfigServiceInterface $config,
+ JwtServiceInterface $jwtService,
+ PlayerActionServiceInterface $playerActionService,
+ ContainerInterface $container
+ ) {
+ $this->pool = $pool;
+ $this->config = $config;
+ $this->jwtService = $jwtService;
+ $this->playerActionService = $playerActionService;
+ $this->container = $container;
+ }
+
+ /**
+ * @param Connector $connector
+ * @return ConnectorInterface
+ * @throws \Xibo\Support\Exception\NotFoundException|\Xibo\Support\Exception\GeneralException
+ */
+ public function create(Connector $connector): ConnectorInterface
+ {
+ // Check to see if this connector class exists
+ if (!\class_exists($connector->className)) {
+ throw new NotFoundException(sprintf(__('Class %s does not exist'), $connector->className));
+ }
+
+ // Instantiate it.
+ $out = new $connector->className();
+
+ if (!$out instanceof ConnectorInterface) {
+ throw new GeneralException('Connector ' . $connector->className . ' must implement ConnectorInterface');
+ }
+
+ return $out
+ ->setFactories($this->container)
+ ->useLogger($this->getLog()->getLoggerInterface())
+ ->useSettings($connector->settings)
+ ->useSettings($this->config->getConnectorSettings($out->getSourceName()), true)
+ ->useHttpOptions($this->config->getGuzzleProxy())
+ ->useJwtService($this->jwtService)
+ ->usePlayerActionService($this->playerActionService)
+ ->usePool($this->pool);
+ }
+
+ /**
+ * @param int $connectorId
+ * @return ConnectorInterface
+ * @throws \Xibo\Support\Exception\NotFoundException|\Xibo\Support\Exception\GeneralException
+ */
+ public function createById(int $connectorId): ConnectorInterface
+ {
+ return $this->create($this->getById($connectorId));
+ }
+
+ /**
+ * @param $connectorId
+ * @return \Xibo\Entity\Connector
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getById($connectorId): Connector
+ {
+ $connectors = $this->query(['connectorId' => $connectorId]);
+
+ if (count($connectors) !== 1) {
+ throw new NotFoundException(__('Connector not found'));
+ }
+
+ return $connectors[0];
+ }
+
+ /**
+ * @param $className
+ * @return \Xibo\Entity\Connector[]
+ */
+ public function getByClassName($className): array
+ {
+ return $this->query(['className' => $className]);
+ }
+
+ /**
+ * @return Connector[]
+ */
+ public function query($filterBy): array
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+
+ $sql = 'SELECT `connectorId`, `className`, `settings`, `isEnabled`, `isVisible` FROM `connectors` WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->hasParam('connectorId')) {
+ $sql .= ' AND connectorId = :connectorId ';
+ $params['connectorId'] = $sanitizedFilter->getInt('connectorId');
+ }
+
+ if ($sanitizedFilter->hasParam('isEnabled')) {
+ $sql .= ' AND isEnabled = :isEnabled ';
+ $params['isEnabled'] = $sanitizedFilter->getCheckbox('isEnabled');
+ }
+
+ if ($sanitizedFilter->hasParam('isVisible')) {
+ $sql .= ' AND isVisible = :isVisible ';
+ $params['isVisible'] = $sanitizedFilter->getCheckbox('isVisible');
+ }
+
+ if ($sanitizedFilter->hasParam('className')) {
+ $sql .= ' AND `className` = :className ';
+ $params['className'] = $sanitizedFilter->getString('className');
+ }
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ // Construct the class
+ $entries[] = $this->hydrate($row);
+ }
+
+ // No paging
+ $this->_countLast = count($entries);
+
+ return $entries;
+ }
+
+ /**
+ * @param $row
+ * @return \Xibo\Entity\Connector
+ */
+ private function hydrate($row): Connector
+ {
+ $connector = new Connector($this->getStore(), $this->getLog(), $this->getDispatcher());
+ $connector->hydrate($row, [
+ 'intProperties' => ['isEnabled', 'isVisible']
+ ]);
+
+ if (empty($row['settings'])) {
+ $connector->settings = [];
+ } else {
+ $connector->settings = json_decode($row['settings'], true);
+ }
+
+ $connector->isSystem = !Str::contains(strtolower($connector->className), '\\custom\\');
+
+ return $connector;
+ }
+
+ /**
+ * @return Connector[]
+ */
+ public function getUninstalled(): array
+ {
+ $connectors = [];
+
+ // Any system connectors are installed by default, so we're only concerned here with custom connectors
+ // which we would expect to me in the custom folder.
+ foreach (glob(PROJECT_ROOT . '/custom/*.connector') as $file) {
+ $config = json_decode(file_get_contents($file), true);
+ if (!is_array($config)) {
+ $this->getLog()->error('Problem with connector config: '
+ . json_last_error_msg() . ' ' . var_export($config, true));
+ continue;
+ }
+ $connector = $this->hydrate($config);
+
+ // Is this connector already installed?
+ if (count($this->getByClassName($connector->className)) > 0) {
+ continue;
+ }
+
+ $connector->connectorId = str_replace([' ', '.'], '-', basename($file));
+ $connector->isInstalled = false;
+ $connector->isVisible = 1;
+ $connector->isEnabled = 0;
+ if (empty($connector->settings)) {
+ $connector->settings = [];
+ }
+ $connectors[] = $connector;
+ }
+
+ return $connectors;
+ }
+
+ /**
+ * @param string $id
+ * @return \Xibo\Entity\Connector
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getUninstalledById(string $id): Connector
+ {
+ $connector = null;
+ foreach ($this->getUninstalled() as $item) {
+ if ($item->connectorId === $id) {
+ $connector = $item;
+ break;
+ }
+ }
+ if ($connector === null) {
+ throw new NotFoundException(__('Connector not found'), 'id');
+ }
+
+ return $connector;
+ }
+}
diff --git a/lib/Factory/ContainerFactory.php b/lib/Factory/ContainerFactory.php
new file mode 100644
index 0000000..d8139d0
--- /dev/null
+++ b/lib/Factory/ContainerFactory.php
@@ -0,0 +1,264 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use DI\ContainerBuilder;
+use Exception;
+use Psr\Container\ContainerInterface;
+use Slim\Views\Twig;
+use Stash\Driver\Composite;
+use Stash\Pool;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Dependencies\Controllers;
+use Xibo\Dependencies\Factories;
+use Xibo\Entity\User;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\Environment;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\BaseDependenciesService;
+use Xibo\Service\ConfigService;
+use Xibo\Service\HelpService;
+use Xibo\Service\ImageProcessingService;
+use Xibo\Service\JwtService;
+use Xibo\Service\MediaService;
+use Xibo\Service\ModuleService;
+use Xibo\Storage\MySqlTimeSeriesStore;
+use Xibo\Storage\PdoStorageService;
+use Xibo\Twig\ByteFormatterTwigExtension;
+use Xibo\Twig\DateFormatTwigExtension;
+use Xibo\Twig\TransExtension;
+
+if (!defined('PROJECT_ROOT')) {
+ define('PROJECT_ROOT', realpath(__DIR__ . '/..'));
+}
+
+/**
+ * Class ContainerFactory
+ * @package Xibo\Factory
+ */
+class ContainerFactory
+{
+ /**
+ * Create DI Container with definitions
+ *
+ * @return ContainerInterface
+ * @throws Exception
+ */
+ public static function create()
+ {
+ $containerBuilder = new ContainerBuilder();
+
+ $containerBuilder->addDefinitions([
+ 'basePath' => function (ContainerInterface $c) {
+ // Server params
+ $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; // <-- "/foo/index.php"
+ $requestUri = $_SERVER['REQUEST_URI'] ?? ''; // <-- "/foo/bar?test=abc" or "/foo/index.php/bar?test=abc"
+
+ // Physical path
+ if (empty($scriptName) || empty($requestUri)) {
+ return '';
+ } else if (strpos($requestUri, $scriptName) !== false) {
+ $physicalPath = $scriptName; // <-- Without rewriting
+ } else {
+ $physicalPath = str_replace('\\', '', dirname($scriptName)); // <-- With rewriting
+ }
+ return rtrim($physicalPath, '/'); // <-- Remove trailing slashes
+ },
+ 'rootUri' => function (ContainerInterface $c) {
+ // Work out whether we're in a folder, and what our base path is relative to that folder
+ // Static source, so remove index.php from the path
+ // this should only happen if rewrite is disabled
+ $basePath = str_replace('/index.php', '', $c->get('basePath') . '/');
+
+ // Replace out all of the entrypoints to get back to the root
+ $basePath = str_replace('/api/authorize', '', $basePath);
+ $basePath = str_replace('/api', '', $basePath);
+ $basePath = str_replace('/maintenance', '', $basePath);
+ $basePath = str_replace('/install', '', $basePath);
+
+ // Handle an empty (we always have our root with reference to `/`
+ if ($basePath == null) {
+ $basePath = '/';
+ }
+
+ return $basePath;
+ },
+ 'logService' => function (ContainerInterface $c) {
+ return new \Xibo\Service\LogService($c->get('logger'));
+ },
+ 'view' => function (ContainerInterface $c) {
+ $view = Twig::create([
+ PROJECT_ROOT . '/views',
+ PROJECT_ROOT . '/modules',
+ PROJECT_ROOT . '/reports',
+ PROJECT_ROOT . '/custom'
+ ], [
+ 'cache' => Environment::isDevMode() ? false : PROJECT_ROOT . '/cache'
+ ]);
+ $view->addExtension(new TransExtension());
+ $view->addExtension(new ByteFormatterTwigExtension());
+ $view->addExtension(new DateFormatTwigExtension());
+
+ return $view;
+ },
+ 'sanitizerService' => function (ContainerInterface $c) {
+ return new SanitizerService();
+ },
+ 'store' => function (ContainerInterface $c) {
+ return (new PdoStorageService($c->get('logService')))->setConnection();
+ },
+ 'timeSeriesStore' => function (ContainerInterface $c) {
+ if ($c->get('configService')->timeSeriesStore == null) {
+ $timeSeriesStore = new MySqlTimeSeriesStore();
+ } else {
+ $timeSeriesStore = $c->get('configService')->timeSeriesStore;
+ $timeSeriesStore = $timeSeriesStore();
+ }
+
+ return $timeSeriesStore
+ ->setDependencies(
+ $c->get('logService'),
+ $c->get('layoutFactory'),
+ $c->get('campaignFactory'),
+ $c->get('mediaFactory'),
+ $c->get('widgetFactory'),
+ $c->get('displayFactory'),
+ $c->get('displayGroupFactory')
+ )
+ ->setStore($c->get('store'));
+ },
+ 'state' => function () {
+ return new ApplicationState();
+ },
+ 'configService' => function (ContainerInterface $c) {
+ return ConfigService::Load($c, PROJECT_ROOT . '/web/settings.php');
+ },
+ 'user' => function (ContainerInterface $c) {
+ return new User(
+ $c->get('store'),
+ $c->get('logService'),
+ $c->get('dispatcher'),
+ $c->get('configService'),
+ $c->get('userFactory'),
+ $c->get('permissionFactory'),
+ $c->get('userOptionFactory'),
+ $c->get('applicationScopeFactory')
+ );
+ },
+ 'helpService' => function (ContainerInterface $c) {
+ // Pass in the help base configuration.
+ $helpBase = $c->get('configService')->getSetting('HELP_BASE');
+ if (stripos($helpBase, 'http://') === false && stripos($helpBase, 'https://') === false) {
+ // We need to convert the URL to a full URL
+ $helpBase = $c->get('configService')->rootUri() . $helpBase;
+ }
+ return new HelpService($helpBase);
+ },
+ 'pool' => function (ContainerInterface $c) {
+ $drivers = [];
+
+ if ($c->get('configService')->getCacheDrivers() != null && is_array($c->get('configService')->getCacheDrivers())) {
+ $drivers = $c->get('configService')->getCacheDrivers();
+ } else {
+ // File System Driver
+ $realPath = realpath($c->get('configService')->getSetting('LIBRARY_LOCATION'));
+ $cachePath = ($realPath)
+ ? $realPath . '/cache/'
+ : $c->get('configService')->getSetting('LIBRARY_LOCATION') . 'cache/';
+
+ $drivers[] = new \Stash\Driver\FileSystem(['path' => $cachePath]);
+ }
+
+ // Create a composite driver
+ $composite = new Composite(['drivers' => $drivers]);
+
+ $pool = new Pool($composite);
+ $pool->setLogger($c->get('logService')->getLoggerInterface());
+ $pool->setNamespace($c->get('configService')->getCacheNamespace());
+ $c->get('configService')->setPool($pool);
+ return $pool;
+ },
+ 'imageProcessingService' => function (ContainerInterface $c) {
+ $imageProcessingService = new ImageProcessingService();
+ $imageProcessingService->setDependencies(
+ $c->get('logService')
+ );
+ return $imageProcessingService;
+ },
+ 'httpCache' => function () {
+ return new \Xibo\Helper\HttpCacheProvider();
+ },
+ 'mediaService' => function (ContainerInterface $c) {
+ $mediaSevice = new MediaService(
+ $c->get('configService'),
+ $c->get('logService'),
+ $c->get('store'),
+ $c->get('sanitizerService'),
+ $c->get('pool'),
+ $c->get('mediaFactory'),
+ $c->get('fontFactory')
+ );
+ $mediaSevice->setDispatcher($c->get('dispatcher'));
+ return $mediaSevice;
+ },
+ 'ControllerBaseDependenciesService' => function (ContainerInterface $c) {
+ $controller = new BaseDependenciesService();
+ $controller->setLogger($c->get('logService'));
+ $controller->setSanitizer($c->get('sanitizerService'));
+ $controller->setState($c->get('state'));
+ $controller->setUser($c->get('user'));
+ $controller->setConfig($c->get('configService'));
+ $controller->setView($c->get('view'));
+ $controller->setDispatcher($c->get('dispatcher'));
+ return $controller;
+ },
+ 'RepositoryBaseDependenciesService' => function (ContainerInterface $c) {
+ $repository = new BaseDependenciesService();
+ $repository->setLogger($c->get('logService'));
+ $repository->setSanitizer($c->get('sanitizerService'));
+ $repository->setStore($c->get('store'));
+ $repository->setDispatcher($c->get('dispatcher'));
+ $repository->setConfig($c->get('configService'));
+ return $repository;
+ },
+ 'dispatcher' => function (ContainerInterface $c) {
+ return new EventDispatcher();
+ },
+ 'jwtService' => function (ContainerInterface $c) {
+ return (new JwtService())
+ ->useLogger($c->get('logService')->getLoggerInterface())
+ ->useKeys($c->get('configService')->getApiKeyDetails());
+ }
+ ]);
+
+ $containerBuilder->addDefinitions(Controllers::registerControllersWithDi());
+ $containerBuilder->addDefinitions(Factories::registerFactoriesWithDi());
+
+ // Should we compile the container?
+ /*if (!Environment::isDevMode()) {
+ $containerBuilder->enableCompilation(PROJECT_ROOT . '/cache');
+ }*/
+
+ return $containerBuilder->build();
+ }
+}
diff --git a/lib/Factory/DataSetColumnFactory.php b/lib/Factory/DataSetColumnFactory.php
new file mode 100644
index 0000000..f62939b
--- /dev/null
+++ b/lib/Factory/DataSetColumnFactory.php
@@ -0,0 +1,171 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\DataSetColumn;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DataSetColumnFactory
+ * @package Xibo\Factory
+ */
+class DataSetColumnFactory extends BaseFactory
+{
+ /** @var DataTypeFactory */
+ private $dataTypeFactory;
+
+ /** @var DataSetColumnTypeFactory */
+ private $dataSetColumnTypeFactory;
+
+ /**
+ * Construct a factory
+ * @param DataTypeFactory $dataTypeFactory
+ * @param DataSetColumnTypeFactory $dataSetColumnTypeFactory
+ */
+ public function __construct($dataTypeFactory, $dataSetColumnTypeFactory)
+ {
+ $this->dataTypeFactory = $dataTypeFactory;
+ $this->dataSetColumnTypeFactory = $dataSetColumnTypeFactory;
+ }
+
+ /**
+ * @return DataSetColumn
+ */
+ public function createEmpty()
+ {
+ return new DataSetColumn(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this,
+ $this->dataTypeFactory,
+ $this->dataSetColumnTypeFactory
+ );
+ }
+
+ /**
+ * Get by Id
+ * @param int $dataSetColumnId
+ * @return DataSetColumn
+ * @throws NotFoundException
+ */
+ public function getById($dataSetColumnId)
+ {
+ $columns = $this->query(null, ['dataSetColumnId' => $dataSetColumnId]);
+
+ if (count($columns) <= 0)
+ throw new NotFoundException();
+
+ return $columns[0];
+ }
+
+ /**
+ * Get by dataSetId
+ * @param $dataSetId
+ * @return DataSetColumn[]
+ */
+ public function getByDataSetId($dataSetId)
+ {
+ return $this->query(null, ['dataSetId' => $dataSetId]);
+ }
+
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null)
+ $sortOrder = ['columnOrder'];
+
+ $select = '
+ SELECT dataSetColumnId,
+ dataSetId,
+ heading,
+ datatype.dataTypeId,
+ datatype.dataType,
+ datasetcolumn.dataSetColumnTypeId,
+ datasetcolumntype.dataSetColumnType,
+ listContent,
+ columnOrder,
+ formula,
+ remoteField,
+ showFilter,
+ showSort,
+ tooltip,
+ isRequired,
+ dateFormat
+ ';
+
+ $body = '
+ FROM `datasetcolumn`
+ INNER JOIN `datatype`
+ ON datatype.DataTypeID = datasetcolumn.DataTypeID
+ INNER JOIN `datasetcolumntype`
+ ON datasetcolumntype.DataSetColumnTypeID = datasetcolumn.DataSetColumnTypeID
+ WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('dataSetColumnId') !== null) {
+ $body .= ' AND dataSetColumnId = :dataSetColumnId ';
+ $params['dataSetColumnId'] = $sanitizedFilter->getInt('dataSetColumnId');
+ }
+
+ if ($sanitizedFilter->getInt('dataSetId') !== null) {
+ $body .= ' AND DataSetID = :dataSetId ';
+ $params['dataSetId'] = $sanitizedFilter->getInt('dataSetId');
+ }
+
+ if ($sanitizedFilter->getInt('remoteField') !== null) {
+ $body .= ' AND remoteField = :remoteField ';
+ $params['remoteField'] = $sanitizedFilter->getInt('remoteField');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => ['showFilter', 'showSort']]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/DataSetColumnTypeFactory.php b/lib/Factory/DataSetColumnTypeFactory.php
new file mode 100644
index 0000000..64c3899
--- /dev/null
+++ b/lib/Factory/DataSetColumnTypeFactory.php
@@ -0,0 +1,84 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\DataSetColumnType;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DataSetColumnTypeFactory
+ * @package Xibo\Factory
+ */
+class DataSetColumnTypeFactory extends BaseFactory
+{
+ /**
+ * @return DataSetColumnType
+ */
+ public function createEmpty()
+ {
+ return new DataSetColumnType($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get By Id
+ * @param int $id
+ * @return DataSetColumnType
+ * @throws NotFoundException
+ */
+ public function getById($id)
+ {
+ $results = $this->query(null, ['dataSetColumnTypeId' => $id]);
+
+ if (count($results) <= 0)
+ throw new NotFoundException();
+
+ return $results[0];
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return array[DataSetColumnType]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $sql = 'SELECT dataSetColumnTypeId, dataSetColumnType FROM `datasetcolumntype` WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('dataSetColumnTypeId') !== null) {
+ $sql .= ' AND `datasetcolumntype`.dataSetColumnTypeId = :dataSetColumnTypeId ';
+ $params['dataSetColumnTypeId'] = $sanitizedFilter->getInt('dataSetColumnTypeId');
+ }
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/DataSetFactory.php b/lib/Factory/DataSetFactory.php
new file mode 100644
index 0000000..942e103
--- /dev/null
+++ b/lib/Factory/DataSetFactory.php
@@ -0,0 +1,917 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\DataSet;
+use Xibo\Entity\DataSetColumn;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DataSetFactory
+ * @package Xibo\Factory
+ */
+class DataSetFactory extends BaseFactory
+{
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var DataSetColumnFactory */
+ private $dataSetColumnFactory;
+
+ /** @var PermissionFactory */
+ private $permissionFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Construct a factory
+ * @param \Xibo\Entity\User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ * @param PoolInterface $pool
+ * @param DataSetColumnFactory $dataSetColumnFactory
+ * @param PermissionFactory $permissionFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct($user, $userFactory, $config, $pool, $dataSetColumnFactory, $permissionFactory, $displayNotifyService)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ $this->config = $config;
+ $this->pool = $pool;
+ $this->dataSetColumnFactory = $dataSetColumnFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * @return DataSetColumnFactory
+ */
+ public function getDataSetColumnFactory()
+ {
+ return $this->dataSetColumnFactory;
+ }
+
+ /**
+ * @return DataSet
+ */
+ public function createEmpty()
+ {
+ return new DataSet(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->getSanitizerService(),
+ $this->config,
+ $this->pool,
+ $this,
+ $this->dataSetColumnFactory,
+ $this->permissionFactory,
+ $this->displayNotifyService
+ );
+ }
+
+ /**
+ * Get DataSets by ID
+ * @param $dataSetId
+ * @return DataSet
+ * @throws NotFoundException
+ */
+ public function getById($dataSetId)
+ {
+ $dataSets = $this->query(null, ['disableUserCheck' => 1, 'dataSetId' => $dataSetId]);
+
+ if (count($dataSets) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $dataSets[0];
+ }
+
+ /**
+ * Get DataSets by Code
+ * @param $code
+ * @return DataSet
+ * @throws NotFoundException
+ */
+ public function getByCode($code)
+ {
+ $dataSets = $this->query(null, ['disableUserCheck' => 1, 'code' => $code]);
+
+ if (count($dataSets) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $dataSets[0];
+ }
+
+ /**
+ * Get DataSets by Name
+ * @param $dataSet
+ * @param int|null $userId the userId
+ * @return DataSet
+ * @throws NotFoundException
+ */
+ public function getByName($dataSet, $userId = null)
+ {
+ $dataSets = $this->query(null, ['dataSetExact' => $dataSet, 'userId' => $userId]);
+
+ if (count($dataSets) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $dataSets[0];
+ }
+
+ /**
+ * @param $userId
+ * @return DataSet[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($userId)
+ {
+ $dataSets = $this->query(null, ['disableUserCheck' => 1, 'userId' => $userId]);
+
+ return $dataSets;
+ }
+
+ /**
+ * @param int $folderId
+ * @return DataSet[]
+ * @throws NotFoundException
+ */
+ public function getByFolderId(int $folderId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'folderId' => $folderId]);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[DataSet]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $parsedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder === null) {
+ $sortOrder = ['dataSet'];
+ }
+
+ $select = '
+ SELECT dataset.dataSetId,
+ dataset.dataSet,
+ dataset.description,
+ dataset.userId,
+ dataset.lastDataEdit,
+ dataset.`code`,
+ dataset.`isLookup`,
+ dataset.`isRemote`,
+ dataset.`isRealTime`,
+ dataset.`dataConnectorSource`,
+ dataset.`method`,
+ dataset.`uri`,
+ dataset.`postData`,
+ dataset.`authentication`,
+ dataset.`username`,
+ dataset.`password`,
+ dataset.`customHeaders`,
+ dataset.`userAgent`,
+ dataset.`refreshRate`,
+ dataset.`clearRate`,
+ dataset.`truncateOnEmpty`,
+ dataset.`runsAfter`,
+ dataset.`dataRoot`,
+ dataset.`summarize`,
+ dataset.`summarizeField`,
+ dataset.`lastSync`,
+ dataset.`lastClear`,
+ dataset.`sourceId`,
+ dataset.`ignoreFirstRow`,
+ dataset.`rowLimit`,
+ dataset.`limitPolicy`,
+ dataset.`csvSeparator`,
+ dataset.`folderId`,
+ dataset.`permissionsFolderId`,
+ user.userName AS owner,
+ (
+ SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :groupsWithPermissionsEntity
+ AND objectId = dataset.dataSetId
+ ) AS groupsWithPermissions
+ ';
+
+ $params['groupsWithPermissionsEntity'] = 'Xibo\\Entity\\DataSet';
+
+ $body = '
+ FROM dataset
+ INNER JOIN `user` ON user.userId = dataset.userId
+ WHERE 1 = 1
+ ';
+
+ if ($parsedFilter->getInt('dataSetId') !== null) {
+ $body .= ' AND dataset.dataSetId = :dataSetId ';
+ $params['dataSetId'] = $parsedFilter->getInt('dataSetId');
+ }
+
+ if ($parsedFilter->getInt('userId') !== null) {
+ $body .= ' AND dataset.userId = :userId ';
+ $params['userId'] = $parsedFilter->getInt('userId');
+ }
+
+ if ($parsedFilter->getInt('isRemote') !== null) {
+ $body .= ' AND dataset.isRemote = :isRemote ';
+ $params['isRemote'] = $parsedFilter->getInt('isRemote');
+ }
+
+ if ($parsedFilter->getInt('isRealTime') !== null) {
+ $body .= ' AND dataset.isRealTime = :isRealTime ';
+ $params['isRealTime'] = $parsedFilter->getInt('isRealTime');
+ }
+
+ if ($parsedFilter->getString('dataSet') != null) {
+ $terms = explode(',', $parsedFilter->getString('dataSet'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'dataset',
+ 'dataSet',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($parsedFilter->getString('dataSetExact') != '') {
+ $body.= " AND dataset.dataSet = :exact ";
+ $params['exact'] = $parsedFilter->getString('dataSetExact');
+ }
+
+ if ($parsedFilter->getString('code') != null) {
+ $body .= ' AND `dataset`.`code` = :code ';
+ $params['code'] = $parsedFilter->getString('code');
+ }
+
+ if ($parsedFilter->getInt('folderId') !== null) {
+ $body .= ' AND dataset.folderId = :folderId ';
+ $params['folderId'] = $parsedFilter->getInt('folderId');
+ }
+
+ // View Permissions
+ $this->viewPermissionSql('Xibo\Entity\DataSet', $body, $params, '`dataset`.dataSetId', '`dataset`.userId', $filterBy, '`dataset`.permissionsFolderId');
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $parsedFilter->getInt('start') !== null && $parsedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'isLookup',
+ 'isRemote',
+ 'isRealTime',
+ 'clearRate',
+ 'refreshRate',
+ 'lastDataEdit',
+ 'runsAfter',
+ 'lastSync',
+ 'lastClear',
+ 'ignoreFirstRow',
+ ],
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['groupsWithPermissionsEntity']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Makes a call to a Remote Dataset and returns all received data as a JSON decoded Object.
+ * In case of an Error, null is returned instead.
+ * @param DataSet $dataSet The Dataset to get Data for
+ * @param DataSet|null $dependant The Dataset $dataSet depends on
+ * @param bool $enableCaching Should we cache check the results and store the resulting cache
+ * @return \stdClass {entries:[], number:int, isEligibleToTruncate:bool}
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function callRemoteService(DataSet $dataSet, ?DataSet $dependant = null, $enableCaching = true)
+ {
+ $this->getLog()->debug('Calling remote service for DataSet: ' . $dataSet->dataSet
+ . ' and URL ' . $dataSet->uri);
+
+ // Record our max memory
+ $maxMemory = Environment::getMemoryLimitBytes() / 2;
+
+ // Guzzle for this and add proxy support.
+ $client = new Client($this->config->getGuzzleProxy());
+
+ $result = new \stdClass();
+ $result->entries = [];
+ $result->number = 0;
+ $result->isEligibleToTruncate = false;
+
+ // Getting all dependant values if needed
+ // just an empty array if we don't have a dependent
+ $values = [
+ []
+ ];
+
+ if ($dependant != null && $dataSet->containsDependantFieldsInRequest()) {
+ $this->getLog()->debug('Dependant provided with fields in the request.');
+
+ $values = $dependant->getData();
+ }
+
+ // Fetching data for every field in the dependant dataSet
+ foreach ($values as $options) {
+ // Make some request params to provide to the HTTP client
+ $resolvedUri = $this->replaceParams($dataSet->uri, $options);
+ $requestParams = [];
+
+ // Auth
+ switch ($dataSet->authentication) {
+ case 'basic':
+ $requestParams['auth'] = [$dataSet->username, $dataSet->password];
+ break;
+
+ case 'digest':
+ $requestParams['auth'] = [$dataSet->username, $dataSet->password, 'digest'];
+ break;
+
+ case 'ntlm':
+ $requestParams['auth'] = [$dataSet->username, $dataSet->password, 'ntlm'];
+ break;
+
+ case 'bearer':
+ $requestParams['headers'] = ['Authorization' => 'Bearer ' . $dataSet->password];
+ break;
+
+ case 'none':
+ default:
+ $this->getLog()->debug('No authentication required');
+ }
+
+ if (isset($dataSet->customHeaders)) {
+ $arrayOfCustomHeaders = array_filter(explode(',', $dataSet->customHeaders));
+
+ foreach ($arrayOfCustomHeaders as $customHeader) {
+ $header = array_filter(explode(':', $customHeader));
+ $requestParams['headers'][$header[0]] = $header[1];
+ }
+ }
+
+ // Post request?
+ if ($dataSet->method === 'POST') {
+ parse_str($this->replaceParams($dataSet->postData, $options), $requestParams['form_params']);
+ } else {
+ // Get the query params from the URL.
+ $queryParamsArray = [];
+ $dataSetPostData = [];
+ $parsedUrl = parse_url($resolvedUri, PHP_URL_QUERY);
+ if ($parsedUrl) {
+ parse_str($parsedUrl, $queryParamsArray);
+ }
+ parse_str($this->replaceParams($dataSet->postData, $options), $dataSetPostData);
+ $requestParams['query'] = array_merge($queryParamsArray, $dataSetPostData);
+ }
+
+ if (!empty($dataSet->userAgent)) {
+ $requestParams['headers']['User-Agent'] = trim($dataSet->userAgent);
+ }
+
+ $this->getLog()->debug('Making request to ' . $resolvedUri . ' with params: ' . var_export($requestParams, true));
+
+ try {
+ // Make a HEAD request to the URI and see if we are able to process this.
+ if ($dataSet->method === 'GET') {
+ try {
+ $request = $client->head($resolvedUri, $requestParams);
+
+ $contentLength = $request->getHeader('Content-Length');
+ if ($maxMemory > 0 && count($contentLength) > 0 && $contentLength[0] > $maxMemory)
+ throw new InvalidArgumentException(__('The request %d is too large to fit inside the configured memory limit. %d', $contentLength[0], $maxMemory), 'contentLength');
+ } catch (RequestException $requestException) {
+ $this->getLog()->info('Cannot make head request for remote dataSet ' . $dataSet->dataSetId);
+ }
+ }
+
+ $request = $client->request($dataSet->method, $resolvedUri, $requestParams);
+
+ // Check the cache control situation
+ if ($enableCaching) {
+ // recache if necessary
+ $cacheControlKey = $this->pool->getItem('/dataset/cache/' . $dataSet->dataSetId . '/' . md5($resolvedUri . json_encode($requestParams)));
+ $cacheControlKeyValue = ($cacheControlKey->isMiss()) ? '' : $cacheControlKey->get();
+
+ $this->getLog()->debug('Cache Control Key is ' . $cacheControlKeyValue);
+
+ $etags = $request->getHeader('E-Tag');
+ $lastModifieds = $request->getHeader('Last-Modified');
+
+ if (count($etags) > 0) {
+ // Compare the etag with the cache key and see if they are the same, if they are
+ // then we stop processing this data set
+ if ($cacheControlKeyValue === $etags[0]) {
+ $this->getLog()->debug('Skipping due to eTag');
+ continue;
+ }
+
+ $cacheControlKeyValue = $etags[0];
+ } else if (count($lastModifieds) > 0) {
+ if ($cacheControlKeyValue === $lastModifieds[0]) {
+ $this->getLog()->debug('Skipping due to Last-Modified');
+ continue;
+ }
+
+ $cacheControlKeyValue = $lastModifieds[0];
+ } else {
+ // Request doesn't have any cache control of its own
+ // use the md5
+ $md5 = md5($request->getBody());
+
+ // Rewind so we can use it again
+ $request->getBody()->rewind();
+
+ if ($cacheControlKeyValue === $md5) {
+ $this->getLog()->debug('Skipping due to MD5');
+ continue;
+ }
+
+ $cacheControlKeyValue = $md5;
+ }
+
+ $this->getLog()->debug('Cache Control Key is now ' . $cacheControlKeyValue);
+
+ // Store the cache key
+ $cacheControlKey->set($cacheControlKeyValue);
+ $cacheControlKey->expiresAfter(86400 * 365);
+ $this->pool->saveDeferred($cacheControlKey);
+ }
+
+ // We have passed any caching and therefore expect results
+ $result->isEligibleToTruncate = true;
+
+ if ($dataSet->sourceId === 1) {
+ // Make sure we have JSON in the response
+ $body = $request->getBody()->getContents();
+
+ try {
+ $json = \GuzzleHttp\json_decode($body);
+ } catch (\GuzzleHttp\Exception\InvalidArgumentException $invalidArgumentException) {
+ $this->getLog()->debug('JSON decode error: ' . $invalidArgumentException->getMessage());
+ throw new InvalidArgumentException(__('Unable to get Data for %s because the response was not valid JSON.', $dataSet->dataSet), 'url');
+ }
+
+ $result->entries[] = $json;
+ foreach ($result->entries as $entry) {
+ $data = $this->getDataRootFromResult($dataSet->dataRoot, $entry);
+
+ if (is_array($data)) {
+ $result->number = count($data);
+ } elseif (is_object($data)) {
+ $result->number = count(get_object_vars($data));
+ }
+ }
+ } else {
+ $csv = $request->getBody()->getContents();
+ $array = array_map(
+ function ($v) use ($dataSet) {
+ return str_getcsv($v, $dataSet->csvSeparator ?? ',');
+ },
+ explode("\n", $csv)
+ );
+
+ if ($dataSet->ignoreFirstRow == 1) {
+ array_shift($array);
+ }
+
+ // Filter out rows that are entirely empty
+ $array = array_filter($array, function($row) {
+ // Check if the row is empty (all elements are empty or null)
+ return array_filter($row, function($value) {
+ return !empty($value);
+ });
+ });
+
+ $result->entries = $array;
+ $result->number = count($array);
+ }
+ } catch (RequestException $requestException) {
+ $this->getLog()->error('Error making request. ' . $requestException->getMessage());
+
+ // No point in carrying on through this stack of requests, dependent or original data will be
+ // missing
+ throw new InvalidArgumentException(__('Unable to get Data for %s because %s.', $dataSet->dataSet, $requestException->getMessage()), 'dataSetId');
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Replaces all URI/PostData parameters
+ * @param string String to replace {{DATE}}, {{TIME}} and {{COL.xxx}}
+ * @param array $values ColumnValues to use on {{COL.xxx}} parts
+ * @return string
+ */
+ private function replaceParams($string = '', array $values = [])
+ {
+ if (empty($string)) {
+ return $string;
+ }
+
+ $string = str_replace('{{DATE}}', date('Y-m-d'), $string);
+ $string = str_replace('%7B%7BDATE%7D%7D', date('Y-m-d'), $string);
+ $string = str_replace('{{TIME}}', date('H:m:s'), $string);
+ $string = str_replace('%7B%7BTIME%7D%7D', date('H:m:s'), $string);
+
+ foreach ($values as $k => $v) {
+ $string = str_replace('{{COL.' . $k . '}}', urlencode($v), $string);
+ $string = str_replace('%7B%7BCOL.' . $k . '%7D%7D', urlencode($v), $string);
+ }
+
+ return $string;
+ }
+
+ /**
+ * Tries to process received Data against the configured DataSet with all Columns
+ *
+ * @param DataSet $dataSet The RemoteDataset to process
+ * @param \stdClass $results A simple Object with one Property 'entries' which contains all results
+ * @param bool $save
+ * @throws InvalidArgumentException
+ */
+ public function processResults(DataSet $dataSet, \stdClass $results, $save = true)
+ {
+ $results->processed = [];
+
+ if (property_exists($results, 'entries') && is_array($results->entries)) {
+ // Load the DataSet fully
+ $dataSet->load();
+
+ $results->messages = [__('Processing %d results into %d potential columns', count($results->entries), count($dataSet->columns))];
+
+ foreach ($results->entries as $result) {
+ $results->messages[] = __('Processing Result with Data Root %s', $dataSet->dataRoot);
+
+ // Remote Data has to have the configured DataRoot which has to be an Array
+ $data = $this->getDataRootFromResult($dataSet->dataRoot, $result);
+
+ $columns = $dataSet->columns;
+ $entries = [];
+
+ // Process the data root according to its type
+ if (is_array($data)) {
+ // An array of results as the DataRoot
+ $results->messages[] = 'DataRoot is an array';
+
+ // First process each entry form the remote and try to map the values to the configured columns
+ foreach ($data as $k => $entry) {
+ $this->getLog()->debug('Processing key ' . $k . ' from the remote results');
+ $this->getLog()->debug('Entry is: ' . var_export($entry, true));
+
+ $results->messages[] = 'Processing ' . $k;
+
+ if (is_array($entry) || is_object($entry)) {
+ $entries[] = $this->processEntry((array)$entry, $columns);
+ } else {
+ $this->getLog()->error('DataSet ' . $dataSet->dataSet . ' failed: DataRoot ' . $dataSet->dataRoot . ' contains data which is not arrays or objects.');
+ break;
+ }
+ }
+ } else if (is_object($data)) {
+ // An object as the DataRoot.
+ $results->messages[] = 'DataRoot is an object';
+
+ // We should treat this as a single row? Or as multiple rows?
+ // we could try and guess from the configuration of the dataset columns
+ $singleRow = false;
+ foreach ($columns as $column) {
+ if ($column->dataSetColumnTypeId === 3 && $column->remoteField != null && !is_numeric($column->remoteField)) {
+ $singleRow = true;
+ break;
+ }
+ }
+
+ if ($singleRow) {
+ // Process as a single row
+ $results->messages[] = __('Processing as a Single Row');
+
+ $entries[] = $this->processEntry((array)$data, $columns);
+ } else {
+ // Process as multiple rows
+ $results->messages[] = __('Processing as Multiple Rows');
+
+ foreach (get_object_vars($data) as $property => $value) {
+ // Treat each property as an index key (flattening the array)
+ $results->messages[] = 'Processing ' . $property;
+
+ $entries[] = $this->processEntry([$property, $value], $columns);
+ }
+ }
+ } else {
+ throw new InvalidArgumentException(__('No data found at the DataRoot %s', $dataSet->dataRoot), 'dataRoot');
+ }
+
+ $results->messages[] = __('Consolidating entries');
+
+ // If there is a Consolidation-Function, use the Data against it
+ $entries = $this->consolidateEntries($dataSet, $entries, $columns);
+
+ $results->messages[] = __('There are %d entries in total', count($entries));
+
+ // Finally add each entry as a new Row in the DataSet
+ if ($save) {
+ foreach ($entries as $entry) {
+ $dataSet->addRow($entry);
+ }
+ }
+
+ $results->processed[] = $entries;
+ }
+ }
+ }
+
+ /**
+ * Process the RemoteResult to get the main DataRoot value which can be stay in a structure as well as the values
+ *
+ * @param String Chunks split by a Dot where the main entries are hold
+ * @param array|\stdClass The Value from the remote request
+ * @return array|\stdClass The Data hold in the configured dataRoot
+ */
+ private function getDataRootFromResult($dataRoot, $result)
+ {
+ $this->getLog()->debug('Getting ' . $dataRoot . 'from result.');
+
+ if (empty($dataRoot)) {
+ return $result;
+ }
+ $chunks = explode('.', $dataRoot);
+ $entries = $this->getFieldValueFromEntry($chunks, $result);
+ return $entries[1];
+ }
+
+ /**
+ * Process a single Data-Entry form the remote system and map it to the configured Columns
+ *
+ * @param array $entry The Data from the remote system
+ * @param DataSetColumn[] $dataSetColumns The configured Columns form the current DataSet
+ * @return array The processed $entry as a List of Fields from $columns
+ */
+ private function processEntry(array $entry, array $dataSetColumns)
+ {
+ $result = [];
+
+ foreach ($dataSetColumns as $column) {
+ if ($column->dataSetColumnTypeId === 3 && $column->remoteField != null) {
+ $this->getLog()->debug('Trying to match dataSetColumn ' . $column->heading . ' with remote field ' . $column->remoteField);
+
+ // The Field may be a Date, timestamp or a real field
+ if ($column->remoteField == '{{DATE}}') {
+ $value = [0, date('Y-m-d')];
+ } else if ($column->remoteField == '{{TIMESTAMP}}') {
+ $value = [0, Carbon::now()->format('U')];
+ } else {
+ $chunks = explode('.', $column->remoteField);
+ $value = $this->getFieldValueFromEntry($chunks, $entry);
+ }
+
+ $this->getLog()->debug('Resolved value: ' . var_export($value, true));
+
+ // Only add it to the result if we were able to process the field
+ if ($value != null && $value[1] !== null) {
+ // 1,String
+ // 2,Number
+ // 3,Date
+ // 4,External Image
+ // 5,Library Image
+ // 6,HTML
+ $validator = $this->getValidator();
+
+ switch ($column->dataTypeId) {
+ case 2:
+ // Number
+ if (empty($value[1]) || !($validator->double($value[1]) || $validator->int($value[1]))) {
+ $result[$column->heading] = 0;
+ } else {
+ $result[$column->heading] = doubleval($value[1]);
+ }
+ break;
+ case 3:
+ // Date
+ // This expects an ISO date
+ // check if we were provided with custom dateFormat
+ $dateFormat = $column->dateFormat ?: DateFormatHelper::getSystemFormat();
+
+ try {
+ // Parse into a date object from any format, and then save using the system format
+ $result[$column->heading] = Carbon::createFromFormat($dateFormat, $value[1])
+ ->format(DateFormatHelper::getSystemFormat());
+ } catch (\Exception $e) {
+ $this->getLog()->error(
+ sprintf(
+ 'Incorrect date provided %s, expected date format %s',
+ $value[1],
+ $dateFormat
+ )
+ );
+ }
+
+ break;
+ case 5:
+ // Library Image
+ if (empty($value[1]) || !$validator->int($value[1])) {
+ $result[$column->heading] = 0;
+ } else {
+ $result[$column->heading] = intval($value[1]);
+ }
+ break;
+ case 6:
+ // HTML, without any sanitization
+ $result[$column->heading] = $value[1];
+ break;
+ default:
+ // Default value, assume it will be a string and filter it accordingly.
+ $result[$column->heading] = strip_tags($value[1]);
+ }
+ }
+ } else {
+ $this->getLog()->debug('Column not matched');
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns the Value of the remote DataEntry based on the remoteField definition split into chunks
+ *
+ * This function is recursive, so be sure you remove the first value from chunks and pass it in again
+ *
+ * @param array List of Chunks which interprets the FieldNames in the actual DataEntry
+ * @param array|\stdClass $entry Current DataEntry
+ * @return array of the last FieldName and the corresponding value
+ */
+ private function getFieldValueFromEntry(array $chunks, $entry)
+ {
+ $value = null;
+ $key = array_shift($chunks);
+
+ $this->getLog()->debug('Entry: ' . var_export($entry, true));
+ $this->getLog()->debug('Looking for key: ' . $key . '. Chunks: ' . var_export($chunks, true));
+
+ if (($entry instanceof \stdClass) && property_exists($entry, $key)) {
+ $value = $entry->{$key};
+ } else if (array_key_exists($key, $entry)) {
+ $value = $entry[$key];
+ }
+
+ $this->getLog()->debug('Value found is: ' . var_export($value, true));
+
+ if (($value != null) && (count($chunks) > 0)) {
+ return $this->getFieldValueFromEntry($chunks, (array) $value);
+ }
+
+ return [ $key, $value ];
+ }
+
+ /**
+ * Consolidates all Entries by the defined Function in the DataSet
+ *
+ * This Method *sums* or *counts* all same entries and returns them.
+ * If no consolidation function is configured, nothing is done here.
+ *
+ * @param DataSet $dataSet the current DataSet
+ * @param array $entries All processed entries which may be consolidated
+ * @param array $columns The columns form this DataSet
+ * @return array which contains all Entries to be added to the DataSet
+ */
+ private function consolidateEntries(DataSet $dataSet, array $entries, array $columns)
+ {
+ // Do we need to consolidate?
+ if ((count($entries) > 0) && $dataSet->doConsolidate()) {
+ // Yes
+ $this->getLog()->debug('Consolidate Required on field ' . $dataSet->getConsolidationField());
+
+ $consolidated = [];
+ $field = $dataSet->getConsolidationField();
+
+ // Get the Field-Heading based on the consolidation field
+ foreach ($columns as $k => $column) {
+ if ($column->remoteField == $dataSet->summarizeField) {
+ $field = $column->heading;
+ break;
+ }
+ }
+
+ // Check each entry and consolidate the value form the defined field
+ foreach ($entries as $entry) {
+ if (array_key_exists($field, $entry)) {
+ $key = $field . '-' . $entry[$field];
+ $existing = (isset($consolidated[$key])) ? $consolidated[$key] : null;
+
+ // Create a new one if there is no currently consolidated field for this value
+ if ($existing == null) {
+ $existing = $entry;
+ $existing[$field] = 0;
+ }
+
+ // Consolidate: Summarize, Count, Unknown
+ if ($dataSet->summarize == 'sum') {
+ $existing[$field] = $existing[$field] + $entry[$field];
+
+ } else if ($dataSet->summarize == 'count') {
+ $existing[$field] = $existing[$field] + 1;
+
+ } else {
+ // Unknown consolidation type :?
+ $existing[$field] = 0;
+ }
+
+ $consolidated[$key] = $existing;
+ }
+ }
+
+ return $consolidated;
+ }
+
+ return $entries;
+ }
+
+ public function processCsvEntries(DataSet $dataSet, \stdClass $results, $save = true)
+ {
+ $this->getLog()->debug('Processing CSV results');
+
+ $dataSet->load();
+ $entries = [];
+
+ foreach ($results->entries as $entry) {
+ $entries[] = $this->processEntry((array)$entry, $dataSet->columns);
+ }
+
+ $results->processed = $entries;
+
+ if ($save) {
+ foreach ($entries as $row) {
+ $dataSet->addRow($row);
+ }
+ }
+ }
+}
diff --git a/lib/Factory/DataSetRssFactory.php b/lib/Factory/DataSetRssFactory.php
new file mode 100644
index 0000000..407a14e
--- /dev/null
+++ b/lib/Factory/DataSetRssFactory.php
@@ -0,0 +1,165 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\DataSetRss;
+use Xibo\Support\Exception\NotFoundException;
+
+class DataSetRssFactory extends BaseFactory
+{
+ /**
+ * Construct a factory
+ * @param \Xibo\Entity\User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ public function createEmpty()
+ {
+ return new DataSetRss($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get DataSets by ID
+ * @param $id
+ * @return DataSetRss
+ * @throws NotFoundException
+ */
+ public function getById($id)
+ {
+ $feeds = $this->query(null, ['disableUserCheck' => 1, 'id' => $id]);
+
+ if (count($feeds) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $feeds[0];
+ }
+
+ /**
+ * Get DataSets by PSK
+ * @param $psk
+ * @return DataSetRss
+ * @throws NotFoundException
+ */
+ public function getByPsk($psk)
+ {
+ $feeds = $this->query(null, ['disableUserCheck' => 1, 'psk' => $psk]);
+
+ if (count($feeds) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $feeds[0];
+ }
+
+ /**
+ * @param $sortOrder
+ * @param $filterBy
+ * @return DataSetRss[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder, $filterBy)
+ {
+ $entries = [];
+ $params = [];
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $select = '
+ SELECT `datasetrss`.id,
+ `datasetrss`.dataSetId,
+ `datasetrss`.psk,
+ `datasetrss`.title,
+ `datasetrss`.author,
+ `datasetrss`.titleColumnId,
+ `datasetrss`.summaryColumnId,
+ `datasetrss`.contentColumnId,
+ `datasetrss`.publishedDateColumnId,
+ `datasetrss`.sort,
+ `datasetrss`.filter
+ ';
+
+ $body = '
+ FROM `datasetrss`
+ INNER JOIN `dataset`
+ ON `dataset`.dataSetId = `datasetrss`.dataSetId
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('id') !== null) {
+ $body .= ' AND `datasetrss`.id = :id ';
+ $params['id'] = $sanitizedFilter->getInt('id');
+ }
+
+ if ($sanitizedFilter->getInt('dataSetId') !== null) {
+ $body .= ' AND `datasetrss`.dataSetId = :dataSetId ';
+ $params['dataSetId'] = $sanitizedFilter->getInt('dataSetId');
+ }
+
+ if ($sanitizedFilter->getString('psk') !== null) {
+ $body .= ' AND `datasetrss`.psk = :psk ';
+ $params['psk'] = $sanitizedFilter->getString('psk');
+ }
+
+ if ($sanitizedFilter->getString('title', $filterBy) != null) {
+ $terms = explode(',', $sanitizedFilter->getString('title'));
+ $this->nameFilter('datasetrss', 'title', $terms, $body, $params, ($sanitizedFilter->getCheckbox('useRegexForName') == 1));
+ }
+
+ // View Permissions
+ $this->viewPermissionSql('Xibo\Entity\DataSet', $body, $params, '`datasetrss`.dataSetId', '`dataset`.userId', $filterBy);
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['id']
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/DataTypeFactory.php b/lib/Factory/DataTypeFactory.php
new file mode 100644
index 0000000..9faa1ca
--- /dev/null
+++ b/lib/Factory/DataTypeFactory.php
@@ -0,0 +1,84 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\DataType;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DataTypeFactory
+ * @package Xibo\Factory
+ */
+class DataTypeFactory extends BaseFactory
+{
+ /**
+ * @return DataType
+ */
+ public function createEmpty()
+ {
+ return new DataType($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get By Id
+ * @param int $id
+ * @return DataType
+ * @throws NotFoundException
+ */
+ public function getById($id)
+ {
+ $results = $this->query(null, ['dataTypeId' => $id]);
+
+ if (count($results) <= 0)
+ throw new NotFoundException();
+
+ return $results[0];
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return array[DataType]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $sql = 'SELECT dataTypeId, dataType FROM `datatype` WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('dataTypeId') !== null) {
+ $sql .= ' AND `datatype`.dataTypeId = :dataTypeId ';
+ $params['dataTypeId'] = $sanitizedFilter->getInt('dataTypeId');
+ }
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/DayPartFactory.php b/lib/Factory/DayPartFactory.php
new file mode 100644
index 0000000..846dfd9
--- /dev/null
+++ b/lib/Factory/DayPartFactory.php
@@ -0,0 +1,217 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\DayPart;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DayPartFactory
+ * @package Xibo\Factory
+ */
+class DayPartFactory extends BaseFactory
+{
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ /**
+ * Create Empty
+ * @return DayPart
+ */
+ public function createEmpty()
+ {
+ return new DayPart(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher()
+ );
+ }
+
+ /**
+ * Get DayPart by Id
+ * @param $dayPartId
+ * @return DayPart
+ * @throws NotFoundException
+ */
+ public function getById($dayPartId)
+ {
+ $dayParts = $this->query(null, ['dayPartId' => $dayPartId, 'disableUserCheck' => 1]);
+
+ if (count($dayParts) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $dayParts[0];
+ }
+
+ /**
+ * Get the Always DayPart
+ * @return DayPart
+ * @throws NotFoundException
+ */
+ public function getAlwaysDayPart()
+ {
+ $dayParts = $this->query(null, ['disableUserCheck' => 1, 'isAlways' => 1]);
+
+ if (count($dayParts) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $dayParts[0];
+ }
+
+ /**
+ * Get the Custom DayPart
+ * @return DayPart
+ * @throws NotFoundException
+ */
+ public function getCustomDayPart()
+ {
+ $dayParts = $this->query(null, ['disableUserCheck' => 1, 'isCustom' => 1]);
+
+ if (count($dayParts) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $dayParts[0];
+ }
+
+ /**
+ * Get all dayparts with the system entries (always and custom)
+ * @param array $filter
+ * @return DayPart[]
+ * @throws NotFoundException
+ */
+ public function allWithSystem($filter = [])
+ {
+ $dayParts = $this->query(['isAlways DESC', 'isCustom DESC', 'name'], $filter);
+
+ return $dayParts;
+ }
+
+ /**
+ * Get by OwnerId
+ * @param int $ownerId
+ * @return DayPart[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['userId' => $ownerId]);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[Schedule]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null) {
+ $sortOrder = ['name'];
+ }
+
+ $params = [];
+ $select = 'SELECT `daypart`.dayPartId, `name`, `description`, `isRetired`, `userId`, `startTime`, `endTime`, `exceptions`, `isCustom`, `isAlways` ';
+
+ $body = ' FROM `daypart` ';
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('dayPartId') !== null) {
+ $body .= ' AND `daypart`.dayPartId = :dayPartId ';
+ $params['dayPartId'] = $sanitizedFilter->getInt('dayPartId');
+ }
+
+ if ($sanitizedFilter->getInt('isAlways') !== null) {
+ $body .= ' AND `daypart`.isAlways = :isAlways ';
+ $params['isAlways'] = $sanitizedFilter->getInt('isAlways');
+ }
+
+ if ($sanitizedFilter->getInt('isCustom') !== null) {
+ $body .= ' AND `daypart`.isCustom = :isCustom ';
+ $params['isCustom'] = $sanitizedFilter->getInt('isCustom');
+ }
+
+ if ($sanitizedFilter->getInt('isRetired', ['default'=> -1]) == 1)
+ $body .= ' AND daypart.isRetired = 1 ';
+
+ if ($sanitizedFilter->getInt('isRetired', ['default'=> -1]) == 0)
+ $body .= ' AND daypart.isRetired = 0 ';
+
+ if ($sanitizedFilter->getString('name') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $this->nameFilter('daypart', 'name', $terms, $body, $params, ($sanitizedFilter->getCheckbox('useRegexForName') == 1));
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `daypart`.userId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // View Permissions
+ $this->viewPermissionSql('Xibo\Entity\DayPart', $body, $params, '`daypart`.dayPartId', '`daypart`.userId', $filterBy);
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $dayPart = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['isAlways', 'isCustom']
+ ]);
+ $dayPart->exceptions = json_decode($dayPart->exceptions, true);
+
+ $entries[] = $dayPart;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/DisplayEventFactory.php b/lib/Factory/DisplayEventFactory.php
new file mode 100644
index 0000000..1f1590b
--- /dev/null
+++ b/lib/Factory/DisplayEventFactory.php
@@ -0,0 +1,40 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+use Xibo\Entity\DisplayEvent;
+
+/**
+ * Class DisplayEventFactory
+ * @package Xibo\Factory
+ */
+class DisplayEventFactory extends BaseFactory
+{
+ /**
+ * @return DisplayEvent
+ */
+ public function createEmpty()
+ {
+ return new DisplayEvent($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/DisplayFactory.php b/lib/Factory/DisplayFactory.php
new file mode 100644
index 0000000..876302d
--- /dev/null
+++ b/lib/Factory/DisplayFactory.php
@@ -0,0 +1,790 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Display;
+use Xibo\Entity\User;
+use Xibo\Helper\Environment;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DisplayFactory
+ * @package Xibo\Factory
+ */
+class DisplayFactory extends BaseFactory
+{
+ use TagTrait;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ * @param ConfigServiceInterface $config
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param DisplayProfileFactory $displayProfileFactory
+ * @param FolderFactory $folderFactory
+ */
+ public function __construct(
+ $user,
+ $userFactory,
+ $displayNotifyService,
+ $config,
+ $displayGroupFactory,
+ $displayProfileFactory,
+ $folderFactory
+ ) {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->displayNotifyService = $displayNotifyService;
+ $this->config = $config;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->displayProfileFactory = $displayProfileFactory;
+ $this->folderFactory = $folderFactory;
+ }
+
+ /**
+ * Get the Display Notify Service
+ * @return DisplayNotifyServiceInterface
+ */
+ public function getDisplayNotifyService()
+ {
+ return $this->displayNotifyService->init();
+ }
+
+ /**
+ * Create Empty Display Object
+ * @return Display
+ */
+ public function createEmpty()
+ {
+ return new Display(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this->displayGroupFactory,
+ $this->displayProfileFactory,
+ $this,
+ $this->folderFactory
+ );
+ }
+
+ /**
+ * @param int $displayId
+ * @param bool|false $showTags
+ * @return Display
+ * @throws NotFoundException
+ */
+ public function getById($displayId, $showTags = false)
+ {
+ $displays = $this->query(null, ['disableUserCheck' => 1, 'displayId' => $displayId, 'showTags' => $showTags]);
+
+ if (count($displays) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $displays[0];
+ }
+
+ /**
+ * @param string $licence
+ * @return Display
+ * @throws NotFoundException
+ */
+ public function getByLicence($licence)
+ {
+ if (empty($licence)) {
+ throw new NotFoundException(__('Hardware key cannot be empty'));
+ }
+
+ $displays = $this->query(null, ['disableUserCheck' => 1, 'license' => $licence]);
+
+ if (count($displays) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $displays[0];
+ }
+
+ /**
+ * @param int $displayGroupId
+ * @return Display[]
+ * @throws NotFoundException
+ */
+ public function getByDisplayGroupId($displayGroupId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'displayGroupId' => $displayGroupId]);
+ }
+
+ /**
+ * @param array $displayGroupIds
+ * @return Display[]
+ * @throws NotFoundException
+ */
+ public function getByDisplayGroupIds(array $displayGroupIds)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'displayGroupIds' => $displayGroupIds]);
+ }
+
+ /**
+ * @param int $syncGroupId
+ * @return Display[]
+ * @throws NotFoundException
+ */
+ public function getBySyncGroupId(int $syncGroupId): array
+ {
+ return $this->query(null, ['syncGroupId' => $syncGroupId]);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Display[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedBody = $this->getSanitizer($filterBy);
+
+ if ($sortOrder === null) {
+ $sortOrder = ['display'];
+ }
+
+ $newSortOrder = [];
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`clientSort`') {
+ $newSortOrder[] = '`clientType`';
+ $newSortOrder[] = '`clientCode`';
+ $newSortOrder[] = '`clientVersion`';
+ continue;
+ }
+
+ if ($sort == '`clientSort` DESC') {
+ $newSortOrder[] = '`clientType` DESC';
+ $newSortOrder[] = '`clientCode` DESC';
+ $newSortOrder[] = '`clientVersion` DESC';
+ continue;
+ }
+
+ if ($sort == '`isCmsTransferInProgress`') {
+ $newSortOrder[] = '`newCmsAddress`';
+ continue;
+ }
+
+ if ($sort == '`isCmsTransferInProgress` DESC') {
+ $newSortOrder[] = '`newCmsAddress` DESC';
+ continue;
+ }
+ $newSortOrder[] = $sort;
+ }
+ $sortOrder = $newSortOrder;
+
+ // SQL function for ST_X/X and ST_Y/Y dependent on MySQL version
+ $version = $this->getStore()->getVersion();
+
+ $functionPrefix = ($version === null || version_compare($version, '5.6.1', '>=')) ? 'ST_' : '';
+
+ $entries = [];
+ $params = [];
+ $select = '
+ SELECT display.displayId,
+ display.display,
+ display.defaultLayoutId,
+ display.displayTypeId,
+ display.venueId,
+ display.address,
+ display.isMobile,
+ display.languages,
+ `display_types`.displayType,
+ display.screenSize,
+ display.isOutdoor,
+ display.customId,
+ display.costPerPlay,
+ display.impressionsPerPlay,
+ layout.layout AS defaultLayout,
+ display.license,
+ display.licensed,
+ display.licensed AS currentlyLicensed,
+ display.loggedIn,
+ display.lastAccessed,
+ display.auditingUntil,
+ display.inc_schedule AS incSchedule,
+ display.email_alert AS emailAlert,
+ display.alert_timeout AS alertTimeout,
+ display.clientAddress,
+ display.mediaInventoryStatus,
+ display.macAddress,
+ display.macAddress AS currentMacAddress,
+ display.lastChanged,
+ display.numberOfMacAddressChanges,
+ display.lastWakeOnLanCommandSent,
+ display.wakeOnLan AS wakeOnLanEnabled,
+ display.wakeOnLanTime,
+ display.broadCastAddress,
+ display.secureOn,
+ display.cidr,
+ ' . $functionPrefix . 'X(display.GeoLocation) AS latitude,
+ ' . $functionPrefix . 'Y(display.GeoLocation) AS longitude,
+ display.client_type AS clientType,
+ display.client_version AS clientVersion,
+ display.client_code AS clientCode,
+ display.displayProfileId,
+ display.screenShotRequested,
+ display.storageAvailableSpace,
+ display.storageTotalSpace,
+ display.osVersion,
+ display.osSdk,
+ display.manufacturer,
+ display.brand,
+ display.model,
+ displaygroup.displayGroupId,
+ displaygroup.description,
+ displaygroup.bandwidthLimit,
+ displaygroup.createdDt,
+ displaygroup.modifiedDt,
+ displaygroup.folderId,
+ displaygroup.permissionsFolderId,
+ displaygroup.ref1,
+ displaygroup.ref2,
+ displaygroup.ref3,
+ displaygroup.ref4,
+ displaygroup.ref5,
+ `display`.xmrChannel,
+ `display`.xmrPubKey,
+ `display`.lastCommandSuccess,
+ `display`.deviceName,
+ `display`.timeZone,
+ `display`.overrideConfig,
+ `display`.newCmsAddress,
+ `display`.newCmsKey,
+ `display`.orientation,
+ `display`.resolution,
+ `display`.commercialLicence,
+ `display`.teamViewerSerial,
+ `display`.webkeySerial,
+ `display`.lanIpAddress,
+ `display`.syncGroupId,
+ (SELECT COUNT(*) FROM player_faults WHERE player_faults.displayId = display.displayId) AS countFaults,
+ (SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :entity
+ AND objectId = `displaygroup`.displayGroupId
+ AND view = 1
+ ) AS groupsWithPermissions
+ ';
+
+ $params['entity'] = 'Xibo\\Entity\\DisplayGroup';
+
+ $body = '
+ FROM `display`
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.displayid = display.displayId
+ INNER JOIN `displaygroup`
+ ON displaygroup.displaygroupid = lkdisplaydg.displaygroupid
+ AND `displaygroup`.isDisplaySpecific = 1
+ LEFT OUTER JOIN layout
+ ON layout.layoutid = display.defaultlayoutid
+ LEFT OUTER JOIN `display_types`
+ ON `display_types`.displayTypeId = `display`.displayTypeId
+ ';
+
+ // Restrict to members of a specific display group
+ if ($parsedBody->getInt('displayGroupId') !== null) {
+ $body .= '
+ INNER JOIN `lkdisplaydg` othergroups
+ ON othergroups.displayId = `display`.displayId
+ AND othergroups.displayGroupId = :displayGroupId
+ ';
+
+ $params['displayGroupId'] = $parsedBody->getInt('displayGroupId');
+ }
+
+ // Restrict to members of display groups
+ if ($parsedBody->getIntArray('displayGroupIds') !== null) {
+ $body .= '
+ INNER JOIN `lkdisplaydg` othergroups
+ ON othergroups.displayId = `display`.displayId
+ AND othergroups.displayGroupId IN (0
+ ';
+
+ $i = 0;
+ foreach ($parsedBody->getIntArray('displayGroupIds') as $displayGroupId) {
+ $i++;
+ $body .= ',:displayGroupId' . $i;
+ $params['displayGroupId' . $i] = $displayGroupId;
+ }
+ $body .= ')';
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+ // Filter by map bound?
+ if ($parsedBody->getString('bounds') !== null) {
+ $coordinates = explode(',', $parsedBody->getString('bounds'));
+ $defaultLat = $this->config->getSetting('DEFAULT_LAT');
+ $defaultLng = $this->config->getSetting('DEFAULT_LONG');
+
+ $body .= ' AND IFNULL( ' . $functionPrefix . 'X(display.GeoLocation), ' . $defaultLat
+ . ') BETWEEN :coordinates_1 AND :coordinates_3 '
+ . ' AND IFNULL( ' . $functionPrefix . 'Y(display.GeoLocation), ' . $defaultLng
+ . ') BETWEEN :coordinates_0 AND :coordinates_2 ';
+
+ $params['coordinates_0'] = $coordinates[0];
+ $params['coordinates_1'] = $coordinates[1];
+ $params['coordinates_2'] = $coordinates[2];
+ $params['coordinates_3'] = $coordinates[3];
+ }
+
+ // Filter by Display ID?
+ if ($parsedBody->getInt('displayId') !== null) {
+ $body .= ' AND display.displayid = :displayId ';
+ $params['displayId'] = $parsedBody->getInt('displayId');
+ }
+
+ // Display Profile
+ if ($parsedBody->getInt('displayProfileId') !== null) {
+ if ($parsedBody->getInt('displayProfileId') == -1) {
+ $body .= ' AND IFNULL(displayProfileId, 0) = 0 ';
+ } else {
+ $displayProfileSelected = $this->displayProfileFactory->getById($parsedBody->getInt('displayProfileId'));
+ $displayProfileDefault = $this->displayProfileFactory->getDefaultByType($displayProfileSelected->type);
+
+ $body .= ' AND (`display`.displayProfileId = :displayProfileId OR (IFNULL(displayProfileId, :displayProfileDefaultId) = :displayProfileId AND display.client_type = :displayProfileType ) ) ';
+
+ $params['displayProfileId'] = $parsedBody->getInt('displayProfileId');
+ $params['displayProfileDefaultId'] = $displayProfileDefault->displayProfileId;
+ $params['displayProfileType'] = $displayProfileDefault->type;
+ }
+ }
+
+ // Filter by Wake On LAN
+ if ($parsedBody->getInt('wakeOnLan') !== null) {
+ $body .= ' AND display.wakeOnLan = :wakeOnLan ';
+ $params['wakeOnLan'] = $parsedBody->getInt('wakeOnLan');
+ }
+
+ // Filter by Licence?
+ if ($parsedBody->getString('license') !== null) {
+ $body .= ' AND display.license = :license ';
+ $params['license'] = $parsedBody->getString('license');
+ }
+
+ // Filter by authorised?
+ if ($parsedBody->getInt('authorised', ['default' => -1]) != -1) {
+ $body .= ' AND display.licensed = :authorised ';
+ $params['authorised'] = $parsedBody->getInt('authorised');
+ }
+
+ // Filter by Display Name?
+ if ($parsedBody->getString('display') != null) {
+ $terms = explode(',', $parsedBody->getString('display'));
+ $logicalOperator = $parsedBody->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'display',
+ 'display',
+ $terms,
+ $body,
+ $params,
+ ($parsedBody->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($parsedBody->getString('macAddress') != '') {
+ $body .= ' AND display.macaddress LIKE :macAddress ';
+ $params['macAddress'] = '%' . $parsedBody->getString('macAddress') . '%';
+ }
+
+ if ($parsedBody->getString('clientAddress') != '') {
+ $body .= ' AND display.clientaddress LIKE :clientAddress ';
+ $params['clientAddress'] = '%' . $parsedBody->getString('clientAddress') . '%';
+ }
+
+ if ($parsedBody->getString('clientVersion') != '') {
+ $body .= ' AND display.client_version LIKE :clientVersion ';
+ $params['clientVersion'] = '%' . $parsedBody->getString('clientVersion') . '%';
+ }
+
+ if ($parsedBody->getString('clientType') != '') {
+ $body .= ' AND display.client_type = :clientType ';
+ $params['clientType'] = $parsedBody->getString('clientType');
+ }
+
+ if ($parsedBody->getString('clientCode') != '') {
+ $body .= ' AND display.client_code LIKE :clientCode ';
+ $params['clientCode'] = '%' . $parsedBody->getString('clientCode') . '%';
+ }
+
+ if ($parsedBody->getString('customId') != '') {
+ $body .= ' AND display.customId LIKE :customId ';
+ $params['customId'] = '%' . $parsedBody->getString('customId') . '%';
+ }
+
+ if ($parsedBody->getString('orientation', $filterBy) != '') {
+ $body .= ' AND display.orientation = :orientation ';
+ $params['orientation'] = $parsedBody->getString('orientation', $filterBy);
+ }
+
+ if ($parsedBody->getInt('mediaInventoryStatus', $filterBy) != '') {
+ if ($parsedBody->getInt('mediaInventoryStatus', $filterBy) === -1) {
+ $body .= ' AND display.mediaInventoryStatus <> 1 ';
+ } else {
+ $body .= ' AND display.mediaInventoryStatus = :mediaInventoryStatus ';
+ $params['mediaInventoryStatus'] = $parsedBody->getInt('mediaInventoryStatus');
+ }
+ }
+
+ if ($parsedBody->getInt('loggedIn', ['default' => -1]) != -1) {
+ $body .= ' AND display.loggedIn = :loggedIn ';
+ $params['loggedIn'] = $parsedBody->getInt('loggedIn');
+ }
+
+ if ($parsedBody->getDate('lastAccessed', ['dateFormat' => 'U']) !== null) {
+ $body .= ' AND display.lastAccessed > :lastAccessed ';
+ $params['lastAccessed'] = $parsedBody->getDate('lastAccessed', ['dateFormat' => 'U'])->format('U');
+ }
+
+ // Exclude a group?
+ if ($parsedBody->getInt('exclude_displaygroupid') !== null) {
+ $body .= " AND display.DisplayID NOT IN ";
+ $body .= " (SELECT display.DisplayID ";
+ $body .= " FROM display ";
+ $body .= " INNER JOIN lkdisplaydg ";
+ $body .= " ON lkdisplaydg.DisplayID = display.DisplayID ";
+ $body .= " WHERE lkdisplaydg.DisplayGroupID = :excludeDisplayGroupId ";
+ $body .= " )";
+ $params['excludeDisplayGroupId'] = $parsedBody->getInt('exclude_displaygroupid');
+ }
+
+ // Media ID - direct assignment
+ if ($parsedBody->getInt('mediaId') !== null) {
+ $body .= '
+ AND display.displayId IN (
+ SELECT `lkdisplaydg`.displayId
+ FROM `lkmediadisplaygroup`
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkmediadisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ WHERE `lkmediadisplaygroup`.mediaId = :mediaId
+ UNION
+ SELECT `lkdisplaydg`.displayId
+ FROM `lklayoutdisplaygroup`
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lklayoutdisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ WHERE `lklayoutdisplaygroup`.layoutId IN (
+ SELECT `region`.layoutId
+ FROM `lkwidgetmedia`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `widget`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN layout
+ ON layout.LayoutID = region.layoutId
+ WHERE lkwidgetmedia.mediaId = :mediaId
+ UNION
+ SELECT `layout`.layoutId
+ FROM `layout`
+ WHERE `layout`.backgroundImageId = :mediaId
+ )
+ )
+ ';
+
+ $params['mediaId'] = $parsedBody->getInt('mediaId');
+ }
+
+ // Tags
+ if ($parsedBody->getString('tags') != '') {
+ $tagFilter = $parsedBody->getString('tags');
+
+ if (trim($tagFilter) === '--no-tag') {
+ $body .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ )
+ ';
+ } else {
+ $operator = $parsedBody->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $parsedBody->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $body .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $notTags,
+ 'lktagdisplaygroup',
+ 'lkTagDisplayGroupId',
+ 'displayGroupId',
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $body .= ' AND `displaygroup`.displaygroupId IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $tags,
+ 'lktagdisplaygroup',
+ 'lkTagDisplayGroupId',
+ 'displayGroupId',
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+ }
+ }
+ }
+
+ // run the special query to help sort by displays already assigned to this display group,
+ // or by displays assigned to this syncGroup
+ // we want to run it only if we're sorting by member column.
+ if (in_array('`member`', $sortOrder) || in_array('`member` DESC', $sortOrder)) {
+ $members = [];
+
+ // DisplayGroup members with provided Display Group ID
+ if ($parsedBody->getInt('displayGroupIdMembers') !== null) {
+ $displayGroupId = $parsedBody->getInt('displayGroupIdMembers');
+
+ foreach ($this->getStore()->select($select . $body, $params) as $row) {
+ $displayId = $this->getSanitizer($row)->getInt('displayId');
+
+ if ($this->getStore()->exists(
+ 'SELECT display.display, display.displayId, displaygroup.displayGroupId
+ FROM display
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.displayId = `display`.displayId
+ AND lkdisplaydg.displayGroupId = :displayGroupId
+ AND lkdisplaydg.displayId = :displayId
+ INNER JOIN `displaygroup`
+ ON displaygroup.displaygroupid = lkdisplaydg.displaygroupid
+ AND `displaygroup`.isDisplaySpecific = 0',
+ [
+ 'displayGroupId' => $displayGroupId,
+ 'displayId' => $displayId
+ ]
+ )) {
+ $members[] = $displayId;
+ }
+ }
+ } else if ($parsedBody->getInt('syncGroupIdMembers') !== null) {
+ // Sync Group Members with provided Sync Group ID
+ foreach ($this->getStore()->select($select . $body, $params) as $row) {
+ $displayId = $this->getSanitizer($row)->getInt('displayId');
+
+ if ($this->getStore()->exists(
+ 'SELECT display.displayId
+ FROM `display`
+ WHERE `display`.syncGroupId = :syncGroupId
+ AND `display`.displayId = :displayId',
+ [
+ 'syncGroupId' => $parsedBody->getInt('syncGroupIdMembers'),
+ 'displayId' => $displayId
+ ]
+ )) {
+ $members[] = $displayId;
+ }
+ }
+ }
+ }
+
+ // filter by commercial licence
+ if ($parsedBody->getInt('commercialLicence') !== null) {
+ $body .= ' AND display.commercialLicence = :commercialLicence ';
+ $params['commercialLicence'] = $parsedBody->getInt('commercialLicence');
+ }
+
+ if ($parsedBody->getInt('folderId') !== null) {
+ $body .= ' AND displaygroup.folderId = :folderId ';
+ $params['folderId'] = $parsedBody->getInt('folderId');
+ }
+
+ if ($parsedBody->getInt('syncGroupId') !== null) {
+ $body .= ' AND `display`.syncGroupId = :syncGroupId ';
+ $params['syncGroupId'] = $parsedBody->getInt('syncGroupId');
+ }
+
+ if ($parsedBody->getInt('xmrRegistered') === 1) {
+ $body .= ' AND `display`.xmrChannel IS NOT NULL ';
+ } else if ($parsedBody->getInt('xmrRegistered') === 0) {
+ $body .= ' AND `display`.xmrChannel IS NULL ';
+ }
+
+ // Player version supported
+ if ($parsedBody->getInt('isPlayerSupported') !== null) {
+ if ($parsedBody->getInt('isPlayerSupported') === 1) {
+ $body .= ' AND `display`.client_code >= :playerSupport ';
+ } else {
+ $body .= ' AND `display`.client_code < :playerSupport ';
+ }
+
+ $params['playerSupport'] = Environment::$PLAYER_SUPPORT;
+ }
+
+ $this->viewPermissionSql(
+ 'Xibo\Entity\DisplayGroup',
+ $body,
+ $params,
+ 'displaygroup.displayGroupId',
+ null,
+ $filterBy,
+ '`displaygroup`.permissionsFolderId'
+ );
+
+ // Sorting?
+ $order = '';
+
+ if (isset($members) && $members != []) {
+ $sqlOrderMembers = 'ORDER BY FIELD(display.displayId,' . implode(',', $members) . ')';
+
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`member`') {
+ $order .= $sqlOrderMembers;
+ continue;
+ }
+
+ if ($sort == '`member` DESC') {
+ $order .= $sqlOrderMembers . ' DESC';
+ continue;
+ }
+ }
+ }
+
+ if (is_array($sortOrder) && (!in_array('`member`', $sortOrder) && !in_array('`member` DESC', $sortOrder))) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($parsedBody->hasParam('start') && $parsedBody->hasParam('length')) {
+ $limit = ' LIMIT ' . $parsedBody->getInt('start', ['default' => 0])
+ . ', ' . $parsedBody->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+ $displayGroupIds = [];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $display = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'auditingUntil',
+ 'wakeOnLanEnabled',
+ 'numberOfMacAddressChanges',
+ 'loggedIn',
+ 'incSchedule',
+ 'licensed',
+ 'lastAccessed',
+ 'emailAlert',
+ 'alertTimeout',
+ 'mediaInventoryStatus',
+ 'clientCode',
+ 'screenShotRequested',
+ 'lastCommandSuccess',
+ 'bandwidthLimit',
+ 'countFaults',
+ 'isMobile',
+ 'isOutdoor'
+ ],
+ 'stringProperties' => ['customId']
+ ]);
+ $display->overrideConfig = ($display->overrideConfig == '') ? [] : json_decode($display->overrideConfig, true);
+ $displayGroupIds[] = $display->displayGroupId;
+ $entries[] = $display;
+ }
+
+ // decorate with TagLinks
+ if (count($entries) > 0) {
+ $this->decorateWithTagLinks('lktagdisplaygroup', 'displayGroupId', $displayGroupIds, $entries);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['entity']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/DisplayGroupFactory.php b/lib/Factory/DisplayGroupFactory.php
new file mode 100644
index 0000000..9063750
--- /dev/null
+++ b/lib/Factory/DisplayGroupFactory.php
@@ -0,0 +1,612 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\DisplayGroup;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DisplayGroupFactory
+ * @package Xibo\Factory
+ */
+class DisplayGroupFactory extends BaseFactory
+{
+ use TagTrait;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param PermissionFactory $permissionFactory
+ */
+ public function __construct($user, $userFactory, $permissionFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->permissionFactory = $permissionFactory;
+ }
+
+ /**
+ * @param int|null $userId
+ * @param int $bandwidthLimit
+ * @return DisplayGroup
+ */
+ public function create($userId = null, $bandwidthLimit = 0)
+ {
+ $displayGroup = $this->createEmpty();
+
+ if ($userId === null) {
+ $userId = $this->getUserFactory()->getSystemUser()->userId;
+ }
+
+ $displayGroup->userId = $userId;
+ $displayGroup->bandwidthLimit = $bandwidthLimit;
+
+ return $displayGroup;
+ }
+
+ /**
+ * Create Empty
+ * @return DisplayGroup
+ */
+ public function createEmpty()
+ {
+ return new DisplayGroup(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this,
+ $this->permissionFactory
+ );
+ }
+
+ /**
+ * @param int $displayGroupId
+ * @return DisplayGroup
+ * @throws NotFoundException
+ */
+ public function getById($displayGroupId)
+ {
+ $groups = $this->query(null, ['disableUserCheck' => 1, 'displayGroupId' => $displayGroupId, 'isDisplaySpecific' => -1]);
+
+ if (count($groups) <= 0)
+ throw new NotFoundException();
+
+ return $groups[0];
+ }
+
+ /**
+ * @param int $displayId
+ * @return DisplayGroup
+ * @throws NotFoundException
+ */
+ public function getDisplaySpecificByDisplayId(int $displayId): DisplayGroup
+ {
+ $groups = $this->query(null, ['disableUserCheck' => 1, 'displayId' => $displayId, 'isDisplaySpecific' => 1]);
+
+ if (count($groups) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $groups[0];
+ }
+
+ /**
+ * @param int $displayId
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByDisplayId($displayId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'displayId' => $displayId, 'isDisplaySpecific' => -1]);
+ }
+
+ /**
+ * Get Display Groups by MediaId
+ * @param int $mediaId
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByMediaId($mediaId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'mediaId' => $mediaId, 'isDisplaySpecific' => -1]);
+ }
+
+ /**
+ * Get Display Groups by eventId
+ * @param int $eventId
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByEventId($eventId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'eventId' => $eventId, 'isDisplaySpecific' => -1]);
+ }
+
+ /**
+ * Get Display Groups by isDynamic
+ * @param int $isDynamic
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByIsDynamic($isDynamic)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'isDynamic' => $isDynamic]);
+ }
+
+ /**
+ * Get Display Groups by their ParentId
+ * @param int $parentId
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByParentId($parentId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'parentId' => $parentId]);
+ }
+
+ /**
+ * @param string $tag
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByTag($tag)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'tags' => $tag, 'exactTags' => 1, 'isDisplaySpecific' => 1]);
+ }
+
+ /**
+ * Get Relationship Tree
+ * @param $displayGroupId
+ * @return DisplayGroup[]
+ */
+ public function getRelationShipTree($displayGroupId)
+ {
+ $tree = [];
+
+ foreach ($this->getStore()->select('
+ SELECT `displaygroup`.displayGroupId, `displaygroup`.displayGroup, depth, 1 AS level
+ FROM `lkdgdg`
+ INNER JOIN `displaygroup`
+ ON `lkdgdg`.childId = `displaygroup`.displayGroupId
+ WHERE `lkdgdg`.parentId = :displayGroupId AND displaygroup.isDynamic = 0
+ UNION ALL
+ SELECT `displaygroup`.displayGroupId, `displaygroup`.displayGroup, depth * -1, 0 AS level
+ FROM `lkdgdg`
+ INNER JOIN `displaygroup`
+ ON `lkdgdg`.parentId = `displaygroup`.displayGroupId
+ WHERE `lkdgdg`.childId = :displayGroupId AND `lkdgdg`.parentId <> :displayGroupId AND displaygroup.isDynamic = 0
+ ORDER BY level, depth, displayGroup
+ ', [
+ 'displayGroupId' => $displayGroupId
+ ]) as $row) {
+ $item = $this->createEmpty()->hydrate($row);
+ $item->setUnmatchedProperty('depth', intval($row['depth']));
+ $item->setUnmatchedProperty('level', intval($row['level']));
+ $tree[] = $item;
+ }
+
+ return $tree;
+ }
+
+ /**
+ * Get Display Groups assigned to Notifications
+ * @param int $notificationId
+ * @return array[DisplayGroup]
+ * @throws NotFoundException
+ */
+ public function getByNotificationId($notificationId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'notificationId' => $notificationId, 'isDisplaySpecific' => -1]);
+ }
+
+ /**
+ * Get by OwnerId
+ * @param int $ownerId
+ * @param int $isDisplaySpecific
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId, $isDisplaySpecific = 0)
+ {
+ return $this->query(null, ['userId' => $ownerId, 'isDisplaySpecific' => $isDisplaySpecific]);
+ }
+
+ /**
+ * @param $folderId
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function getByFolderId($folderId, $isDisplaySpecific = -1)
+ {
+ return $this->query(null, [
+ 'disableUserCheck' => 1,
+ 'folderId' => $folderId,
+ 'isDisplaySpecific' => $isDisplaySpecific
+ ]);
+ }
+
+ /**
+ * Set Bandwidth limit
+ * @param int $bandwidthLimit
+ * @param array $displayIds
+ * @return DisplayGroup[]
+ * @throws NotFoundException
+ */
+ public function setBandwidth($bandwidthLimit, $displayGroupIds)
+ {
+ $sql = 'UPDATE `displaygroup` SET bandwidthLimit = :bandwidthLimit WHERE displayGroupId IN (0';
+ $params['bandwidthLimit'] = $bandwidthLimit;
+
+ $i = 0;
+ foreach ($displayGroupIds as $displayGroupId) {
+ $i++;
+ $sql .= ',:displayGroupId' . $i;
+ $params['displayGroupId' . $i] = $displayGroupId;
+ }
+ $sql .= ')';
+
+ $this->getStore()->update($sql, $params);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[DisplayGroup]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedBody = $this->getSanitizer($filterBy);
+ if ($sortOrder == null)
+ $sortOrder = ['displayGroup'];
+
+ $entries = [];
+ $params = [];
+
+ $select = '
+ SELECT `displaygroup`.displayGroupId,
+ `displaygroup`.displayGroup,
+ `displaygroup`.isDisplaySpecific,
+ `displaygroup`.description,
+ `displaygroup`.isDynamic,
+ `displaygroup`.dynamicCriteria,
+ `displaygroup`.dynamicCriteriaLogicalOperator,
+ `displaygroup`.dynamicCriteriaTags,
+ `displaygroup`.dynamicCriteriaExactTags,
+ `displaygroup`.dynamicCriteriaTagsLogicalOperator,
+ `displaygroup`.bandwidthLimit,
+ `displaygroup`.createdDt,
+ `displaygroup`.modifiedDt,
+ `displaygroup`.userId,
+ `displaygroup`.folderId,
+ `displaygroup`.permissionsFolderId,
+ `displaygroup`.ref1,
+ `displaygroup`.ref2,
+ `displaygroup`.ref3,
+ `displaygroup`.ref4,
+ `displaygroup`.ref5,
+ (
+ SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :entity
+ AND objectId = `displaygroup`.displayGroupId
+ AND view = 1
+ ) AS groupsWithPermissions
+ ';
+
+ $params['entity'] = 'Xibo\\Entity\\DisplayGroup';
+
+ $body = '
+ FROM `displaygroup`
+ ';
+
+ if ($parsedBody->getInt('mediaId') !== null) {
+ $body .= '
+ INNER JOIN lkmediadisplaygroup
+ ON lkmediadisplaygroup.displayGroupId = `displaygroup`.displayGroupId
+ AND lkmediadisplaygroup.mediaId = :mediaId
+ ';
+ $params['mediaId'] = $parsedBody->getInt('mediaId');
+ }
+
+ if ($parsedBody->getInt('eventId') !== null) {
+ $body .= '
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.displayGroupId = `displaygroup`.displayGroupId
+ AND `lkscheduledisplaygroup`.eventId = :eventId
+ ';
+ $params['eventId'] = $parsedBody->getInt('eventId');
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($parsedBody->getInt('displayGroupId') !== null) {
+ $body .= ' AND displaygroup.displayGroupId = :displayGroupId ';
+ $params['displayGroupId'] = $parsedBody->getInt('displayGroupId');
+ }
+
+ if ($parsedBody->getIntArray('displayGroupIds') !== null) {
+ $body .= ' AND displaygroup.displayGroupId IN (0';
+ $i = 0;
+ foreach ($parsedBody->getIntArray('displayGroupIds') as $displayGroupId) {
+ $i++;
+ $body .= ',:displayGroupId' . $i;
+ $params['displayGroupId' . $i] = $displayGroupId;
+ }
+ $body .= ')';
+ }
+
+ if ($parsedBody->getInt('parentId') !== null) {
+ $body .= ' AND `displaygroup`.displayGroupId IN (SELECT `childId` FROM `lkdgdg` WHERE `parentId` = :parentId AND `depth` = 1) ';
+ $params['parentId'] = $parsedBody->getInt('parentId');
+ }
+
+ if ($parsedBody->getInt('userId') !== null) {
+ $body .= ' AND `displaygroup`.userId = :userId ';
+ $params['userId'] = $parsedBody->getInt('userId');
+ }
+
+ if ($parsedBody->getInt('isDisplaySpecific', ['default' => 0]) != -1) {
+ $body .= ' AND displaygroup.isDisplaySpecific = :isDisplaySpecific ';
+ $params['isDisplaySpecific'] = $parsedBody->getInt('isDisplaySpecific', ['default' => 0]);
+ }
+
+ if ($parsedBody->getInt('isDynamic') !== null) {
+ $body .= ' AND `displaygroup`.isDynamic = :isDynamic ';
+ $params['isDynamic'] = $parsedBody->getInt('isDynamic');
+ }
+ if (!empty($parsedBody->getString('dynamicCriteria'))) {
+ $body .= ' AND `displaygroup`.dynamicCriteria = :dynamicCriteria ';
+ $params['dynamicCriteria'] = $parsedBody->getString('dynamicCriteria');
+ }
+
+ if ($parsedBody->getInt('displayId') !== null) {
+ $body .= ' AND displaygroup.displayGroupId IN (SELECT displayGroupId FROM lkdisplaydg WHERE displayId = :displayId) ';
+ $params['displayId'] = $parsedBody->getInt('displayId');
+ }
+
+ if ($parsedBody->getInt('nestedDisplayId') !== null) {
+ $body .= '
+ AND displaygroup.displayGroupId IN (
+ SELECT DISTINCT parentId
+ FROM `lkdgdg`
+ INNER JOIN `lkdisplaydg`
+ ON `lkdisplaydg`.displayGroupId = `lkdgdg`.childId
+ WHERE displayId = :nestedDisplayId
+ )
+ ';
+ $params['nestedDisplayId'] = $parsedBody->getInt('nestedDisplayId');
+ }
+
+ if ($parsedBody->getInt('notificationId') !== null) {
+ $body .= ' AND displaygroup.displayGroupId IN (SELECT displayGroupId FROM `lknotificationdg` WHERE notificationId = :notificationId) ';
+ $params['notificationId'] = $parsedBody->getInt('notificationId');
+ }
+
+ // Filter by DisplayGroup Name?
+ if ($parsedBody->getString('displayGroup') != null) {
+ $terms = explode(',', $parsedBody->getString('displayGroup'));
+ $logicalOperator = $parsedBody->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'displaygroup',
+ 'displayGroup',
+ $terms,
+ $body,
+ $params,
+ ($parsedBody->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ // Tags
+ if ($parsedBody->getString('tags') != '') {
+ $tagFilter = $parsedBody->getString('tags');
+
+ if (trim($tagFilter) === '--no-tag') {
+ $body .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ )
+ ';
+ } else {
+ $operator = $parsedBody->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $parsedBody->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $body .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $notTags,
+ 'lktagdisplaygroup',
+ 'lkTagDisplayGroupId',
+ 'displayGroupId',
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $body .= ' AND `displaygroup`.displaygroupId IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $tags,
+ 'lktagdisplaygroup',
+ 'lkTagDisplayGroupId',
+ 'displayGroupId',
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+ }
+ }
+ }
+
+ if ($parsedBody->getInt('displayGroupIdMembers') !== null) {
+ $members = [];
+ foreach ($this->getStore()->select($select . $body, $params) as $row) {
+ $displayGroupId = $this->getSanitizer($row)->getInt('displayGroupId');
+ $parentId = $parsedBody->getInt('displayGroupIdMembers');
+
+ if ($this->getStore()->exists('SELECT `childId` FROM `lkdgdg` WHERE `parentId` = :parentId AND `childId` = :childId AND `depth` = 1',
+ [
+ 'parentId' => $parentId,
+ 'childId' => $displayGroupId
+ ]
+ )) {
+ $members[] = $displayGroupId;
+ }
+ }
+ } else if ($parsedBody->getInt('displayIdMember') !== null) {
+ $members = [];
+
+ foreach ($this->getStore()->select($select . $body, $params) as $row) {
+ $displayGroupId = $this->getSanitizer($row)->getInt('displayGroupId');
+ $displayId = $parsedBody->getInt('displayIdMember');
+
+ if ($this->getStore()->exists('SELECT `displayGroupId` FROM `lkdisplaydg` WHERE `displayId` = :displayId AND `displayGroupId` = :displayGroupId ',
+ [
+ 'displayId' => $displayId,
+ 'displayGroupId' => $displayGroupId
+ ]
+ )) {
+ $members[] = $displayGroupId;
+ }
+ }
+ }
+
+ if ($parsedBody->getInt('folderId') !== null) {
+ $body .= ' AND `displaygroup`.folderId = :folderId ';
+ $params['folderId'] = $parsedBody->getInt('folderId');
+ }
+
+ // View Permissions
+ $this->viewPermissionSql(
+ 'Xibo\Entity\DisplayGroup',
+ $body,
+ $params,
+ '`displaygroup`.displayGroupId',
+ '`displaygroup`.userId',
+ $filterBy,
+ '`displaygroup`.permissionsFolderId',
+ false
+ );
+
+ // Sorting?
+ $order = '';
+
+ if (isset($members) && $members != []) {
+ $sqlOrderMembers = 'ORDER BY FIELD(displaygroup.displayGroupId,' . implode(',', $members) . ')';
+
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`member`') {
+ $order .= $sqlOrderMembers;
+ continue;
+ }
+
+ if ($sort == '`member` DESC') {
+ $order .= $sqlOrderMembers . ' DESC';
+ continue;
+ }
+ }
+ }
+
+ if (is_array($sortOrder) && (!in_array('`member`', $sortOrder) && !in_array('`member` DESC', $sortOrder))) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($parsedBody->hasParam('start') && $parsedBody->hasParam('length')) {
+ $limit = ' LIMIT ' . $parsedBody->getInt('start', ['default' => 0])
+ . ', ' . $parsedBody->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+ $displayGroupIds = [];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $displayGroup = $this->createEmpty()->hydrate($row, ['intProperties' => ['isDisplaySpecific', 'isDynamic']]);
+ $displayGroup->excludeProperty('displays');
+ $displayGroup->excludeProperty('media');
+ $displayGroup->excludeProperty('events');
+ $displayGroup->excludeProperty('layouts');
+
+ $entries[] = $displayGroup;
+ $displayGroupIds[] = $displayGroup->displayGroupId;
+ }
+
+ // decorate with TagLinks
+ if (count($entries) > 0) {
+ $this->decorateWithTagLinks('lktagdisplaygroup', 'displayGroupId', $displayGroupIds, $entries);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['entity']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/DisplayProfileFactory.php b/lib/Factory/DisplayProfileFactory.php
new file mode 100644
index 0000000..5bb882b
--- /dev/null
+++ b/lib/Factory/DisplayProfileFactory.php
@@ -0,0 +1,642 @@
+.
+ */
+namespace Xibo\Factory;
+
+use Xibo\Entity\DisplayProfile;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class DisplayProfileFactory
+ * @package Xibo\Factory
+ */
+class DisplayProfileFactory extends BaseFactory
+{
+ private $customProfileSettings = [];
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ /**
+ * Construct a factory
+ * @param ConfigServiceInterface $config
+ * @param CommandFactory $commandFactory
+ */
+ public function __construct($config, $commandFactory)
+ {
+ $this->config = $config;
+ $this->commandFactory = $commandFactory;
+ }
+
+ /**
+ * @return DisplayProfile
+ */
+ public function createEmpty()
+ {
+ $displayProfile = new DisplayProfile(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this->commandFactory,
+ $this
+ );
+ $displayProfile->config = [];
+
+ return $displayProfile;
+ }
+
+ /**
+ * @param int $displayProfileId
+ * @return DisplayProfile
+ * @throws NotFoundException
+ */
+ public function getById($displayProfileId)
+ {
+ $profiles = $this->query(null, ['disableUserCheck' => 1, 'displayProfileId' => $displayProfileId]);
+
+ if (count($profiles) <= 0) {
+ throw new NotFoundException();
+ }
+
+ $profile = $profiles[0];
+ /* @var DisplayProfile $profile */
+
+ $profile->load([]);
+ return $profile;
+ }
+
+ /**
+ * @param string $type
+ * @return DisplayProfile
+ * @throws NotFoundException
+ */
+ public function getDefaultByType(string $type): DisplayProfile
+ {
+ $profiles = $this->query(null, ['disableUserCheck' => 1, 'type' => $type, 'isDefault' => 1]);
+
+ if (count($profiles) <= 0) {
+ throw new NotFoundException(sprintf(__('No default display profile for %s'), $type));
+ }
+
+ $profile = $profiles[0];
+ $profile->load();
+ return $profile;
+ }
+
+ /**
+ * @param $clientType
+ * @return DisplayProfile
+ * @throws NotFoundException
+ */
+ public function getUnknownProfile($clientType): DisplayProfile
+ {
+ $profile = $this->createEmpty();
+ $profile->type = 'unknown';
+ $profile->setClientType($clientType);
+ $profile->isCustom = 0;
+ $profile->config = [];
+ $profile->configDefault = [];
+ // We shoud not call load as it would be recursive due to unknown also being an unknown profile
+ // $profile->load();
+ return $profile;
+ }
+
+ /**
+ * Get by Command Id
+ * @param $commandId
+ * @return DisplayProfile[]
+ * @throws NotFoundException
+ */
+ public function getByCommandId($commandId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'commandId' => $commandId]);
+ }
+
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ public function createCustomProfile($options)
+ {
+ $params = $this->getSanitizer($options);
+ $displayProfile = $this->createEmpty();
+ $displayProfile->name = $params->getString('name');
+ $displayProfile->type = $params->getString('type');
+ $displayProfile->isDefault = $params->getCheckbox('isDefault');
+ $displayProfile->userId = $params->getInt('userId');
+ $displayProfile->isCustom = 1;
+
+ return $displayProfile;
+ }
+
+ /**
+ * Load the config from the file
+ */
+ public function loadForType($type)
+ {
+ $config = [
+ 'unknown' => [],
+ 'windows' => [
+ ['name' => 'collectInterval', 'default' => 300, 'type' => 'int'],
+ ['name' => 'downloadStartWindow', 'default' => '00:00', 'type' => 'string'],
+ ['name' => 'downloadEndWindow', 'default' => '00:00', 'type' => 'string'],
+ ['name' => 'dayPartId', 'default' => null],
+ ['name' => 'xmrNetworkAddress', 'default' => '', 'type' => 'string'],
+ ['name' => 'xmrWebSocketAddress', 'default' => '', 'type' => 'string'],
+ [
+ 'name' => 'statsEnabled',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0),
+ 'type' => 'checkbox',
+ ],
+ [
+ 'name' => 'aggregationLevel',
+ 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'),
+ 'type' => 'string',
+ ],
+ ['name' => 'powerpointEnabled', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'sizeX', 'default' => 0, 'type' => 'double'],
+ ['name' => 'sizeY', 'default' => 0, 'type' => 'double'],
+ ['name' => 'offsetX', 'default' => 0, 'type' => 'double'],
+ ['name' => 'offsetY', 'default' => 0, 'type' => 'double'],
+ ['name' => 'clientInfomationCtrlKey', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'clientInformationKeyCode', 'default' => 'I', 'type' => 'string'],
+ ['name' => 'logLevel', 'default' => 'error', 'type' => 'string'],
+ ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'],
+ ['name' => 'logToDiskLocation', 'default' => '', 'type' => 'string'],
+ ['name' => 'showInTaskbar', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'cursorStartPosition', 'default' => 'Unchanged', 'type' => 'string'],
+ ['name' => 'doubleBuffering', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'emptyLayoutDuration', 'default' => 10, 'type' => 'int'],
+ ['name' => 'enableMouse', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'enableShellCommands', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'expireModifiedLayouts', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'maxConcurrentDownloads', 'default' => 2, 'type' => 'int'],
+ ['name' => 'shellCommandAllowList', 'default' => '', 'type' => 'string'],
+ ['name' => 'sendCurrentLayoutAsStatusUpdate', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'screenShotRequestInterval', 'default' => 0, 'type' => 'int'],
+ [
+ 'name' => 'screenShotSize',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', 200),
+ 'type' => 'int',
+ ],
+ ['name' => 'maxLogFileUploads', 'default' => 3, 'type' => 'int'],
+ ['name' => 'embeddedServerPort', 'default' => 9696, 'type' => 'int'],
+ ['name' => 'preventSleep', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'forceHttps', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'authServerWhitelist', 'default' => null, 'type' => 'string'],
+ ['name' => 'edgeBrowserWhitelist', 'default' => null, 'type' => 'string'],
+ ['name' => 'embeddedServerAllowWan', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'isRecordGeoLocationOnProofOfPlay', 'default' => 0, 'type' => 'checkbox']
+ ],
+ 'android' => [
+ ['name' => 'emailAddress', 'default' => ''],
+ ['name' => 'settingsPassword', 'default' => ''],
+ ['name' => 'collectInterval', 'default' => 300],
+ ['name' => 'downloadStartWindow', 'default' => '00:00'],
+ ['name' => 'downloadEndWindow', 'default' => '00:00'],
+ ['name' => 'xmrNetworkAddress', 'default' => ''],
+ ['name' => 'xmrWebSocketAddress', 'default' => ''],
+ [
+ 'name' => 'statsEnabled',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0),
+ 'type' => 'checkbox',
+ ],
+ [
+ 'name' => 'aggregationLevel',
+ 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'),
+ 'type' => 'string',
+ ],
+ ['name' => 'orientation', 'default' => 0],
+ ['name' => 'screenDimensions', 'default' => ''],
+ ['name' => 'blacklistVideo', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'storeHtmlOnInternal', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'useSurfaceVideoView', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'logLevel', 'default' => 'error'],
+ ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'],
+ ['name' => 'versionMediaId', 'default' => null],
+ ['name' => 'startOnBoot', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'actionBarMode', 'default' => 1],
+ ['name' => 'actionBarDisplayDuration', 'default' => 30],
+ ['name' => 'actionBarIntent', 'default' => ''],
+ ['name' => 'autoRestart', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'startOnBootDelay', 'default' => 60],
+ ['name' => 'sendCurrentLayoutAsStatusUpdate', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'screenShotRequestInterval', 'default' => 0],
+ ['name' => 'expireModifiedLayouts', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'screenShotIntent', 'default' => ''],
+ [
+ 'name' => 'screenShotSize',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', 200),
+ ],
+ ['name' => 'updateStartWindow', 'default' => '00:00'],
+ ['name' => 'updateEndWindow', 'default' => '00:00'],
+ ['name' => 'dayPartId', 'default' => null],
+ ['name' => 'restartWifiOnConnectionFailure', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'webViewPluginState', 'default' => 'DEMAND'],
+ ['name' => 'hardwareAccelerateWebViewMode', 'default' => '2'],
+ ['name' => 'timeSyncFromCms', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'webCacheEnabled', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'serverPort', 'default' => 9696],
+ ['name' => 'installWithLoadedLinkLibraries', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'forceHttps', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'isUseMultipleVideoDecoders', 'default' => 'default', 'type' => 'string'],
+ ['name' => 'maxRegionCount', 'default' => 0],
+ ['name' => 'embeddedServerAllowWan', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'isRecordGeoLocationOnProofOfPlay', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'videoEngine', 'default' => 'exoplayer', 'type' => 'string'],
+ ['name' => 'isTouchEnabled', 'default' => 0, 'type' => 'checkbox']
+ ],
+ 'linux' => [
+ ['name' => 'collectInterval', 'default' => 300],
+ ['name' => 'downloadStartWindow', 'default' => '00:00'],
+ ['name' => 'downloadEndWindow', 'default' => '00:00'],
+ ['name' => 'dayPartId', 'default' => null],
+ ['name' => 'xmrNetworkAddress', 'default' => ''],
+ ['name' => 'xmrWebSocketAddress', 'default' => ''],
+ [
+ 'name' => 'statsEnabled',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0),
+ 'type' => 'checkbox',
+ ],
+ [
+ 'name' => 'aggregationLevel',
+ 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'),
+ 'type' => 'string',
+ ],
+ ['name' => 'sizeX', 'default' => 0],
+ ['name' => 'sizeY', 'default' => 0],
+ ['name' => 'offsetX', 'default' => 0],
+ ['name' => 'offsetY', 'default' => 0],
+ ['name' => 'logLevel', 'default' => 'error'],
+ ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'],
+ ['name' => 'enableShellCommands', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'expireModifiedLayouts', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'maxConcurrentDownloads', 'default' => 2],
+ ['name' => 'shellCommandAllowList', 'default' => ''],
+ ['name' => 'sendCurrentLayoutAsStatusUpdate', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'screenShotRequestInterval', 'default' => 0],
+ [
+ 'name' => 'screenShotSize',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', 200),
+ ],
+ ['name' => 'maxLogFileUploads', 'default' => 3],
+ ['name' => 'embeddedServerPort', 'default' => 9696],
+ ['name' => 'preventSleep', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'forceHttps', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'embeddedServerAllowWan', 'default' => 0, 'type' => 'checkbox']
+ ],
+ 'lg' => [
+ ['name' => 'emailAddress', 'default' => ''],
+ ['name' => 'collectInterval', 'default' => 300],
+ ['name' => 'downloadStartWindow', 'default' => '00:00'],
+ ['name' => 'downloadEndWindow', 'default' => '00:00'],
+ ['name' => 'dayPartId', 'default' => null],
+ ['name' => 'xmrNetworkAddress', 'default' => ''],
+ ['name' => 'xmrWebSocketAddress', 'default' => ''],
+ [
+ 'name' => 'statsEnabled',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0),
+ 'type' => 'checkbox',
+ ],
+ [
+ 'name' => 'aggregationLevel',
+ 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'),
+ 'type' => 'string',
+ ],
+ ['name' => 'orientation', 'default' => 0],
+ ['name' => 'logLevel', 'default' => 'error'],
+ ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'],
+ ['name' => 'versionMediaId', 'default' => null],
+ ['name' => 'actionBarMode', 'default' => 1],
+ ['name' => 'actionBarDisplayDuration', 'default' => 30],
+ ['name' => 'sendCurrentLayoutAsStatusUpdate', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'mediaInventoryTimer', 'default' => 0],
+ ['name' => 'screenShotRequestInterval', 'default' => 0, 'type' => 'int'],
+ ['name' => 'screenShotSize', 'default' => 1],
+ ['name' => 'timers', 'default' => '{}'],
+ ['name' => 'pictureOptions', 'default' => '{}'],
+ ['name' => 'lockOptions', 'default' => '{}'],
+ ['name' => 'forceHttps', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'updateStartWindow', 'default' => '00:00'],
+ ['name' => 'updateEndWindow', 'default' => '00:00'],
+ ['name' => 'embeddedServerAllowWan', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'serverPort', 'default' => 9696],
+ ['name' => 'isUseMultipleVideoDecoders', 'default' => 'default', 'type' => 'string'],
+ ],
+ 'sssp' => [
+ ['name' => 'emailAddress', 'default' => ''],
+ ['name' => 'collectInterval', 'default' => 300],
+ ['name' => 'downloadStartWindow', 'default' => '00:00'],
+ ['name' => 'downloadEndWindow', 'default' => '00:00'],
+ ['name' => 'dayPartId', 'default' => null],
+ ['name' => 'xmrNetworkAddress', 'default' => ''],
+ ['name' => 'xmrWebSocketAddress', 'default' => ''],
+ [
+ 'name' => 'statsEnabled',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0),
+ 'type' => 'checkbox',
+ ],
+ [
+ 'name' => 'aggregationLevel',
+ 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'),
+ 'type' => 'string',
+ ],
+ ['name' => 'orientation', 'default' => 0],
+ ['name' => 'logLevel', 'default' => 'error'],
+ ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'],
+ ['name' => 'versionMediaId', 'default' => null],
+ ['name' => 'actionBarMode', 'default' => 1],
+ ['name' => 'actionBarDisplayDuration', 'default' => 30],
+ ['name' => 'sendCurrentLayoutAsStatusUpdate', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'mediaInventoryTimer', 'default' => 0],
+ ['name' => 'screenShotRequestInterval', 'default' => 0, 'type' => 'int'],
+ ['name' => 'screenShotSize', 'default' => 1],
+ ['name' => 'timers', 'default' => '{}'],
+ ['name' => 'pictureOptions', 'default' => '{}'],
+ ['name' => 'lockOptions', 'default' => '{}'],
+ ['name' => 'forceHttps', 'default' => 1, 'type' => 'checkbox'],
+ ['name' => 'updateStartWindow', 'default' => '00:00'],
+ ['name' => 'updateEndWindow', 'default' => '00:00'],
+ ['name' => 'embeddedServerAllowWan', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'serverPort', 'default' => 9696],
+ ['name' => 'isUseMultipleVideoDecoders', 'default' => 'default', 'type' => 'string'],
+ ],
+ 'chromeOS' => [
+ ['name' => 'licenceCode', 'default' => ''],
+ ['name' => 'collectInterval', 'default' => 300],
+ ['name' => 'xmrNetworkAddress', 'default' => ''],
+ ['name' => 'xmrWebSocketAddress', 'default' => ''],
+ [
+ 'name' => 'statsEnabled',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0),
+ 'type' => 'checkbox',
+ ],
+ [
+ 'name' => 'aggregationLevel',
+ 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'),
+ 'type' => 'string',
+ ],
+ ['name' => 'playerVersionId', 'default' => null],
+ ['name' => 'dayPartId', 'default' => null],
+ ['name' => 'logLevel', 'default' => 'error'],
+ ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'],
+ ['name' => 'sendCurrentLayoutAsStatusUpdate', 'default' => 0, 'type' => 'checkbox'],
+ ['name' => 'screenShotRequestInterval', 'default' => 0, 'type' => 'int'],
+ [
+ 'name' => 'screenShotSize',
+ 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', 200),
+ ],
+ ]
+ ];
+
+ // get array keys (player types) from the default config
+ // called by getAvailableTypes function, to ensure the default types are always available for selection
+ if ($type === 'defaultTypes') {
+ // remove unknown from the array
+ array_shift($config);
+
+ // build key value array to merge with distinct types from database
+ $defaultTypes = [];
+ foreach (array_keys($config) as $type) {
+ $defaultTypes[]['type'] = $type;
+ }
+ return $defaultTypes;
+ }
+
+ if (!isset($config[$type])) {
+ try {
+ $config[$type] = $this->getCustomProfileConfig($type);
+ } catch (GeneralException $exception) {
+ $this->getLog()->error('loadForType: error with ' . $type . ', e = ' . $exception->getMessage());
+ $config[$type] = $this->getUnknownProfile($type)->getProfileConfig();
+ }
+ }
+
+ return $config[$type];
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return DisplayProfile[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $profiles = [];
+ $parsedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder === null) {
+ $sortOrder = ['name'];
+ }
+
+
+ $params = [];
+ $select = 'SELECT displayProfileId, name, type, config, isDefault, userId, isCustom ';
+
+ $body = ' FROM `displayprofile` WHERE 1 = 1 ';
+
+ if ($parsedFilter->getInt('displayProfileId') !== null) {
+ $body .= ' AND displayProfileId = :displayProfileId ';
+ $params['displayProfileId'] = $parsedFilter->getInt('displayProfileId');
+ }
+
+ if ($parsedFilter->getInt('isDefault') !== null) {
+ $body .= ' AND isDefault = :isDefault ';
+ $params['isDefault'] = $parsedFilter->getInt('isDefault');
+ }
+
+ // Filter by DisplayProfile Name?
+ if ($parsedFilter->getString('displayProfile') != null) {
+ $terms = explode(',', $parsedFilter->getString('displayProfile'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'displayprofile',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($parsedFilter->getString('type') != null) {
+ $body .= ' AND type = :type ';
+ $params['type'] = $parsedFilter->getString('type');
+ }
+
+ if ($parsedFilter->getInt('commandId') !== null) {
+ $body .= '
+ AND `displayprofile`.displayProfileId IN (
+ SELECT `lkcommanddisplayprofile`.displayProfileId
+ FROM `lkcommanddisplayprofile`
+ WHERE `lkcommanddisplayprofile`.commandId = :commandId
+ )
+ ';
+
+ $params['commandId'] = $parsedFilter->getInt('commandId');
+ }
+
+ if ($parsedFilter->getInt('userId') !== null) {
+ $body .= ' AND `displayprofile`.userId = :userId ';
+ $params['userId'] = $parsedFilter->getInt('userId');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $parsedFilter->getInt('start') !== null && $parsedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) .
+ ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $profile = $this->createEmpty()->hydrate($row, ['intProperties' => ['isDefault', 'isCustom']]);
+
+ $profile->excludeProperty('configDefault');
+ $profile->excludeProperty('configTabs');
+ $profiles[] = $profile;
+ }
+
+ // Paging
+ if ($limit != '' && count($profiles) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $profiles;
+ }
+
+ /**
+ * Called by the Display Profile page and Add form
+ * @return array
+ */
+ public function getAvailableTypes(): array
+ {
+ // get distinct player types from the displayprofile table, this will include any custom profile types as well
+ $dbTypes = $this->getStore()->select('SELECT DISTINCT type FROM `displayprofile` ORDER BY type', []);
+ // get an array of default player types from default config,
+ // this is to ensure we will always have the default types available for add form
+ $defaultTypes = $this->loadForType('defaultTypes');
+
+ // merge arrays removing any duplicates
+ $types = array_unique(array_merge($dbTypes, $defaultTypes), SORT_REGULAR);
+
+ $entries = [];
+ foreach ($types as $row) {
+ $sanitizedRow = $this->getSanitizer($row);
+ if ($sanitizedRow->getString('type') === 'sssp') {
+ $typeName = 'Tizen';
+ } elseif ($sanitizedRow->getString('type') === 'lg') {
+ $typeName = 'webOS';
+ } else {
+ $typeName = ucfirst($sanitizedRow->getString('type'));
+ }
+
+ $entries[] = ['typeId' => $sanitizedRow->getString('type'), 'type' => $typeName];
+ }
+
+ return $entries;
+ }
+
+ public function registerCustomDisplayProfile($type, $class, $template, $defaultConfig, $handleCustomFields)
+ {
+ if (!array_key_exists($type, $this->customProfileSettings)) {
+ $this->customProfileSettings[$type] = [
+ 'class' => $class,
+ 'template' => $template,
+ 'defaultConfig' => $defaultConfig,
+ 'handleCustomFields' => $handleCustomFields
+ ];
+ }
+ }
+
+ public function getCustomEditTemplate($type)
+ {
+ if (!array_key_exists($type, $this->customProfileSettings)) {
+ throw new InvalidArgumentException(sprintf(__('Custom Display Profile not registered correctly for type %s'), $type));
+ }
+
+ if (!array_key_exists('template', $this->customProfileSettings[$type])) {
+ throw new InvalidArgumentException(sprintf(__('Custom template not registered correctly for type %s'), $type));
+ }
+
+ $function = $this->customProfileSettings[$type]['template'];
+ return $this->customProfileSettings[$type]['class']::$function();
+ }
+
+ public function getCustomProfileConfig($type)
+ {
+ if (!array_key_exists($type, $this->customProfileSettings)) {
+ throw new InvalidArgumentException(sprintf(__('Custom Display Profile not registered correctly for type %s'), $type));
+ }
+
+ if (!array_key_exists('defaultConfig', $this->customProfileSettings[$type])) {
+ throw new InvalidArgumentException(sprintf(__('Custom config not registered correctly for type %s'), $type));
+ }
+
+ $function = $this->customProfileSettings[$type]['defaultConfig'];
+ return $this->customProfileSettings[$type]['class']::$function($this->config);
+ }
+
+ public function isCustomType($type)
+ {
+ $results = $this->getStore()->select('SELECT displayProfileId FROM `displayprofile` WHERE isCustom = 1 AND type = :type', [
+ 'type' => $type
+ ]);
+
+ return (count($results) >= 1) ? 1 : 0;
+ }
+
+ public function handleCustomFields(DisplayProfile $displayProfile, SanitizerInterface $sanitizedParams, $config, $display)
+ {
+ if (!array_key_exists($displayProfile->getClientType(), $this->customProfileSettings)) {
+ throw new InvalidArgumentException(sprintf(__('Custom Display Profile not registered correctly for type %s'), $displayProfile->getClientType()));
+ }
+
+ if (!array_key_exists('handleCustomFields', $this->customProfileSettings[$displayProfile->getClientType()])) {
+ throw new InvalidArgumentException(sprintf(__('Custom fields handling not registered correctly for type %s'), $displayProfile->getClientType()));
+ }
+
+ $function = $this->customProfileSettings[$displayProfile->getClientType()]['handleCustomFields'];
+ return $this->customProfileSettings[$displayProfile->getClientType()]['class']::$function($displayProfile, $sanitizedParams, $config, $display, $this->getLog());
+ }
+}
diff --git a/lib/Factory/DisplayTypeFactory.php b/lib/Factory/DisplayTypeFactory.php
new file mode 100644
index 0000000..115f9e5
--- /dev/null
+++ b/lib/Factory/DisplayTypeFactory.php
@@ -0,0 +1,84 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\DisplayType;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DisplayTypeFactory
+ * @package Xibo\Factory
+ */
+class DisplayTypeFactory extends BaseFactory
+{
+ /**
+ * @return DisplayType
+ */
+ public function createEmpty()
+ {
+ return new DisplayType($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get By Id
+ * @param int $id
+ * @return DisplayType
+ * @throws NotFoundException
+ */
+ public function getById($id)
+ {
+ $results = $this->query(null, ['displayTypeId' => $id]);
+
+ if (count($results) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $results[0];
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return DisplayType[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $sql = 'SELECT displayTypeId, displayType FROM `display_types` WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('displayTypeId') !== null) {
+ $sql .= ' AND `display_type`.displayTypeId = :displayTypeId ';
+ $params['displayTypeId'] = $sanitizedFilter->getInt('displayTypeId');
+ }
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/FolderFactory.php b/lib/Factory/FolderFactory.php
new file mode 100644
index 0000000..6e2d1bb
--- /dev/null
+++ b/lib/Factory/FolderFactory.php
@@ -0,0 +1,329 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Folder;
+use Xibo\Entity\User;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Support\Exception\NotFoundException;
+
+class FolderFactory extends BaseFactory
+{
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * Construct a factory
+ * @param PermissionFactory $permissionFactory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($permissionFactory, $user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ $this->permissionFactory = $permissionFactory;
+ }
+
+ /**
+ * @return Folder
+ */
+ public function createEmpty()
+ {
+ return new Folder(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this,
+ $this->permissionFactory
+ );
+ }
+
+ /**
+ * @param int $folderId
+ * @return Folder
+ * @throws NotFoundException
+ */
+ public function getById($folderId, $disableUserCheck = 1)
+ {
+ $folder = $this->query(null, ['folderId' => $folderId, 'disableUserCheck' => $disableUserCheck]);
+
+ if (count($folder) <= 0) {
+ throw new NotFoundException(__('Folder not found'));
+ }
+
+ return $folder[0];
+ }
+
+ /**
+ * @param int $folderId
+ * @return Folder
+ * @throws NotFoundException
+ */
+ public function getByParentId($folderId)
+ {
+ $folder = $this->query(null, ['parentId' => $folderId]);
+
+ if (count($folder) <= 0) {
+ throw new NotFoundException(__('Folder not found'));
+ }
+
+ return $folder[0];
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Folder[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $select = 'SELECT `folderId`,
+ `folderName`,
+ `folderId` AS id,
+ IF(`isRoot`=1, \'Root Folder\', `folderName`) AS text,
+ `parentId`,
+ `isRoot`,
+ `children`,
+ `permissionsFolderId`
+ ';
+
+ $body = '
+ FROM `folder`
+ WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('folderId') !== null) {
+ $body .= ' AND folder.folderId = :folderId ';
+ $params['folderId'] = $sanitizedFilter->getInt('folderId');
+ }
+
+ if ($sanitizedFilter->getInt('parentId') !== null) {
+ $body .= ' AND folder.parentId = :parentId ';
+ $params['parentId'] = $sanitizedFilter->getInt('parentId');
+ }
+
+ if ($sanitizedFilter->getString('folderName') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('folderName'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'folder',
+ 'folderName',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getInt('isRoot') !== null) {
+ $body .= ' AND folder.isRoot = :isRoot ';
+ $params['isRoot'] = $sanitizedFilter->getInt('isRoot');
+ }
+
+ // for the "grid" ie tree view, we need the root folder to keep the tree structure
+ if ($sanitizedFilter->getInt('includeRoot') === 1) {
+ $body .= 'OR folder.isRoot = 1';
+ }
+
+ // get the exact match for the search functionality
+ if ($sanitizedFilter->getInt('exactFolderName') === 1) {
+ $body.= " AND folder.folderName = :exactFolderName ";
+ $params['exactFolderName'] = $sanitizedFilter->getString('folderName');
+ }
+
+ // View Permissions (home folder included in here)
+ $this->viewPermissionSql(
+ 'Xibo\Entity\Folder',
+ $body,
+ $params,
+ '`folder`.folderId',
+ null,
+ $filterBy,
+ 'folder.permissionsFolderId'
+ );
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ if ($filterBy !== null &&
+ $sanitizedFilter->getInt('start') !== null &&
+ $sanitizedFilter->getInt('length') !== null
+ ) {
+ $limit .= ' LIMIT ' . $sanitizedFilter->getInt('start') .
+ ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => ['isRoot', 'homeFolderCount']]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Add the count of times the provided folder has been used as a home folder
+ * @param Folder $folder
+ * @return void
+ */
+ public function decorateWithHomeFolderCount(Folder $folder)
+ {
+ $results = $this->getStore()->select('
+ SELECT COUNT(*) AS cnt
+ FROM `user`
+ WHERE `user`.homeFolderId = :folderId
+ AND `user`.retired = 0
+ ', [
+ 'folderId' => $folder->id,
+ ]);
+
+ $folder->setUnmatchedProperty('homeFolderCount', intval($results[0]['cnt'] ?? 0));
+ }
+
+ /**
+ * Add sharing information to the provided folder
+ * @param Folder $folder
+ * @return void
+ */
+ public function decorateWithSharing(Folder $folder)
+ {
+ $results = $this->getStore()->select('
+ SELECT `group`.group,
+ `group`.isUserSpecific
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :permissionEntity
+ AND objectId = :folderId
+ AND `view` = 1
+ ORDER BY `group`.isUserSpecific
+ ', [
+ 'folderId' => $folder->id,
+ 'permissionEntity' => 'Xibo\Entity\Folder',
+ ]);
+
+ $sharing = [];
+ foreach ($results as $row) {
+ $sharing[] = [
+ 'name' => $row['group'],
+ 'isGroup' => intval($row['isUserSpecific']) !== 1,
+ ];
+ }
+ $folder->setUnmatchedProperty('sharing', $sharing);
+ }
+
+ /**
+ * Add usage information to the provided folder
+ * @param Folder $folder
+ * @return void
+ */
+ public function decorateWithUsage(Folder $folder)
+ {
+ $usage = [];
+
+ $results = $this->getStore()->select('
+ SELECT \'Library\' AS `type`,
+ COUNT(mediaId) AS cnt,
+ SUM(fileSize) AS `size`
+ FROM media
+ WHERE folderId = :folderId
+ AND moduleSystemFile = 0
+ UNION ALL
+ SELECT IF (campaign.isLayoutSpecific = 1, \'Layouts\', \'Campaigns\') AS `type`,
+ COUNT(*) AS cnt,
+ 0 AS `size`
+ FROM campaign
+ WHERE campaign.folderId = :folderId
+ GROUP BY campaign.isLayoutSpecific
+ UNION ALL
+ SELECT IF (displaygroup.isDisplaySpecific = 1, \'Displays\', \'Display Groups\') AS `type`,
+ COUNT(*) AS cnt,
+ 0 AS `size`
+ FROM displaygroup
+ WHERE displaygroup.folderId = :folderId
+ GROUP BY displaygroup.isDisplaySpecific
+ UNION ALL
+ SELECT \'DataSets\' AS `type`,
+ COUNT(*) AS cnt,
+ 0 AS `size`
+ FROM dataset
+ WHERE dataset.folderId = :folderId
+ UNION ALL
+ SELECT \'Playlists\' AS `type`,
+ COUNT(*) AS cnt,
+ 0 AS `size`
+ FROM playlist
+ WHERE playlist.folderId = :folderId
+ AND IFNULL(playlist.regionId, 0) = 0
+ UNION ALL
+ SELECT \'Menu Boards\' AS `type`,
+ COUNT(*) AS cnt,
+ 0 AS `size`
+ FROM menu_board
+ WHERE menu_board.folderId = :folderId
+ UNION ALL
+ SELECT \'Sync Groups\' AS `type`,
+ COUNT(*) AS cnt,
+ 0 AS `size`
+ FROM syncgroup
+ WHERE syncgroup.folderId = :folderId
+ ORDER BY 1
+ ', [
+ 'folderId' => $folder->id,
+ ]);
+
+ foreach ($results as $row) {
+ $count = intval($row['cnt'] ?? 0);
+ if ($count > 0) {
+ $usage[] = [
+ 'type' => __($row['type']),
+ 'count' => $count,
+ 'sizeBytes' => intval($row['size'] ?? 0),
+ 'size' => ByteFormatter::format(intval($row['size'] ?? 0)),
+ ];
+ }
+ }
+
+ $folder->setUnmatchedProperty('usage', $usage);
+ }
+}
diff --git a/lib/Factory/FontFactory.php b/lib/Factory/FontFactory.php
new file mode 100644
index 0000000..726afe4
--- /dev/null
+++ b/lib/Factory/FontFactory.php
@@ -0,0 +1,222 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use FontLib\Exception\FontNotFoundException;
+use Xibo\Entity\Font;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+class FontFactory extends BaseFactory
+{
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ public function __construct(ConfigServiceInterface $configService)
+ {
+ $this->config = $configService;
+ }
+ /**
+ * @return Font
+ */
+ public function createEmpty()
+ {
+ return new Font(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this
+ );
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws FontNotFoundException
+ */
+ public function createFontFromUpload(string $file, string $name, string $fileName, $modifiedBy): Font
+ {
+ $font = $this->createEmpty();
+ $fontLib = \FontLib\Font::load($file);
+
+ // check embed flag
+ $embed = intval($fontLib->getData('OS/2', 'fsType'));
+
+ // if it's not embeddable, throw exception
+ if ($embed != 0 && $embed != 8) {
+ throw new InvalidArgumentException(__('Font file is not embeddable due to its permissions'));
+ }
+
+ $name = ($name == '') ? $fontLib->getFontName() . ' ' . $fontLib->getFontSubfamily() : $name;
+
+ $font->modifiedBy = $modifiedBy;
+ $font->name = $name;
+ $font->familyName = strtolower(
+ preg_replace(
+ '/\s+/',
+ ' ',
+ preg_replace(
+ '/\d+/u',
+ '',
+ $fontLib->getFontName() . ' ' . $fontLib->getFontSubfamily()
+ )
+ )
+ );
+
+ $font->fileName = preg_replace('/[^-.\w]/', '-', $fileName);
+ $font->size = filesize($file);
+ $font->md5 = md5_file($file);
+
+ return $font;
+ }
+
+ /**
+ * @param $id
+ * @return Font
+ * @throws NotFoundException
+ */
+ public function getById($id): Font
+ {
+ $fonts = $this->query(null, ['id' => $id]);
+
+ if (count($fonts) <= 0) {
+ throw new NotFoundException('Font with id ' . $id . ' not found');
+ }
+
+ return $fonts[0];
+ }
+
+ /**
+ * @param $name
+ * @return Font[]
+ */
+ public function getByName($name)
+ {
+ return $this->query(null, ['name' => $name]);
+ }
+
+ /**
+ * @param $fileName
+ * @return Font[]
+ */
+ public function getByFileName($fileName)
+ {
+ return $this->query(null, ['fileName' => $fileName]);
+ }
+
+ /**
+ * Get the number of fonts and their total size
+ * @return mixed
+ */
+ public function getFontsSizeAndCount()
+ {
+ return $this->getStore()->select('
+ SELECT IFNULL(SUM(size), 0) AS SumSize, COUNT(*) AS totalCount FROM `fonts`
+ ', [])[0];
+ }
+
+ /**
+ * @param $sortOrder
+ * @param $filterBy
+ * @return Font[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $select = 'SELECT
+ `id`,
+ `createdAt`,
+ `modifiedAt`,
+ `modifiedBy`,
+ `name`,
+ `fileName`,
+ `familyName`,
+ `size`,
+ `md5`
+ ';
+
+ $body = '
+ FROM `fonts`
+ WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('id') !== null) {
+ $body .= ' AND `fonts`.id = :id ';
+ $params['id'] = $sanitizedFilter->getInt('id');
+ }
+
+ if ($sanitizedFilter->getString('name') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'fonts',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getString('fileName') != null) {
+ $body .= ' AND `fonts`.fileName = :fileName ';
+ $params['fileName'] = $sanitizedFilter->getString('fileName');
+ }
+
+ if ($sanitizedFilter->getString('md5') != null) {
+ $body .= ' AND `fonts`.md5 = :md5 ';
+ $params['md5'] = $sanitizedFilter->getString('md5');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit .= ' LIMIT ' . intval($sanitizedFilter->getInt('start')) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => ['size']]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/LayoutFactory.php b/lib/Factory/LayoutFactory.php
new file mode 100644
index 0000000..56b88d7
--- /dev/null
+++ b/lib/Factory/LayoutFactory.php
@@ -0,0 +1,3363 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use Stash\Invalidation;
+use Stash\Pool;
+use Xibo\Entity\DataSet;
+use Xibo\Entity\Folder;
+use Xibo\Entity\Layout;
+use Xibo\Entity\Module;
+use Xibo\Entity\Playlist;
+use Xibo\Entity\Region;
+use Xibo\Entity\User;
+use Xibo\Entity\Widget;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\SubPlaylistItem;
+
+/**
+ * Class LayoutFactory
+ * @package Xibo\Factory
+ */
+class LayoutFactory extends BaseFactory
+{
+ use TagTrait;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var RegionFactory
+ */
+ private $regionFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var ModuleFactory
+ */
+ private $moduleFactory;
+
+ /**
+ * @var ModuleTemplateFactory
+ */
+ private $moduleTemplateFactory;
+
+ /**
+ * @var ResolutionFactory
+ */
+ private $resolutionFactory;
+
+ /**
+ * @var WidgetFactory
+ */
+ private $widgetFactory;
+
+ /**
+ * @var WidgetOptionFactory
+ */
+ private $widgetOptionFactory;
+
+ /** @var WidgetAudioFactory */
+ private $widgetAudioFactory;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var ActionFactory */
+ private $actionFactory;
+
+ /** @var FolderFactory */
+ private $folderFactory;
+ /**
+ * @var FontFactory
+ */
+ private $fontFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ * @param PermissionFactory $permissionFactory
+ * @param RegionFactory $regionFactory
+ * @param TagFactory $tagFactory
+ * @param CampaignFactory $campaignFactory
+ * @param MediaFactory $mediaFactory
+ * @param ModuleFactory $moduleFactory
+ * @param ModuleTemplateFactory $moduleTemplateFactory
+ * @param ResolutionFactory $resolutionFactory
+ * @param WidgetFactory $widgetFactory
+ * @param WidgetOptionFactory $widgetOptionFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param WidgetAudioFactory $widgetAudioFactory
+ * @param ActionFactory $actionFactory
+ * @param FolderFactory $folderFactory
+ * @param FontFactory $fontFactory
+ */
+ public function __construct(
+ $user,
+ $userFactory,
+ $config,
+ $permissionFactory,
+ $regionFactory,
+ $tagFactory,
+ $campaignFactory,
+ $mediaFactory,
+ $moduleFactory,
+ $moduleTemplateFactory,
+ $resolutionFactory,
+ $widgetFactory,
+ $widgetOptionFactory,
+ $playlistFactory,
+ $widgetAudioFactory,
+ $actionFactory,
+ $folderFactory,
+ FontFactory $fontFactory,
+ private readonly WidgetDataFactory $widgetDataFactory
+ ) {
+ $this->setAclDependencies($user, $userFactory);
+ $this->config = $config;
+ $this->permissionFactory = $permissionFactory;
+ $this->regionFactory = $regionFactory;
+ $this->tagFactory = $tagFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ $this->resolutionFactory = $resolutionFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->widgetOptionFactory = $widgetOptionFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->widgetAudioFactory = $widgetAudioFactory;
+ $this->actionFactory = $actionFactory;
+ $this->folderFactory = $folderFactory;
+ $this->fontFactory = $fontFactory;
+ }
+
+ /**
+ * Create an empty layout
+ * @return Layout
+ */
+ public function createEmpty()
+ {
+ return new Layout(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this->permissionFactory,
+ $this->regionFactory,
+ $this->tagFactory,
+ $this->campaignFactory,
+ $this,
+ $this->mediaFactory,
+ $this->moduleFactory,
+ $this->moduleTemplateFactory,
+ $this->playlistFactory,
+ $this->actionFactory,
+ $this->folderFactory,
+ $this->fontFactory
+ );
+ }
+
+ /**
+ * Create Layout from Resolution
+ * @param int $resolutionId
+ * @param int $ownerId
+ * @param string $name
+ * @param string $description
+ * @param string|array $tags
+ * @param string $code
+ * @param bool $addRegion
+ * @return Layout
+ *
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function createFromResolution($resolutionId, $ownerId, $name, $description, $tags, $code, $addRegion = true)
+ {
+ $resolution = $this->resolutionFactory->getById($resolutionId);
+
+ // Create a new Layout
+ $layout = $this->createEmpty();
+ $layout->width = $resolution->width;
+ $layout->height = $resolution->height;
+ $layout->orientation = ($layout->width >= $layout->height) ? 'landscape' : 'portrait';
+
+ // Set the properties
+ $layout->layout = $name;
+ $layout->description = $description;
+ $layout->backgroundzIndex = 0;
+ $layout->backgroundColor = '#000000';
+ $layout->code = $code;
+
+ // Set the owner
+ $layout->setOwner($ownerId);
+
+ // Create some tags
+ if (is_array($tags)) {
+ $layout->updateTagLinks($tags);
+ } else {
+ $layout->updateTagLinks($this->tagFactory->tagsFromString($tags));
+ }
+
+ // Add a blank, full screen region
+ if ($addRegion) {
+ $layout->regions[] = $this->regionFactory->create(
+ 'zone',
+ $ownerId,
+ $name . '-1',
+ $layout->width,
+ $layout->height,
+ 0,
+ 0
+ );
+ }
+
+ return $layout;
+ }
+
+ /**
+ * @param \Xibo\Entity\Layout $layout
+ * @param string $type
+ * @param int $width
+ * @param int $height
+ * @param int $top
+ * @param int $left
+ * @return \Xibo\Entity\Layout
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function addRegion(Layout $layout, string $type, int $width, int $height, int $top, int $left): Layout
+ {
+ $layout->regions[] = $this->regionFactory->create(
+ $type,
+ $layout->ownerId,
+ $layout->layout . '-' . count($layout->regions),
+ $width,
+ $height,
+ $top,
+ $left
+ );
+
+ return $layout;
+ }
+
+ /**
+ * Load a layout by its ID
+ * @param int $layoutId
+ * @return Layout The Layout
+ * @throws NotFoundException
+ */
+ public function loadById($layoutId)
+ {
+ // Get the layout
+ $layout = $this->getById($layoutId);
+ // Load the layout
+ $layout->load();
+
+ return $layout;
+ }
+
+ /**
+ * Loads only the layout information
+ * @param int $layoutId
+ * @return Layout
+ * @throws NotFoundException
+ */
+ public function getById($layoutId)
+ {
+ if (empty($layoutId)) {
+ throw new NotFoundException(__('LayoutId is 0'));
+ }
+
+ $layouts = $this->query(null, array('disableUserCheck' => 1, 'layoutId' => $layoutId, 'excludeTemplates' => -1, 'retired' => -1));
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Layout not found'));
+ }
+
+ // Set our layout
+ return $layouts[0];
+ }
+
+ /**
+ * Get CampaignId from layout history
+ * @param int $layoutId
+ * @return int campaignId
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function getCampaignIdFromLayoutHistory($layoutId)
+ {
+ if ($layoutId == null) {
+ throw new InvalidArgumentException(__('Invalid Input'), 'layoutId');
+ }
+
+ $row = $this->getStore()->select('SELECT campaignId FROM `layouthistory` WHERE layoutId = :layoutId LIMIT 1', ['layoutId' => $layoutId]);
+
+ if (count($row) <= 0) {
+ throw new NotFoundException(__('Layout does not exist'));
+ }
+
+ return intval($row[0]['campaignId']);
+ }
+
+
+ /**
+ * Get layout by layout history
+ * @param int $layoutId
+ * @return Layout
+ * @throws NotFoundException
+ */
+ public function getByLayoutHistory($layoutId)
+ {
+ // Get a Layout by its Layout HistoryId
+ $layouts = $this->query(null, array('disableUserCheck' => 1, 'layoutHistoryId' => $layoutId, 'excludeTemplates' => -1, 'retired' => -1));
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Layout not found'));
+ }
+
+ // Set our layout
+ return $layouts[0];
+ }
+
+ /**
+ * Get latest layoutId by CampaignId from layout history
+ * @param int campaignId
+ * @return int layoutId
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function getLatestLayoutIdFromLayoutHistory($campaignId)
+ {
+ if ($campaignId == null) {
+ throw new InvalidArgumentException(__('Invalid Input'), 'campaignId');
+ }
+
+ $row = $this->getStore()->select('SELECT MAX(layoutId) AS layoutId FROM `layouthistory` WHERE campaignId = :campaignId ', ['campaignId' => $campaignId]);
+
+ if (count($row) <= 0) {
+ throw new NotFoundException(__('Layout does not exist'));
+ }
+
+ // Set our Layout ID
+ return intval($row[0]['layoutId']);
+ }
+
+ /**
+ * Loads only the layout information
+ * @param int $layoutId
+ * @return Layout
+ * @throws NotFoundException
+ */
+ public function getByParentId($layoutId)
+ {
+ if (empty($layoutId)) {
+ throw new NotFoundException();
+ }
+
+ $layouts = $this->query(null, array('disableUserCheck' => 1, 'parentId' => $layoutId, 'excludeTemplates' => -1, 'retired' => -1));
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Layout not found'));
+ }
+
+ // Set our layout
+ return $layouts[0];
+ }
+
+ /**
+ * Get a Layout by its Layout Specific Campaign OwnerId
+ * @param int $campaignId
+ * @return Layout
+ * @throws NotFoundException
+ */
+ public function getByParentCampaignId($campaignId)
+ {
+ if ($campaignId == 0)
+ throw new NotFoundException();
+
+ $layouts = $this->query(null, array('disableUserCheck' => 1, 'ownerCampaignId' => $campaignId, 'excludeTemplates' => -1, 'retired' => -1));
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Layout not found'));
+ }
+
+ // Set our layout
+ return $layouts[0];
+ }
+
+ /**
+ * Get by OwnerId
+ * @param int $ownerId
+ * @return Layout[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, array('userId' => $ownerId, 'excludeTemplates' => -1, 'retired' => -1, 'showDrafts' => 1));
+ }
+
+ /**
+ * Get by CampaignId
+ * @param int $campaignId
+ * @param bool $permissionsCheck Should we check permissions?
+ * @param bool $includeDrafts Should we include draft Layouts in the results?
+ * @return Layout[]
+ * @throws NotFoundException
+ */
+ public function getByCampaignId($campaignId, $permissionsCheck = true, $includeDrafts = false)
+ {
+ return $this->query(['displayOrder'], [
+ 'campaignId' => $campaignId,
+ 'excludeTemplates' => -1,
+ 'retired' => -1,
+ 'disableUserCheck' => $permissionsCheck ? 0 : 1,
+ 'showDrafts' => $includeDrafts ? 1 : 0
+ ]);
+ }
+
+ /**
+ * Get by RegionId
+ * @param int $regionId
+ * @param bool $permissionsCheck Should we check permissions?
+ * @return Layout
+ * @throws NotFoundException
+ */
+ public function getByRegionId($regionId, $permissionsCheck = true)
+ {
+ $layouts = $this->query(['displayOrder'], [
+ 'regionId' => $regionId,
+ 'excludeTemplates' => -1,
+ 'retired' => -1,
+ 'disableUserCheck' => $permissionsCheck ? 0 : 1,
+ 'showDrafts' => 1
+ ]);
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Layout not found'));
+ }
+
+ // Set our layout
+ return $layouts[0];
+ }
+
+ /**
+ * Get by Display Group Id
+ * @param int $displayGroupId
+ * @return Layout[]
+ * @throws NotFoundException
+ */
+ public function getByDisplayGroupId($displayGroupId)
+ {
+ if ($displayGroupId == null) {
+ return [];
+ }
+
+ return $this->query(null, ['disableUserCheck' => 1, 'displayGroupId' => $displayGroupId]);
+ }
+
+ /**
+ * Get by Background Image Id
+ * @param int $backgroundImageId
+ * @return Layout[]
+ * @throws NotFoundException
+ */
+ public function getByBackgroundImageId($backgroundImageId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'backgroundImageId' => $backgroundImageId, 'showDrafts' => 1]);
+ }
+
+ /**
+ * @param string $tag
+ * @return Layout[]
+ * @throws NotFoundException
+ */
+ public function getByTag($tag)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'tags' => $tag, 'exactTags' => 1]);
+ }
+
+ /**
+ * Get by Code identifier
+ * @param string $code
+ * @return Layout
+ * @throws NotFoundException
+ */
+ public function getByCode($code)
+ {
+ $layouts = $this->query(null, ['disableUserCheck' => 1, 'code' => $code, 'excludeTemplates' => -1, 'retired' => -1]);
+
+ if (count($layouts) <= 0) {
+ throw new NotFoundException(__('Layout not found'));
+ }
+
+ // Set our layout
+ return $layouts[0];
+ }
+
+ /**
+ * @param string $type
+ * @param int $id
+ * @param array $properties
+ * @return Layout|null
+ * @throws NotFoundException
+ */
+ public function getLinkedFullScreenLayout(string $type, int $id, array $properties = []): ?Layout
+ {
+ $params = [
+ 'campaignType' => $type
+ ];
+
+ if ($type === 'media') {
+ $params['mediaId'] = $id;
+ } else if ($type === 'playlist') {
+ $params['playlistId'] = $id;
+ }
+
+ if (!empty($properties)) {
+ $params = array_merge($params, $properties);
+ }
+
+ $layouts = $this->query(null, $params);
+
+ if (count($layouts) <= 0) {
+ return null;
+ }
+
+ return $layouts[0];
+ }
+
+ /**
+ * @param int $campaignId
+ * @return int|null
+ */
+ public function getLinkedFullScreenMediaId(int $campaignId): ?int
+ {
+ $mediaId = $this->getStore()->select('SELECT `lkwidgetmedia`.mediaId
+ FROM region
+ INNER JOIN playlist
+ ON playlist.regionId = region.regionId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ INNER JOIN widget
+ ON widget.playlistId = lkplaylistplaylist.childId
+ INNER JOIN lkwidgetmedia
+ ON widget.widgetId = lkwidgetmedia.widgetId
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = region.layoutid AND lkcl.CampaignID = :campaignId',
+ ['campaignId' => $campaignId]
+ );
+
+ if (count($mediaId) <= 0) {
+ return null;
+ }
+
+ return $mediaId[0]['mediaId'];
+ }
+
+ /**
+ * @param int $campaignId
+ * @return int|null
+ */
+ public function getLinkedFullScreenPlaylistId(int $campaignId): ?int
+ {
+ $playlistId = $this->getStore()->select('SELECT `lkplaylistplaylist`.childId AS playlistId
+ FROM region
+ INNER JOIN playlist
+ ON `playlist`.regionId = `region`.regionId
+ INNER JOIN lkplaylistplaylist
+ ON `lkplaylistplaylist`.parentId = `playlist`.playlistId
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = region.layoutid
+ AND lkcl.CampaignID = :campaignId',
+ ['campaignId' => $campaignId]
+ );
+
+ if (count($playlistId) <= 0) {
+ return null;
+ }
+
+ return $playlistId[0]['playlistId'];
+ }
+
+ /**
+ * Load a layout by its XLF
+ * @param string $layoutXlf
+ * @param null $layout
+ * @return \Xibo\Entity\Layout
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function loadByXlf($layoutXlf, $layout = null)
+ {
+ $this->getLog()->debug('Loading Layout by XLF');
+
+ // New Layout
+ if ($layout == null) {
+ $layout = $this->createEmpty();
+ }
+
+ // Parse the XML and fill in the details for this layout
+ $document = new \DOMDocument();
+ if ($document->loadXML($layoutXlf) === false) {
+ throw new InvalidArgumentException(__('Layout import failed, invalid xlf supplied'));
+ }
+
+ $layout->schemaVersion = (int)$document->documentElement->getAttribute('schemaVersion');
+ $layout->width = $document->documentElement->getAttribute('width');
+ $layout->height = $document->documentElement->getAttribute('height');
+ $layout->backgroundColor = $document->documentElement->getAttribute('bgcolor');
+ $layout->backgroundzIndex = (int)$document->documentElement->getAttribute('zindex');
+
+ // Xpath to use when getting media
+ $xpath = new \DOMXPath($document);
+
+ // Populate Region Nodes
+ foreach ($document->getElementsByTagName('region') as $regionNode) {
+ /* @var \DOMElement $regionNode */
+ $this->getLog()->debug('Found Region');
+
+ // Get the ownerId
+ $regionOwnerId = $regionNode->getAttribute('userId');
+ if ($regionOwnerId == null) {
+ $regionOwnerId = $layout->ownerId;
+ }
+
+ // Create the region
+ // we only import from XLF for older layouts which only had playlist type regions.
+ // we start assuming this will be a playlist and update it later if necessary
+ $region = $this->regionFactory->create(
+ 'playlist',
+ $regionOwnerId,
+ $regionNode->getAttribute('name'),
+ (double)$regionNode->getAttribute('width'),
+ (double)$regionNode->getAttribute('height'),
+ (double)$regionNode->getAttribute('top'),
+ (double)$regionNode->getAttribute('left'),
+ (int)$regionNode->getAttribute('zindex')
+ );
+
+ // Use the regionId locally to parse the rest of the XLF
+ $region->tempId = $regionNode->getAttribute('id');
+
+ // Set the region name if empty
+ if ($region->name == '') {
+ $region->name = count($layout->regions) + 1;
+ // make sure we have a string as the region name, otherwise sanitizer will get confused.
+ $region->name = (string)$region->name;
+ }
+ // Populate Playlists (XLF doesn't contain any playlists)
+ $playlist = $this->playlistFactory->create($region->name, $regionOwnerId);
+
+ // Populate region options.
+ foreach ($xpath->query('//region[@id="' . $region->tempId . '"]/options') as $regionOptionsNode) {
+ /* @var \DOMElement $regionOptionsNode */
+ foreach ($regionOptionsNode->childNodes as $regionOption) {
+ /* @var \DOMElement $regionOption */
+ $region->setOptionValue($regionOption->nodeName, $regionOption->textContent);
+ }
+ }
+
+ // Get all widgets
+ foreach ($xpath->query('//region[@id="' . $region->tempId . '"]/media') as $mediaNode) {
+ /* @var \DOMElement $mediaNode */
+
+ $mediaOwnerId = $mediaNode->getAttribute('userId');
+ if ($mediaOwnerId == null) {
+ $mediaOwnerId = $regionOwnerId;
+ }
+ $widget = $this->widgetFactory->createEmpty();
+ $widget->type = $mediaNode->getAttribute('type');
+ $widget->ownerId = $mediaOwnerId;
+ $widget->duration = $mediaNode->getAttribute('duration');
+ $widget->useDuration = $mediaNode->getAttribute('useDuration');
+ // Additional check for importing layouts from 1.7 series, where the useDuration did not exist
+ $widget->useDuration = ($widget->useDuration === '') ? 1 : $widget->useDuration;
+ $widget->tempId = $mediaNode->getAttribute('fileId');
+ $widget->schemaVersion = (int)$mediaNode->getAttribute('schemaVersion');
+ $widgetId = $mediaNode->getAttribute('id');
+
+ // Widget from/to dates.
+ $widget->fromDt = ($mediaNode->getAttribute('fromDt') === '')
+ ? Widget::$DATE_MIN
+ : $mediaNode->getAttribute('fromDt');
+ $widget->toDt = ($mediaNode->getAttribute('toDt') === '')
+ ? Widget::$DATE_MAX
+ : $mediaNode->getAttribute('toDt');
+
+ $this->setWidgetExpiryDatesOrDefault($widget);
+
+ //
+ // Get all widget options
+ //
+ $xpathQuery = '//region[@id="' . $region->tempId . '"]/media[@id="' . $widgetId . '"]/options';
+ foreach ($xpath->query($xpathQuery) as $optionsNode) {
+ /* @var \DOMElement $optionsNode */
+ foreach ($optionsNode->childNodes as $mediaOption) {
+ /* @var \DOMElement $mediaOption */
+ $widgetOption = $this->widgetOptionFactory->createEmpty();
+ $widgetOption->type = 'attrib';
+ $widgetOption->option = $mediaOption->nodeName;
+ $widgetOption->value = $mediaOption->textContent;
+
+ $widget->widgetOptions[] = $widgetOption;
+
+ // Convert the module type of known legacy widgets
+ if ($widget->type == 'ticker'
+ && $widgetOption->option == 'sourceId'
+ && $widgetOption->value == '2'
+ ) {
+ $widget->type = 'datasetticker';
+ }
+ }
+ }
+
+ $this->getLog()->debug(sprintf(
+ 'Added %d options with xPath query: %s',
+ count($widget->widgetOptions),
+ $xpathQuery
+ ));
+
+ // Check legacy types from conditions, set widget type and upgrade
+ try {
+ $module = $this->prepareWidgetAndGetModule($widget);
+ } catch (NotFoundException) {
+ // Skip this widget
+ $this->getLog()->info('loadByJson: ' . $widget->type . ' could not be found or resolved');
+ continue;
+ }
+
+ //
+ // Get the MediaId associated with this widget (using the URI)
+ //
+ if ($module->regionSpecific == 0) {
+ $this->getLog()->debug('Library Widget, getting mediaId');
+
+ if (empty($widget->tempId)) {
+ $this->getLog()->debug(sprintf(
+ 'FileId node is empty, setting tempId from uri option. Options: %s',
+ json_encode($widget->widgetOptions)
+ ));
+ $mediaId = explode('.', $widget->getOptionValue('uri', '0.*'));
+ $widget->tempId = $mediaId[0];
+ }
+
+ $this->getLog()->debug('Assigning mediaId %d', $widget->tempId);
+ $widget->assignMedia($widget->tempId);
+ }
+
+ //
+ // Get all widget raw content
+ //
+ $rawNodes = $xpath->query('//region[@id="' . $region->tempId . '"]/media[@id="' . $widgetId . '"]/raw');
+ foreach ($rawNodes as $rawNode) {
+ /* @var \DOMElement $rawNode */
+ // Get children
+ foreach ($rawNode->childNodes as $mediaOption) {
+ /* @var \DOMElement $mediaOption */
+ if ($mediaOption->textContent == null) {
+ continue;
+ }
+ $widgetOption = $this->widgetOptionFactory->createEmpty();
+ $widgetOption->type = 'cdata';
+ $widgetOption->option = $mediaOption->nodeName;
+ $widgetOption->value = $mediaOption->textContent;
+
+ $widget->widgetOptions[] = $widgetOption;
+ }
+ }
+
+ //
+ // Audio
+ //
+ $rawNodes = $xpath
+ ->query('//region[@id="' . $region->tempId . '"]/media[@id="' . $widgetId . '"]/audio');
+ foreach ($rawNodes as $rawNode) {
+ /* @var \DOMElement $rawNode */
+ // Get children
+ foreach ($rawNode->childNodes as $audioNode) {
+ /* @var \DOMElement $audioNode */
+ if ($audioNode->textContent == null) {
+ continue;
+ }
+ $audioMediaId = $audioNode->getAttribute('mediaId');
+
+ if (empty($audioMediaId)) {
+ // Try to parse it from the text content
+ $audioMediaId = explode('.', $audioNode->textContent)[0];
+ }
+
+ $widgetAudio = $this->widgetAudioFactory->createEmpty();
+ $widgetAudio->mediaId = $audioMediaId;
+ $widgetAudio->volume = $audioNode->getAttribute('volume');
+ $widgetAudio->loop = $audioNode->getAttribute('loop');
+
+ $widget->assignAudio($widgetAudio);
+ }
+ }
+
+ // Add the widget to the playlist
+ $playlist->assignWidget($widget);
+ }
+
+ // See if this region can be converted to a frame or zone (it is already a playlist)
+ if (count($playlist->widgets) === 1) {
+ $region->type = 'frame';
+ } else if (count($playlist->widgets) === 0) {
+ $region->type = 'zone';
+ }
+
+ // Assign Playlist to the Region
+ $region->regionPlaylist = $playlist;
+
+ // Assign the region to the Layout
+ $layout->regions[] = $region;
+ }
+
+ $this->getLog()->debug(sprintf('Finished loading layout - there are %d regions.', count($layout->regions)));
+
+ // Load any existing tags
+ if (!is_array($layout->tags)) {
+ $layout->tags = $this->tagFactory->tagsFromString($layout->tags);
+ }
+
+ foreach ($xpath->query('//tags/tag') as $tagNode) {
+ /* @var \DOMElement $tagNode */
+ if (trim($tagNode->textContent) == '') {
+ continue;
+ }
+ $layout->tags[] = $this->tagFactory->tagFromString($tagNode->textContent);
+ }
+
+ // The parsed, finished layout
+ return $layout;
+ }
+
+ /**
+ * @param $layoutJson
+ * @param $playlistJson
+ * @param $nestedPlaylistJson
+ * @param Folder $folder
+ * @param null $layout
+ * @param bool $importTags
+ * @return array
+ * @throws DuplicateEntityException
+ * @throws GeneralException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function loadByJson($layoutJson, $playlistJson, $nestedPlaylistJson, Folder $folder, $layout = null, $importTags = false): array
+ {
+ $this->getLog()->debug('Loading Layout by JSON');
+
+ // New Layout
+ if ($layout == null) {
+ $layout = $this->createEmpty();
+ }
+
+ $playlists = [];
+ $oldIds = [];
+ $newIds = [];
+ $widgets = [];
+
+ $layout->schemaVersion = (int)$layoutJson['layoutDefinitions']['schemaVersion'];
+ $layout->width = $layoutJson['layoutDefinitions']['width'];
+ $layout->height = $layoutJson['layoutDefinitions']['height'];
+ $layout->backgroundColor = $layoutJson['layoutDefinitions']['backgroundColor'];
+ $layout->backgroundzIndex = (int)$layoutJson['layoutDefinitions']['backgroundzIndex'];
+ $layout->actions = [];
+ $layout->autoApplyTransitions = $layoutJson['layoutDefinitions']['autoApplyTransitions'] ?? 0;
+ $actions = $layoutJson['layoutDefinitions']['actions'] ?? [];
+
+ foreach ($actions as $action) {
+ $newAction = $this->actionFactory->create(
+ $action['triggerType'],
+ $action['triggerCode'],
+ $action['actionType'],
+ 'importLayout',
+ $action['sourceId'],
+ $action['target'],
+ $action['targetId'],
+ $action['widgetId'],
+ $action['layoutCode'],
+ $action['layoutId'] ?? null
+ );
+ $newAction->save(['validate' => false]);
+ }
+
+
+ // Nested Playlists are Playlists which exist below the first level of Playlists in Sub-Playlist Widgets
+ // we need to import and save them first.
+ if ($nestedPlaylistJson != null) {
+ $this->getLog()->debug('Layout import, creating nested Playlists from JSON, there are ' . count($nestedPlaylistJson) . ' Playlists to create');
+
+ // create all nested Playlists, save their widgets to key=>value array
+ foreach ($nestedPlaylistJson as $nestedPlaylist) {
+ $newPlaylist = $this->playlistFactory->createEmpty()->hydrate($nestedPlaylist);
+ $newPlaylist->tags = [];
+
+ // Populate tags
+ if ($nestedPlaylist['tags'] !== null && count($nestedPlaylist['tags']) > 0 && $importTags) {
+ foreach ($nestedPlaylist['tags'] as $tag) {
+ $newPlaylist->tags[] = $this->tagFactory->tagFromString(
+ $tag['tag'] . (!empty($tag['value']) ? '|' . $tag['value'] : '')
+ );
+ }
+ }
+
+ $oldIds[] = $newPlaylist->playlistId;
+ $widgets[$newPlaylist->playlistId] = $newPlaylist->widgets;
+
+ $this->setOwnerAndSavePlaylist($newPlaylist, $folder);
+
+ $newIds[] = $newPlaylist->playlistId;
+ }
+
+ $combined = array_combine($oldIds, $newIds);
+
+ // this function will go through all widgets assigned to the nested Playlists, create the widgets, adjust the Ids and return an array of Playlists
+ // then the Playlists array is used later on to adjust mediaIds if needed
+ $playlists = $this->createNestedPlaylistWidgets($widgets, $combined, $playlists);
+
+ $this->getLog()->debug('Finished creating nested playlists there are ' . count($playlists) . ' Playlists created');
+ }
+
+ $drawers = (array_key_exists('drawers', $layoutJson['layoutDefinitions'])) ? $layoutJson['layoutDefinitions']['drawers'] : [];
+
+ // merge Layout Regions and Drawers into one array.
+ $allRegions = array_merge($layoutJson['layoutDefinitions']['regions'], $drawers);
+
+ // Populate Region Nodes
+ foreach ($allRegions as $regionJson) {
+ $this->getLog()->debug('Found Region');
+
+ // Get the ownerId
+ $regionOwnerId = $regionJson['ownerId'];
+ if ($regionOwnerId == null) {
+ $regionOwnerId = $layout->ownerId;
+ }
+
+ $regionIsDrawer = isset($regionJson['isDrawer']) ? (int)$regionJson['isDrawer'] : 0;
+ $regionWidgets = $regionJson['regionPlaylist']['widgets'] ?? [];
+
+ // Do we have a region type specified (i.e. is the export from v4)
+ // Or determine the region type based on how many widgets we have and whether we're the drawer
+ if (!empty($regionJson['type'] ?? null)) {
+ $regionType = $regionJson['type'];
+ } else if ($regionIsDrawer === 1) {
+ $regionType = 'drawer';
+ } else if (count($regionWidgets) === 1 && !$this->hasSubPlaylist($regionWidgets)) {
+ $regionType = 'frame';
+ } else if (count($regionWidgets) === 0) {
+ $regionType = 'zone';
+ } else {
+ $regionType = 'playlist';
+ }
+
+ // Create the region
+ $region = $this->regionFactory->create(
+ $regionType,
+ $regionOwnerId,
+ $regionJson['name'],
+ (double)$regionJson['width'],
+ (double)$regionJson['height'],
+ (double)$regionJson['top'],
+ (double)$regionJson['left'],
+ (int)$regionJson['zIndex'],
+ $regionIsDrawer
+ );
+
+ // Use the regionId locally to parse the rest of the JSON
+ $region->tempId = $regionJson['tempId'] ?? $regionJson['regionId'];
+
+ // Set the region name if empty
+ if ($region->name == '') {
+ $region->name = count($layout->regions) + 1;
+ // make sure we have a string as the region name, otherwise sanitizer will get confused.
+ $region->name = (string)$region->name;
+ }
+
+ // Populate Playlists
+ $playlist = $this->playlistFactory->create($region->name, $regionOwnerId);
+
+ // interactive Actions
+ $actions = $regionJson['actions'] ?? [];
+ foreach ($actions as $action) {
+ $newAction = $this->actionFactory->create(
+ $action['triggerType'],
+ $action['triggerCode'],
+ $action['actionType'],
+ 'importRegion',
+ $action['sourceId'],
+ $action['target'],
+ $action['targetId'],
+ $action['widgetId'],
+ $action['layoutCode'],
+ $action['layoutId'] ?? null
+ );
+ $newAction->save(['validate' => false]);
+ }
+
+ foreach ($regionJson['regionOptions'] as $regionOption) {
+ $region->setOptionValue($regionOption['option'], $regionOption['value']);
+ }
+
+ // Get all widgets
+ foreach ($regionWidgets as $mediaNode) {
+ $mediaOwnerId = $mediaNode['ownerId'];
+ if ($mediaOwnerId == null) {
+ $mediaOwnerId = $regionOwnerId;
+ }
+
+ $widget = $this->widgetFactory->createEmpty();
+ $widget->type = $mediaNode['type'];
+ $widget->ownerId = $mediaOwnerId;
+ $widget->duration = $mediaNode['duration'];
+ $widget->useDuration = $mediaNode['useDuration'];
+ $widget->tempId = (int)implode(',', $mediaNode['mediaIds']);
+ $widget->tempWidgetId = $mediaNode['widgetId'];
+ $widget->schemaVersion = isset($mediaNode['schemaVersion']) ? (int)$mediaNode['schemaVersion'] : 1;
+
+ // Widget from/to dates.
+ $widget->fromDt = ($mediaNode['fromDt'] === '') ? Widget::$DATE_MIN : $mediaNode['fromDt'];
+ $widget->toDt = ($mediaNode['toDt'] === '') ? Widget::$DATE_MAX : $mediaNode['toDt'];
+
+ $this->setWidgetExpiryDatesOrDefault($widget);
+
+ $this->getLog()->debug('Adding Widget to object model. ' . $widget);
+
+ // Prepare widget options
+ foreach ($mediaNode['widgetOptions'] as $optionsNode) {
+ $widgetOption = $this->widgetOptionFactory->createEmpty();
+ $widgetOption->type = $optionsNode['type'];
+ $widgetOption->option = $optionsNode['option'];
+ $widgetOption->value = $optionsNode['value'];
+ $widget->widgetOptions[] = $widgetOption;
+ }
+
+ // Resolve the module
+ try {
+ $module = $this->prepareWidgetAndGetModule($widget);
+ } catch (NotFoundException) {
+ // Skip this widget
+ $this->getLog()->info('loadByJson: ' . $widget->type . ' could not be found or resolved');
+ continue;
+ }
+
+ //
+ // Get the MediaId associated with this widget
+ //
+ if ($module->regionSpecific == 0) {
+ $this->getLog()->debug('Library Widget, getting mediaId');
+
+ $this->getLog()->debug(sprintf('Assigning mediaId %d', $widget->tempId));
+ $widget->assignMedia($widget->tempId);
+ }
+
+ // if we have any elements with mediaIds, make sure we assign them here
+ if ($module->type === 'global' && !empty($mediaNode['mediaIds'])) {
+ foreach ($mediaNode['mediaIds'] as $mediaId) {
+ $this->getLog()->debug(sprintf('Assigning mediaId %d to element', $mediaId));
+ $widget->assignMedia($mediaId);
+ }
+ }
+
+ //
+ // Audio
+ //
+ foreach ($mediaNode['audio'] as $audioNode) {
+ if ($audioNode == []) {
+ continue;
+ }
+
+ $widgetAudio = $this->widgetAudioFactory->createEmpty();
+ $widgetAudio->mediaId = $audioNode['mediaId'];
+ $widgetAudio->volume = $audioNode['volume'];
+ $widgetAudio->loop = $audioNode['loop'];
+ $widget->assignAudio($widgetAudio);
+ }
+
+ // Sub-Playlist widgets with Playlists
+ if ($widget->type == 'subplaylist') {
+ $widgets = [];
+ $this->getLog()->debug(
+ 'Layout import, creating layout Playlists from JSON, there are ' .
+ count($playlistJson) . ' Playlists to create'
+ );
+
+ // Get the subplaylists from widget option
+ $subPlaylistsOption = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
+
+ foreach ($playlistJson as $playlistDetail) {
+ $newPlaylist = $this->playlistFactory->createEmpty()->hydrate($playlistDetail);
+ $newPlaylist->tags = [];
+
+ // Populate tags
+ if ($playlistDetail['tags'] !== null && count($playlistDetail['tags']) > 0 && $importTags) {
+ foreach ($playlistDetail['tags'] as $tag) {
+ $newPlaylist->tags[] = $this->tagFactory->tagFromString(
+ $tag['tag'] . (!empty($tag['value']) ? '|' . $tag['value'] : '')
+ );
+ }
+ }
+
+ // Check to see if it matches our Sub-Playlist widget config
+ foreach ($subPlaylistsOption as $subPlaylistItem) {
+ if ($newPlaylist->playlistId === intval($subPlaylistItem['playlistId'])) {
+ // Store the oldId to swap permissions later
+ $oldIds[] = $newPlaylist->playlistId;
+
+ // Store the Widgets on the Playlist
+ $widgets[$newPlaylist->playlistId] = $newPlaylist->widgets;
+
+ // Save a new Playlist and capture the Id
+ $this->setOwnerAndSavePlaylist($newPlaylist, $folder);
+
+ $newIds[] = $newPlaylist->playlistId;
+ }
+ }
+ }
+
+ $combined = array_combine($oldIds, $newIds);
+
+ $playlists = $this->createNestedPlaylistWidgets($widgets, $combined, $playlists);
+ $updatedSubPlaylists = [];
+ foreach ($combined as $old => $new) {
+ foreach ($subPlaylistsOption as $subPlaylistItem) {
+ if (intval($subPlaylistItem['playlistId']) === $old) {
+ $subPlaylistItem['playlistId'] = $new;
+ $updatedSubPlaylists[] = $subPlaylistItem;
+ }
+ }
+ }
+
+ $widget->setOptionValue('subPlaylists', 'attrib', json_encode($updatedSubPlaylists));
+ }
+
+ // Add the widget to the regionPlaylist
+ $playlist->assignWidget($widget);
+
+ // interactive Actions
+ $actions = $mediaNode['actions'] ?? [];
+ foreach ($actions as $action) {
+ $newAction = $this->actionFactory->create(
+ $action['triggerType'],
+ $action['triggerCode'],
+ $action['actionType'],
+ 'importWidget',
+ $action['sourceId'],
+ $action['target'],
+ $action['targetId'],
+ $action['widgetId'],
+ $action['layoutCode'],
+ $action['layoutId'] ?? null
+ );
+ $newAction->save(['validate' => false]);
+ }
+ }
+
+ // Assign Playlist to the Region
+ $region->regionPlaylist = $playlist;
+
+ // Assign the region to the Layout
+ if ($region->isDrawer === 1) {
+ $layout->drawers[] = $region;
+ } else {
+ $layout->regions[] = $region;
+ }
+ }
+
+ $this->getLog()->debug(sprintf('Finished loading layout - there are %d regions.', count($layout->regions)));
+
+ $this->getLog()->debug(sprintf('Finished loading layout - there are %d drawer regions.', count($layout->drawers)));
+
+ if ($importTags) {
+ foreach ($layoutJson['layoutDefinitions']['tags'] as $tagNode) {
+ if ($tagNode == []) {
+ continue;
+ }
+
+ $layout->assignTag($this->tagFactory->tagFromString(
+ $tagNode['tag'] . (!empty($tagNode['value']) ? '|' . $tagNode['value'] : '')
+ ));
+ }
+ }
+
+ // The parsed, finished layout
+ return [$layout, $playlists];
+ }
+
+ /**
+ * Create Layout from ZIP File
+ * @param string $zipFile
+ * @param string $layoutName
+ * @param int $userId
+ * @param int $template Are we importing a layout to be used as a template?
+ * @param int $replaceExisting
+ * @param int $importTags
+ * @param bool $useExistingDataSets
+ * @param bool $importDataSetData
+ * @param DataSetFactory $dataSetFactory
+ * @param string $tags
+ * @param MediaServiceInterface $mediaService
+ * @param int $folderId
+ * @param bool $isSystemTags Should we add the system tags (currently the "imported" tag)
+ * @return Layout
+ * @throws \FontLib\Exception\FontNotFoundException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function createFromZip(
+ $zipFile,
+ $layoutName,
+ $userId,
+ $template,
+ $replaceExisting,
+ $importTags,
+ $useExistingDataSets,
+ $importDataSetData,
+ $dataSetFactory,
+ $tags,
+ MediaServiceInterface $mediaService,
+ int $folderId,
+ bool $isSystemTags = true,
+ ) {
+ $this->getLog()->debug(sprintf(
+ 'Create Layout from ZIP File: %s, imported name will be %s.',
+ $zipFile,
+ $layoutName
+ ));
+
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ $libraryLocationTemp = $libraryLocation . 'temp/';
+
+ // Do some pre-checks on the arguments we have been provided
+ if (!file_exists($zipFile)) {
+ throw new InvalidArgumentException(__('File does not exist'));
+ }
+
+ // Open the Zip file
+ $zip = new \ZipArchive();
+ if (!$zip->open($zipFile)) {
+ throw new InvalidArgumentException(__('Unable to open ZIP'));
+ }
+
+ // Get the layout details
+ $layoutJson = $zip->getFromName('layout.json');
+ if (!$layoutJson) {
+ throw new InvalidArgumentException(__('Unable to read layout details from ZIP'));
+ }
+
+ $layoutDetails = json_decode($layoutJson, true);
+
+ // Get the Playlist details
+ $playlistDetails = $zip->getFromName('playlist.json');
+ $nestedPlaylistDetails = $zip->getFromName('nestedPlaylist.json');
+ $folder = $this->folderFactory->getById($folderId);
+
+ // it is no longer possible to re-create a Layout just from xlf
+ // as such if layoutDefinitions are missing, we need to throw an error here.
+ if (array_key_exists('layoutDefinitions', $layoutDetails)) {
+ // Construct the Layout
+ if ($playlistDetails !== false) {
+ $playlistDetails = json_decode(($playlistDetails), true);
+ } else {
+ $playlistDetails = [];
+ }
+
+ if ($nestedPlaylistDetails !== false) {
+ $nestedPlaylistDetails = json_decode($nestedPlaylistDetails, true);
+ } else {
+ $nestedPlaylistDetails = [];
+ }
+
+ $jsonResults = $this->loadByJson(
+ $layoutDetails,
+ $playlistDetails,
+ $nestedPlaylistDetails,
+ $folder,
+ null,
+ $importTags
+ );
+ /** @var Layout $layout */
+ $layout = $jsonResults[0];
+ /** @var Playlist[] $playlists */
+ $playlists = $jsonResults[1];
+
+ if (array_key_exists('code', $layoutDetails['layoutDefinitions'])) {
+ // Layout code, remove it if Layout with the same code already exists in the CMS,
+ // otherwise import would fail.
+ // if the code does not exist, then persist it on import.
+ try {
+ $this->getByCode($layoutDetails['layoutDefinitions']['code']);
+ $layout->code = null;
+ } catch (NotFoundException $exception) {
+ $layout->code = $layoutDetails['layoutDefinitions']['code'];
+ }
+ }
+ } else {
+ throw new InvalidArgumentException(
+ __('Unsupported format. Missing Layout definitions from layout.json file in the archive.')
+ );
+ }
+
+ $this->getLog()->debug('Layout Loaded: ' . $layout);
+
+ // Ensure width and height are integer type for resolution validation purpose xibosignage/xibo#1648
+ $layout->width = (int)$layout->width;
+ $layout->height = (int)$layout->height;
+
+ // Override the name/description
+ $layout->layout = (($layoutName != '') ? $layoutName : $layoutDetails['layout']);
+ $layout->description = $layoutDetails['description'] ?? '';
+
+ // Get global stat setting of layout to on/off proof of play statistics
+ $layout->enableStat = $this->config->getSetting('LAYOUT_STATS_ENABLED_DEFAULT');
+
+ $this->getLog()->debug('Layout Loaded: ' . $layout);
+
+ // Check that the resolution we have in this layout exists, and if not create it.
+ try {
+ if ($layout->schemaVersion < 2) {
+ $this->resolutionFactory->getByDesignerDimensions($layout->width, $layout->height);
+ } else {
+ $this->resolutionFactory->getByDimensions($layout->width, $layout->height);
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->info('Import is for an unknown resolution, we will create it with name: '
+ . $layout->width . ' x ' . $layout->height);
+
+ $resolution = $this->resolutionFactory->create(
+ $layout->width . ' x ' . $layout->height,
+ (int)$layout->width,
+ (int)$layout->height
+ );
+ $resolution->userId = $userId;
+ $resolution->save();
+ }
+
+ // Update region names
+ if (isset($layoutDetails['regions']) && count($layoutDetails['regions']) > 0) {
+ $this->getLog()->debug('Updating region names according to layout.json');
+ foreach ($layout->regions as $region) {
+ if (array_key_exists($region->tempId, $layoutDetails['regions'])
+ && !empty($layoutDetails['regions'][$region->tempId])
+ ) {
+ $region->name = $layoutDetails['regions'][$region->tempId];
+ $region->regionPlaylist->name = $layoutDetails['regions'][$region->tempId];
+ }
+ }
+ }
+
+ // Update drawer region names
+ if (isset($layoutDetails['drawers']) && count($layoutDetails['drawers']) > 0) {
+ $this->getLog()->debug('Updating drawer region names according to layout.json');
+ foreach ($layout->drawers as $drawer) {
+ if (array_key_exists($drawer->tempId, $layoutDetails['drawers'])
+ && !empty($layoutDetails['drawers'][$drawer->tempId])
+ ) {
+ $drawer->name = $layoutDetails['drawers'][$drawer->tempId];
+ $drawer->regionPlaylist->name = $layoutDetails['drawers'][$drawer->tempId];
+ }
+ }
+ }
+
+ // Remove the tags if necessary
+ if (!$importTags) {
+ $this->getLog()->debug('Removing tags from imported layout');
+ $layout->tags = [];
+ }
+
+ // Add the template tag if we are importing a template
+ if ($template) {
+ $layout->assignTag($this->tagFactory->tagFromString('template'));
+ }
+
+ // Add system tags?
+ if ($isSystemTags) {
+ // Tag as imported
+ $layout->assignTag($this->tagFactory->tagFromString('imported'));
+ }
+
+ // Tag from the upload form
+ $tagsFromForm = (($tags != '') ? $this->tagFactory->tagsFromString($tags) : []);
+ foreach ($tagsFromForm as $tagFromForm) {
+ $layout->assignTag($tagFromForm);
+ }
+
+ // Set the owner
+ $layout->setOwner($userId, true);
+
+ // Track if we've added any fonts
+ $fontsAdded = false;
+
+ $widgets = $layout->getAllWidgets();
+ $this->getLog()->debug('Layout has ' . count($widgets) . ' widgets');
+ $this->getLog()->debug('Process mapping.json file.');
+
+ // Go through each region and add the media (updating the media ids)
+ $mappings = json_decode($zip->getFromName('mapping.json'), true);
+ $oldMediaIds = [];
+ $newMediaIds = [];
+ foreach ($mappings as $file) {
+ // Import the Media File
+ $intendedMediaName = $file['name'];
+
+ // Validate the file name
+ $fileName = basename($file['file']);
+ if (empty($fileName) || $fileName == '.' || $fileName == '..') {
+ $this->getLog()->error('Skipping file on import due to invalid filename. ' . $fileName);
+ continue;
+ }
+
+ $temporaryFileName = $libraryLocationTemp . $fileName;
+
+ // Get the file from the ZIP
+ $fileStream = $zip->getStream('library/' . $fileName);
+
+ if ($fileStream === false) {
+ // Log out the entire ZIP file and all entries.
+ $log = 'Problem getting library/' . $fileName . '. Files: ';
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $log .= $zip->getNameIndex($i) . ', ';
+ }
+
+ $this->getLog()->error($log);
+
+ throw new InvalidArgumentException(__('Empty file in ZIP'));
+ }
+
+ // Open a file pointer to stream into
+ $temporaryFileStream = fopen($temporaryFileName, 'w');
+ if (!$temporaryFileStream) {
+ throw new InvalidArgumentException(__('Cannot save media file from ZIP file'), 'temp');
+ }
+
+ // Loop over the file and write into the stream
+ while (!feof($fileStream)) {
+ fwrite($temporaryFileStream, fread($fileStream, 8192));
+ }
+
+ fclose($fileStream);
+ fclose($temporaryFileStream);
+
+ // Check if it's a font file
+ $isFont = (isset($file['font']) && $file['font'] == 1);
+
+ if ($isFont) {
+ try {
+ $font = $this->fontFactory->getByName($intendedMediaName);
+ if (count($font) <= 0) {
+ throw new NotFoundException();
+ }
+ $this->getLog()->debug('Font already exists with name: ' . $intendedMediaName);
+ } catch (NotFoundException) {
+ $this->getLog()->debug('Font does not exist in Library, add it ' . $fileName);
+ // Add the Font
+ $font = $this->fontFactory->createFontFromUpload(
+ $temporaryFileName,
+ $file['name'],
+ $fileName,
+ $this->getUser()->userName,
+ );
+ $font->save();
+ $fontsAdded = true;
+
+ // everything is fine, move the file from temp folder.
+ rename($temporaryFileName, $libraryLocation . 'fonts/' . $font->fileName);
+ }
+
+ // Fonts do not create media records, so we have nothing left to do in the rest of this loop
+ continue;
+ } else {
+ try {
+ $media = $this->mediaFactory->getByName($intendedMediaName);
+
+ $this->getLog()->debug('Media already exists with name: ' . $intendedMediaName);
+
+ if ($replaceExisting) {
+ // Media with this name already exists, but we don't want to use it.
+ $intendedMediaName = 'import_' . $layout->layout . '_' . uniqid();
+ throw new NotFoundException();
+ }
+ } catch (NotFoundException $e) {
+ // Create it instead
+ $this->getLog()->debug('Media does not exist in Library, add it ' . $fileName);
+
+ $media = $this->mediaFactory->create(
+ $intendedMediaName,
+ $fileName,
+ $file['type'],
+ $userId,
+ $file['duration']
+ );
+
+ if ($importTags && isset($file['tags'])) {
+ foreach ($file['tags'] as $tagNode) {
+ if ($tagNode == []) {
+ continue;
+ }
+
+ $media->assignTag($this->tagFactory->tagFromString(
+ $tagNode['tag'] . (!empty($tagNode['value']) ? '|' . $tagNode['value'] : '')
+ ));
+ }
+ }
+
+ $media->assignTag($this->tagFactory->tagFromString('imported'));
+ $media->folderId = $folder->id;
+ $media->permissionsFolderId =
+ ($folder->permissionsFolderId == null) ? $folder->id : $folder->permissionsFolderId;
+ // Get global stat setting of media to set to on/off/inherit
+ $media->enableStat = $this->config->getSetting('MEDIA_STATS_ENABLED_DEFAULT');
+ $media->save();
+ }
+ }
+
+ // Find where this is used and swap for the real mediaId
+ $oldMediaId = $file['mediaid'];
+ $newMediaId = $media->mediaId;
+ $oldMediaIds[] = $oldMediaId;
+ $newMediaIds[] = $newMediaId;
+
+ if ($file['background'] == 1) {
+ // Set the background image on the new layout
+ $layout->backgroundImageId = $newMediaId;
+ } else {
+ // Go through all widgets and replace if necessary
+ // Keep the keys the same? Doesn't matter
+ foreach ($widgets as $widget) {
+ $audioIds = $widget->getAudioIds();
+
+ $this->getLog()->debug(sprintf(
+ 'Checking Widget for the old mediaID [%d] so we can replace it with the new mediaId '
+ . '[%d] and storedAs [%s]. Media assigned to widget %s.',
+ $oldMediaId,
+ $newMediaId,
+ $media->storedAs,
+ json_encode($widget->mediaIds)
+ ));
+
+ if (in_array($oldMediaId, $widget->mediaIds)) {
+ $this->getLog()->debug(sprintf('Removing %d and replacing with %d', $oldMediaId, $newMediaId));
+
+ // Are we an audio record?
+ if (in_array($oldMediaId, $audioIds)) {
+ // Swap the mediaId on the audio record
+ foreach ($widget->audio as $widgetAudio) {
+ if ($widgetAudio->mediaId == $oldMediaId) {
+ $widgetAudio->mediaId = $newMediaId;
+ break;
+ }
+ }
+ } else {
+ // Non audio
+ $widget->setOptionValue('uri', 'attrib', $media->storedAs);
+ }
+
+ // Always manage the assignments
+ // Unassign the old ID
+ $widget->unassignMedia($oldMediaId);
+
+ // Assign the new ID
+ $widget->assignMedia($newMediaId);
+ }
+
+ // change mediaId references in applicable widgets, outside the if condition,
+ // because if the Layout is loadByXLF we will not have mediaIds set on Widget at this point
+ // the mediaIds array for Widgets with Library references will be correctly populated on
+ // getResource call from Player/CMS.
+ // if the Layout was loadByJson then it will already have correct mediaIds array at this point.
+ $this->handleWidgetMediaIdReferences($widget, $newMediaId, $oldMediaId);
+ }
+ }
+ }
+ $uploadedMediaIds = array_combine($oldMediaIds, $newMediaIds);
+
+ foreach ($widgets as $widget) {
+ // handle importing elements with image.
+ // if we have multiple images in global widget
+ // we need to go through them here and replace all old media with new ones
+ // this cannot be done one by one in the loop when uploading from mapping
+ // as one widget can have multiple elements with mediaId in it.
+ if ($widget->type === 'global' && !empty($widget->getOptionValue('elements', []))) {
+ $widgetElements = $widget->getOptionValue('elements', null);
+ $widgetElements = json_decode($widgetElements, true);
+ $updatedWidgetElements = [];
+ $updatedElements = [];
+ foreach (($widgetElements ?? []) as $widgetElement) {
+ foreach (($widgetElement['elements'] ?? []) as $element) {
+ if (isset($element['mediaId'])) {
+ foreach ($uploadedMediaIds as $old => $new) {
+ if ($element['mediaId'] === $old) {
+ $element['mediaId'] = $new;
+ }
+ }
+ }
+ // if we have combo of say text element and image
+ // make sure we have the element updated here (outside the if condition),
+ // otherwise we would end up only with image elements in the options.
+ $updatedElements[] = $element;
+ }
+ }
+
+ if (!empty($updatedElements)) {
+ $updatedWidgetElements[]['elements'] = $updatedElements;
+ $widget->setOptionValue(
+ 'elements',
+ 'raw',
+ json_encode($updatedWidgetElements)
+ );
+ }
+ }
+ }
+
+ // Playlists with media widgets
+ // We will iterate through all Playlists we've created during layout import here and
+ // replace any mediaIds if needed
+ if (isset($playlists) && $playlistDetails !== false) {
+ foreach ($playlists as $playlist) {
+ foreach ($playlist->widgets as $widget) {
+ $audioIds = $widget->getAudioIds();
+
+ foreach ($widget->mediaIds as $mediaId) {
+ foreach ($uploadedMediaIds as $old => $new) {
+ if ($mediaId == $old) {
+ $this->getLog()->debug(sprintf(
+ 'Playlist import Removing %d and replacing with %d',
+ $old,
+ $new
+ ));
+
+ // Are we an audio record?
+ if (in_array($old, $audioIds)) {
+ // Swap the mediaId on the audio record
+ foreach ($widget->audio as $widgetAudio) {
+ if ($widgetAudio->mediaId == $old) {
+ $widgetAudio->mediaId = $new;
+ break;
+ }
+ }
+ } else {
+ $addedMedia = $this->mediaFactory->getById($new);
+ // Non audio
+ $widget->setOptionValue('uri', 'attrib', $addedMedia->storedAs);
+ }
+
+ // Always manage the assignments
+ // Unassign the old ID
+ $widget->unassignMedia($old);
+
+ // Assign the new ID
+ $widget->assignMedia($new);
+
+ // change mediaId references in applicable widgets in all Playlists we have created
+ // on this import.
+ $this->handleWidgetMediaIdReferences($widget, $new, $old);
+ }
+ }
+ }
+ $widget->save();
+
+ if (!in_array($widget, $playlist->widgets)) {
+ $playlist->assignWidget($widget);
+ $playlist->requiresDurationUpdate = 1;
+ $playlist->save();
+ }
+
+ // add Playlist widgets to the $widgets (which already has all widgets from layout regionPlaylists)
+ // this will be needed if any Playlist has widgets with dataSets
+ if ($widget->type == 'datasetview'
+ || $widget->type == 'datasetticker'
+ || $widget->type == 'chart'
+ ) {
+ $widgets[] = $widget;
+ $playlistWidgets[] = $widget;
+ }
+ }
+ }
+ }
+
+ // Handle any datasets provided with the layout
+ $dataSets = $zip->getFromName('dataSet.json');
+
+ if ($dataSets !== false) {
+ $dataSets = json_decode($dataSets, true);
+
+ $this->getLog()->debug('There are ' . count($dataSets) . ' DataSets to import.');
+
+ foreach ($dataSets as $item) {
+ // Hydrate a new dataset object with this json object
+ $dataSet = $dataSetFactory->createEmpty()->hydrate($item);
+ $dataSet->columns = [];
+ $dataSetId = $dataSet->dataSetId;
+ $columnWithImages = [];
+ // We must null the ID so that we don't try to load the dataset when we assign columns
+ $dataSet->dataSetId = null;
+
+ // Hydrate the columns
+ foreach ($item['columns'] as $columnItem) {
+ $this->getLog()->debug(sprintf('Assigning column: %s', json_encode($columnItem)));
+ if ($columnItem['dataTypeId'] === 5) {
+ $columnWithImages[] = $columnItem['heading'];
+ }
+ $dataSet->assignColumn($dataSetFactory
+ ->getDataSetColumnFactory()
+ ->createEmpty()
+ ->hydrate($columnItem));
+ }
+
+ /** @var DataSet $existingDataSet */
+ $existingDataSet = null;
+
+ // Do we want to try and use a dataset that already exists?
+ if ($useExistingDataSets) {
+ // Check to see if we already have a dataset with the same code/name, prefer code.
+ if ($dataSet->code != '') {
+ try {
+ // try and get by code
+ $existingDataSet = $dataSetFactory->getByCode($dataSet->code);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug(sprintf('Existing dataset not found with code %s', $dataSet->code));
+ }
+ }
+
+ if ($existingDataSet === null) {
+ // try by name
+ try {
+ $existingDataSet = $dataSetFactory->getByName($dataSet->dataSet);
+ } catch (NotFoundException $e) {
+ $this->getLog()->debug(sprintf('Existing dataset not found with name %s', $dataSet->code));
+ }
+ }
+ }
+
+ if ($existingDataSet === null) {
+ $this->getLog()->debug(sprintf(
+ 'Matching DataSet not found, will need to add one. useExistingDataSets = %s',
+ $useExistingDataSets
+ ));
+
+ // We want to add the dataset we have as a new dataset.
+ // we will need to make sure we clear the ID's and save it
+ $existingDataSet = clone $dataSet;
+ $existingDataSet->userId = $this->getUser()->userId;
+ $existingDataSet->folderId = $folder->id;
+ $existingDataSet->permissionsFolderId =
+ ($folder->permissionsFolderId == null) ? $folder->id : $folder->permissionsFolderId;
+
+ // Save to get the IDs created
+ $existingDataSet->save([
+ 'activate' => false,
+ 'notify' => false,
+ 'testFormulas' => false,
+ 'allowSpacesInHeading' => true,
+ ]);
+
+ // Do we need to add data
+ if ($importDataSetData) {
+ // Import the data here
+ $this->getLog()->debug(sprintf(
+ 'Importing data into new DataSet %d',
+ $existingDataSet->dataSetId
+ ));
+
+ foreach (($item['data'] ?? []) as $itemData) {
+ if (isset($itemData['id'])) {
+ unset($itemData['id']);
+ }
+
+ foreach ($columnWithImages as $columnHeading) {
+ foreach ($uploadedMediaIds as $old => $new) {
+ if ($itemData[$columnHeading] == $old) {
+ $itemData[$columnHeading] = $new;
+ }
+ }
+ }
+
+ $existingDataSet->addRow($itemData);
+ }
+ }
+ } else {
+ $this->getLog()->debug('Matching DataSet found, validating the columns');
+
+ // Load the existing dataset
+ $existingDataSet->load();
+
+ // Validate that the columns are the same
+ if (count($dataSet->columns) != count($existingDataSet->columns)) {
+ $this->getLog()->debug(sprintf(
+ 'Columns for Imported DataSet = %s',
+ json_encode($dataSet->columns)
+ ));
+ throw new InvalidArgumentException(sprintf(
+ __('DataSets have different number of columns imported = %d, existing = %d'),
+ count($dataSet->columns),
+ count($existingDataSet->columns)
+ ));
+ }
+
+ // Loop over the desired column headings and the ones in the existing dataset and error out
+ // as soon as we have one that isn't found.
+ foreach ($dataSet->columns as $column) {
+ // Loop through until we find it
+ foreach ($existingDataSet->columns as $existingDataSetColumn) {
+ if ($column->heading === $existingDataSetColumn->heading) {
+ // Drop out to the next column we want to find.
+ continue 2;
+ }
+ }
+ // We have not found that column in our existing data set
+ throw new InvalidArgumentException(__('DataSets have different column names'));
+ }
+ }
+
+ // Set the prior dataSetColumnId on each column.
+ foreach ($existingDataSet->columns as $column) {
+ // Lookup the matching column in the external dataSet definition.
+ foreach ($dataSet->columns as $externalColumn) {
+ if ($externalColumn->heading == $column->heading) {
+ $column->priorDatasetColumnId = $externalColumn->dataSetColumnId;
+ break;
+ }
+ }
+ }
+
+ // Replace instances of this dataSetId with the existing dataSetId, which will either be the existing
+ // dataSet or one we've added above.
+ // Also make sure we replace the columnId's with the columnId's in the new "existing" DataSet.
+ foreach ($widgets as $widget) {
+ if ($widget->type == 'dataset') {
+ $widgetDataSetId = $widget->getOptionValue('dataSetId', 0);
+
+ if ($widgetDataSetId != 0 && $widgetDataSetId == $dataSetId) {
+ // Widget has a dataSet, and it matches the one we've just actioned.
+ $widget->setOptionValue('dataSetId', 'attrib', $existingDataSet->dataSetId);
+
+ // Check for and replace column references.
+ // We are looking in the "columns" option for datasetview
+ // and the "template" option for datasetticker
+ // DataSetView (now just dataset)
+ $existingColumns = $widget->getOptionValue('columns', '');
+ if (!empty($existingColumns)) {
+ // Get the columns option
+ $columns = json_decode($existingColumns, true);
+
+ $this->getLog()->debug(sprintf(
+ 'Looking to replace columns from %s',
+ $existingColumns
+ ));
+
+ foreach ($existingDataSet->columns as $column) {
+ foreach ($columns as $index => $col) {
+ if ($col == $column->priorDatasetColumnId) {
+ // v4 uses integers as its column ids.
+ $columns[$index] = $column->dataSetColumnId;
+ }
+ }
+ }
+
+ $columns = json_encode($columns);
+ $widget->setOptionValue('columns', 'attrib', $columns);
+
+ $this->getLog()->debug(sprintf('Replaced columns with %s', $columns));
+ }
+
+ // DataSetTicker (now just dataset)
+ $template = $widget->getOptionValue('template', '');
+ if (!empty($template)) {
+ $this->getLog()->debug(sprintf('Looking to replace columns from %s', $template));
+
+ foreach ($existingDataSet->columns as $column) {
+ // We replace with the |%d] so that we don't experience double replacements
+ $template = str_replace(
+ '|' . $column->priorDatasetColumnId . ']',
+ '|' . $column->dataSetColumnId . ']',
+ $template
+ );
+ }
+
+ $widget->setOptionValue('template', 'cdata', $template);
+
+ $this->getLog()->debug(sprintf('Replaced columns with %s', $template));
+ }
+ }
+
+ // save widgets with dataSets on Playlists, widgets directly on the layout are saved later on.
+ if (isset($playlistWidgets) && in_array($widget, $playlistWidgets)) {
+ $widget->save();
+ }
+ }
+ }
+ }
+ }
+
+ // Load widget data into an array for processing outside (once the layout has been saved)
+ $fallback = $zip->getFromName('fallback.json');
+ if ($fallback !== false) {
+ $layout->setUnmatchedProperty('fallback', json_decode($fallback, true));
+ }
+
+ // Save the thumbnail to a temporary location.
+ $image_path = $zip->getFromName('library/thumbs/campaign_thumb.png');
+ if ($image_path !== false) {
+ $temporaryLayoutThumb = $libraryLocationTemp . $layout->layout . '-campaign_thumb.png';
+ $layout->setUnmatchedProperty('thumbnail', $temporaryLayoutThumb);
+ $image = imagecreatefromstring($image_path);
+ imagepng($image, $temporaryLayoutThumb);
+ }
+
+ $this->getLog()->debug('Finished creating from Zip');
+
+ // Finished
+ $zip->close();
+
+ // We need one final pass through all widgets on the layout so that we can set the durations properly.
+ foreach ($layout->getAllWidgets() as $widget) {
+ // By now we should not have any modules which don't exist.
+ $module = $this->moduleFactory->getByType($widget->type);
+ $widget->calculateDuration($module);
+
+ // Get global stat setting of widget to set to on/off/inherit
+ $widget->setOptionValue('enableStat', 'attrib', $this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT'));
+ }
+
+ if ($fontsAdded) {
+ $this->getLog()->debug('Fonts have been added');
+ $mediaService->setUser($this->getUser())->updateFontsCss();
+ }
+
+ return $layout;
+ }
+
+ /**
+ * Create widgets in nested Playlists and handle their closure table
+ *
+ * @param $widgets array An array of playlist widgets with old playlistId as key
+ * @param $combined array An array of key and value pairs with oldPlaylistId => newPlaylistId
+ * @param $playlists array An array of Playlist objects
+ * @return array An array of Playlist objects with widgets
+ * @throws NotFoundException
+ */
+ public function createNestedPlaylistWidgets($widgets, $combined, &$playlists)
+ {
+ foreach ($widgets as $playlistId => $widgetsDetails) {
+ foreach ($combined as $old => $new) {
+ if ($old == $playlistId) {
+ $playlistId = $new;
+ }
+ }
+
+ $playlist = $this->playlistFactory->getById($playlistId);
+
+ foreach ($widgetsDetails as $widgetsDetail) {
+ $modules = $this->moduleFactory->getKeyedArrayOfModules();
+ $playlistWidget = $this->widgetFactory->createEmpty();
+ $playlistWidget->playlistId = $playlistId;
+ $playlistWidget->widgetId = null;
+ $playlistWidget->type = $widgetsDetail['type'];
+ $playlistWidget->ownerId = $playlist->ownerId;
+ $playlistWidget->displayOrder = $widgetsDetail['displayOrder'];
+ $playlistWidget->duration = $widgetsDetail['duration'];
+ $playlistWidget->useDuration = $widgetsDetail['useDuration'];
+ $playlistWidget->calculatedDuration = $widgetsDetail['calculatedDuration'];
+ $playlistWidget->fromDt = $widgetsDetail['fromDt'];
+ $playlistWidget->toDt = $widgetsDetail['toDt'];
+ $playlistWidget->tempId = $widgetsDetail['tempId'];
+ $playlistWidget->mediaIds = $widgetsDetail['mediaIds'];
+ $playlistWidget->widgetOptions = [];
+ $playlistWidget->schemaVersion = isset($widgetsDetail['schemaVersion'])
+ ? (int)$widgetsDetail['schemaVersion']
+ : 1;
+
+ // Prepare widget options
+ foreach ($widgetsDetail['widgetOptions'] as $optionsNode) {
+ $widgetOption = $this->widgetOptionFactory->createEmpty();
+ $widgetOption->type = $optionsNode['type'];
+ $widgetOption->option = $optionsNode['option'];
+ $widgetOption->value = $optionsNode['value'];
+ $playlistWidget->widgetOptions[] = $widgetOption;
+ }
+
+ try {
+ $module = $this->prepareWidgetAndGetModule($playlistWidget);
+ } catch (NotFoundException) {
+ // Skip this widget
+ $this->getLog()->info('createNestedPlaylistWidgets: ' . $playlistWidget->type
+ . ' could not be found or resolved');
+ continue;
+ }
+
+ if ($playlistWidget->type == 'subplaylist') {
+ // Get the subplaylists from widget option
+ $nestedSubPlaylists = json_decode($playlistWidget->getOptionValue('subPlaylists', '[]'), true);
+
+ $updatedSubPlaylists = [];
+ foreach ($combined as $old => $new) {
+ foreach ($nestedSubPlaylists as $subPlaylistItem) {
+ if (intval($subPlaylistItem['playlistId']) === $old) {
+ $subPlaylistItem['playlistId'] = $new;
+ $updatedSubPlaylists[] = $subPlaylistItem;
+ }
+ }
+ }
+
+ foreach ($updatedSubPlaylists as $updatedSubPlaylistItem) {
+ $this->getStore()->insert('
+ INSERT INTO `lkplaylistplaylist` (parentId, childId, depth)
+ SELECT p.parentId, c.childId, p.depth + c.depth + 1
+ FROM lkplaylistplaylist p, lkplaylistplaylist c
+ WHERE p.childId = :parentId AND c.parentId = :childId
+ ', [
+ 'parentId' => $playlist->playlistId,
+ 'childId' => $updatedSubPlaylistItem['playlistId']
+ ]);
+ }
+
+ $playlistWidget->setOptionValue('subPlaylists', 'attrib', json_encode($updatedSubPlaylists));
+ }
+
+ $playlist->assignWidget($playlistWidget);
+ $playlist->requiresDurationUpdate = 1;
+
+ // save non-media based widget, we can't save media based widgets here as we don't have updated mediaId yet.
+ if ($module->regionSpecific == 1 && $playlistWidget->mediaIds == []) {
+ $playlistWidget->save();
+ }
+ }
+
+ $playlists[] = $playlist;
+ $this->getLog()->debug('Finished creating Playlist added the following Playlist ' . json_encode($playlist));
+ }
+
+ return $playlists;
+ }
+
+ public function hasSubPlaylist(array $widgets)
+ {
+ $hasSubPlaylist = false;
+
+ foreach ($widgets as $widget) {
+ if ($widget['type'] === 'subplaylist') {
+ $hasSubPlaylist = true;
+ }
+ }
+
+ return $hasSubPlaylist;
+ }
+
+ /**
+ * Get all Codes assigned to Layouts
+ * @param array $filterBy
+ * @return array
+ */
+ public function getLayoutCodes($filterBy = []): array
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+ $params = [];
+ $select = 'SELECT DISTINCT code, `layout`.layout, `campaign`.CampaignID, `campaign`.permissionsFolderId ';
+ $body = ' FROM layout INNER JOIN `lkcampaignlayout` ON lkcampaignlayout.LayoutID = layout.LayoutID INNER JOIN `campaign` ON lkcampaignlayout.CampaignID = campaign.CampaignID AND campaign.IsLayoutSpecific = 1 WHERE `layout`.code IS NOT NULL AND `layout`.code <> \'\' ';
+
+ // get by Code
+ if ($parsedFilter->getString('code') != '') {
+ $body.= ' AND layout.code LIKE :code ';
+ $params['code'] = '%' . $parsedFilter->getString('code') . '%';
+ }
+
+ // Logged in user view permissions
+ $this->viewPermissionSql('Xibo\Entity\Campaign', $body, $params, 'campaign.campaignId', 'layout.userId', $filterBy, 'campaign.permissionsFolderId');
+
+ $order = ' ORDER BY code';
+
+ // Paging
+ $limit = '';
+ if ($filterBy !== null && $parsedFilter->getInt('start') !== null && $parsedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+ $entries = $this->getStore()->select($sql, $params);
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Query for all Layouts
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Layout[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+
+ if ($sortOrder === null) {
+ $sortOrder = ['layout'];
+ }
+
+ $select = 'SELECT `layout`.layoutID,
+ `layout`.parentId,
+ `layout`.layout,
+ `layout`.description,
+ `layout`.duration,
+ `layout`.userID,
+ `user`.userName as owner,
+ `campaign`.CampaignID,
+ `campaign`.type,
+ `layout`.status,
+ `layout`.statusMessage,
+ `layout`.enableStat,
+ `layout`.width,
+ `layout`.height,
+ `layout`.retired,
+ `layout`.createdDt,
+ `layout`.modifiedDt,
+ `layout`.backgroundImageId,
+ `layout`.backgroundColor,
+ `layout`.backgroundzIndex,
+ `layout`.schemaVersion,
+ `layout`.publishedStatusId,
+ `status`.status AS publishedStatus,
+ `layout`.publishedDate,
+ `layout`.autoApplyTransitions,
+ `layout`.code,
+ `campaign`.folderId,
+ `campaign`.permissionsFolderId,
+ ';
+
+ if ($parsedFilter->getInt('campaignId') !== null) {
+ $select .= ' lkcl.displayOrder, ';
+ } else {
+ $select .= ' NULL as displayOrder, ';
+ }
+
+ $select .= " (SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :permissionEntityForGroup
+ AND objectId = campaign.CampaignID
+ AND view = 1
+ ) AS groupsWithPermissions ";
+ $params['permissionEntityForGroup'] = 'Xibo\\Entity\\Campaign';
+
+ $body = " FROM layout ";
+ $body .= ' INNER JOIN status ON status.id = layout.publishedStatusId ';
+ $body .= " INNER JOIN `lkcampaignlayout` ";
+ $body .= " ON lkcampaignlayout.LayoutID = layout.LayoutID ";
+ $body .= " INNER JOIN `campaign` ";
+ $body .= " ON lkcampaignlayout.CampaignID = campaign.CampaignID ";
+ $body .= " AND campaign.IsLayoutSpecific = 1";
+ $body .= " INNER JOIN `user` ON `user`.userId = `campaign`.userId ";
+
+ if ($parsedFilter->getInt('campaignId') !== null) {
+ // Join Campaign back onto it again
+ $body .= "
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = layout.layoutid
+ AND lkcl.CampaignID = :campaignId
+ ";
+ $params['campaignId'] = $parsedFilter->getInt('campaignId');
+ }
+
+ if ($parsedFilter->getInt('displayGroupId') !== null) {
+ $body .= '
+ INNER JOIN `lklayoutdisplaygroup`
+ ON lklayoutdisplaygroup.layoutId = `layout`.layoutId
+ AND lklayoutdisplaygroup.displayGroupId = :displayGroupId
+ ';
+
+ $params['displayGroupId'] = $parsedFilter->getInt('displayGroupId');
+ }
+
+ if ($parsedFilter->getInt('activeDisplayGroupId') !== null) {
+ $displayGroupIds = [];
+ $displayId = null;
+
+ // get the displayId if we were provided with display specific displayGroup in the filter
+ $sql = 'SELECT display.displayId FROM display INNER JOIN lkdisplaydg ON lkdisplaydg.displayId = display.displayId INNER JOIN displaygroup ON displaygroup.displayGroupId = lkdisplaydg.displayGroupId WHERE displaygroup.displayGroupId = :displayGroupId AND displaygroup.isDisplaySpecific = 1';
+
+ foreach ($this->getStore()->select($sql, ['displayGroupId' => $parsedFilter->getInt('activeDisplayGroupId')]) as $row) {
+ $displayId = $this->getSanitizer($row)->getInt('displayId');
+ }
+
+ // if we have displayId, get all displayGroups to which the display is a member of
+ if ($displayId !== null) {
+ $sql = 'SELECT displayGroupId FROM lkdisplaydg WHERE displayId = :displayId';
+
+ foreach ($this->getStore()->select($sql, ['displayId' => $displayId]) as $row) {
+ $displayGroupIds[] = $this->getSanitizer($row)->getInt('displayGroupId');
+ }
+ }
+
+ // if we are filtering by actual displayGroup, use just the displayGroupId in the param
+ if ($displayGroupIds == []) {
+ $displayGroupIds[] = $parsedFilter->getInt('activeDisplayGroupId');
+ }
+
+ // get events for the selected displayGroup / Display and all displayGroups the display is member of
+ $body .= '
+ INNER JOIN `lkscheduledisplaygroup`
+ ON lkscheduledisplaygroup.displayGroupId IN ( ' . implode(',', $displayGroupIds) . ' )
+ INNER JOIN schedule
+ ON schedule.eventId = lkscheduledisplaygroup.eventId
+ ';
+ }
+
+ // MediaID
+ if ($parsedFilter->getInt('mediaId', ['default' => 0]) != 0) {
+ $body .= ' INNER JOIN (
+ SELECT DISTINCT `region`.layoutId
+ FROM `lkwidgetmedia`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ INNER JOIN `lkplaylistplaylist`
+ ON `widget`.playlistId = `lkplaylistplaylist`.childId
+ INNER JOIN `playlist`
+ ON `lkplaylistplaylist`.parentId = `playlist`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ WHERE `lkwidgetmedia`.mediaId = :mediaId
+ ) layoutsWithMedia
+ ON layoutsWithMedia.layoutId = `layout`.layoutId
+ ';
+
+ $params['mediaId'] = $parsedFilter->getInt('mediaId', ['default' => 0]);
+ }
+
+ // Media Like
+ if (!empty($parsedFilter->getString('mediaLike'))) {
+ $body .= ' INNER JOIN (
+ SELECT DISTINCT `region`.layoutId
+ FROM `lkwidgetmedia`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ INNER JOIN `lkplaylistplaylist`
+ ON `widget`.playlistId = `lkplaylistplaylist`.childId
+ INNER JOIN `playlist`
+ ON `lkplaylistplaylist`.parentId = `playlist`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN `media`
+ ON `lkwidgetmedia`.mediaId = `media`.mediaId
+ WHERE `media`.name LIKE :mediaLike
+ ) layoutsWithMediaLike
+ ON layoutsWithMediaLike.layoutId = `layout`.layoutId
+ ';
+
+ $params['mediaLike'] = '%' . $parsedFilter->getString('mediaLike') . '%';
+ }
+
+ $body .= " WHERE 1 = 1 ";
+
+ // Layout Like
+ if ($parsedFilter->getString('layout') != '') {
+ $terms = explode(',', $parsedFilter->getString('layout'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'layout',
+ 'layout',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($parsedFilter->getString('layoutExact') != '') {
+ $body.= " AND layout.layout = :exact ";
+ $params['exact'] = $parsedFilter->getString('layoutExact');
+ }
+
+ // Layout
+ if ($parsedFilter->getInt('layoutId', ['default' => 0]) != 0) {
+ $body .= " AND layout.layoutId = :layoutId ";
+ $params['layoutId'] = $parsedFilter->getInt('layoutId', ['default' => 0]);
+ } else if ($parsedFilter->getInt('excludeTemplates', ['default' => 1]) != -1) {
+ // Exclude templates by default
+ if ($parsedFilter->getInt('excludeTemplates', ['default' => 1]) == 1) {
+ $body .= " AND layout.layoutID NOT IN (SELECT layoutId FROM lktaglayout INNER JOIN tag ON lktaglayout.tagId = tag.tagId WHERE tag = 'template') ";
+ } else {
+ $body .= " AND layout.layoutID IN (SELECT layoutId FROM lktaglayout INNER JOIN tag ON lktaglayout.tagId = tag.tagId WHERE tag = 'template') ";
+ }
+ }
+
+ // Layout Draft
+ if ($parsedFilter->getInt('parentId', ['default' => 0]) != 0) {
+ $body .= " AND layout.parentId = :parentId ";
+ $params['parentId'] = $parsedFilter->getInt('parentId', ['default' => 0]);
+ } else if ($parsedFilter->getInt('layoutId', ['default' => 0]) == 0
+ && $parsedFilter->getInt('showDrafts', ['default' => 0]) == 0) {
+ // If we're not searching for a parentId and we're not searching for a layoutId, then don't show any
+ // drafts (parentId will be empty on drafts)
+ $body .= ' AND layout.parentId IS NULL ';
+ }
+
+ // Layout Published Status
+ if ($parsedFilter->getInt('publishedStatusId') !== null) {
+ $body .= " AND layout.publishedStatusId = :publishedStatusId ";
+ $params['publishedStatusId'] = $parsedFilter->getInt('publishedStatusId');
+ }
+
+ // Layout Status
+ if ($parsedFilter->getInt('status') !== null) {
+ $body .= " AND layout.status = :status ";
+ $params['status'] = $parsedFilter->getInt('status');
+ }
+
+ // Layout Duration
+ if ($parsedFilter->getInt('duration') !== null) {
+ $body .= " AND layout.duration = :duration ";
+ $params['duration'] = $parsedFilter->getInt('duration');
+ }
+
+ // Layout Background Color
+ if ($parsedFilter->getString('backgroundColor') !== null) {
+ $bgConvertedHex = $parsedFilter->getString('backgroundColor');
+
+ // Handle both shorthand and normal hex values
+ if (preg_match('/^#?([0-9a-fA-F]{3})$/', $bgConvertedHex, $matches)) {
+ // Convert shorthand hex to normal hex (i.e. #000 -> #000000)
+ $bgConvertedHex = '#' . preg_replace('/(.)/', '$1$1', $matches[1]);
+ } else {
+ // Convert normal hex to shorthand hex (i.e. #000000 -> #000)
+ $bgConvertedHex = preg_replace(
+ '/^#?([0-9a-fA-F])\1([0-9a-fA-F])\2([0-9a-fA-F])\3$/',
+ '#$1$2$3',
+ $bgConvertedHex
+ );
+ }
+
+ $body .= " AND (layout.backgroundColor = :backgroundColor OR layout.backgroundColor = :bgConvertedHex) ";
+ $params['backgroundColor'] = $parsedFilter->getString('backgroundColor');
+ $params['bgConvertedHex'] = $bgConvertedHex;
+ }
+
+ // Layout Height
+ if ($parsedFilter->getInt('height') !== null) {
+ $body .= " AND layout.height = :height ";
+ $params['height'] = $parsedFilter->getInt('height');
+ }
+
+ // Layout Width
+ if ($parsedFilter->getInt('width') !== null) {
+ $body .= " AND layout.width = :width ";
+ $params['width'] = $parsedFilter->getInt('width');
+ }
+
+ // Background Image
+ if ($parsedFilter->getInt('backgroundImageId') !== null) {
+ $body .= " AND layout.backgroundImageId = :backgroundImageId ";
+ $params['backgroundImageId'] = $parsedFilter->getInt('backgroundImageId', ['default' => 0]);
+ }
+ // Not Layout
+ if ($parsedFilter->getInt('notLayoutId', ['default' => 0]) != 0) {
+ $body .= " AND layout.layoutId <> :notLayoutId ";
+ $params['notLayoutId'] = $parsedFilter->getInt('notLayoutId', ['default' => 0]);
+ }
+
+ // Owner filter
+ if ($parsedFilter->getInt('userId', ['default' => 0]) != 0) {
+ $body .= " AND layout.userid = :userId ";
+ $params['userId'] = $parsedFilter->getInt('userId', ['default' => 0]);
+ }
+
+ if ($parsedFilter->getCheckbox('onlyMyLayouts') === 1) {
+ $body .= ' AND layout.userid = :userId ';
+ $params['userId'] = $this->getUser()->userId;
+ }
+
+ // User Group filter
+ if ($parsedFilter->getInt('ownerUserGroupId', ['default' => 0]) != 0) {
+ $body .= ' AND layout.userid IN (SELECT DISTINCT userId FROM `lkusergroup` WHERE groupId = :ownerUserGroupId) ';
+ $params['ownerUserGroupId'] = $parsedFilter->getInt('ownerUserGroupId', ['default' => 0]);
+ }
+
+ // Retired options (provide -1 to return all)
+ if ($parsedFilter->getInt('retired', ['default' => -1]) != -1) {
+ $body .= " AND layout.retired = :retired ";
+ $params['retired'] = $parsedFilter->getInt('retired',['default' => 0]);
+ }
+
+ // Modified Since?
+ if ($parsedFilter->getDate('modifiedSinceDt') != null) {
+ $body .= ' AND layout.modifiedDt > :modifiedSinceDt ';
+ $params['modifiedSinceDt'] = $parsedFilter->getDate('modifiedSinceDt')
+ ->format(DateFormatHelper::getSystemFormat());
+ }
+
+ if ($parsedFilter->getInt('ownerCampaignId') !== null) {
+ // Join Campaign back onto it again
+ $body .= " AND `campaign`.campaignId = :ownerCampaignId ";
+ $params['ownerCampaignId'] = $parsedFilter->getInt('ownerCampaignId', ['default' => 0]);
+ }
+
+ if ($parsedFilter->getInt('layoutHistoryId') !== null) {
+ $body .= ' AND `campaign`.campaignId IN (
+ SELECT MAX(campaignId)
+ FROM `layouthistory`
+ WHERE `layouthistory`.layoutId = :layoutHistoryId
+ ) ';
+ $params['layoutHistoryId'] = $parsedFilter->getInt('layoutHistoryId');
+ }
+
+ // Get by regionId
+ if ($parsedFilter->getInt('regionId') !== null) {
+ // Join Campaign back onto it again
+ $body .= " AND `layout`.layoutId IN (SELECT layoutId FROM `region` WHERE regionId = :regionId) ";
+ $params['regionId'] = $parsedFilter->getInt('regionId', ['default' => 0]);
+ }
+
+ // get by Code
+ if ($parsedFilter->getString('code') != '') {
+ $body.= " AND layout.code = :code ";
+ $params['code'] = $parsedFilter->getString('code');
+ }
+
+ if ($parsedFilter->getString('codeLike') != '') {
+ $body.= ' AND layout.code LIKE :codeLike ';
+ $params['codeLike'] = '%' . $parsedFilter->getString('codeLike') . '%';
+ }
+
+ // Tags
+ if ($parsedFilter->getString('tags') != '') {
+ $tagFilter = $parsedFilter->getString('tags');
+
+ if (trim($tagFilter) === '--no-tag') {
+ $body .= ' AND `layout`.layoutID NOT IN (
+ SELECT `lktaglayout`.layoutId
+ FROM `tag`
+ INNER JOIN `lktaglayout`
+ ON `lktaglayout`.tagId = `tag`.tagId
+ )
+ ';
+ } else {
+ $operator = $parsedFilter->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $parsedFilter->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $body .= ' AND layout.layoutID NOT IN (
+ SELECT lktaglayout.layoutId
+ FROM tag
+ INNER JOIN lktaglayout
+ ON lktaglayout.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $notTags,
+ 'lktaglayout',
+ 'lkTagLayoutId',
+ 'layoutId',
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $body .= ' AND layout.layoutID IN (
+ SELECT lktaglayout.layoutId
+ FROM tag
+ INNER JOIN lktaglayout
+ ON lktaglayout.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $tags,
+ 'lktaglayout',
+ 'lkTagLayoutId',
+ 'layoutId',
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+ }
+ }
+ }
+
+ // Show All, Used or UnUsed
+ // Used - In active schedule, scheduled in the future, directly assigned to displayGroup, default Layout.
+ // Unused - Every layout NOT matching the Used ie not in active schedule, not scheduled in the future, not directly assigned to any displayGroup, not default layout.
+ if ($parsedFilter->getInt('filterLayoutStatusId', ['default' => 1]) != 1) {
+ if ($parsedFilter->getInt('filterLayoutStatusId') == 2) {
+
+ // Only show used layouts
+ $now = Carbon::now()->format('U');
+ $sql = 'SELECT DISTINCT schedule.CampaignID FROM schedule WHERE ( ( schedule.fromDt < '. $now . ' OR schedule.fromDt = 0 ) ' . ' AND schedule.toDt > ' . $now . ') OR schedule.fromDt > ' . $now;
+ $campaignIds = [];
+ foreach ($this->getStore()->select($sql, []) as $row) {
+ $campaignIds[] = $row['CampaignID'];
+ }
+ $body .= ' AND ('
+ . ' campaign.CampaignID IN ( ' . implode(',', array_filter($campaignIds)) . ' )
+ OR layout.layoutID IN (SELECT DISTINCT defaultlayoutid FROM display)
+ OR layout.layoutID IN (SELECT DISTINCT layoutId FROM lklayoutdisplaygroup)'
+ . ' ) ';
+ }
+ else {
+ // Only show unused layouts
+ $now = Carbon::now()->format('U');
+ $sql = 'SELECT DISTINCT schedule.CampaignID FROM schedule WHERE ( ( schedule.fromDt < '. $now . ' OR schedule.fromDt = 0 ) ' . ' AND schedule.toDt > ' . $now . ') OR schedule.fromDt > ' . $now;
+ $campaignIds = [];
+ foreach ($this->getStore()->select($sql, []) as $row) {
+ $campaignIds[] = $row['CampaignID'];
+ }
+
+ $body .= ' AND campaign.CampaignID NOT IN ( ' . implode(',', array_filter($campaignIds)) . ' )
+ AND layout.layoutID NOT IN (SELECT DISTINCT defaultlayoutid FROM display)
+ AND layout.layoutID NOT IN (SELECT DISTINCT layoutId FROM lklayoutdisplaygroup)
+ ';
+ }
+ }
+
+ // PlaylistID
+ if ($parsedFilter->getInt('playlistId', ['default' => 0]) != 0) {
+ $body .= ' AND layout.layoutId IN (SELECT DISTINCT `region`.layoutId
+ FROM `lkplaylistplaylist`
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `lkplaylistplaylist`.parentId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ WHERE `lkplaylistplaylist`.childId = :playlistId )
+ ';
+
+ $params['playlistId'] = $parsedFilter->getInt('playlistId', ['default' => 0]);
+ }
+
+ // publishedDate
+ if ($parsedFilter->getInt('havePublishDate', ['default' => -1]) != -1) {
+ $body .= " AND `layout`.publishedDate IS NOT NULL ";
+ }
+
+ if ($parsedFilter->getInt('activeDisplayGroupId') !== null) {
+
+ $date = Carbon::now()->format('U');
+
+ // for filter by displayGroup, we need to add some additional filters in WHERE clause to show only relevant Layouts at the time the Layout grid is viewed
+ $body .= ' AND campaign.campaignId = schedule.campaignId
+ AND ( schedule.fromDt < '. $date . ' OR schedule.fromDt = 0 ) ' . ' AND schedule.toDt > ' . $date;
+ }
+
+ if ($parsedFilter->getInt('folderId') !== null) {
+ $body .= " AND campaign.folderId = :folderId ";
+ $params['folderId'] = $parsedFilter->getInt('folderId');
+ }
+
+ if ($parsedFilter->getString('orientation') !== null) {
+ if ($parsedFilter->getString('orientation') === 'portrait') {
+ $body .= ' AND layout.width < layout.height ';
+ } else {
+ $body .= ' AND layout.width >= layout.height ';
+ }
+ }
+
+ // Get the fullscreen media or playlist layout
+ if ($parsedFilter->getInt('isFullScreenCampaign', ['default' => -1]) == 1) {
+ $body .= ' AND campaign.type IN ("media", "playlist") ';
+ } else if ($parsedFilter->getString('campaignType') != '') {
+ $body .= ' AND campaign.type = :type ';
+ $params['type'] = $parsedFilter->getString('campaignType');
+ }
+
+ // Logged in user view permissions
+ $this->viewPermissionSql('Xibo\Entity\Campaign', $body, $params, 'campaign.campaignId', 'layout.userId', $filterBy, 'campaign.permissionsFolderId');
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $parsedFilter->getInt('start') !== null && $parsedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+ $layoutIds = [];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $layout = $this->createEmpty();
+
+ $parsedRow = $this->getSanitizer($row);
+
+ // Validate each param and add it to the array.
+ $layout->layoutId = $parsedRow->getInt('layoutID');
+ $layout->parentId = $parsedRow->getInt('parentId');
+ $layout->schemaVersion = $parsedRow->getInt('schemaVersion');
+ $layout->layout = $parsedRow->getString('layout');
+ $layout->description = $parsedRow->getString('description');
+ $layout->duration = $parsedRow->getInt('duration');
+ $layout->backgroundColor = $parsedRow->getString('backgroundColor');
+ $layout->owner = $parsedRow->getString('owner');
+ $layout->ownerId = $parsedRow->getInt('userID');
+ $layout->campaignId = $parsedRow->getInt('CampaignID');
+ $layout->retired = $parsedRow->getInt('retired');
+ $layout->status = $parsedRow->getInt('status');
+ $layout->backgroundImageId = $parsedRow->getInt('backgroundImageId');
+ $layout->backgroundzIndex = $parsedRow->getInt('backgroundzIndex');
+ $layout->width = $parsedRow->getDouble('width');
+ $layout->height = $parsedRow->getDouble('height');
+ $layout->orientation = $layout->width >= $layout->height ? 'landscape' : 'portrait';
+ $layout->createdDt = $parsedRow->getString('createdDt');
+ $layout->modifiedDt = $parsedRow->getString('modifiedDt');
+ $layout->displayOrder = $parsedRow->getInt('displayOrder');
+ $layout->statusMessage = $parsedRow->getString('statusMessage');
+ $layout->enableStat = $parsedRow->getInt('enableStat');
+ $layout->publishedStatusId = $parsedRow->getInt('publishedStatusId');
+ $layout->publishedStatus = $parsedRow->getString('publishedStatus');
+ $layout->publishedDate = $parsedRow->getString('publishedDate');
+ $layout->autoApplyTransitions = $parsedRow->getInt('autoApplyTransitions');
+ $layout->code = $parsedRow->getString('code');
+ $layout->folderId = $parsedRow->getInt('folderId');
+ $layout->permissionsFolderId = $parsedRow->getInt('permissionsFolderId');
+
+ $layout->groupsWithPermissions = $row['groupsWithPermissions'];
+ $layout->setOriginals();
+
+ $entries[] = $layout;
+ $layoutIds[] = $layout->layoutId;
+ }
+
+ // decorate with TagLinks
+ if (count($entries) > 0) {
+ $this->decorateWithTagLinks('lktaglayout', 'layoutId', $layoutIds, $entries);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['permissionEntityForGroup']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @param \Xibo\Entity\Widget $widget
+ * @return \Xibo\Entity\Widget
+ */
+ private function setWidgetExpiryDatesOrDefault($widget)
+ {
+ $minSubYear = Carbon::createFromTimestamp(Widget::$DATE_MIN)->subYear()->format('U');
+ $minAddYear = Carbon::createFromTimestamp(Widget::$DATE_MIN)->addYear()->format('U');
+ $maxSubYear = Carbon::createFromTimestamp(Widget::$DATE_MAX)->subYear()->format('U');
+ $maxAddYear = Carbon::createFromTimestamp(Widget::$DATE_MAX)->addYear()->format('U');
+
+ // if we are importing from layout.json the Widget from/to expiry dates are already timestamps
+ // for old Layouts when the Widget from/to dt are missing we set them to timestamps as well.
+ $timestampFromDt = is_integer($widget->fromDt) ? $widget->fromDt : Carbon::createFromTimeString($widget->fromDt)->format('U');
+ $timestampToDt = is_integer($widget->toDt) ? $widget->toDt : Carbon::createFromTimeString($widget->toDt)->format('U');
+
+ // convert the date string to a unix timestamp, if the layout xlf does not contain dates, then set it to the $DATE_MIN / $DATE_MAX which are already unix timestamps, don't attempt to convert them
+ // we need to check if provided from and to dates are within $DATE_MIN +- year to avoid issues with CMS Instances in different timezones https://github.com/xibosignage/xibo/issues/1934
+ if ($widget->fromDt === Widget::$DATE_MIN || ($timestampFromDt > $minSubYear && $timestampFromDt < $minAddYear)) {
+ $widget->fromDt = Widget::$DATE_MIN;
+ } else {
+ $widget->fromDt = $timestampFromDt;
+ }
+
+ if ($widget->toDt === Widget::$DATE_MAX || ($timestampToDt > $maxSubYear && $timestampToDt < $maxAddYear)) {
+ $widget->toDt = Widget::$DATE_MAX;
+ } else {
+ $widget->toDt = $timestampToDt;
+ }
+
+ return $widget;
+ }
+
+ /**
+ * @param \Xibo\Entity\Playlist $newPlaylist
+ * @param Folder $folder
+ * @return \Xibo\Entity\Playlist
+ * @throws DuplicateEntityException
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ private function setOwnerAndSavePlaylist($newPlaylist, Folder $folder)
+ {
+ // try to save with the name from import, if it already exists add "imported - " to the name
+ try {
+ // The new Playlist should be owned by the importing user
+ $newPlaylist->ownerId = $this->getUser()->getId();
+ $newPlaylist->playlistId = null;
+ $newPlaylist->widgets = [];
+ $newPlaylist->folderId = $folder->id;
+ $newPlaylist->permissionsFolderId =
+ ($folder->permissionsFolderId == null) ? $folder->id : $folder->permissionsFolderId;
+ $newPlaylist->save();
+ } catch (DuplicateEntityException $e) {
+ $newPlaylist->name = 'imported - ' . $newPlaylist->name;
+ $newPlaylist->save();
+ }
+
+ return $newPlaylist;
+ }
+
+ /**
+ * Checkout a Layout
+ * @param \Xibo\Entity\Layout $layout
+ * @param bool $returnDraft Should we return the Draft or the pre-checkout Layout
+ * @return \Xibo\Entity\Layout
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function checkoutLayout($layout, $returnDraft = true)
+ {
+ // Load the Layout
+ $layout->load();
+
+ // Make a skeleton copy of the Layout
+ $draft = clone $layout;
+ $draft->parentId = $layout->layoutId;
+ $draft->campaignId = $layout->campaignId;
+ $draft->publishedStatusId = 2; // Draft
+ $draft->publishedStatus = __('Draft');
+ $draft->autoApplyTransitions = $layout->autoApplyTransitions;
+ $draft->code = $layout->code;
+ $draft->folderId = $layout->folderId;
+
+ // Save without validation or notification.
+ $draft->save([
+ 'validate' => false,
+ 'notify' => false
+ ]);
+
+ // Update the original
+ $layout->publishedStatusId = 2; // Draft
+ $layout->publishedStatus = __('Draft');
+ $layout->save([
+ 'saveLayout' => true,
+ 'saveRegions' => false,
+ 'saveTags' => false,
+ 'setBuildRequired' => false,
+ 'validate' => false,
+ 'notify' => false
+ ]);
+
+ /** @var Region[] $allRegions */
+ $allRegions = array_merge($draft->regions, $draft->drawers);
+
+ // Skip the action validation on checkout
+ $draft->copyActions($draft, $layout, false);
+
+ // Permissions && Sub-Playlists
+ // Layout level permissions are managed on the Campaign entity, so we do not need to worry about that
+ // Regions/Widgets need to copy down our layout permissions
+ foreach ($allRegions as $region) {
+ // Match our original region id to the id in the parent layout
+ $original = $layout->getRegionOrDrawer($region->getOriginalValue('regionId'));
+
+ // Make sure Playlist closure table from the published one are copied over
+ $original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId);
+
+ // Copy over original permissions
+ foreach ($original->permissions as $permission) {
+ $new = clone $permission;
+ $new->objectId = $region->regionId;
+ $new->save();
+ }
+
+ // Playlist
+ foreach ($original->getPlaylist()->permissions as $permission) {
+ $new = clone $permission;
+ $new->objectId = $region->getPlaylist()->playlistId;
+ $new->save();
+ }
+
+ // Widgets
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ $originalWidget = $original->getPlaylist()->getWidget($widget->getOriginalValue('widgetId'));
+ // Copy over original permissions
+ foreach ($originalWidget->permissions as $permission) {
+ $new = clone $permission;
+ $new->objectId = $widget->widgetId;
+ $new->save();
+ }
+
+ // Copy widget data
+ $this->widgetDataFactory->copyByWidgetId($originalWidget->widgetId, $widget->widgetId);
+ }
+ }
+
+ return $returnDraft ? $draft : $layout;
+ }
+
+ /**
+ * Function called during Layout Import
+ * Check if provided Widget has options to have Library references
+ * if it does, then go through them find and replace old media references
+ *
+ * @param Widget $widget
+ * @param int $newMediaId
+ * @param int $oldMediaId
+ * @throws NotFoundException
+ */
+ public function handleWidgetMediaIdReferences(Widget $widget, int $newMediaId, int $oldMediaId)
+ {
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ foreach ($module->getPropertiesAllowingLibraryRefs() as $property) {
+ $widget->setOptionValue(
+ $property->id,
+ 'cdata',
+ str_replace(
+ '[' . $oldMediaId . ']',
+ '[' . $newMediaId . ']',
+ $widget->getOptionValue($property->id, null)
+ )
+ );
+ }
+ }
+
+ /**
+ * @param int $layoutId
+ * @param array $actionLayoutIds
+ * @param array $processedLayoutIds
+ * @return array
+ */
+ public function getActionPublishedLayoutIds(int $layoutId, array &$actionLayoutIds, array &$processedLayoutIds): array
+ {
+ // if Layout was already processed, do not attempt to do it again
+ // we should have all actionLayoutsIds from it at this point, there is no need to process it again
+ if (!in_array($layoutId, $processedLayoutIds)) {
+ // Get Layout Codes set in Actions on this Layout
+ // Actions directly on this Layout
+ $sql = '
+ SELECT DISTINCT `action`.layoutCode
+ FROM `action`
+ INNER JOIN `layout`
+ ON `layout`.layoutId = `action`.sourceId
+ WHERE `action`.actionType = :actionType
+ AND `layout`.layoutId = :layoutId
+ AND `layout`.parentId IS NULL
+ ';
+
+ // Actions on this Layout's Regions
+ $sql .= '
+ UNION
+ SELECT DISTINCT `action`.layoutCode
+ FROM `action`
+ INNER JOIN `region`
+ ON `region`.regionId = `action`.sourceId
+ INNER JOIN `layout`
+ ON `layout`.layoutId = `region`.layoutId
+ WHERE `action`.actionType = :actionType
+ AND `layout`.layoutId = :layoutId
+ AND `layout`.parentId IS NULL
+ ';
+
+ // Actions on this Layout's Widgets
+ $sql .= '
+ UNION
+ SELECT DISTINCT `action`.layoutCode
+ FROM `action`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `action`.sourceId
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `widget`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN `layout`
+ ON `layout`.layoutId = `region`.layoutId
+ WHERE `action`.actionType = :actionType
+ AND `layout`.layoutId = :layoutId
+ AND `layout`.parentId IS NULL
+ ';
+
+ // Join them together and get the Layout's referenced by those codes
+ $actionLayoutCodes = $this->getStore()->select('
+ SELECT `layout`.layoutId
+ FROM `layout`
+ WHERE `layout`.code IN (
+ ' . $sql . '
+ )
+ ', [
+ 'actionType' => 'navLayout',
+ 'layoutId' => $layoutId,
+ ]);
+
+ $processedLayoutIds[] = $layoutId;
+
+ foreach ($actionLayoutCodes as $row) {
+ // if we have not processed this Layout yet, do it now
+ if (!in_array($row['layoutId'], $actionLayoutIds)) {
+ $actionLayoutIds[] = $row['layoutId'];
+ // check if this layout is linked with any further navLayout actions
+ $this->getActionPublishedLayoutIds($row['layoutId'], $actionLayoutIds, $processedLayoutIds);
+ }
+ }
+ }
+
+ return $actionLayoutIds;
+ }
+
+ //
+
+ /**
+ * @param \Stash\Interfaces\PoolInterface|null $pool
+ * @return $this
+ */
+ public function usePool($pool)
+ {
+ $this->pool = $pool;
+ return $this;
+ }
+
+ /**
+ * @return \Stash\Interfaces\PoolInterface|\Stash\Pool
+ */
+ private function getPool()
+ {
+ if ($this->pool === null) {
+ $this->pool = new Pool();
+ }
+ return $this->pool;
+ }
+
+ /**
+ * @param \Xibo\Entity\Layout $layout
+ * @return \Xibo\Entity\Layout
+ */
+ public function decorateLockedProperties(Layout $layout): Layout
+ {
+ $locked = $this->pool->getItem('locks/layout/' . $layout->layoutId);
+ $layout->isLocked = $locked->isMiss() ? [] : $locked->get();
+ if (!empty($layout->isLocked)) {
+ $layout->isLocked->lockedUser = ($layout->isLocked->userId != $this->getUser()->userId);
+ }
+
+ return $layout;
+ }
+
+ /**
+ * Hold a lock on concurrent requests
+ * blocks if the request is locked
+ * @param int $ttl seconds
+ * @param int $wait seconds
+ * @param int $tries
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function concurrentRequestLock(Layout $layout, $force = false, $pass = 1, $ttl = 300, $wait = 6, $tries = 10): Layout
+ {
+ // Does this layout require building?
+ if (!$force && !$layout->isBuildRequired()) {
+ return $layout;
+ }
+
+ $lock = $this->getPool()->getItem('locks/layout_build/' . $layout->campaignId);
+
+ // Set the invalidation method to simply return the value (not that we use it, but it gets us a miss on expiry)
+ // isMiss() returns false if the item is missing or expired, no exceptions.
+ $lock->setInvalidationMethod(Invalidation::NONE);
+
+ // Get the lock
+ // other requests will wait here until we're done, or we've timed out
+ $locked = $lock->get();
+
+ // Did we get a lock?
+ // if we're a miss, then we're not already locked
+ if ($lock->isMiss() || $locked === false) {
+ $this->getLog()->debug('Lock miss or false. Locking for ' . $ttl . ' seconds. $locked is '. var_export($locked, true));
+
+ // so lock now
+ $lock->set(true);
+ $lock->expiresAfter($ttl);
+ $lock->save();
+
+ // If we have been locked previously, then reload our layout before passing back out.
+ if ($pass > 1) {
+ $layout = $this->getById($layout->layoutId);
+ }
+
+ return $layout;
+ } else {
+ // We are a hit - we must be locked
+ $this->getLog()->debug('LOCK hit for ' . $layout->campaignId . ' expires '
+ . $lock->getExpiration()->format('Y-m-d H:i:s') . ', created '
+ . $lock->getCreation()->format('Y-m-d H:i:s'));
+
+ // Try again?
+ $tries--;
+
+ if ($tries <= 0) {
+ // We've waited long enough
+ throw new GeneralException('Concurrent record locked, time out.');
+ } else {
+ $this->getLog()->debug('Unable to get a lock, trying again. Remaining retries: ' . $tries);
+
+ // Hang about waiting for the lock to be released.
+ sleep($wait);
+
+ // Recursive request (we've decremented the number of tries)
+ $pass++;
+ return $this->concurrentRequestLock($layout, $force, $pass, $ttl, $wait, $tries);
+ }
+ }
+ }
+
+ /**
+ * Release a lock on concurrent requests
+ */
+ public function concurrentRequestRelease(Layout $layout, bool $force = false)
+ {
+ if (!$force && !$layout->hasBuilt()) {
+ return;
+ }
+
+ $this->getLog()->debug('Releasing lock ' . $layout->campaignId);
+
+ $lock = $this->getPool()->getItem('locks/layout_build/' . $layout->campaignId);
+
+ // Release lock
+ $lock->set(false);
+ $lock->expiresAfter(10); // Expire straight away (but give it time to save the thing)
+
+ $this->getPool()->save($lock);
+ }
+
+ public function convertOldPlaylistOptions($playlistIds, $playlistOptions)
+ {
+ $convertedPlaylistOption = [];
+ $i = 0;
+ foreach ($playlistIds as $playlistId) {
+ $i++;
+ $convertedPlaylistOption[] = [
+ 'rowNo' => $i,
+ 'playlistId' => $playlistId,
+ 'spotFill' => $playlistOptions[$playlistId]['subPlaylistIdSpotFill'] ?? null,
+ 'spotLength' => $playlistOptions[$playlistId]['subPlaylistIdSpotLength'] ?? null,
+ 'spots' => $playlistOptions[$playlistId]['subPlaylistIdSpots'] ?? null,
+ ];
+ }
+
+ return $convertedPlaylistOption;
+ }
+
+ /**
+ * Prepare widget options, check legacy types from conditions, set widget type and upgrade
+ * @throws NotFoundException
+ */
+ private function prepareWidgetAndGetModule(Widget $widget): Module
+ {
+ // Form conditions from the widget's option and value, e.g, templateId==worldclock1
+ $widgetConditionMatch = [];
+ foreach ($widget->widgetOptions as $option) {
+ $widgetConditionMatch[] = $option->option . '==' . $option->value;
+ }
+
+ // Get module
+ try {
+ $module = $this->moduleFactory->getByType($widget->type, $widgetConditionMatch);
+ } catch (NotFoundException $notFoundException) {
+ throw new NotFoundException(__('Module not found'));
+ }
+
+ // Set the widget type and then assert the new one
+ $widget->setOriginalValue('type', $widget->type);
+ $widget->type = $module->type;
+
+ // Upgrade if necessary
+ // We do not upgrade widgets which are already at the right schema version
+ if ($widget->schemaVersion < $module->schemaVersion && $module->isWidgetCompatibilityAvailable()) {
+ // Grab a widget compatibility interface, if there is one
+ $widgetCompatibilityInterface = $module->getWidgetCompatibilityOrNull();
+ if ($widgetCompatibilityInterface !== null) {
+ try {
+ // We will leave the widget save for later
+ $upgraded = $widgetCompatibilityInterface->upgradeWidget(
+ $widget,
+ $widget->schemaVersion,
+ $module->schemaVersion
+ );
+
+ if ($upgraded) {
+ $widget->schemaVersion = $module->schemaVersion;
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Error upgrading widget '. $e->getMessage());
+ }
+ }
+ }
+
+ return $module;
+ }
+
+ /**
+ * Creates fullscreen layout from media or playlist
+ * @params $id
+ * @params $resolutionId
+ * @params $backgroundColor
+ * @params $duration
+ * @params $type
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function createFullScreenLayout($type, $id, $resolutionId, $backgroundColor, $duration): Layout
+ {
+ $media = null;
+ $playlist = null;
+ $playlistItems = [];
+
+ if ($type === 'media') {
+ $media = $this->mediaFactory->getById($id);
+ } else if ($type === 'playlist') {
+ $playlist = $this->playlistFactory->getById($id);
+ $playlist->load();
+ }
+
+ if (empty($resolutionId)) {
+ if ($type === 'media') {
+ $resolutionId = $this->resolutionFactory->getClosestMatchingResolution(
+ $media->width,
+ $media->height
+ )->resolutionId;
+ } else if ($type === 'playlist') {
+ $resolutionId = $this->resolutionFactory->getClosestMatchingResolution(
+ 1920,
+ 1080
+ )->resolutionId;
+ }
+ }
+
+ $module = $this->moduleFactory->getByType($type === 'media' ? $media->mediaType : 'subplaylist');
+
+ // Determine the duration
+ // if we have a duration provided, then use it, otherwise use the duration recorded on the
+ // library item/playlist already
+ $itemDuration = $duration;
+
+ if (empty($itemDuration)) {
+ $itemDuration = ($type === 'media' ? $media->duration : $playlist->duration);
+ }
+
+ // If the library item duration (or provided duration) is 0, then default to the Module Default
+ // Duration as configured in settings.
+ $itemDuration = ($itemDuration == 0) ? $module->defaultDuration : $itemDuration;
+
+ // Do we have an existing layout with the same properties as the current one?
+ $currentLayoutDimension = $this->resolutionFactory->getById($resolutionId);
+
+ if (empty($backgroundColor)) {
+ $backgroundColor = '#000000';
+ }
+
+ $currentLayoutProperties = [
+ 'backgroundColor' => $backgroundColor,
+ 'height' => $currentLayoutDimension->height,
+ 'width' => $currentLayoutDimension->width
+ ];
+
+ if ($type === 'media') {
+ // do we already have a full screen layout with this media?
+ $existingFullscreenLayout = $this->getLinkedFullScreenLayout(
+ 'media',
+ $media->mediaId,
+ array_merge($currentLayoutProperties, ['duration' => $itemDuration])
+ );
+ } else if ($type === 'playlist') {
+ // do we already have a full screen layout with this playlist?
+ $existingFullscreenLayout = $this->getLinkedFullScreenLayout(
+ 'playlist',
+ $playlist->playlistId,
+ $currentLayoutProperties
+ );
+ }
+
+ if (!empty($existingFullscreenLayout)) {
+ // Return
+ return $existingFullscreenLayout;
+ }
+
+ $layout = $this->createFromResolution(
+ $resolutionId,
+ $this->getUser()->userId,
+ $type . '_' .
+ ($type === 'media' ? $media->name : $playlist->name) .
+ '_' . ($type === 'media' ? $media->mediaId : $playlist->playlistId),
+ 'Full Screen Layout created from ' . ($type === 'media' ? $media->name : $playlist->name),
+ '',
+ null,
+ false
+ );
+
+ if (!empty($backgroundColor)) {
+ $layout->backgroundColor = $backgroundColor;
+ }
+
+ $this->addRegion(
+ $layout,
+ $type === 'media' ? 'frame' : 'playlist',
+ $layout->width,
+ $layout->height,
+ 0,
+ 0
+ );
+
+ $layout->setUnmatchedProperty('type', $type);
+ $layout->autoApplyTransitions = 0;
+ $layout->schemaVersion = Environment::$XLF_VERSION;
+ $layout->folderId = ($type === 'media') ? $media->folderId : $playlist->folderId;
+
+ // Media files have their own validation so we can skip
+ $layout->save(['validate' => false]);
+
+ $draft = $this->checkoutLayout($layout);
+
+ $region = $draft->regions[0];
+
+ // Create a widget
+ $widget = $this->widgetFactory->create(
+ $this->getUser()->userId,
+ $region->getPlaylist()->playlistId,
+ $type === 'media' ? $media->mediaType : 'subplaylist',
+ $itemDuration,
+ $module->schemaVersion
+ );
+
+ if ($type === 'playlist') {
+ // save here, simulate add Widget
+ // next save (with playlist) will edit and save the Widget and dispatch event that manages closure table.
+ $widget->save();
+ $item = new SubPlaylistItem();
+ $item->rowNo = 1;
+ $item->playlistId = $playlist->playlistId;
+ $item->spotFill = 'repeat';
+ $item->spotLength = '';
+ $item->spots = '';
+
+ $playlistItems[] = $item;
+ $widget->setOptionValue('subPlaylists', 'attrib', json_encode($playlistItems));
+ } else {
+ $widget->useDuration = 1;
+ $widget->assignMedia($media->mediaId);
+ }
+
+ // Calculate the duration
+ $widget->calculateDuration($module);
+
+ // Set loop for media items with custom duration
+ if ($type === 'media' && $media->mediaType === 'video' && $itemDuration > $media->duration) {
+ $widget->setOptionValue('loop', 'attrib', 1);
+ $widget->save();
+ }
+
+ // Assign the widget to the playlist
+ $region->getPlaylist()->assignWidget($widget);
+ // Save the playlist
+ $region->getPlaylist()->save();
+ $region->save();
+
+ // look up the record in the database
+ // as we do not set modifiedDt on the object on save.
+ $draft = $this->getByParentId($layout->layoutId);
+ $draft->publishDraft();
+ $draft->load();
+
+ // We also build the XLF at this point, and if we have a problem we prevent publishing and raise as an
+ // error message
+ $draft->xlfToDisk(['notify' => true, 'exceptionOnError' => true, 'exceptionOnEmptyRegion' => false]);
+
+ // Return
+ return $draft;
+ }
+
+ /**
+ * Get the layout resolutionId
+ * @params $layout
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function getLayoutResolutionId($layout)
+ {
+ return $this->resolutionFactory->getClosestMatchingResolution($layout->width, $layout->height);
+ }
+
+ //
+}
diff --git a/lib/Factory/LogFactory.php b/lib/Factory/LogFactory.php
new file mode 100644
index 0000000..da195a9
--- /dev/null
+++ b/lib/Factory/LogFactory.php
@@ -0,0 +1,205 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\LogEntry;
+use Xibo\Helper\DateFormatHelper;
+
+/**
+ * Class LogFactory
+ * @package Xibo\Factory
+ */
+class LogFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return LogEntry
+ */
+ public function createEmpty()
+ {
+ return new LogEntry($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Query
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[\Xibo\Entity\Log]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null) {
+ $sortOrder = ['logId DESC'];
+ }
+
+ $entries = [];
+ $params = [];
+ $order = '';
+ $limit = '';
+
+ $select = '
+ SELECT `logId`,
+ `runNo`,
+ `logDate`,
+ `channel`,
+ `page`,
+ `function`,
+ `message`,
+ `display`.`displayId`,
+ `display`.`display`,
+ `type`,
+ `userId`,
+ `sessionHistoryId`
+ ';
+
+ $body = '
+ FROM `log`
+ LEFT OUTER JOIN `display`
+ ON `display`.`displayid` = `log`.`displayid`
+ ';
+ if ($parsedFilter->getInt('displayGroupId') !== null) {
+ $body .= 'INNER JOIN `lkdisplaydg`
+ ON `lkdisplaydg`.`DisplayID` = `log`.`displayid` ';
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+
+ if ($parsedFilter->getInt('fromDt') !== null) {
+ $body .= ' AND `logdate` > :fromDt ';
+ $params['fromDt'] = Carbon::createFromTimestamp(
+ $parsedFilter->getInt('fromDt')
+ )->format(DateFormatHelper::getSystemFormat());
+ }
+
+ if ($parsedFilter->getInt('toDt') !== null) {
+ $body .= ' AND `logdate` <= :toDt ';
+ $params['toDt'] = Carbon::createFromTimestamp(
+ $parsedFilter->getInt('toDt')
+ )->format(DateFormatHelper::getSystemFormat());
+ }
+
+ if ($parsedFilter->getString('runNo') != null) {
+ $body .= ' AND `runNo` = :runNo ';
+ $params['runNo'] = $parsedFilter->getString('runNo');
+ }
+
+ if ($parsedFilter->getString('type') != null) {
+ $body .= ' AND `type` = :type ';
+ $params['type'] = $parsedFilter->getString('type');
+ }
+
+ if ($parsedFilter->getString('channel') != null) {
+ $body .= ' AND `channel` LIKE :channel ';
+ $params['channel'] = '%' . $parsedFilter->getString('channel') . '%';
+ }
+
+ if ($parsedFilter->getString('page') != null) {
+ $body .= ' AND `page` LIKE :page ';
+ $params['page'] = '%' . $parsedFilter->getString('page') . '%';
+ }
+
+ if ($parsedFilter->getString('function') != null) {
+ $body .= ' AND `function` LIKE :function ';
+ $params['function'] = '%' . $parsedFilter->getString('function') . '%';
+ }
+
+ if ($parsedFilter->getString('message') != null) {
+ $body .= ' AND `message` LIKE :message ';
+ $params['message'] = '%' . $parsedFilter->getString('message') . '%';
+ }
+
+ if ($parsedFilter->getInt('displayId') !== null) {
+ $body .= ' AND `log`.`displayId` = :displayId ';
+ $params['displayId'] = $parsedFilter->getInt('displayId');
+ }
+
+ if ($parsedFilter->getInt('userId') !== null) {
+ $body .= ' AND `log`.`userId` = :userId ';
+ $params['userId'] = $parsedFilter->getInt('userId');
+ }
+
+ if ($parsedFilter->getCheckbox('excludeLog') == 1) {
+ $body .= ' AND (`log`.`page` NOT LIKE \'/log%\' OR `log`.`page` = \'/login\') ';
+ $body .= ' AND `log`.`page` NOT IN(\'/user/pref\', \'/clock\', \'/fonts/fontcss\') ';
+ }
+
+ // Filter by Display Name?
+ if ($parsedFilter->getString('display') != null) {
+ $terms = explode(',', $parsedFilter->getString('display'));
+ $this->nameFilter(
+ 'display',
+ 'display',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1)
+ );
+ }
+
+ if ($parsedFilter->getInt('displayGroupId') !== null) {
+ $body .= ' AND `lkdisplaydg`.`displaygroupid` = :displayGroupId ';
+ $params['displayGroupId'] = $parsedFilter->getInt('displayGroupId');
+ }
+
+ if ($parsedFilter->getInt('sessionHistoryId') !== null) {
+ $body .= ' AND `log`.`sessionHistoryId` = :sessionHistoryId ';
+ $params['sessionHistoryId'] = $parsedFilter->getInt('sessionHistoryId');
+ }
+
+ // Sorting?
+ if (is_array($sortOrder)) {
+ $order = ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ // Paging
+ if ($filterBy !== null
+ && $parsedFilter->getInt('start') !== null
+ && $parsedFilter->getInt('length', ['default' => 10]) !== null
+ ) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) . ', '
+ . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['htmlStringProperties' => ['message']]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/MediaFactory.php b/lib/Factory/MediaFactory.php
new file mode 100644
index 0000000..3155f97
--- /dev/null
+++ b/lib/Factory/MediaFactory.php
@@ -0,0 +1,1014 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Pool;
+use Psr\Http\Message\ResponseInterface;
+use Xibo\Entity\Media;
+use Xibo\Entity\User;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\Environment;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class MediaFactory
+ * @package Xibo\Factory
+ */
+class MediaFactory extends BaseFactory
+{
+ use TagTrait;
+
+ /** @var Media[] */
+ private $remoteDownloadQueue = [];
+
+ /** @var Media[] */
+ private $remoteDownloadNotRequiredQueue = [];
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var PlaylistFactory
+ */
+ private $playlistFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ * @param PermissionFactory $permissionFactory
+ * @param PlaylistFactory $playlistFactory
+ */
+ public function __construct($user, $userFactory, $config, $permissionFactory, $playlistFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->config = $config;
+ $this->permissionFactory = $permissionFactory;
+ $this->playlistFactory = $playlistFactory;
+ }
+
+ /**
+ * Create Empty
+ * @return Media
+ */
+ public function createEmpty()
+ {
+ return new Media(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this,
+ $this->permissionFactory
+ );
+ }
+
+ /**
+ * Create New Media
+ * @param string $name
+ * @param string $fileName
+ * @param string $type
+ * @param int $ownerId
+ * @param int $duration
+ * @return Media
+ */
+ public function create($name, $fileName, $type, $ownerId, $duration = 0)
+ {
+ $media = $this->createEmpty();
+ $media->name = $name;
+ $media->fileName = $fileName;
+ $media->mediaType = $type;
+ $media->ownerId = $ownerId;
+ $media->duration = $duration;
+
+ return $media;
+ }
+
+ /**
+ * Create Module File
+ * @param $name
+ * @param string|null $file
+ * @return Media
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function createModuleFile($name, ?string $file = ''): Media
+ {
+ if ($file == '') {
+ $file = $name;
+ $name = basename($file);
+ }
+
+ try {
+ $media = $this->getByNameAndType($name, 'module');
+
+ // Reassert the new file (which we might want to download)
+ $media->fileName = $file;
+ $media->storedAs = $name;
+ } catch (NotFoundException $e) {
+ $media = $this->createEmpty();
+ $media->name = $name;
+ $media->fileName = $file;
+ $media->mediaType = 'module';
+ $media->expires = 0;
+ $media->storedAs = $name;
+ $media->ownerId = $this->getUserFactory()->getSystemUser()->getOwnerId();
+ $media->moduleSystemFile = 0;
+ }
+
+ return $media;
+ }
+
+ /**
+ * Queue remote file download
+ * @param $name
+ * @param $uri
+ * @param $expiry
+ * @param array $requestOptions
+ * @return Media
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function queueDownload($name, $uri, $expiry, $requestOptions = [])
+ {
+ // Determine the save name
+ if (!isset($requestOptions['fileType'])) {
+ $media = $this->createModuleFile($name, $uri);
+ $media->isRemote = true;
+ } else {
+ $media = $this->createEmpty();
+ $media->name = $name;
+ $media->fileName = $uri;
+ $media->ownerId = $this->getUserFactory()->getUser()->userId;
+ $media->mediaType = $requestOptions['fileType'];
+ $media->duration = $requestOptions['duration'];
+ $media->moduleSystemFile = 0;
+ $media->isRemote = true;
+ $media->setUnmatchedProperty('urlDownload', true);
+ $media->setUnmatchedProperty('extension', $requestOptions['extension'] ?? null);
+ $media->enableStat = $requestOptions['enableStat'];
+ $media->folderId = $requestOptions['folderId'];
+ $media->permissionsFolderId = $requestOptions['permissionsFolderId'];
+ $media->apiRef = $requestOptions['apiRef'] ?? null;
+ }
+
+ $this->getLog()->debug('Queue download of: ' . $uri . ', current mediaId for this download is '
+ . $media->mediaId . '.');
+
+ // We update the desired expiry here - isSavedRequired is tested against the original value
+ $media->expires = $expiry;
+
+ // Save the file, but do not download yet.
+ $media->saveAsync(['requestOptions' => $requestOptions]);
+
+ // Add to our collection of queued downloads
+ // but only if it's not already in the queue (we might have tried to queue it multiple times in
+ // the same request)
+ if ($media->isSaveRequired) {
+ $this->getLog()->debug('We are required to download as this file is either expired or not existing');
+
+ $queueItem = true;
+ if ($media->getId() != null) {
+ // Existing media, check to see if we're already queued
+ foreach ($this->remoteDownloadQueue as $queue) {
+ // If we find this item already, don't queue
+ if ($queue->getId() === $media->getId()) {
+ $queueItem = false;
+ break;
+ }
+ }
+ }
+
+ if ($queueItem) {
+ $this->remoteDownloadQueue[] = $media;
+ }
+ } else {
+ // Queue in the not required download queue
+ $this->getLog()->debug('Download not required as this file exists and is up to date. Expires = ' . $media->getOriginalValue('expires'));
+
+ $queueItem = true;
+ if ($media->getId() != null) {
+ // Existing media, check to see if we're already queued
+ foreach ($this->remoteDownloadNotRequiredQueue as $queue) {
+ // If we find this item already, don't queue
+ if ($queue->getId() === $media->getId()) {
+ $queueItem = false;
+ break;
+ }
+ }
+ }
+
+ if ($queueItem) {
+ $this->remoteDownloadNotRequiredQueue[] = $media;
+ }
+ }
+
+ // Return the media item
+ return $media;
+ }
+
+ /**
+ * Process the queue of downloads
+ * @param null|callable $success success callable
+ * @param null|callable $failure failure callable
+ * @param null|callable $rejected rejected callable
+ */
+ public function processDownloads($success = null, $failure = null, $rejected = null)
+ {
+ if (count($this->remoteDownloadQueue) > 0) {
+ $this->getLog()->debug('Processing Queue of ' . count($this->remoteDownloadQueue) . ' downloads.');
+
+ // Create a generator and Pool
+ $log = $this->getLog();
+ $queue = $this->remoteDownloadQueue;
+ $client = new Client($this->config->getGuzzleProxy(['timeout' => 0]));
+
+ $downloads = function () use ($client, $queue) {
+ foreach ($queue as $media) {
+ $url = $media->downloadUrl();
+ $sink = $media->downloadSink();
+ $requestOptions = array_merge($media->downloadRequestOptions(), [
+ 'sink' => $sink,
+ 'on_headers' => function (ResponseInterface $response) {
+ $this->getLog()->debug('processDownloads: on_headers status code = '
+ . $response->getStatusCode());
+
+ if ($response->getStatusCode() < 299) {
+ $this->getLog()->debug('processDownloads: successful, headers = '
+ . var_export($response->getHeaders(), true));
+
+ // Get the content length
+ $contentLength = $response->getHeaderLine('Content-Length');
+ if (empty($contentLength)
+ || intval($contentLength) > ByteFormatter::toBytes(Environment::getMaxUploadSize())
+ ) {
+ throw new \Exception(__('File too large'));
+ }
+ }
+ }
+ ]);
+
+ yield function () use ($client, $url, $requestOptions) {
+ return $client->getAsync($url, $requestOptions);
+ };
+ }
+ };
+
+ $pool = new Pool($client, $downloads(), [
+ 'concurrency' => 5,
+ 'fulfilled' => function ($response, $index) use ($log, $queue, $success, $failure) {
+ $item = $queue[$index];
+
+ // File is downloaded, call save to move it appropriately
+ try {
+ $item->saveFile();
+
+ // If a success callback has been provided, call it
+ if ($success !== null && is_callable($success)) {
+ $success($item);
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('processDownloads: Unable to save mediaId '
+ . $item->mediaId . '. ' . $e->getMessage());
+
+ // Remove it
+ $item->delete(['rollback' => true]);
+
+ // If a failure callback has been provided, call it
+ if ($failure !== null && is_callable($failure)) {
+ $failure($item);
+ }
+ }
+ },
+ 'rejected' => function ($reason, $index) use ($log, $queue, $rejected) {
+ /* @var RequestException $reason */
+ $log->error(
+ sprintf(
+ 'Rejected Request %d to %s because %s',
+ $index,
+ $reason->getRequest()->getUri(),
+ $reason->getMessage()
+ )
+ );
+
+ // We should remove the media record.
+ $queue[$index]->delete(['rollback' => true]);
+
+ // If a rejected callback has been provided, call it
+ if ($rejected !== null && is_callable($rejected)) {
+ // Do we have a wrapped exception?
+ $reasonMessage = $reason->getPrevious() !== null
+ ? $reason->getPrevious()->getMessage()
+ : $reason->getMessage();
+
+ call_user_func($rejected, $reasonMessage);
+ }
+ }
+ ]);
+
+ $promise = $pool->promise();
+ $promise->wait();
+ }
+
+ // Handle the downloads that did not require downloading
+ if (count($this->remoteDownloadNotRequiredQueue) > 0) {
+ $this->getLog()->debug('Processing Queue of ' . count($this->remoteDownloadNotRequiredQueue) . ' items which do not need downloading.');
+
+ foreach ($this->remoteDownloadNotRequiredQueue as $item) {
+ // If a success callback has been provided, call it
+ if ($success !== null && is_callable($success)) {
+ $success($item);
+ }
+ }
+ }
+
+ // Clear the queue for next time.
+ $this->remoteDownloadQueue = [];
+ $this->remoteDownloadNotRequiredQueue = [];
+ }
+
+ /**
+ * Get by Media Id
+ * @param int $mediaId
+ * @return Media
+ * @throws NotFoundException
+ */
+ public function getById($mediaId, bool $isDisableUserCheck = true)
+ {
+ $media = $this->query(null, [
+ 'disableUserCheck' => $isDisableUserCheck ? 1 : 0,
+ 'mediaId' => $mediaId,
+ 'allModules' => 1,
+ ]);
+
+ if (count($media) <= 0) {
+ throw new NotFoundException(__('Cannot find media'));
+ }
+
+ return $media[0];
+ }
+
+ /**
+ * Get by Parent Media Id
+ * @param int $mediaId
+ * @return Media
+ * @throws NotFoundException
+ */
+ public function getParentById($mediaId)
+ {
+ $media = $this->query(null, array('disableUserCheck' => 1, 'parentMediaId' => $mediaId, 'allModules' => 1));
+
+ if (count($media) <= 0) {
+ throw new NotFoundException(__('Cannot find media'));
+ }
+
+ return $media[0];
+ }
+
+ /**
+ * Get by Media Name
+ * @param string $name
+ * @return Media
+ * @throws NotFoundException
+ */
+ public function getByName($name)
+ {
+ $media = $this->query(null, array('disableUserCheck' => 1, 'nameExact' => $name, 'allModules' => 1));
+
+ if (count($media) <= 0)
+ throw new NotFoundException(__('Cannot find media'));
+
+ return $media[0];
+ }
+
+ /**
+ * Get by Media Name
+ * @param string $name
+ * @param string $type
+ * @return Media
+ * @throws NotFoundException
+ */
+ public function getByNameAndType($name, $type)
+ {
+ $media = $this->query(null, array('disableUserCheck' => 1, 'nameExact' => $name, 'type' => $type, 'allModules' => 1));
+
+ if (count($media) <= 0)
+ throw new NotFoundException(__('Cannot find media'));
+
+ return $media[0];
+ }
+
+ /**
+ * Get by Owner Id
+ * @param int $ownerId
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId, $allModules = 0)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'ownerId' => $ownerId, 'isEdited' => 1, 'allModules' => $allModules]);
+ }
+
+ /**
+ * Get by Type
+ * @param string $type
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function getByMediaType($type)
+ {
+ return $this->query(null, array('disableUserCheck' => 1, 'type' => $type, 'allModules' => 1));
+ }
+
+ /**
+ * Get by Display Group Id
+ * @param int $displayGroupId
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function getByDisplayGroupId($displayGroupId)
+ {
+ if ($displayGroupId == null) {
+ return [];
+ }
+
+ return $this->query(null, array('disableUserCheck' => 1, 'displayGroupId' => $displayGroupId));
+ }
+
+ /**
+ * Get Media by LayoutId
+ * @param int $layoutId
+ * @param int $edited
+ * @param int $excludeDynamicPlaylistMedia
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function getByLayoutId($layoutId, $edited = -1, $excludeDynamicPlaylistMedia = 0)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'layoutId' => $layoutId, 'isEdited' => $edited, 'excludeDynamicPlaylistMedia' => $excludeDynamicPlaylistMedia]);
+ }
+
+ /**
+ * Get Media by campaignId
+ * @param int $campaignId
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function getByCampaignId($campaignId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'campaignId' => $campaignId]);
+ }
+
+ public function getForMenuBoards()
+ {
+ return $this->query(null, ['onlyMenuBoardAllowed' => 1]);
+ }
+
+ /**
+ * @param int $folderId
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function getByFolderId(int $folderId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'folderId' => $folderId]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return Media[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder === null) {
+ $sortOrder = ['name'];
+ }
+
+ $newSortOrder = [];
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`revised`') {
+ $newSortOrder[] = '`parentId`';
+ continue;
+ }
+
+ if ($sort == '`revised` DESC') {
+ $newSortOrder[] = '`parentId` DESC';
+ continue;
+ }
+ $newSortOrder[] = $sort;
+ }
+ $sortOrder = $newSortOrder;
+
+ $entries = [];
+
+ $params = [];
+ $select = '
+ SELECT `media`.mediaId,
+ `media`.name,
+ `media`.type AS mediaType,
+ `media`.duration,
+ `media`.userId AS ownerId,
+ `media`.fileSize,
+ `media`.storedAs,
+ `media`.valid,
+ `media`.moduleSystemFile,
+ `media`.expires,
+ `media`.md5,
+ `media`.retired,
+ `media`.isEdited,
+ IFNULL(parentmedia.mediaId, 0) AS parentId,
+ `media`.released,
+ `media`.apiRef,
+ `media`.createdDt,
+ `media`.modifiedDt,
+ `media`.enableStat,
+ `media`.folderId,
+ `media`.permissionsFolderId,
+ `media`.orientation,
+ `media`.width,
+ `media`.height,
+ `user`.UserName AS owner,
+ ';
+ $select .= ' (SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :entity
+ AND objectId = media.mediaId
+ AND view = 1
+ ) AS groupsWithPermissions, ';
+ $params['entity'] = 'Xibo\\Entity\\Media';
+
+ $select .= ' media.originalFileName AS fileName ';
+
+ $body = ' FROM media ';
+ $body .= ' LEFT OUTER JOIN media parentmedia ';
+ $body .= ' ON media.editedMediaId = parentmedia.mediaId ';
+
+ // Media might be linked to the system user (userId 0)
+ $body .= ' LEFT OUTER JOIN `user` ON `user`.userId = `media`.userId ';
+
+ if ($sanitizedFilter->getInt('displayGroupId') !== null) {
+ $body .= '
+ INNER JOIN `lkmediadisplaygroup`
+ ON lkmediadisplaygroup.mediaid = media.mediaid
+ AND lkmediadisplaygroup.displayGroupId = :displayGroupId
+ ';
+
+ $params['displayGroupId'] = $sanitizedFilter->getInt('displayGroupId');
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('allModules') == 0) {
+ $body .= ' AND media.type <> \'module\' ';
+ }
+
+ if ($sanitizedFilter->getInt('assignable', ['default'=> -1]) == 1) {
+ $body .= '
+ AND media.type <> \'genericfile\'
+ AND media.type <> \'playersoftware\'
+ AND media.type <> \'savedreport\'
+ AND media.type <> \'font\'
+ ';
+ }
+
+ if ($sanitizedFilter->getInt('assignable', ['default'=> -1]) == 0) {
+ $body .= '
+ AND (media.type = \'genericfile\'
+ OR media.type = \'playersoftware\'
+ OR media.type = \'savedreport\'
+ OR media.type = \'font\')
+ ';
+ }
+
+ // Unused only?
+ if ($sanitizedFilter->getInt('unusedOnly') === 1) {
+ $body .= '
+ AND `media`.`mediaId` NOT IN (SELECT `mediaId` FROM `display_media`)
+ AND `media`.`mediaId` NOT IN (SELECT `mediaId` FROM `lkwidgetmedia`)
+ AND `media`.`mediaId` NOT IN (SELECT `mediaId` FROM `lkmediadisplaygroup`)
+ AND `media`.`mediaId` NOT IN (SELECT `mediaId` FROM `menu_category` WHERE `mediaId` IS NOT NULL)
+ AND `media`.`mediaId` NOT IN (SELECT `mediaId` FROM `menu_product` WHERE `mediaId` IS NOT NULL)
+ AND `media`.`mediaId` NOT IN (
+ SELECT `backgroundImageId` FROM `layout` WHERE `backgroundImageId` IS NOT NULL
+ )
+ AND `media`.`type` <> \'module\'
+ AND `media`.`type` <> \'font\'
+ AND `media`.`type` <> \'playersoftware\'
+ AND `media`.`type` <> \'savedreport\'
+ ';
+
+ // DataSets with library images
+ $dataSetSql = '
+ SELECT dataset.dataSetId, datasetcolumn.heading
+ FROM dataset
+ INNER JOIN datasetcolumn
+ ON datasetcolumn.DataSetID = dataset.DataSetID
+ WHERE DataTypeID = 5 AND `datasetcolumn`.dataSetColumnTypeId <> 2;
+ ';
+
+ $dataSets = $this->getStore()->select($dataSetSql, []);
+
+ if (count($dataSets) > 0) {
+ $body .= ' AND media.mediaID NOT IN (';
+
+ $first = true;
+ foreach ($dataSets as $dataSet) {
+ $sanitizedDataSet = $this->getSanitizer($dataSet);
+
+ if (!$first) {
+ $body .= ' UNION ALL ';
+ }
+
+ $first = false;
+
+ $dataSetId = $sanitizedDataSet->getInt('dataSetId');
+ $heading = $sanitizedDataSet->getString('heading');
+
+ $body .= ' SELECT `' . $heading . '` AS mediaId FROM `dataset_' . $dataSetId . '`';
+ }
+
+ $body .= ') ';
+ }
+ }
+
+ // Unlinked only?
+ if ($sanitizedFilter->getInt('unlinkedOnly') === 1) {
+ $body .= '
+ AND `media`.`mediaId` NOT IN (SELECT `mediaId` FROM `display_media`)
+ ';
+ }
+
+ if ($sanitizedFilter->getString('name') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'media',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getString('nameExact') != '') {
+ $body .= ' AND media.name = :exactName ';
+ $params['exactName'] = $sanitizedFilter->getString('nameExact');
+ }
+
+ if ($sanitizedFilter->getInt('mediaId', ['default'=> -1]) != -1) {
+ $body .= ' AND media.mediaId = :mediaId ';
+ $params['mediaId'] = $sanitizedFilter->getInt('mediaId');
+ } else if ($sanitizedFilter->getInt('parentMediaId') !== null) {
+ $body .= ' AND media.editedMediaId = :mediaId ';
+ $params['mediaId'] = $sanitizedFilter->getInt('parentMediaId');
+ } else if ($sanitizedFilter->getInt('isEdited', ['default' => -1]) != -1) {
+ $body .= ' AND media.isEdited <> -1 ';
+ } else {
+ $body .= ' AND media.isEdited = 0 ';
+ }
+
+ if ($sanitizedFilter->getString('type') != '') {
+ $body .= 'AND media.type = :type ';
+ $params['type'] = $sanitizedFilter->getString('type');
+ }
+
+ if (!empty($sanitizedFilter->getArray('types'))) {
+ $body .= 'AND (';
+ foreach ($sanitizedFilter->getArray('types') as $key => $type) {
+ $body .= 'media.type = :types' . $key . ' ';
+
+ if ($key !== array_key_last($sanitizedFilter->getArray('types'))) {
+ $body .= ' OR ';
+ }
+
+ $params['types' . $key] = $type;
+ }
+ $body .= ') ';
+ }
+
+ if ($sanitizedFilter->getInt('imageProcessing') !== null) {
+ $body .= 'AND ( media.type = \'image\' OR (media.type = \'module\' AND media.moduleSystemFile = 0) ) ';
+ }
+
+ if ($sanitizedFilter->getString('storedAs') != '') {
+ $body .= 'AND media.storedAs = :storedAs ';
+ $params['storedAs'] = $sanitizedFilter->getString('storedAs');
+ }
+
+ if ($sanitizedFilter->getInt('ownerId') !== null) {
+ $body .= ' AND media.userid = :ownerId ';
+ $params['ownerId'] = $sanitizedFilter->getInt('ownerId');
+ }
+
+ // User Group filter
+ if ($sanitizedFilter->getInt('ownerUserGroupId', ['default'=> 0]) != 0) {
+ $body .= ' AND media.userid IN (SELECT DISTINCT userId FROM `lkusergroup` WHERE groupId = :ownerUserGroupId) ';
+ $params['ownerUserGroupId'] = $sanitizedFilter->getInt('ownerUserGroupId');
+ }
+
+ if ($sanitizedFilter->getInt('released') !== null) {
+ $body .= ' AND media.released = :released ';
+ $params['released'] = $sanitizedFilter->getInt('released');
+ }
+
+ if ($sanitizedFilter->getCheckbox('unreleasedOnly') === 1) {
+ $body .= ' AND media.released <> 1 ';
+ }
+
+ if ($sanitizedFilter->getInt('retired', ['default'=> -1]) == 1)
+ $body .= ' AND media.retired = 1 ';
+
+ if ($sanitizedFilter->getInt('retired', ['default'=> -1]) == 0)
+ $body .= ' AND media.retired = 0 ';
+
+ // Expired files?
+ if ($sanitizedFilter->getInt('expires') != 0) {
+ $body .= '
+ AND media.expires < :expires
+ AND IFNULL(media.expires, 0) <> 0
+ AND ( media.mediaId NOT IN (SELECT mediaId FROM `lkwidgetmedia`) OR media.type <> \'module\')
+ ';
+ $params['expires'] = $sanitizedFilter->getInt('expires');
+ }
+
+ if ($sanitizedFilter->getInt('layoutId') !== null) {
+ // handles the closure table link with sub-playlists
+ $body .= '
+ AND media.mediaId IN (
+ SELECT `lkwidgetmedia`.mediaId
+ FROM region
+ INNER JOIN playlist
+ ON playlist.regionId = region.regionId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ INNER JOIN widget
+ ON widget.playlistId = lkplaylistplaylist.childId
+ INNER JOIN lkwidgetmedia
+ ON widget.widgetId = lkwidgetmedia.widgetId
+ WHERE region.layoutId = :layoutId ';
+
+ // include Media only for non-dynamic Playlists #2392
+ if ($sanitizedFilter->getInt('excludeDynamicPlaylistMedia') === 1) {
+ $body .= ' AND lkplaylistplaylist.childId IN (
+ SELECT playlistId
+ FROM playlist
+ WHERE playlist.playlistId = lkplaylistplaylist.childId
+ AND playlist.isDynamic = 0
+ ) ';
+ }
+
+ if ($sanitizedFilter->getInt('widgetId') !== null) {
+ $body .= ' AND `widget`.widgetId = :widgetId ';
+ $params['widgetId'] = $sanitizedFilter->getInt('widgetId');
+ }
+
+ if ($sanitizedFilter->getInt('includeLayoutBackgroundImage') === 1) {
+ $body .= ' UNION ALL
+ SELECT `layout`.backgroundImageId AS mediaId
+ FROM `layout`
+ WHERE `layout`.layoutId = :layoutId
+ ';
+ }
+
+ $body .= ' )
+ AND media.type <> \'module\'
+ ';
+
+ $params['layoutId'] = $sanitizedFilter->getInt('layoutId');
+ }
+
+ if ($sanitizedFilter->getInt('campaignId') !== null) {
+ $body .= '
+ AND media.mediaId IN (
+ SELECT `lkwidgetmedia`.mediaId
+ FROM region
+ INNER JOIN playlist
+ ON playlist.regionId = region.regionId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ INNER JOIN widget
+ ON widget.playlistId = lkplaylistplaylist.childId
+ INNER JOIN lkwidgetmedia
+ ON widget.widgetId = lkwidgetmedia.widgetId
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = region.layoutid
+ AND lkcl.CampaignID = :campaignId)';
+
+ $params['campaignId'] = $sanitizedFilter->getInt('campaignId');
+ }
+
+ // Tags
+ if ($sanitizedFilter->getString('tags') != '') {
+ $tagFilter = $sanitizedFilter->getString('tags');
+
+ if (trim($tagFilter) === '--no-tag') {
+ $body .= ' AND `media`.mediaId NOT IN (
+ SELECT `lktagmedia`.mediaId
+ FROM tag
+ INNER JOIN `lktagmedia`
+ ON `lktagmedia`.tagId = tag.tagId
+ )
+ ';
+ } else {
+ $operator = $sanitizedFilter->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $sanitizedFilter->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $body .= ' AND `media`.mediaId NOT IN (
+ SELECT `lktagmedia`.mediaId
+ FROM tag
+ INNER JOIN `lktagmedia`
+ ON `lktagmedia`.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $notTags,
+ 'lktagmedia',
+ 'lkTagMediaId',
+ 'mediaId',
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $body .= ' AND `media`.mediaId IN (
+ SELECT `lktagmedia`.mediaId
+ FROM tag
+ INNER JOIN `lktagmedia`
+ ON `lktagmedia`.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $tags,
+ 'lktagmedia',
+ 'lkTagMediaId',
+ 'mediaId',
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+ }
+ }
+ }
+
+ // File size
+ if ($sanitizedFilter->getString('fileSize') != null) {
+ $fileSize = $this->parseComparisonOperator($sanitizedFilter->getString('fileSize'));
+
+ $body .= ' AND `media`.fileSize ' . $fileSize['operator'] . ' :fileSize ';
+ $params['fileSize'] = $fileSize['variable'];
+ }
+
+ // Duration
+ if ($sanitizedFilter->getInt('duration') != null) {
+ $duration = $this->parseComparisonOperator($sanitizedFilter->getInt('duration'));
+
+ $body .= ' AND `media`.duration ' . $duration['operator'] . ' :duration ';
+ $params['duration'] = $duration['variable'];
+ }
+
+ if ($sanitizedFilter->getInt('folderId') !== null) {
+ $body .= ' AND media.folderId = :folderId ';
+ $params['folderId'] = $sanitizedFilter->getInt('folderId');
+ }
+
+ if ($sanitizedFilter->getInt('onlyMenuBoardAllowed') !== null) {
+ $body .= ' AND ( media.type = \'image\' OR media.type = \'video\' ) ';
+ }
+
+ if ($sanitizedFilter->getString('orientation') !== null) {
+ $body .= ' AND media.orientation = :orientation ';
+ $params['orientation'] = $sanitizedFilter->getString('orientation');
+ }
+
+ if ($sanitizedFilter->getInt('requiresMetaUpdate') === 1) {
+ $body .= ' AND (`media`.orientation IS NULL OR IFNULL(`media`.width, 0) = 0)';
+ }
+
+ // View Permissions
+ $this->viewPermissionSql(
+ 'Xibo\Entity\Media',
+ $body,
+ $params,
+ '`media`.mediaId',
+ '`media`.userId',
+ $filterBy,
+ '`media`.permissionsFolderId'
+ );
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($sanitizedFilter->hasParam('start') && $sanitizedFilter->hasParam('length')) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0])
+ . ', ' . $sanitizedFilter->getInt('length', ['default'=> 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+ $mediaIds = [];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $media = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'duration',
+ 'size',
+ 'released',
+ 'moduleSystemFile',
+ 'isEdited',
+ 'expires',
+ 'valid',
+ 'width',
+ 'height'
+ ]
+ ]);
+
+ $mediaIds[] = $media->mediaId;
+
+ $media->excludeProperty('layoutBackgroundImages');
+ $media->excludeProperty('widgets');
+ $media->excludeProperty('displayGroups');
+
+ $entries[] = $media;
+ }
+
+ // decorate with TagLinks
+ if (count($entries) > 0) {
+ $this->decorateWithTagLinks('lktagmedia', 'mediaId', $mediaIds, $entries);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['entity']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/MenuBoardCategoryFactory.php b/lib/Factory/MenuBoardCategoryFactory.php
new file mode 100644
index 0000000..5ef6d32
--- /dev/null
+++ b/lib/Factory/MenuBoardCategoryFactory.php
@@ -0,0 +1,398 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\MenuBoardCategory;
+use Xibo\Entity\MenuBoardProduct;
+use Xibo\Support\Exception\NotFoundException;
+
+class MenuBoardCategoryFactory extends BaseFactory
+{
+ /** @var MenuBoardProductOptionFactory */
+ private $menuBoardProductOptionFactory;
+
+ /**
+ * Construct a factory
+ * @param MenuBoardProductOptionFactory $menuBoardProductOptionFactory
+ */
+ public function __construct($menuBoardProductOptionFactory)
+ {
+ $this->menuBoardProductOptionFactory = $menuBoardProductOptionFactory;
+ }
+
+ /**
+ * Create Empty
+ * @return MenuBoardCategory
+ */
+ public function createEmpty()
+ {
+ return new MenuBoardCategory(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this
+ );
+ }
+
+ /**
+ * Create Empty
+ * @return MenuBoardProduct
+ */
+ public function createEmptyProduct()
+ {
+ return new MenuBoardProduct(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->menuBoardProductOptionFactory
+ );
+ }
+
+ /**
+ * Create a new Category
+ * @param int $menuId
+ * @param string $name
+ * @param int $mediaId
+ * @param string $code
+ * @return MenuBoardCategory
+ */
+ public function create($menuId, $name, $mediaId, $code, $description)
+ {
+ $menuBoardCategory = $this->createEmpty();
+ $menuBoardCategory->menuId = $menuId;
+ $menuBoardCategory->name = $name;
+ $menuBoardCategory->mediaId = $mediaId;
+ $menuBoardCategory->code = $code;
+ $menuBoardCategory->description = $description;
+ return $menuBoardCategory;
+ }
+
+ /**
+ * Create a new Product
+ * @param int $menuId
+ * @param int $menuCategoryId
+ * @param string $name
+ * @param float $price
+ * @param string $description
+ * @param string $allergyInfo
+ * @param int $calories
+ * @param int $availability
+ * @param int $mediaId
+ * @param string $code
+ * @return MenuBoardProduct
+ */
+ public function createProduct(
+ $menuId,
+ $menuCategoryId,
+ $name,
+ $price,
+ $description,
+ $allergyInfo,
+ $calories,
+ $displayOrder,
+ $availability,
+ $mediaId,
+ $code
+ ) {
+ $menuBoardProduct = $this->createEmptyProduct();
+ $menuBoardProduct->menuId = $menuId;
+ $menuBoardProduct->menuCategoryId = $menuCategoryId;
+ $menuBoardProduct->name = $name;
+ $menuBoardProduct->price = $price;
+ $menuBoardProduct->description = $description;
+ $menuBoardProduct->allergyInfo = $allergyInfo;
+ $menuBoardProduct->calories = $calories;
+ $menuBoardProduct->displayOrder = $displayOrder;
+ $menuBoardProduct->availability = $availability;
+ $menuBoardProduct->mediaId = $mediaId;
+ $menuBoardProduct->code = $code;
+ return $menuBoardProduct;
+ }
+
+ /**
+ * @param int $menuCategoryId
+ * @return MenuBoardCategory
+ * @throws NotFoundException
+ */
+ public function getById(int $menuCategoryId)
+ {
+ $this->getLog()->debug('MenuBoardCategoryFactory getById ' . $menuCategoryId);
+
+ $menuCategories = $this->query(null, ['disableUserCheck' => 1, 'menuCategoryId' => $menuCategoryId]);
+
+ if (count($menuCategories) <= 0) {
+ $this->getLog()->debug('Menu Board Category not found with ID ' . $menuCategoryId);
+ throw new NotFoundException(__('Menu Board Category not found'));
+ }
+
+ return $menuCategories[0];
+ }
+
+ /**
+ * @param int $menuId
+ * @return MenuBoardCategory[]
+ * @throws NotFoundException
+ */
+ public function getByMenuId(int $menuId)
+ {
+ $this->getLog()->debug('MenuBoardCategoryFactory getById ' . $menuId);
+
+ $menuCategories = $this->query(null, ['disableUserCheck' => 1, 'menuId' => $menuId]);
+
+ if (count($menuCategories) <= 0) {
+ $this->getLog()->debug('Menu Board Category not found for Menu Board ID ' . $menuId);
+ }
+
+ return $menuCategories;
+ }
+
+ /**
+ * @param int $menuProductId
+ * @return MenuBoardProduct
+ * @throws NotFoundException
+ */
+ public function getByProductId(int $menuProductId)
+ {
+ $this->getLog()->debug('MenuBoardCategoryFactory getByProductId ' . $menuProductId);
+
+ $menuProducts = $this->getProductData(null, ['disableUserCheck' => 1, 'menuProductId' => $menuProductId]);
+
+ if (count($menuProducts) <= 0) {
+ $this->getLog()->debug('Menu Board Product not found with ID ' . $menuProductId);
+ throw new NotFoundException(__('Menu Board Product not found'));
+ }
+
+ return $menuProducts[0];
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return MenuBoardCategory[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['menuCategoryId DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT `menu_category`.`menuCategoryId`,
+ `menu_category`.`menuId`,
+ `menu_category`.`name`,
+ `menu_category`.`description`,
+ `menu_category`.`code`,
+ `menu_category`.`mediaId`
+ ';
+
+ $body = ' FROM menu_category WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('menuCategoryId') !== null) {
+ $body .= ' AND `menu_category`.menuCategoryId = :menuCategoryId ';
+ $params['menuCategoryId'] = $sanitizedFilter->getInt('menuCategoryId');
+ }
+
+ if ($sanitizedFilter->getInt('menuId') !== null) {
+ $body .= ' AND `menu_category`.menuId = :menuId ';
+ $params['menuId'] = $sanitizedFilter->getInt('menuId');
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `menu_category`.userId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ if ($sanitizedFilter->getString('name') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $this->nameFilter('menu_category', 'name', $terms, $body, $params, ($sanitizedFilter->getCheckbox('useRegexForName') == 1));
+ }
+
+ if ($sanitizedFilter->getString('code') != '') {
+ $body.= ' AND `menu_category`.code LIKE :code ';
+ $params['code'] = '%' . $sanitizedFilter->getString('code') . '%';
+ }
+
+ if ($sanitizedFilter->getInt('mediaId') !== null) {
+ $body .= ' AND `menu_category`.mediaId = :mediaId ';
+ $params['mediaId'] = $sanitizedFilter->getInt('mediaId');
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $menuCategory = $this->createEmpty()->hydrate($row);
+ $entries[] = $menuCategory;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ public function getNextDisplayOrder(int $categoryId): int
+ {
+ $results = $this->getStore()->select('
+ SELECT MAX(`displayOrder`) AS next
+ FROM menu_product
+ WHERE menuCategoryId = :categoryId
+ ', [
+ 'categoryId' => $categoryId,
+ ]);
+
+ return ($results[0]['next'] ?? 0) + 1;
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return MenuBoardProduct[]
+ */
+ public function getProductData($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['`displayOrder`, `availability` DESC, `menuProductId`'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT
+ `menu_product`.`menuProductId`,
+ `menu_product`.`menuId`,
+ `menu_product`.`menuCategoryId`,
+ `menu_product`.`name`,
+ `menu_product`.`price`,
+ `menu_product`.`description`,
+ `menu_product`.`mediaId`,
+ `menu_product`.`displayOrder`,
+ `menu_product`.`availability`,
+ `menu_product`.`allergyInfo`,
+ `menu_product`.`calories`,
+ `menu_product`.`code`
+ ';
+
+ $body = ' FROM menu_product WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('menuProductId') !== null) {
+ $body .= ' AND `menu_product`.`menuProductId` = :menuProductId ';
+ $params['menuProductId'] = $sanitizedFilter->getInt('menuProductId');
+ }
+
+ if ($sanitizedFilter->getInt('menuId') !== null) {
+ $body .= ' AND `menu_product`.`menuId` = :menuId ';
+ $params['menuId'] = $sanitizedFilter->getInt('menuId');
+ }
+
+ if ($sanitizedFilter->getInt('menuCategoryId') !== null) {
+ $body .= ' AND `menu_product`.`menuCategoryId` = :menuCategoryId ';
+ $params['menuCategoryId'] = $sanitizedFilter->getInt('menuCategoryId');
+ }
+
+ if ($sanitizedFilter->getString('name') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $this->nameFilter('menu_product', 'name', $terms, $body, $params, ($sanitizedFilter->getCheckbox('useRegexForName') == 1));
+ }
+
+ if ($sanitizedFilter->getInt('availability') !== null) {
+ $body .= ' AND `menu_product`.`availability` = :availability ';
+ $params['availability'] = $sanitizedFilter->getInt('availability');
+ }
+
+ if ($sanitizedFilter->getString('categories') != null) {
+ $categories = implode('","', array_map('intval', explode(',', $sanitizedFilter->getString('categories'))));
+ $body .= ' AND `menu_product`.`menuCategoryId` IN ("' . $categories . '") ';
+ }
+
+ if ($sanitizedFilter->getInt('mediaId') !== null) {
+ $body .= ' AND `menu_product`.`mediaId` = :mediaId ';
+ $params['mediaId'] = $sanitizedFilter->getInt('mediaId');
+ }
+
+ if ($sanitizedFilter->getString('code') != '') {
+ $body.= ' AND `menu_product`.`code` LIKE :code ';
+ $params['code'] = '%' . $sanitizedFilter->getString('code') . '%';
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $menuProduct = $this->createEmptyProduct()->hydrate($row, [
+ 'intProperties' => [
+ 'availability',
+ 'calories',
+ 'displayOrder',
+ ],
+ 'doubleProperties' => [
+ 'price',
+ ]
+ ]);
+ $entries[] = $menuProduct;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/MenuBoardFactory.php b/lib/Factory/MenuBoardFactory.php
new file mode 100644
index 0000000..5be43d7
--- /dev/null
+++ b/lib/Factory/MenuBoardFactory.php
@@ -0,0 +1,297 @@
+.
+ */
+namespace Xibo\Factory;
+
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\MenuBoard;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Menu Board Factory
+ */
+class MenuBoardFactory extends BaseFactory
+{
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var MenuBoardCategoryFactory
+ */
+ private $menuBoardCategoryFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ * @param PoolInterface $pool
+ * @param PermissionFactory $permissionFactory
+ * @param MenuBoardCategoryFactory $menuBoardCategoryFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ */
+ public function __construct(
+ $user,
+ $userFactory,
+ $config,
+ $pool,
+ $permissionFactory,
+ $menuBoardCategoryFactory,
+ $displayNotifyService
+ ) {
+ $this->setAclDependencies($user, $userFactory);
+ $this->config = $config;
+ $this->pool = $pool;
+
+ $this->permissionFactory = $permissionFactory;
+ $this->menuBoardCategoryFactory = $menuBoardCategoryFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * Create Empty
+ * @return MenuBoard
+ */
+ public function createEmpty()
+ {
+ return new MenuBoard(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->getSanitizerService(),
+ $this->pool,
+ $this->config,
+ $this->permissionFactory,
+ $this->menuBoardCategoryFactory,
+ $this->displayNotifyService
+ );
+ }
+
+ /**
+ * Create a new menuboard
+ * @param string $name
+ * @param string|null $description
+ * @param string|null $code
+ * @return MenuBoard
+ */
+ public function create(string $name, ?string $description, ?string $code): MenuBoard
+ {
+ $menuBoard = $this->createEmpty();
+ $menuBoard->name = $name;
+ $menuBoard->description = $description;
+ $menuBoard->code = $code;
+ $menuBoard->userId = $this->getUser()->userId;
+
+ return $menuBoard;
+ }
+
+ /**
+ * @param int $menuId
+ * @return MenuBoard
+ * @throws NotFoundException
+ */
+ public function getById(int $menuId)
+ {
+ $this->getLog()->debug('MenuBoardFactory getById ' . $menuId);
+
+ $menuBoards = $this->query(null, ['disableUserCheck' => 1, 'menuId' => $menuId]);
+
+ if (count($menuBoards) <= 0) {
+ $this->getLog()->debug('Menu Board not found with ID ' . $menuId);
+ throw new NotFoundException(__('Menu Board not found'));
+ }
+
+ return $menuBoards[0];
+ }
+
+
+ /**
+ * @param int $userId
+ * @return MenuBoard[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId(int $userId): array
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $userId]);
+ }
+
+ /**
+ * @param int $menuCategoryId
+ * @return MenuBoard
+ * @throws NotFoundException
+ */
+ public function getByMenuCategoryId(int $menuCategoryId)
+ {
+ $menuBoards = $this->query(null, ['disableUserCheck' => 1, 'menuCategoryId' => $menuCategoryId]);
+
+ if (count($menuBoards) <= 0) {
+ $this->getLog()->debug('Menu Board not found with Menu Board Category ID ' . $menuCategoryId);
+ throw new NotFoundException(__('Menu Board not found'));
+ }
+
+ return $menuBoards[0];
+ }
+
+ /**
+ * @param $folderId
+ * @return MenuBoard[]
+ * @throws NotFoundException
+ */
+ public function getByFolderId($folderId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'folderId' => $folderId]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return MenuBoard[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['menuId DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT
+ `menu_board`.menuId,
+ `menu_board`.name,
+ `menu_board`.description,
+ `menu_board`.code,
+ `menu_board`.modifiedDt,
+ `menu_board`.userId,
+ `user`.UserName AS owner,
+ `menu_board`.folderId,
+ `menu_board`.permissionsFolderId,
+ (SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :permissionEntityForGroup
+ AND objectId = menu_board.menuId
+ AND view = 1
+ ) AS groupsWithPermissions
+ ';
+ $params['permissionEntityForGroup'] = 'Xibo\\Entity\\MenuBoard';
+
+ $body = ' FROM menu_board
+ INNER JOIN `user` ON `user`.userId = `menu_board`.userId
+ ';
+
+ if ($sanitizedFilter->getInt('menuCategoryId') !== null) {
+ $body .= ' INNER JOIN `menu_category` ON `menu_category`.menuId = `menu_board`.menuId ';
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+ $this->viewPermissionSql('Xibo\Entity\MenuBoard', $body, $params, 'menu_board.menuId', 'menu_board.userId', $filterBy, '`menu_board`.permissionsFolderId');
+
+ if ($sanitizedFilter->getInt('menuId') !== null) {
+ $body .= ' AND `menu_board`.menuId = :menuId ';
+ $params['menuId'] = $sanitizedFilter->getInt('menuId');
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `menu_board`.userId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ if ($sanitizedFilter->getString('name') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'menu_board',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getInt('folderId') !== null) {
+ $body .= ' AND `menu_board`.folderId = :folderId ';
+ $params['folderId'] = $sanitizedFilter->getInt('folderId');
+ }
+
+ if ($sanitizedFilter->getInt('menuCategoryId') !== null) {
+ $body .= ' AND `menu_category`.menuCategoryId = :menuCategoryId ';
+ $params['menuCategoryId'] = $sanitizedFilter->getInt('menuCategoryId');
+ }
+
+ if ($sanitizedFilter->getString('code') != '') {
+ $body.= ' AND `menu_board`.code LIKE :code ';
+ $params['code'] = '%' . $sanitizedFilter->getString('code') . '%';
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $menuBoard = $this->createEmpty()->hydrate($row);
+ $entries[] = $menuBoard;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['permissionEntityForGroup']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/MenuBoardProductOptionFactory.php b/lib/Factory/MenuBoardProductOptionFactory.php
new file mode 100644
index 0000000..ee58ae7
--- /dev/null
+++ b/lib/Factory/MenuBoardProductOptionFactory.php
@@ -0,0 +1,86 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\MenuBoardProductOption;
+
+class MenuBoardProductOptionFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return MenuBoardProductOption
+ */
+ public function createEmpty()
+ {
+ return new MenuBoardProductOption($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create a Widget Option
+ * @param int $menuProductId
+ * @param string $option
+ * @param mixed $value
+ * @return MenuBoardProductOption
+ */
+ public function create($menuProductId, $option, $value)
+ {
+ $productOption = $this->createEmpty();
+ $productOption->menuProductId = $menuProductId;
+ $productOption->option = $option;
+ $productOption->value = $value;
+
+ return $productOption;
+ }
+
+ /**
+ * Load by Menu Board Product Id
+ * @param int $menuProductId
+ * @return MenuBoardProductOption[]
+ */
+ public function getByMenuProductId($menuProductId)
+ {
+ return $this->query(null, ['menuProductId' => $menuProductId]);
+ }
+
+ /**
+ * Query Menu Board Product options
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return MenuBoardProductOption[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+
+ $sql = 'SELECT * FROM `menu_product_options` WHERE menuProductId = :menuProductId ORDER BY `option`';
+
+ foreach ($this->getStore()->select($sql, [
+ 'menuProductId' => $sanitizedFilter->getInt('menuProductId')
+ ]) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['doubleProperties' => ['value']]);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/ModuleFactory.php b/lib/Factory/ModuleFactory.php
new file mode 100644
index 0000000..aee3652
--- /dev/null
+++ b/lib/Factory/ModuleFactory.php
@@ -0,0 +1,953 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Illuminate\Support\Str;
+use Slim\Views\Twig;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Module;
+use Xibo\Entity\ModuleTemplate;
+use Xibo\Entity\Widget;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\DataType\DataTypeInterface;
+use Xibo\Widget\Definition\Asset;
+use Xibo\Widget\Definition\DataType;
+use Xibo\Widget\Provider\DataProvider;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProvider;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Render\WidgetDataProviderCache;
+use Xibo\Widget\Render\WidgetHtmlRenderer;
+
+/**
+ * Class ModuleFactory
+ * @package Xibo\Factory
+ */
+class ModuleFactory extends BaseFactory
+{
+ use ModuleXmlTrait;
+
+ public static $systemDataTypes = [
+ 'Article',
+ 'Event',
+ 'Forecast',
+ 'Product',
+ 'ProductCategory',
+ 'SocialMedia',
+ 'dataset'
+ ];
+
+ /** @var Module[] all modules */
+ private $modules = null;
+
+ /** @var \Xibo\Widget\Definition\DataType[] */
+ private $dataTypes = null;
+
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+
+ /** @var string */
+ private $cachePath;
+
+ /** @var \Slim\Views\Twig */
+ private $twig;
+
+ /** @var \Xibo\Service\ConfigServiceInterface */
+ private $config;
+
+ /**
+ * Construct a factory
+ * @param string $cachePath
+ * @param PoolInterface $pool
+ * @param \Slim\Views\Twig $twig
+ * @param \Xibo\Service\ConfigServiceInterface $config
+ */
+ public function __construct(string $cachePath, PoolInterface $pool, Twig $twig, ConfigServiceInterface $config)
+ {
+ $this->cachePath = $cachePath;
+ $this->pool = $pool;
+ $this->twig = $twig;
+ $this->config = $config;
+ }
+
+ /**
+ * @param \Xibo\Entity\Module $module
+ * @param \Xibo\Entity\Widget $widget
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function createDataProvider(Module $module, Widget $widget): DataProviderInterface
+ {
+ return new DataProvider(
+ $module,
+ $widget,
+ $this->config->getGuzzleProxy(),
+ $this->getSanitizerService(),
+ $this->pool,
+ );
+ }
+
+ /**
+ * @param Module $module
+ * @param Widget $widget
+ * @return DurationProviderInterface
+ */
+ public function createDurationProvider(Module $module, Widget $widget): DurationProviderInterface
+ {
+ return new DurationProvider($module, $widget);
+ }
+
+ /**
+ * Create a widget renderer
+ * @return \Xibo\Widget\Render\WidgetHtmlRenderer
+ */
+ public function createWidgetHtmlRenderer(): WidgetHtmlRenderer
+ {
+ return (new WidgetHtmlRenderer($this->cachePath, $this->twig, $this->config, $this))
+ ->useLogger($this->getLog()->getLoggerInterface());
+ }
+
+ /**
+ * Create a widget data provider cache
+ */
+ public function createWidgetDataProviderCache(): WidgetDataProviderCache
+ {
+ return (new WidgetDataProviderCache($this->pool))
+ ->useLogger($this->getLog()->getLoggerInterface());
+ }
+
+ /**
+ * Determine the cache key
+ * @param \Xibo\Entity\Module $module
+ * @param \Xibo\Entity\Widget $widget
+ * @param int $displayId the displayId (0 for preview)
+ * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider
+ * @param \Xibo\Widget\Provider\WidgetProviderInterface|null $widgetInterface
+ * @return string
+ */
+ public function determineCacheKey(
+ Module $module,
+ Widget $widget,
+ int $displayId,
+ DataProviderInterface $dataProvider,
+ ?WidgetProviderInterface $widgetInterface
+ ): string {
+ // Determine the cache key
+ $cacheKey = $widgetInterface?->getDataCacheKey($dataProvider);
+
+ if ($cacheKey === null) {
+ // Determinthe cache key from the setting in XML.
+ if (empty($module->dataCacheKey)) {
+ // Best we can do here is a cache per widget, but we should log this as an error.
+ $this->getLog()->debug('determineCacheKey: module without dataCacheKey: ' . $module->moduleId);
+ $cacheKey = $widget->widgetId;
+ } else {
+ // Start with the one provided
+ $this->getLog()->debug('determineCacheKey: module dataCacheKey: ' . $module->dataCacheKey);
+
+ $cacheKey = $module->dataCacheKey;
+
+ // Properties
+ $module->decorateProperties($widget, true);
+ $properties = $module->getPropertyValues(false);
+
+ // Is display location in use?
+ // We should see if the display location property is set (this is a special property), and if it is
+ // update the lat/lng with the details stored on the display
+ $latitude = $properties['latitude'] ?? '';
+ $longitude = $properties['longitude'] ?? '';
+ if ($dataProvider->getProperty('useDisplayLocation') == 1) {
+ $latitude = $dataProvider->getDisplayLatitude() ?: $latitude;
+ $longitude = $dataProvider->getDisplayLongitude() ?: $longitude;
+ }
+
+ // Parse the cache key for variables.
+ $matches = [];
+ preg_match_all('/%(.*?)%/', $cacheKey, $matches);
+ foreach ($matches[1] as $match) {
+ if ($match === 'displayId') {
+ $cacheKey = str_replace('%displayId%', $displayId, $cacheKey);
+ } else if ($match === 'widgetId') {
+ $cacheKey = str_replace('%widgetId%', $widget->widgetId, $cacheKey);
+ } else if ($match === 'latitude') {
+ $cacheKey = str_replace('%latitude%', $latitude, $cacheKey);
+ } else if ($match === 'longitude') {
+ $cacheKey = str_replace('%longitude%', $longitude, $cacheKey);
+ } else {
+ $this->getLog()->debug($match);
+ $cacheKey = str_replace(
+ '%' . $match . '%',
+ $properties[$match] ?? '',
+ $cacheKey
+ );
+ }
+ }
+ }
+
+ // Include a separate cache per fallback data?
+ if ($module->fallbackData == 1) {
+ $cacheKey .= '_fb ' . $widget->getOptionValue('showFallback', 'never');
+ }
+ }
+
+ $this->getLog()->debug('determineCacheKey: cache key is : ' . $cacheKey);
+
+ return $cacheKey;
+ }
+
+ /**
+ * @param string $dataType
+ * @return void
+ */
+ public function clearCacheForDataType(string $dataType): void
+ {
+ $this->getLog()->debug('clearCacheForDataType: /widget/' . $dataType);
+
+ $this->pool->deleteItem('/widget/' . $dataType);
+ }
+
+ /**
+ * @return \Xibo\Entity\Module[]
+ */
+ public function getKeyedArrayOfModules(): array
+ {
+ $this->getLog()->debug('ModuleFactory: getKeyedArrayOfModules');
+ $modules = [];
+ foreach ($this->load() as $module) {
+ $modules[$module->type] = $module;
+ }
+ return $modules;
+ }
+
+ /**
+ * @return Module[]
+ */
+ public function getAssignableModules(): array
+ {
+ $this->getLog()->debug('ModuleFactory: getAssignableModules');
+ $modules = [];
+ foreach ($this->load() as $module) {
+ if ($module->enabled === 1 && $module->assignable === 1) {
+ $modules[] = $module;
+ }
+ }
+ return $modules;
+ }
+
+ /**
+ * @return Module[]
+ */
+ public function getLibraryModules(): array
+ {
+ $this->getLog()->debug('ModuleFactory: getLibraryModules');
+ $modules = [];
+ foreach ($this->load() as $module) {
+ if ($module->enabled == 1 && $module->regionSpecific === 0) {
+ $modules[] = $module;
+ }
+ }
+ return $modules;
+ }
+
+ /**
+ * Get module by Id
+ * @param string $moduleId
+ * @return Module
+ * @throws NotFoundException
+ */
+ public function getById($moduleId): Module
+ {
+ $this->getLog()->debug('ModuleFactory: getById');
+ foreach ($this->load() as $module) {
+ if ($module->moduleId === $moduleId) {
+ return $module;
+ }
+ }
+
+ throw new NotFoundException();
+ }
+
+ /**
+ * Get an array of all modules
+ * @return Module[]
+ */
+ public function getAll(): array
+ {
+ $this->getLog()->debug('ModuleFactory: getAll');
+ return $this->load();
+ }
+
+ /**
+ * Get an array of all modules except canvas
+ * @param array $filter
+ * @return Module[]
+ */
+ public function getAllExceptCanvas(array $filter = []): array
+ {
+ $sanitizedFilter = $this->getSanitizer($filter);
+ $this->getLog()->debug('ModuleFactory: getAllButCanvas');
+ $modules = [];
+ foreach ($this->load() as $module) {
+ // Hide the canvas module from the module list
+ if ($module->moduleId != 'core-canvas') {
+ // do we have a name filter?
+ if (!empty($sanitizedFilter->getString('name'))) {
+ if (str_contains(strtolower($module->name), strtolower($sanitizedFilter->getString('name')))) {
+ $modules[] = $module;
+ }
+ } else {
+ $modules[] = $module;
+ }
+ }
+ }
+ return $modules;
+ }
+
+ /**
+ * Get an array of all enabled modules
+ * @return Module[]
+ */
+ public function getEnabled(): array
+ {
+ $this->getLog()->debug('ModuleFactory: getEnabled');
+ $modules = [];
+ foreach ($this->load() as $module) {
+ if ($module->enabled == 1) {
+ $modules[] = $module;
+ }
+ }
+ return $modules;
+ }
+
+ /**
+ * Get module by Type
+ * this should return the first module enabled by the type specified.
+ * @param string $type
+ * @param array $conditions Conditions that are created based on the widget's option and value, e.g, templateId==worldclock1
+ * @return Module
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getByType(string $type, array $conditions = []): Module
+ {
+ $this->getLog()->debug('ModuleFactory: getByType ' . $type);
+ $modules = $this->load();
+ usort($modules, function ($a, $b) {
+ /** @var Module $a */
+ /** @var Module $b */
+ return $a->enabled - $b->enabled;
+ });
+
+ foreach ($modules as $module) {
+ if ($module->type === $type) {
+ return $module;
+ }
+ }
+
+ // Match on legacy type
+ foreach ($modules as $module) {
+ // get the name of the legacytypes
+ $legacyTypes = [];
+ $legacyConditions = [];
+ if (count($module->legacyTypes) > 0) {
+ $legacyTypes = array_column($module->legacyTypes, 'name');
+ $legacyConditions = array_column($module->legacyTypes, 'condition');
+ }
+
+ if (in_array($type, $legacyTypes)) {
+ foreach ($conditions as $value) {
+ if (in_array($value, $legacyConditions)) {
+ return $module;
+ }
+ }
+
+ return $module;
+ }
+ }
+
+ throw new NotFoundException();
+ }
+
+ /**
+ * Get module by extension
+ * @param string $extension
+ * @return Module
+ * @throws NotFoundException
+ */
+ public function getByExtension(string $extension): Module
+ {
+ $this->getLog()->debug('ModuleFactory: getByExtension');
+ foreach ($this->load() as $module) {
+ $validExtensions = $module->getSetting('validExtensions');
+ if (!empty($validExtensions) && Str::contains($validExtensions, $extension)) {
+ return $module;
+ }
+ }
+
+ throw new NotFoundException(sprintf(__('Extension %s does not match any enabled Module'), $extension));
+ }
+
+ /**
+ * Get Valid Extensions
+ * @param array $filterBy
+ * @return string[]
+ */
+ public function getValidExtensions($filterBy = []): array
+ {
+ $this->getLog()->debug('ModuleFactory: getValidExtensions');
+ $filterBy = $this->getSanitizer($filterBy);
+
+ // Do we allow media type changes?
+ $isAllowMediaTypeChange = $filterBy->getCheckbox('allowMediaTypeChange');
+
+ if ($isAllowMediaTypeChange) {
+ // Restrict to any file based media type (i.e. any valid extension)
+ $typeFilter = null;
+ } else {
+ // Restrict to type
+ $typeFilter = $filterBy->getString('type');
+ }
+
+ $extensions = [];
+ foreach ($this->load() as $module) {
+ if ($typeFilter !== null && $module->type !== $typeFilter) {
+ continue;
+ }
+
+ if (!empty($module->getSetting('validExtensions'))) {
+ foreach (explode(',', $module->getSetting('validExtensions')) as $extension) {
+ $extensions[] = $extension;
+ }
+ }
+ }
+
+ return $extensions;
+ }
+
+ /**
+ * @param string $dataTypeId
+ * @return \Xibo\Widget\Definition\DataType
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getDataTypeById(string $dataTypeId): DataType
+ {
+ // Rely on a class if we have one.
+ $className = ucfirst(str_replace('-', '', ucwords($dataTypeId, '-')));
+ $className = '\\Xibo\\Widget\\DataType\\' . $className;
+ if (class_exists($className)) {
+ $class = new $className();
+ if ($class instanceof DataTypeInterface) {
+ return ($class->getDefinition());
+ }
+ }
+
+ // Otherwise look in our XML definitions
+ foreach ($this->loadDataTypes() as $dataType) {
+ if ($dataType->id === $dataTypeId) {
+ return $dataType;
+ }
+ }
+
+ throw new NotFoundException(__('DataType not found'));
+ }
+
+ /**
+ * @return DataType[]
+ */
+ public function getAllDataTypes()
+ {
+ $dataTypes = [];
+
+ // get system data types
+ foreach (self::$systemDataTypes as $dataTypeId) {
+ $className = '\\Xibo\\Widget\\DataType\\' . ucfirst($dataTypeId);
+ if (class_exists($className)) {
+ $class = new $className();
+ if ($class instanceof DataTypeInterface) {
+ $dataTypes[] = $class->getDefinition();
+ }
+ }
+
+ // special handling for dataset
+ if ($dataTypeId === 'dataset') {
+ $dataType = new DataType();
+ $dataType->id = $dataTypeId;
+ $dataType->name = 'DataSet';
+ $dataTypes[] = $dataType;
+ }
+ }
+
+ // get data types from xml
+ $files = array_merge(
+ glob(PROJECT_ROOT . '/modules/datatypes/*.xml'),
+ glob(PROJECT_ROOT . '/custom/modules/datatypes/*.xml')
+ );
+
+ foreach ($files as $file) {
+ $xml = new \DOMDocument();
+ $xml->load($file);
+ $dataType = new DataType();
+ $dataType->id = $this->getFirstValueOrDefaultFromXmlNode($xml, 'id');
+ $dataType->name = $this->getFirstValueOrDefaultFromXmlNode($xml, 'name');
+ $dataTypes[] = $dataType;
+ }
+
+ sort($dataTypes);
+ return $dataTypes;
+ }
+
+ /**
+ * @param string $assetId
+ * @return \Xibo\Widget\Definition\Asset
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getAssetById(string $assetId): Asset
+ {
+ $this->getLog()->debug('getAssetById: ' . $assetId);
+ foreach ($this->getEnabled() as $module) {
+ foreach ($module->getAssets() as $asset) {
+ if ($asset->id === $assetId) {
+ return $asset;
+ }
+ }
+ }
+
+ throw new NotFoundException(__('Asset not found'));
+ }
+
+ /**
+ * @param string $alias
+ * @return \Xibo\Widget\Definition\Asset
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getAssetByAlias(string $alias): Asset
+ {
+ $this->getLog()->debug('getAssetByAlias: ' . $alias);
+ foreach ($this->getEnabled() as $module) {
+ foreach ($module->getAssets() as $asset) {
+ if ($asset->alias === $alias) {
+ return $asset;
+ }
+ }
+ }
+
+ throw new NotFoundException(__('Asset not found'));
+ }
+
+ /**
+ * @param ModuleTemplate[] $templates
+ * @return Asset[]
+ */
+ public function getAssetsFromTemplates(array $templates): array
+ {
+ $assets = [];
+ foreach ($this->getEnabled() as $module) {
+ foreach ($module->getAssets() as $asset) {
+ $assets[$asset->id] = $asset;
+ }
+
+ foreach ($templates as $template) {
+ foreach ($template->getAssets() as $asset) {
+ $assets[$asset->id] = $asset;
+ }
+ }
+ }
+
+ return $assets;
+ }
+
+ /**
+ * Get all assets
+ * @return Asset[]
+ */
+ public function getAllAssets(): array
+ {
+ $assets = [];
+ foreach ($this->getEnabled() as $module) {
+ foreach ($module->getAssets() as $asset) {
+ $assets[$asset->id] = $asset;
+ }
+ }
+ return $assets;
+ }
+
+ /**
+ * Get an asset from anywhere by its ID
+ * @param string $assetId
+ * @param ModuleTemplateFactory $moduleTemplateFactory
+ * @param bool $isAlias
+ * @return Asset
+ * @throws NotFoundException
+ */
+ public function getAssetsFromAnywhereById(
+ string $assetId,
+ ModuleTemplateFactory $moduleTemplateFactory,
+ bool $isAlias = false,
+ ): Asset {
+ $asset = null;
+ try {
+ $asset = $isAlias
+ ? $this->getAssetByAlias($assetId)
+ : $this->getAssetById($assetId);
+ } catch (NotFoundException) {
+ // Not a module asset.
+ }
+
+ // Try a template instead
+ try {
+ $asset = $isAlias
+ ? $moduleTemplateFactory->getAssetByAlias($assetId)
+ : $moduleTemplateFactory->getAssetById($assetId);
+ } catch (NotFoundException) {
+ // Not a module template asset.
+ }
+
+ if ($asset !== null) {
+ return $asset;
+ } else {
+ throw new NotFoundException(__('Asset not found'));
+ }
+ }
+
+ /**
+ * Load all modules into an array for use throughout this request
+ * @return \Xibo\Entity\Module[]
+ */
+ private function load(): array
+ {
+ if ($this->modules === null) {
+ // TODO: these are the only fields we require in the settings table
+ $sql = '
+ SELECT `moduleId`, `enabled`, `previewEnabled`, `defaultDuration`, `settings`
+ FROM `module`
+ ';
+
+ $modulesWithSettings = [];
+ foreach ($this->getStore()->select($sql, []) as $row) {
+ // Make a keyed array of these settings
+ $modulesWithSettings[$row['moduleId']] = $this->getSanitizer($row);
+ }
+
+ // Load in our file system modules.
+ // we consider modules in the module folder, and also custom modules
+ $files = array_merge(
+ glob(PROJECT_ROOT . '/modules/*.xml'),
+ glob(PROJECT_ROOT . '/custom/modules/*.xml')
+ );
+
+ foreach ($files as $file) {
+ // Create our module entity from this file
+ try {
+ $module = $this->createFromXml($file, $modulesWithSettings);
+
+ // Create a widget provider if necessary
+ // Take our module and see if it has a class associated with it
+ if (!empty($module->class)) {
+ // We create a module specific provider
+ if (!class_exists($module->class)) {
+ $module->errors[] = 'Module class not found: ' . $module->class;
+ } else {
+ $class = $module->class;
+ $module->setWidgetProvider(new $class());
+ }
+ }
+
+ // Create a widget compatibility if necessary
+ if (!empty($module->compatibilityClass)) {
+ // We create a module specific provider
+ if (!class_exists($module->compatibilityClass)) {
+ $module->errors[] = 'Module compatibilityClass not found: ' . $module->compatibilityClass;
+ } else {
+ $compatibilityClass = $module->compatibilityClass;
+ $module->setWidgetCompatibility(new $compatibilityClass());
+ }
+ }
+
+ // Create a widget validator if necessary
+ foreach ($module->validatorClass as $validatorClass) {
+ // We create a module specific provider
+ if (!class_exists($validatorClass)) {
+ $module->errors[] = 'Module validatorClass not found: ' . $validatorClass;
+ } else {
+ $module->addWidgetValidator(
+ (new $validatorClass())
+ ->setLog($this->getLog()->getLoggerInterface())
+ );
+ }
+ }
+
+ // Set error state
+ $module->isError = $module->errors !== null && count($module->errors) > 0;
+
+ // Register
+ $this->modules[] = $module;
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Unable to create module from '
+ . basename($file) . ', skipping. e = ' . $exception->getMessage());
+ }
+ }
+ }
+
+ return $this->modules;
+ }
+
+ /**
+ * Load all data types into an array for use throughout this request
+ * @return \Xibo\Widget\Definition\DataType[]
+ */
+ private function loadDataTypes(): array
+ {
+ if ($this->dataTypes === null) {
+ $files = array_merge(
+ glob(PROJECT_ROOT . '/modules/datatypes/*.xml'),
+ glob(PROJECT_ROOT . '/custom/modules/datatypes/*.xml')
+ );
+
+ foreach ($files as $file) {
+ $this->dataTypes[] = $this->createDataTypeFromXml($file);
+ }
+ }
+
+ return $this->dataTypes ?? [];
+ }
+
+ /**
+ * Create a module from its XML definition
+ * @param string $file the path to the module definition
+ * @param array $modulesWithSettings
+ * @return \Xibo\Entity\Module
+ */
+ private function createFromXml(string $file, array $modulesWithSettings): Module
+ {
+ // TODO: cache this into Stash
+ $xml = new \DOMDocument();
+ $xml->load($file);
+
+ $module = new Module($this->getStore(), $this->getLog(), $this->getDispatcher(), $this);
+ $module->moduleId = $this->getFirstValueOrDefaultFromXmlNode($xml, 'id');
+ $module->name = __($this->getFirstValueOrDefaultFromXmlNode($xml, 'name'));
+ $module->author = $this->getFirstValueOrDefaultFromXmlNode($xml, 'author');
+ $module->description = __($this->getFirstValueOrDefaultFromXmlNode($xml, 'description'));
+ $module->icon = $this->getFirstValueOrDefaultFromXmlNode($xml, 'icon');
+ $module->class = $this->getFirstValueOrDefaultFromXmlNode($xml, 'class');
+ $module->type = $this->getFirstValueOrDefaultFromXmlNode($xml, 'type');
+ $module->thumbnail = $this->getFirstValueOrDefaultFromXmlNode($xml, 'thumbnail');
+ $module->startWidth = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'startWidth'));
+ $module->startHeight = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'startHeight'));
+ $module->dataType = $this->getFirstValueOrDefaultFromXmlNode($xml, 'dataType');
+ $module->dataCacheKey = $this->getFirstValueOrDefaultFromXmlNode($xml, 'dataCacheKey');
+ $module->fallbackData = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'fallbackData', 0));
+ $module->schemaVersion = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'schemaVersion'));
+ $module->compatibilityClass = $this->getFirstValueOrDefaultFromXmlNode($xml, 'compatibilityClass');
+ $module->showIn = $this->getFirstValueOrDefaultFromXmlNode($xml, 'showIn') ?? 'both';
+ $module->assignable = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'assignable'));
+ $module->regionSpecific = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'regionSpecific'));
+ $module->renderAs = $this->getFirstValueOrDefaultFromXmlNode($xml, 'renderAs');
+ $module->defaultDuration = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'defaultDuration'));
+ $module->hasThumbnail = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'hasThumbnail', 0));
+ $module->allowPreview = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'allowPreview', 1));
+
+ // Validator classes
+ foreach ($xml->getElementsByTagName('validatorClass') as $node) {
+ /** @var \DOMNode $node */
+ if ($node instanceof \DOMElement) {
+ $module->validatorClass[] = trim($node->textContent);
+ }
+ }
+
+ // Event listeners
+ $module->onInitialize = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onInitialize');
+ if (!empty($module->onInitialize)) {
+ $module->onInitialize = trim($module->onInitialize);
+ }
+
+ $module->onParseData = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onParseData');
+ if (!empty($module->onParseData)) {
+ $module->onParseData = trim($module->onParseData);
+ }
+
+ $module->onDataLoad = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onDataLoad');
+ if (!empty($module->onDataLoad)) {
+ $module->onDataLoad = trim($module->onDataLoad);
+ }
+
+ $module->onRender = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onRender');
+ if (!empty($module->onRender)) {
+ $module->onRender = trim($module->onRender);
+ }
+
+ $module->onVisible = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onVisible');
+ if (!empty($module->onVisible)) {
+ $module->onVisible = trim($module->onVisible);
+ }
+
+ // We might have sample data (usually only if there is a dataType)
+ $sampleData = $this->getFirstValueOrDefaultFromXmlNode($xml, 'sampleData');
+
+ if (!empty($sampleData)) {
+ $module->sampleData = json_decode(trim($sampleData), true);
+ }
+
+ // Legacy types.
+ try {
+ $module->legacyTypes = $this->parseLegacyTypes($xml->getElementsByTagName('legacyType'));
+ } catch (\Exception $e) {
+ $module->errors[] = __('Invalid legacyType');
+ $this->getLog()->error('Module ' . $module->moduleId . ' has invalid legacyType. e: ' . $e->getMessage());
+ }
+
+ // Group for non datatype modules
+ $module->group = [];
+ $groupNodes = $xml->getElementsByTagName('group');
+ foreach ($groupNodes as $groupNode) {
+ if ($groupNode instanceof \DOMElement) {
+ $module->group['id'] = $groupNode->getAttribute('id');
+ $module->group['icon'] = $groupNode->getAttribute('icon');
+ $module->group['name'] = $groupNode->textContent;
+ }
+ }
+
+ // Parse assets
+ try {
+ $module->assets = $this->parseAssets($xml->getElementsByTagName('assets'));
+ } catch (\Exception $e) {
+ $module->errors[] = __('Invalid assets');
+ $this->getLog()->error('Module ' . $module->moduleId
+ . ' has invalid assets. e: ' . $e->getMessage());
+ }
+
+ // Default values for remaining expected properties
+ $module->isInstalled = false;
+ $module->isError = false;
+ $module->errors = [];
+ $module->enabled = 0;
+ $module->previewEnabled = 0;
+
+ // Parse settings/property definitions.
+ try {
+ $module->settings = $this->parseProperties($xml->getElementsByTagName('settings'));
+ } catch (\Exception $e) {
+ $module->errors[] = __('Invalid settings');
+ $this->getLog()->error('Module ' . $module->moduleId . ' has invalid settings. e: ' . $e->getMessage());
+ }
+
+ // Add in any settings we already have
+ if (array_key_exists($module->moduleId, $modulesWithSettings)) {
+ $moduleSettings = $modulesWithSettings[$module->moduleId];
+ $module->isInstalled = true;
+
+ // make sure canvas is always enabled
+ if ($module->moduleId === 'core-canvas') {
+ $module->enabled = 1;
+ // update the table
+ if ($moduleSettings->getInt('enabled', ['default' => 0]) === 0) {
+ $this->getStore()->update(
+ 'UPDATE `module` SET enabled = 1 WHERE `module`.moduleId = \'core-canvas\' ',
+ []
+ );
+ }
+ } else {
+ $module->enabled = $moduleSettings->getInt('enabled', ['default' => 0]);
+ }
+
+ $module->previewEnabled = $moduleSettings->getInt('previewEnabled', ['default' => 0]);
+ $module->defaultDuration = $moduleSettings->getInt('defaultDuration', ['default' => 10]);
+
+ $settings = $moduleSettings->getString('settings');
+ if ($settings !== null) {
+ $settings = json_decode($settings, true);
+
+ foreach ($module->settings as $property) {
+ foreach ($settings as $settingId => $setting) {
+ if ($settingId === $property->id) {
+ $property->value = $setting;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ try {
+ $module->properties = $this->parseProperties($xml->getElementsByTagName('properties'), $module);
+ } catch (\Exception $e) {
+ $module->errors[] = __('Invalid properties');
+ $this->getLog()->error('Module ' . $module->moduleId . ' has invalid properties. e: ' . $e->getMessage());
+ }
+
+ // Parse group property definitions.
+ try {
+ $module->propertyGroups = $this->parsePropertyGroups($xml->getElementsByTagName('propertyGroups'));
+ } catch (\Exception $e) {
+ $module->errors[] = __('Invalid property groups');
+ $this->getLog()->error('Module ' . $module->moduleId . ' has invalid property groups. e: '
+ . $e->getMessage());
+ }
+
+ // Parse required elements.
+ $requiredElements = $this->getFirstValueOrDefaultFromXmlNode($xml, 'requiredElements');
+ if (!empty($requiredElements)) {
+ $module->requiredElements = explode(',', $requiredElements);
+ }
+
+ // Parse stencils
+ try {
+ $module->preview = $this->getStencils($xml->getElementsByTagName('preview'))[0] ?? null;
+ $module->stencil = $this->getStencils($xml->getElementsByTagName('stencil'))[0] ?? null;
+ } catch (\Exception $e) {
+ $module->errors[] = __('Invalid stencils');
+ $this->getLog()->error('Module ' . $module->moduleId . ' has invalid stencils. e: ' . $e->getMessage());
+ }
+
+ return $module;
+ }
+
+ /**
+ * Create DataType from XML
+ * @param string $file
+ * @return \Xibo\Widget\Definition\DataType
+ */
+ private function createDataTypeFromXml(string $file): DataType
+ {
+ $xml = new \DOMDocument();
+ $xml->load($file);
+
+ $dataType = new DataType();
+ $dataType->id = $this->getFirstValueOrDefaultFromXmlNode($xml, 'id');
+ $dataType->name = $this->getFirstValueOrDefaultFromXmlNode($xml, 'name');
+
+ // Fields.
+ foreach ($xml->getElementsByTagName('field') as $field) {
+ if ($field instanceof \DOMElement) {
+ $dataType->addField(
+ $field->getAttribute('id'),
+ trim($field->textContent),
+ $field->getAttribute('type'),
+ $field->getAttribute('isRequired') === 'true',
+ );
+ }
+ }
+
+ return $dataType;
+ }
+}
diff --git a/lib/Factory/ModuleTemplateFactory.php b/lib/Factory/ModuleTemplateFactory.php
new file mode 100644
index 0000000..4c2f39f
--- /dev/null
+++ b/lib/Factory/ModuleTemplateFactory.php
@@ -0,0 +1,644 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Slim\Views\Twig;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\ModuleTemplate;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Definition\Asset;
+
+/**
+ * Factory for working with Module Templates
+ */
+class ModuleTemplateFactory extends BaseFactory
+{
+ use ModuleXmlTrait;
+
+ /** @var ModuleTemplate[]|null */
+ private $templates = null;
+
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+
+ /** @var \Slim\Views\Twig */
+ private $twig;
+
+ /**
+ * Construct a factory
+ * @param PoolInterface $pool
+ * @param \Slim\Views\Twig $twig
+ */
+ public function __construct(PoolInterface $pool, Twig $twig)
+ {
+ $this->pool = $pool;
+ $this->twig = $twig;
+ }
+
+ /**
+ * @param string $type The type of template (element|elementGroup|static)
+ * @param string $id
+ * @return \Xibo\Entity\ModuleTemplate
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getByTypeAndId(string $type, string $id): ModuleTemplate
+ {
+ foreach ($this->load() as $template) {
+ if ($template->type === $type && $template->templateId === $id) {
+ return $template;
+ }
+ }
+ throw new NotFoundException(sprintf(__('%s not found for %s'), $type, $id));
+ }
+
+ /**
+ * @param int $id
+ * @return \Xibo\Entity\ModuleTemplate
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getUserTemplateById(int $id): ModuleTemplate
+ {
+ $templates = $this->loadUserTemplates(null, ['id' => $id]);
+ if (count($templates) !== 1) {
+ throw new NotFoundException(sprintf(__('Template not found for %s'), $id));
+ }
+
+ return $templates[0];
+ }
+
+ /**
+ * @param string $dataType
+ * @param string $id
+ * @return \Xibo\Entity\ModuleTemplate
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getByDataTypeAndId(string $dataType, string $id): ModuleTemplate
+ {
+ foreach ($this->load() as $template) {
+ if ($template->dataType === $dataType && $template->templateId === $id) {
+ return $template;
+ }
+ }
+ throw new NotFoundException(sprintf(__('Template not found for %s and %s'), $dataType, $id));
+ }
+
+ /**
+ * @param string $dataType
+ * @return ModuleTemplate[]
+ */
+ public function getByDataType(string $dataType): array
+ {
+ $templates = [];
+ foreach ($this->load() as $template) {
+ if ($template->dataType === $dataType) {
+ $templates[] = $template;
+ }
+ }
+ return $templates;
+ }
+
+ /**
+ * @param string $type
+ * @param string $dataType
+ * @return ModuleTemplate[]
+ */
+ public function getByTypeAndDataType(string $type, string $dataType, bool $includeUserTemplates = true): array
+ {
+ $templates = [];
+ foreach ($this->load($includeUserTemplates) as $template) {
+ if ($template->dataType === $dataType && $template->type === $type) {
+ $templates[] = $template;
+ }
+ }
+ return $templates;
+ }
+
+ /**
+ * @param string $assetId
+ * @return \Xibo\Widget\Definition\Asset
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getAssetById(string $assetId): Asset
+ {
+ foreach ($this->load() as $template) {
+ foreach ($template->getAssets() as $asset) {
+ if ($asset->id === $assetId) {
+ return $asset;
+ }
+ }
+ }
+
+ throw new NotFoundException(__('Asset not found'));
+ }
+
+ /**
+ * @param string $alias
+ * @return \Xibo\Widget\Definition\Asset
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getAssetByAlias(string $alias): Asset
+ {
+ foreach ($this->load() as $template) {
+ foreach ($template->getAssets() as $asset) {
+ if ($asset->alias === $alias) {
+ return $asset;
+ }
+ }
+ }
+
+ throw new NotFoundException(__('Asset not found'));
+ }
+
+ /**
+ * Get an array of all modules
+ * @param string|null $ownership
+ * @param bool $includeUserTemplates
+ * @return ModuleTemplate[]
+ */
+ public function getAll(?string $ownership = null, bool $includeUserTemplates = true): array
+ {
+ $templates = $this->load($includeUserTemplates);
+
+ if ($ownership === null) {
+ return $templates;
+ } else {
+ $ownedBy = [];
+ foreach ($templates as $template) {
+ if ($ownership === $template->ownership) {
+ $ownedBy[] = $template;
+ }
+ }
+ return $ownedBy;
+ }
+ }
+
+ /**
+ * Get an array of all modules
+ * @return Asset[]
+ */
+ public function getAllAssets(): array
+ {
+ $assets = [];
+ foreach ($this->load() as $template) {
+ foreach ($template->getAssets() as $asset) {
+ $assets[$asset->id] = $asset;
+ }
+ }
+ return $assets;
+ }
+
+ /**
+ * Load templates
+ * @param bool $includeUserTemplates
+ * @return ModuleTemplate[]
+ */
+ private function load(bool $includeUserTemplates = true): array
+ {
+ if ($this->templates === null) {
+ $this->getLog()->debug('load: Loading templates');
+
+ $this->templates = array_merge(
+ $this->loadFolder(
+ PROJECT_ROOT . '/modules/templates/*.xml',
+ 'system',
+ ),
+ $this->loadFolder(
+ PROJECT_ROOT . '/custom/modules/templates/*.xml',
+ 'custom'
+ ),
+ );
+
+ if ($includeUserTemplates) {
+ $this->templates = array_merge(
+ $this->templates,
+ $this->loadUserTemplates()
+ );
+ }
+ }
+
+ return $this->templates;
+ }
+
+ /**
+ * Load templates
+ * @return \Xibo\Entity\ModuleTemplate[]
+ */
+ private function loadFolder(string $folder, string $ownership): array
+ {
+ $this->getLog()->debug('loadFolder: Loading templates from ' . $folder);
+ $templates = [];
+
+ foreach (glob($folder) as $file) {
+ // Create our module entity from this file
+ try {
+ $templates = array_merge($templates, $this->createMultiFromXml($file, $ownership));
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Unable to create template from '
+ . basename($file) . ', skipping. e = ' . $exception->getMessage());
+ }
+ }
+
+ return $templates;
+ }
+
+ /**
+ * Load user templates from the database.
+ * @return ModuleTemplate[]
+ */
+ public function loadUserTemplates($sortOrder = [], $filterBy = []): array
+ {
+ $this->getLog()->debug('load: Loading user templates');
+
+ if (empty($sortOrder)) {
+ $sortOrder = ['id'];
+ }
+
+ $templates = [];
+ $params = [];
+
+ $filter = $this->getSanitizer($filterBy);
+
+ $select = 'SELECT *,
+ (SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :permissionEntityGroups
+ AND objectId = `module_templates`.id
+ AND view = 1
+ ) AS groupsWithPermissions';
+
+ $body = ' FROM `module_templates`
+ WHERE 1 = 1 ';
+
+ if ($filter->getInt('id') !== null) {
+ $body .= ' AND `id` = :id ';
+ $params['id'] = $filter->getInt('id');
+ }
+
+ if (!empty($filter->getString('templateId'))) {
+ $body .= ' AND `templateId` LIKE :templateId ';
+ $params['templateId'] = '%' . $filter->getString('templateId') . '%';
+ }
+
+ if (!empty($filter->getString('dataType'))) {
+ $body .= ' AND `dataType` = :dataType ';
+ $params['dataType'] = $filter->getString('dataType') ;
+ }
+
+ $params['permissionEntityGroups'] = 'Xibo\\Entity\\ModuleTemplate';
+
+ $this->viewPermissionSql(
+ 'Xibo\Entity\ModuleTemplate',
+ $body,
+ $params,
+ 'module_templates.id',
+ 'module_templates.ownerId',
+ $filterBy,
+ );
+
+ $order = '';
+ if (is_array($sortOrder) && !empty($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ // Paging
+ $limit = '';
+ if ($filterBy !== null && $filter->getInt('start') !== null && $filter->getInt('length') !== null) {
+ $limit .= ' LIMIT ' .
+ $filter->getInt('start', ['default' => 0]) . ', ' .
+ $filter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order. $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $template = $this->createUserTemplate($row['xml']);
+ $template->id = intval($row['id']);
+ $template->templateId = $row['templateId'];
+ $template->dataType = $row['dataType'];
+ $template->isEnabled = $row['enabled'] == 1;
+ $template->ownerId = intval($row['ownerId'] ?? 0);
+ $template->groupsWithPermissions = $row['groupsWithPermissions'];
+ $templates[] = $template;
+ }
+
+ // Paging
+ if (!empty($limit) && count($templates) > 0) {
+ unset($params['permissionEntityGroups']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $templates;
+ }
+
+ /**
+ * Create a user template from an XML string
+ * @param string $xmlString
+ * @return ModuleTemplate
+ */
+ public function createUserTemplate(string $xmlString): ModuleTemplate
+ {
+ $xml = new \DOMDocument();
+ $xml->loadXML($xmlString);
+
+ $template = $this->createFromXml($xml->documentElement, 'user', 'database');
+ $template->setXml($xmlString);
+ $template->setDocument($xml);
+ return $template;
+ }
+
+ /**
+ * Create multiple templates from XML
+ * @param string $file
+ * @param string $ownership
+ * @return ModuleTemplate[]
+ */
+ private function createMultiFromXml(string $file, string $ownership): array
+ {
+ $templates = [];
+
+ $xml = new \DOMDocument();
+ $xml->load($file);
+
+ foreach ($xml->getElementsByTagName('templates') as $node) {
+ if ($node instanceof \DOMElement) {
+ $this->getLog()->debug('createMultiFromXml: there are ' . count($node->childNodes)
+ . ' templates in ' . $file);
+ foreach ($node->childNodes as $childNode) {
+ if ($childNode instanceof \DOMElement) {
+ $templates[] = $this->createFromXml($childNode, $ownership, $file);
+ }
+ }
+ }
+ }
+
+ return $templates;
+ }
+
+ /**
+ * @param \DOMElement $xml
+ * @param string $ownership
+ * @param string $file
+ * @return \Xibo\Entity\ModuleTemplate
+ */
+ private function createFromXml(\DOMElement $xml, string $ownership, string $file): ModuleTemplate
+ {
+ // TODO: cache this into Stash
+ $template = new ModuleTemplate($this->getStore(), $this->getLog(), $this->getDispatcher(), $this, $file);
+ $template->ownership = $ownership;
+ $template->templateId = $this->getFirstValueOrDefaultFromXmlNode($xml, 'id');
+ $template->type = $this->getFirstValueOrDefaultFromXmlNode($xml, 'type');
+ $template->dataType = $this->getFirstValueOrDefaultFromXmlNode($xml, 'dataType');
+ $template->title = __($this->getFirstValueOrDefaultFromXmlNode($xml, 'title'));
+ $template->description = __($this->getFirstValueOrDefaultFromXmlNode($xml, 'description'));
+ $template->thumbnail = $this->getFirstValueOrDefaultFromXmlNode($xml, 'thumbnail');
+ $template->icon = $this->getFirstValueOrDefaultFromXmlNode($xml, 'icon');
+ $template->isVisible = $this->getFirstValueOrDefaultFromXmlNode($xml, 'isVisible') !== 'false';
+ $template->startWidth = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'startWidth'));
+ $template->startHeight = intval($this->getFirstValueOrDefaultFromXmlNode($xml, 'startHeight'));
+ $template->hasDimensions = $this->getFirstValueOrDefaultFromXmlNode($xml, 'hasDimensions', 'true') === 'true';
+ $template->canRotate = $this->getFirstValueOrDefaultFromXmlNode($xml, 'canRotate', 'false') === 'true';
+ $template->onTemplateRender = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onTemplateRender');
+ $template->onTemplateVisible = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onTemplateVisible');
+ $template->onElementParseData = $this->getFirstValueOrDefaultFromXmlNode($xml, 'onElementParseData');
+ $template->showIn = $this->getFirstValueOrDefaultFromXmlNode($xml, 'showIn') ?? 'both';
+
+ if (!empty($template->onTemplateRender)) {
+ $template->onTemplateRender = trim($template->onTemplateRender);
+ }
+
+ if (!empty($template->onTemplateVisible)) {
+ $template->onTemplateVisible = trim($template->onTemplateVisible);
+ }
+
+ if (!empty($template->onElementParseData)) {
+ $template->onElementParseData = trim($template->onElementParseData);
+ }
+
+ $template->isError = false;
+ $template->errors = [];
+
+ // Parse extends definition
+ try {
+ $template->extends = $this->getExtends($xml->getElementsByTagName('extends'))[0] ?? null;
+ } catch (\Exception $e) {
+ $template->errors[] = __('Invalid Extends');
+ $this->getLog()->error('Module Template ' . $template->templateId
+ . ' has invalid extends definition. e: ' . $e->getMessage());
+ }
+
+ // Parse property definitions.
+ try {
+ $template->properties = $this->parseProperties($xml->getElementsByTagName('properties'));
+ } catch (\Exception $e) {
+ $template->errors[] = __('Invalid properties');
+ $this->getLog()->error('Module Template ' . $template->templateId
+ . ' has invalid properties. e: ' . $e->getMessage());
+ }
+
+ // Parse group property definitions.
+ try {
+ $template->propertyGroups = $this->parsePropertyGroups($xml->getElementsByTagName('propertyGroups'));
+ } catch (\Exception $e) {
+ $template->errors[] = __('Invalid property groups');
+ $this->getLog()->error('Module Template ' . $template->templateId . ' has invalid property groups. e: '
+ . $e->getMessage());
+ }
+
+ // Parse stencil
+ try {
+ $template->stencil = $this->getStencils($xml->getElementsByTagName('stencil'))[0] ?? null;
+ } catch (\Exception $e) {
+ $template->errors[] = __('Invalid stencils');
+ $this->getLog()->error('Module Template ' . $template->templateId
+ . ' has invalid stencils. e: ' . $e->getMessage());
+ }
+
+ // Parse assets
+ try {
+ $template->assets = $this->parseAssets($xml->getElementsByTagName('assets'));
+ } catch (\Exception $e) {
+ $template->errors[] = __('Invalid assets');
+ $this->getLog()->error('Module Template ' . $template->templateId
+ . ' has invalid assets. e: ' . $e->getMessage());
+ }
+
+ return $template;
+ }
+
+ /**
+ * Parse properties json into xml node.
+ *
+ * @param string $properties
+ * @return \DOMDocument
+ * @throws \DOMException
+ */
+ public function parseJsonPropertiesToXml(string $properties): \DOMDocument
+ {
+ $newPropertiesXml = new \DOMDocument();
+ $newPropertiesNode = $newPropertiesXml->createElement('properties');
+ $attributes = [
+ 'id',
+ 'type',
+ 'variant',
+ 'format',
+ 'mode',
+ 'target',
+ 'propertyGroupId',
+ 'allowLibraryRefs',
+ 'allowAssetRefs',
+ 'parseTranslations',
+ 'includeInXlf'
+ ];
+
+ $commonNodes = [
+ 'title',
+ 'helpText',
+ 'default',
+ 'dependsOn',
+ 'customPopOver'
+ ];
+
+ $newProperties = json_decode($properties, true);
+ foreach ($newProperties as $property) {
+ // create property node
+ $propertyNode = $newPropertiesXml->createElement('property');
+
+ // go through possible attributes on the property node.
+ foreach ($attributes as $attribute) {
+ if (!empty($property[$attribute])) {
+ $propertyNode->setAttribute($attribute, $property[$attribute]);
+ }
+ }
+
+ // go through common nodes on property add them if not empty
+ foreach ($commonNodes as $commonNode) {
+ if (!empty($property[$commonNode])) {
+ $propertyNode->appendChild($newPropertiesXml->createElement($commonNode, $property[$commonNode]));
+ }
+ }
+
+ // do we have options?
+ if (!empty($property['options'])) {
+ $options = $property['options'];
+ if (!is_array($options)) {
+ $options = json_decode($options, true);
+ }
+
+ $optionsNode = $newPropertiesXml->createElement('options');
+ foreach ($options as $option) {
+ $optionNode = $newPropertiesXml->createElement('option', $option['title']);
+ $optionNode->setAttribute('name', $option['name']);
+ if (!empty($option['set'])) {
+ $optionNode->setAttribute('set', $option['set']);
+ }
+ if (!empty($option['image'])) {
+ $optionNode->setAttribute('image', $option['image']);
+ }
+ $optionsNode->appendChild($optionNode);
+ }
+ $propertyNode->appendChild($optionsNode);
+ }
+
+ // do we have visibility?
+ if (!empty($property['visibility'])) {
+ $visibility = $property['visibility'];
+ if (!is_array($visibility)) {
+ $visibility = json_decode($visibility, true);
+ }
+
+ $visibilityNode = $newPropertiesXml->createElement('visibility');
+
+ foreach ($visibility as $testElement) {
+ $testNode = $newPropertiesXml->createElement('test');
+ $testNode->setAttribute('type', $testElement['type']);
+ $testNode->setAttribute('message', $testElement['message']);
+ foreach ($testElement['conditions'] as $condition) {
+ $conditionNode = $newPropertiesXml->createElement('condition', $condition['value']);
+ $conditionNode->setAttribute('field', $condition['field']);
+ $conditionNode->setAttribute('type', $condition['type']);
+ $testNode->appendChild($conditionNode);
+ }
+ $visibilityNode->appendChild($testNode);
+ }
+ $propertyNode->appendChild($visibilityNode);
+ }
+
+ // do we have validation rules?
+ if (!empty($property['validation'])) {
+ $validation = $property['validation'];
+ if (!is_array($validation)) {
+ $validation = json_decode($property['validation'], true);
+ }
+
+ // xml uses rule node for this.
+ $ruleNode = $newPropertiesXml->createElement('rule');
+
+ // attributes on rule node;
+ $ruleNode->setAttribute('onSave', $validation['onSave'] ? 'true' : 'false');
+ $ruleNode->setAttribute('onStatus', $validation['onStatus'] ? 'true' : 'false');
+
+ // validation property has an array on tests in it
+ foreach ($validation['tests'] as $validationTest) {
+ $ruleTestNode = $newPropertiesXml->createElement('test');
+ $ruleTestNode->setAttribute('type', $validationTest['type']);
+ $ruleTestNode->setAttribute('message', $validationTest['message']);
+
+ foreach ($validationTest['conditions'] as $condition) {
+ $conditionNode = $newPropertiesXml->createElement('condition', $condition['value']);
+ $conditionNode->setAttribute('field', $condition['field']);
+ $conditionNode->setAttribute('type', $condition['type']);
+ $ruleTestNode->appendChild($conditionNode);
+ }
+ $ruleNode->appendChild($ruleTestNode);
+ }
+ $propertyNode->appendChild($ruleNode);
+ }
+
+ // do we have player compatibility?
+ if (!empty($property['playerCompatibility'])) {
+ $playerCompat = $property['playerCompatibility'];
+ if (!is_array($playerCompat)) {
+ $playerCompat = json_decode($property['playerCompatibility'], true);
+ }
+
+ $playerCompatibilityNode = $newPropertiesXml->createElement('playerCompatibility');
+ foreach ($playerCompat as $player => $value) {
+ $playerCompatibilityNode->setAttribute($player, $value);
+ }
+
+ $propertyNode->appendChild($playerCompatibilityNode);
+ }
+
+ $newPropertiesNode->appendChild($propertyNode);
+ }
+
+ $newPropertiesXml->appendChild($newPropertiesNode);
+
+ return $newPropertiesXml;
+ }
+}
diff --git a/lib/Factory/ModuleXmlTrait.php b/lib/Factory/ModuleXmlTrait.php
new file mode 100644
index 0000000..c8c6f00
--- /dev/null
+++ b/lib/Factory/ModuleXmlTrait.php
@@ -0,0 +1,523 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Illuminate\Support\Str;
+use Xibo\Entity\Module;
+use Xibo\Widget\Definition\Asset;
+use Xibo\Widget\Definition\Element;
+use Xibo\Widget\Definition\ElementGroup;
+use Xibo\Widget\Definition\Extend;
+use Xibo\Widget\Definition\LegacyType;
+use Xibo\Widget\Definition\PlayerCompatibility;
+use Xibo\Widget\Definition\Property;
+use Xibo\Widget\Definition\PropertyGroup;
+use Xibo\Widget\Definition\Rule;
+use Xibo\Widget\Definition\Stencil;
+
+/**
+ * A trait to help with parsing modules from XML
+ */
+trait ModuleXmlTrait
+{
+ /**
+ * @var array cache of already loaded assets - id => asset
+ */
+ private $assetCache = [];
+
+ /**
+ * Get stencils from a DOM node list
+ * @param \DOMNodeList $nodes
+ * @return Stencil[]
+ */
+ private function getStencils(\DOMNodeList $nodes): array
+ {
+ $stencils = [];
+
+ foreach ($nodes as $node) {
+ $stencil = new Stencil();
+
+ /** @var \DOMNode $node */
+ foreach ($node->childNodes as $childNode) {
+ /** @var \DOMElement $childNode */
+ if ($childNode->nodeName === 'twig') {
+ $stencil->twig = $childNode->textContent;
+ } else if ($childNode->nodeName === 'hbs') {
+ $stencil->hbsId = $childNode->getAttribute('id');
+ $stencil->hbs = trim($childNode->textContent);
+ } else if ($childNode->nodeName === 'head') {
+ $stencil->head = trim($childNode->textContent);
+ } else if ($childNode->nodeName === 'style') {
+ $stencil->style = trim($childNode->textContent);
+ } else if ($childNode->nodeName === 'elements') {
+ $stencil->elements = $this->parseElements($childNode->childNodes);
+ } else if ($childNode->nodeName === 'width') {
+ $stencil->width = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'height') {
+ $stencil->height = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'gapBetweenHbs') {
+ $stencil->gapBetweenHbs = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'elementGroups') {
+ $stencil->elementGroups = $this->parseElementGroups($childNode->childNodes);
+ }
+ }
+
+ if ($stencil->twig !== null
+ || $stencil->hbs !== null
+ || $stencil->head !== null
+ || $stencil->style !== null
+ ) {
+ $stencils[] = $stencil;
+ }
+ }
+
+ return $stencils;
+ }
+
+ /**
+ * @param \DOMNode[]|\DOMNodeList $propertyNodes
+ * @return \Xibo\Widget\Definition\Property[]
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function parseProperties($propertyNodes, ?Module $module = null): array
+ {
+ if ($propertyNodes instanceof \DOMNodeList) {
+ // Property nodes are the parent node
+ if (count($propertyNodes) <= 0) {
+ return [];
+ }
+ $propertyNodes = $propertyNodes->item(0)->childNodes;
+ }
+
+ $defaultValues = [];
+ $properties = [];
+ foreach ($propertyNodes as $node) {
+ if ($node->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $node */
+ $property = new Property();
+ $property->id = $node->getAttribute('id');
+ $property->type = $node->getAttribute('type');
+ $property->variant = $node->getAttribute('variant');
+ $property->format = $node->getAttribute('format');
+ $property->mode = $node->getAttribute('mode');
+ $property->target = $node->getAttribute('target');
+ $property->propertyGroupId = $node->getAttribute('propertyGroupId');
+ $property->allowLibraryRefs = $node->getAttribute('allowLibraryRefs') === 'true';
+ $property->allowAssetRefs = $node->getAttribute('allowAssetRefs') === 'true';
+ $property->parseTranslations = $node->getAttribute('parseTranslations') === 'true';
+ $property->saveDefault = $node->getAttribute('saveDefault') === 'true';
+ $property->sendToElements = $node->getAttribute('sendToElements') === 'true';
+ $property->title = __($this->getFirstValueOrDefaultFromXmlNode($node, 'title'));
+ $property->helpText = __($this->getFirstValueOrDefaultFromXmlNode($node, 'helpText'));
+ $property->dependsOn = $this->getFirstValueOrDefaultFromXmlNode($node, 'dependsOn');
+
+ // How should we default includeInXlf?
+ if ($module?->renderAs === 'native') {
+ // Include by default
+ $property->includeInXlf = $node->getAttribute('includeInXlf') !== 'false';
+ } else {
+ // Exclude by default
+ $property->includeInXlf = $node->getAttribute('includeInXlf') === 'true';
+ }
+
+ // Default value
+ $defaultValue = $this->getFirstValueOrDefaultFromXmlNode($node, 'default');
+
+ // Is this a variable?
+ $defaultValues[$property->id] = $this->decorateWithSettings($module, $defaultValue);
+
+ // Validation (rule) conditions
+ $validationNodes = $node->getElementsByTagName('rule');
+ if (count($validationNodes) > 0) {
+ // We have a rule
+ $ruleNode = $validationNodes->item(0);
+ if ($ruleNode->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $ruleNode */
+ $rule = new Rule();
+ $rule->onSave = ($ruleNode->getAttribute('onSave') ?: 'true') === 'true';
+ $rule->onStatus = ($ruleNode->getAttribute('onStatus') ?: 'true') === 'true';
+
+ // Get tests
+ foreach ($ruleNode->childNodes as $testNode) {
+ if ($testNode->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $testNode */
+ $conditions = [];
+ foreach ($testNode->getElementsByTagName('condition') as $condNode) {
+ if ($condNode instanceof \DOMElement) {
+ $conditions[] = [
+ 'field' => $condNode->getAttribute('field'),
+ 'type' => $condNode->getAttribute('type'),
+ 'value' => $this->decorateWithSettings($module, trim($condNode->textContent)),
+ ];
+ }
+ }
+
+ $rule->addRuleTest($property->parseTest(
+ $testNode->getAttribute('type'),
+ $testNode->getAttribute('message'),
+ $conditions,
+ ));
+ }
+ }
+
+ $property->validation = $rule;
+ }
+ }
+
+ // Options
+ $options = $node->getElementsByTagName('options');
+ if (count($options) > 0) {
+ foreach ($options->item(0)->childNodes as $optionNode) {
+ if ($optionNode->nodeType === XML_ELEMENT_NODE) {
+ $set = [];
+ if (!empty($optionNode->getAttribute('set'))) {
+ $set = explode(',', $optionNode->getAttribute('set'));
+ }
+
+ /** @var \DOMElement $optionNode */
+ $property->addOption(
+ $optionNode->getAttribute('name'),
+ $optionNode->getAttribute('image'),
+ $set,
+ trim($optionNode->textContent),
+ );
+ }
+ }
+ }
+
+ // Visibility conditions
+ $visibility = $node->getElementsByTagName('visibility');
+ if (count($visibility) > 0) {
+ foreach ($visibility->item(0)->childNodes as $testNode) {
+ if ($testNode->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $testNode */
+ $conditions = [];
+ foreach ($testNode->getElementsByTagName('condition') as $condNode) {
+ if ($condNode instanceof \DOMElement) {
+ $conditions[] = [
+ 'field' => $condNode->getAttribute('field'),
+ 'type' => $condNode->getAttribute('type'),
+ 'value' => $this->decorateWithSettings($module, trim($condNode->textContent)),
+ ];
+ }
+ }
+
+ $property->addVisibilityTest(
+ $testNode->getAttribute('type'),
+ $testNode->getAttribute('message'),
+ $conditions,
+ );
+ }
+ }
+ }
+
+ // Player compat
+ $playerCompat = $node->getElementsByTagName('playerCompatibility');
+ if (count($playerCompat) > 0) {
+ $playerCompat = $playerCompat->item(0);
+ if ($playerCompat->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $playerCompat */
+ $playerCompatibility = new PlayerCompatibility();
+ $playerCompatibility->message = $playerCompat->textContent;
+ if ($playerCompat->hasAttribute('windows')) {
+ $playerCompatibility->windows = $playerCompat->getAttribute('windows');
+ }
+ if ($playerCompat->hasAttribute('android')) {
+ $playerCompatibility->android = $playerCompat->getAttribute('android');
+ }
+ if ($playerCompat->hasAttribute('linux')) {
+ $playerCompatibility->linux = $playerCompat->getAttribute('linux');
+ }
+ if ($playerCompat->hasAttribute('webos')) {
+ $playerCompatibility->webos = $playerCompat->getAttribute('webos');
+ }
+ if ($playerCompat->hasAttribute('tizen')) {
+ $playerCompatibility->tizen = $playerCompat->getAttribute('tizen');
+ }
+ if ($playerCompat->hasAttribute('chromeos')) {
+ $playerCompatibility->chromeos = $playerCompat->getAttribute('chromeos');
+ }
+ $property->playerCompatibility = $playerCompatibility;
+ }
+ }
+
+ // Custom popover
+ $property->customPopOver = __($this->getFirstValueOrDefaultFromXmlNode($node, 'customPopOver'));
+
+ $properties[] = $property;
+ }
+ }
+
+ // Set the default values
+ $params = $this->getSanitizer($defaultValues);
+ foreach ($properties as $property) {
+ $property->setDefaultByType($params);
+ }
+
+ return $properties;
+ }
+
+ /**
+ * Take a value and decorate it with any module/global settings
+ * @param Module|null $module
+ * @param string|null $value
+ * @return string|null
+ */
+ private function decorateWithSettings(?Module $module, ?string $value): ?string
+ {
+ // If we're not empty, then try and do any variable substitutions
+ if (!empty($value)) {
+ if ($module !== null
+ && Str::startsWith($value, '%')
+ && Str::endsWith($value, '%')
+ ) {
+ $value = $module->getSetting(str_replace('%', '', $value));
+ } else if (Str::startsWith($value, '#')
+ && Str::endsWith($value, '#')
+ ) {
+ $value = $this->getConfig()->getSetting(str_replace('#', '', $value));
+ }
+ }
+ return $value;
+ }
+
+ /**
+ * @param \DOMNode[]|\DOMNodeList $propertyGroupNodes
+ * @return array
+ */
+ private function parsePropertyGroups($propertyGroupNodes): array
+ {
+ if ($propertyGroupNodes instanceof \DOMNodeList) {
+ // Property nodes are the parent node
+ if (count($propertyGroupNodes) <= 0) {
+ return [];
+ }
+ $propertyGroupNodes = $propertyGroupNodes->item(0)->childNodes;
+ }
+
+ $propertyGroups = [];
+ foreach ($propertyGroupNodes as $propertyGroupNode) {
+ /** @var \DOMNode $propertyGroupNode */
+ if ($propertyGroupNode instanceof \DOMElement) {
+ $propertyGroup = new PropertyGroup();
+ $propertyGroup->id = $propertyGroupNode->getAttribute('id');
+ $propertyGroup->expanded = $propertyGroupNode->getAttribute('expanded') === 'true';
+ $propertyGroup->title = __($this->getFirstValueOrDefaultFromXmlNode($propertyGroupNode, 'title'));
+ $propertyGroup->helpText = __($this->getFirstValueOrDefaultFromXmlNode($propertyGroupNode, 'helpText'));
+ $propertyGroups[] = $propertyGroup;
+ }
+ }
+
+ return $propertyGroups;
+ }
+
+ /**
+ * @param \DOMNodeList $elementsNodes
+ * @return \Xibo\Widget\Definition\Property[]
+ */
+ private function parseElements(\DOMNodeList $elementsNodes): array
+ {
+ $elements = [];
+ foreach ($elementsNodes as $elementNode) {
+ /** @var \DOMNode $elementNode */
+ if ($elementNode instanceof \DOMElement) {
+ $element = new Element();
+ $element->id = $elementNode->getAttribute('id');
+ $element->elementGroupId = $elementNode->getAttribute('elementGroupId');
+ foreach ($elementNode->childNodes as $childNode) {
+ if ($childNode instanceof \DOMElement) {
+ if ($childNode->nodeName === 'top') {
+ $element->top = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'left') {
+ $element->left = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'width') {
+ $element->width = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'height') {
+ $element->height = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'layer') {
+ $element->layer = intval($childNode->textContent);
+ } else if ($childNode->nodeName === 'rotation') {
+ $element->rotation = intval($childNode->textContent);
+ } else if ($childNode->nodeName === 'defaultProperties') {
+ foreach ($childNode->childNodes as $defaultPropertyNode) {
+ if ($defaultPropertyNode instanceof \DOMElement) {
+ $element->properties[] = [
+ 'id' => $defaultPropertyNode->getAttribute('id'),
+ 'value' => trim($defaultPropertyNode->textContent)
+ ];
+ }
+ }
+ }
+ }
+ }
+ $elements[] = $element;
+ }
+ }
+
+ return $elements;
+ }
+
+ /**
+ * @param \DOMNodeList $elementGroupsNodes
+ * @return \Xibo\Widget\Definition\Property[]
+ */
+ private function parseElementGroups (\DOMNodeList $elementGroupsNodes): array
+ {
+ $elementGroups = [];
+ foreach ($elementGroupsNodes as $elementGroupsNode) {
+ /** @var \DOMNode $elementNode */
+ if ($elementGroupsNode instanceof \DOMElement) {
+ $elementGroup = new ElementGroup();
+ $elementGroup->id = $elementGroupsNode->getAttribute('id');
+ foreach ($elementGroupsNode->childNodes as $childNode) {
+ if ($childNode instanceof \DOMElement) {
+ if ($childNode->nodeName === 'top') {
+ $elementGroup->top = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'left') {
+ $elementGroup->left = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'width') {
+ $elementGroup->width = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'height') {
+ $elementGroup->height = doubleval($childNode->textContent);
+ } else if ($childNode->nodeName === 'layer') {
+ $elementGroup->layer = intval($childNode->textContent);
+ } else if ($childNode->nodeName === 'title') {
+ $elementGroup->title = $childNode->textContent;
+ } else if ($childNode->nodeName === 'slot') {
+ $elementGroup->slot = intval($childNode->textContent);
+ } else if ($childNode->nodeName === 'pinSlot') {
+ $elementGroup->pinSlot = boolval($childNode->textContent);
+ }
+ }
+ }
+ $elementGroups[] = $elementGroup;
+ }
+ }
+
+ return $elementGroups;
+ }
+
+ /**
+ * @param \DOMNodeList $legacyTypeNodes
+ * @return \Xibo\Widget\Definition\LegacyType[]
+ */
+ private function parseLegacyTypes(\DOMNodeList $legacyTypeNodes): array
+ {
+ $legacyTypes = [];
+ foreach ($legacyTypeNodes as $node) {
+ /** @var \DOMNode $node */
+ if ($node instanceof \DOMElement) {
+ $legacyType = new LegacyType();
+ $legacyType->name = trim($node->textContent);
+ $legacyType->condition = $node->getAttribute('condition');
+
+ $legacyTypes[] = $legacyType;
+ }
+ }
+
+ return $legacyTypes;
+ }
+
+ /**
+ * Parse assets
+ * @param \DOMNode[]|\DOMNodeList $assetNodes
+ * @return \Xibo\Widget\Definition\Asset[]
+ */
+ private function parseAssets($assetNodes): array
+ {
+ if ($assetNodes instanceof \DOMNodeList) {
+ // Asset nodes are the parent node
+ if (count($assetNodes) <= 0) {
+ return [];
+ }
+ $assetNodes = $assetNodes->item(0)->childNodes;
+ }
+
+ $assets = [];
+ foreach ($assetNodes as $node) {
+ if ($node->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $node */
+ $assetId = $node->getAttribute('id');
+
+ if (!array_key_exists($assetId, $this->assetCache)) {
+ $asset = new Asset();
+ $asset->id = $assetId;
+ $asset->alias = $node->getAttribute('alias');
+ $asset->path = $node->getAttribute('path');
+ $asset->mimeType = $node->getAttribute('mimeType');
+ $asset->type = $node->getAttribute('type');
+ $asset->cmsOnly = $node->getAttribute('cmsOnly') === 'true';
+ $asset->autoInclude = $node->getAttribute('autoInclude') !== 'false';
+ $asset->assetNo = count($this->assetCache) + 1;
+ $this->assetCache[$assetId] = $asset;
+ }
+
+ $assets[] = $this->assetCache[$assetId];
+ }
+ }
+
+ return $assets;
+ }
+
+ /**
+ * Parse extends
+ * @param \DOMNodeList $nodes
+ * @return \Xibo\Widget\Definition\Asset[]
+ */
+ private function getExtends(\DOMNodeList $nodes): array
+ {
+ $extends = [];
+ foreach ($nodes as $node) {
+ if ($node->nodeType === XML_ELEMENT_NODE) {
+ /** @var \DOMElement $node */
+ $extend = new Extend();
+ $extend->template = trim($node->textContent);
+ $extend->override = $node->getAttribute('override');
+ $extend->with = $node->getAttribute('with');
+ $extend->escapeHtml = $node->getAttribute('escapeHtml') !== 'false';
+ $extends[] = $extend;
+ }
+ }
+
+ return $extends;
+ }
+
+ /**
+ * Get the first node value
+ * @param \DOMDocument|\DOMElement $xml The XML document
+ * @param string $nodeName The no name
+ * @param string|null $default A default value is none is present
+ * @return string|null
+ */
+ private function getFirstValueOrDefaultFromXmlNode($xml, string $nodeName, $default = null): ?string
+ {
+ foreach ($xml->getElementsByTagName($nodeName) as $node) {
+ /** @var \DOMNode $node */
+ if ($node->nodeType === XML_ELEMENT_NODE) {
+ return $node->textContent;
+ }
+ }
+
+ return $default;
+ }
+}
diff --git a/lib/Factory/NotificationFactory.php b/lib/Factory/NotificationFactory.php
new file mode 100644
index 0000000..0265e55
--- /dev/null
+++ b/lib/Factory/NotificationFactory.php
@@ -0,0 +1,281 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use Xibo\Entity\Notification;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class NotificationFactory
+ * @package Xibo\Factory
+ */
+class NotificationFactory extends BaseFactory
+{
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param UserGroupFactory $userGroupFactory
+ * @param DisplayGroupFactory $displayGroupFactory
+ */
+ public function __construct($user, $userFactory, $userGroupFactory, $displayGroupFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->userGroupFactory = $userGroupFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ }
+
+ /**
+ * @return Notification
+ */
+ public function createEmpty()
+ {
+ return new Notification(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->userGroupFactory,
+ $this->displayGroupFactory
+ );
+ }
+
+ /**
+ * @param string $subject
+ * @param string $body
+ * @param Carbon $date
+ * @param string $type
+ * @param bool $addGroups
+ * @return Notification
+ * @throws NotFoundException
+ */
+ public function createSystemNotification($subject, $body, $date, $type, $addGroups = true)
+ {
+ $userId = $this->getUser()->userId;
+
+ $notification = $this->createEmpty();
+ $notification->subject = $subject;
+ $notification->body = $body;
+ $notification->createDt = $date->format('U');
+ $notification->releaseDt = $date->format('U');
+ $notification->isInterrupt = 0;
+ $notification->userId = $userId;
+ $notification->isSystem = 1;
+ $notification->type = $type;
+
+ if ($addGroups) {
+ // Add the system notifications group - if there is one.
+ foreach ($this->userGroupFactory->getSystemNotificationGroups() as $group) {
+ /* @var \Xibo\Entity\UserGroup $group */
+ $notification->assignUserGroup($group);
+ }
+ }
+
+ return $notification;
+ }
+
+ /**
+ * Get by Id
+ * @param int $notificationId
+ * @return Notification
+ * @throws NotFoundException
+ */
+ public function getById($notificationId)
+ {
+ $notifications = $this->query(null, ['notificationId' => $notificationId]);
+
+ if (count($notifications) <= 0)
+ throw new NotFoundException();
+
+ return $notifications[0];
+ }
+
+ /**
+ * @param string $subject
+ * @param int $fromDt
+ * @param int $toDt
+ * @return Notification[]
+ * @throws NotFoundException
+ */
+ public function getBySubjectAndDate($subject, $fromDt, $toDt)
+ {
+ return $this->query(null, ['subject' => $subject, 'createFromDt' => $fromDt, 'createToDt' => $toDt]);
+ }
+
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['ownerId' => $ownerId, 'disableUserCheck' => 1]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return Notification[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, array $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if (empty($sortOrder)) {
+ $sortOrder = ['subject'];
+ }
+
+ $params = [];
+ $select = 'SELECT `notification`.notificationId,
+ `notification`.subject,
+ `notification`.createDt,
+ `notification`.releaseDt,
+ `notification`.body,
+ `notification`.type,
+ `notification`.isInterrupt,
+ `notification`.isSystem,
+ `notification`.filename,
+ `notification`.originalFileName,
+ `notification`.nonusers,
+ `notification`.userId ';
+
+ $body = ' FROM `notification` ';
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('notificationId') !== null) {
+ $body .= ' AND `notification`.notificationId = :notificationId ';
+ $params['notificationId'] = $sanitizedFilter->getInt('notificationId');
+ }
+
+ if ($sanitizedFilter->getString('subject') != null) {
+ $body .= ' AND `notification`.subject = :subject ';
+ $params['subject'] = $sanitizedFilter->getString('subject');
+ }
+
+ if ($sanitizedFilter->getInt('createFromDt') != null) {
+ $body .= ' AND `notification`.createDt >= :createFromDt ';
+ $params['createFromDt'] = $sanitizedFilter->getInt('createFromDt');
+ }
+
+ if ($sanitizedFilter->getInt('releaseDt') != null) {
+ $body .= ' AND `notification`.releaseDt >= :releaseDt ';
+ $params['releaseDt'] = $sanitizedFilter->getInt('releaseDt');
+ }
+
+ if ($sanitizedFilter->getInt('createToDt') != null) {
+ $body .= ' AND `notification`.createDt < :createToDt ';
+ $params['createToDt'] = $sanitizedFilter->getInt('createToDt');
+ }
+
+ if ($sanitizedFilter->getInt('onlyReleased') === 1) {
+ $body .= ' AND `notification`.releaseDt <= :now ';
+ $params['now'] = Carbon::now()->format('U');
+ }
+
+ if ($sanitizedFilter->getInt('ownerId') !== null) {
+ $body .= ' AND `notification`.userId = :ownerId ';
+ $params['ownerId'] = $sanitizedFilter->getInt('ownerId');
+ }
+
+ // User Id?
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `notification`.notificationId IN (
+ SELECT notificationId
+ FROM `lknotificationuser`
+ WHERE userId = :userId
+ )';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // Display Id?
+ if ($sanitizedFilter->getInt('displayId') !== null) {
+ $body .= ' AND `notification`.notificationId IN (
+ SELECT notificationId
+ FROM `lknotificationdg`
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lknotificationdg`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON `lkdisplaydg`.displayGroupId = `lkdgdg`.childId
+ WHERE `lkdisplaydg`.displayId = :displayId
+ )';
+ $params['displayId'] = $sanitizedFilter->getInt('displayId');
+ }
+
+ // Read
+ if ($sanitizedFilter->getInt('read') !== null) {
+ $body .= ' AND `notification`.notificationId IN (
+ SELECT notificationId
+ FROM `lknotificationuser`
+ WHERE userId = :userId
+ AND `read` = :read
+ )';
+ $params['read'] = $sanitizedFilter->getInt('read');
+ $params['userId'] = $this->getUser()->userId;
+ }
+
+ // Type
+ if (!empty($sanitizedFilter->getString('type'))) {
+ $body .= ' AND `notification`.type = :type ';
+ $params['type'] = $sanitizedFilter->getString('type');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null &&
+ $sanitizedFilter->getInt('start') !== null &&
+ $sanitizedFilter->getInt('length') !== null
+ ) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) .
+ ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['isInterrupt', 'isSystem']
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/PermissionFactory.php b/lib/Factory/PermissionFactory.php
new file mode 100644
index 0000000..9c21d32
--- /dev/null
+++ b/lib/Factory/PermissionFactory.php
@@ -0,0 +1,410 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\Permission;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class PermissionFactory
+ * @package Xibo\Factory
+ */
+class PermissionFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return Permission
+ */
+ public function createEmpty()
+ {
+ return new Permission(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher()
+ );
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function getEntityId(string $entity): int
+ {
+ // Lookup the entityId
+ $results = $this->getStore()->select('SELECT `entityId` FROM `permissionentity` WHERE `entity` = :entity', [
+ 'entity' => $entity,
+ ]);
+
+ if (count($results) <= 0) {
+ throw new InvalidArgumentException(__('Entity not found: ') . $entity);
+ }
+
+ return intval($results[0]['entityId']);
+ }
+
+ /**
+ * Create a new Permission
+ * @param int $groupId
+ * @param string $entity
+ * @param int $objectId
+ * @param int $view
+ * @param int $edit
+ * @param int $delete
+ * @return Permission
+ * @throws InvalidArgumentException
+ */
+ public function create($groupId, $entity, $objectId, $view, $edit, $delete)
+ {
+ $permission = $this->createEmpty();
+ $permission->groupId = $groupId;
+ $permission->entityId = $this->getEntityId($entity);
+ $permission->objectId = $objectId;
+ $permission->view =$view;
+ $permission->edit = $edit;
+ $permission->delete = $delete;
+
+ return $permission;
+ }
+
+ /**
+ * Create a new Permission
+ * @param UserGroupFactory $userGroupFactory
+ * @param string $entity
+ * @param int $objectId
+ * @param int $view
+ * @param int $edit
+ * @param int $delete
+ * @return Permission
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ */
+ public function createForEveryone($userGroupFactory, $entity, $objectId, $view, $edit, $delete)
+ {
+ // Lookup the entityId
+ $results = $this->getStore()->select('SELECT entityId FROM permissionentity WHERE entity = :entity', ['entity' => $entity]);
+
+ if (count($results) <= 0) {
+ throw new InvalidArgumentException(__('Entity not found: ') . $entity);
+ }
+
+ $permission = $this->createEmpty();
+ $permission->groupId = $userGroupFactory->getEveryone()->groupId;
+ $permission->entityId = $results[0]['entityId'];
+ $permission->objectId = $objectId;
+ $permission->view =$view;
+ $permission->edit = $edit;
+ $permission->delete = $delete;
+
+ return $permission;
+ }
+
+ /**
+ * Get Permissions by Entity ObjectId
+ * @param string $entity
+ * @param int $objectId
+ * @return Permission[]
+ */
+ public function getByObjectId($entity, $objectId)
+ {
+ $permissions = array();
+
+ $sql = '
+ SELECT `permissionId`, `groupId`, `view`, `edit`, `delete`, permissionentity.entityId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ WHERE entity = :entity
+ AND objectId = :objectId
+ ';
+
+ $params = array('entity' => $entity, 'objectId' => $objectId);
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $permission = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['view', 'edit', 'delete'],
+ ]);
+ $permission->objectId = $objectId;
+ $permission->entity = $entity;
+
+ $permissions[] = $permission;
+ }
+
+ return $permissions;
+ }
+
+ /**
+ * Get All Permissions by Entity ObjectId
+ * @param User $user
+ * @param string $entity
+ * @param int $objectId
+ * @param array[string] $sortOrder
+ * @param array[mixed] $filterBy
+ * @return Permission[]
+ * @throws NotFoundException
+ */
+ public function getAllByObjectId($user, $entity, $objectId, $sortOrder = null, $filterBy = [])
+ {
+ // Look up the entityId for any add operation that might occur
+ $entityId = $this->getStore()->select('SELECT entityId FROM permissionentity WHERE entity = :entity', ['entity' => $entity]);
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if (count($entityId) <= 0) {
+ throw new NotFoundException(__('Entity not found'));
+ }
+
+ $entityId = $entityId[0]['entityId'];
+
+ $permissions = [];
+ $params = ['entityId' => $entityId, 'objectId' => $objectId];
+
+ // SQL gets all Groups/User Specific Groups for non-retired users
+ // then it joins them to the permission table for the object specified
+ $select = 'SELECT `permissionId`, joinedGroup.`groupId`, `view`, `edit`, `delete`, joinedGroup.isuserspecific, joinedGroup.group ';
+ $body = ' FROM (
+ SELECT `group`.*
+ FROM `group`
+ WHERE IsUserSpecific = 0 ';
+
+ // Permissions for the group section
+ if ($sanitizedFilter->getCheckbox('disableUserCheck') == 0) {
+ // Normal users can only see their group
+ if ($user->userTypeId != 1) {
+ $body .= '
+ AND `group`.groupId IN (
+ SELECT `group`.groupId
+ FROM `lkusergroup`
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 0
+ WHERE `lkusergroup`.userId = :currentUserId
+ )
+ ';
+ $params['currentUserId'] = $user->userId;
+ }
+ }
+
+ $body .= '
+ UNION ALL
+ SELECT `group`.*
+ FROM `group`
+ INNER JOIN lkusergroup
+ ON lkusergroup.GroupID = group.GroupID
+ AND IsUserSpecific = 1
+ INNER JOIN `user`
+ ON lkusergroup.UserID = user.UserID
+ AND retired = 0 ';
+
+ // Permissions for the user section
+ if ($sanitizedFilter->getCheckbox('disableUserCheck') == 0) {
+ // Normal users can only see themselves
+ if ($user->userTypeId == 3) {
+ $body .= ' AND `user`.userId = :currentUserId ';
+ $params['currentUserId'] = $user->userId;
+ }
+ // Group admins can only see users from their groups.
+ else if ($user->userTypeId == 2) {
+ $body .= '
+ AND user.userId IN (
+ SELECT `otherUserLinks`.userId
+ FROM `lkusergroup`
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 0
+ INNER JOIN `lkusergroup` `otherUserLinks`
+ ON `otherUserLinks`.groupId = `group`.groupId
+ WHERE `lkusergroup`.userId = :currentUserId
+ )
+ ';
+ $params['currentUserId'] = $user->userId;
+ }
+ }
+
+ $body .= '
+ ) joinedGroup
+ ';
+
+ if ($sanitizedFilter->getInt('setOnly', ['default' => 0]) == 1) {
+ $body .= ' INNER JOIN ';
+ } else {
+ $body .= ' LEFT OUTER JOIN ';
+ }
+
+ $body .= '
+ `permission`
+ ON `permission`.groupId = joinedGroup.groupId
+ AND objectId = :objectId
+ AND entityId = :entityId
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getString('name') != null) {
+ $body .= ' AND joinedGroup.group LIKE :name ';
+ $params['name'] = '%' . $sanitizedFilter->getString('name') . '%';
+ }
+
+ $order = '';
+ if ($sortOrder == null) {
+ $order = 'ORDER BY joinedGroup.isEveryone DESC, joinedGroup.isUserSpecific, joinedGroup.`group`';
+ } else if (is_array($sortOrder)) {
+ $order = 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $row['entityId'] = $entityId;
+ $row['entity'] = $entity;
+ $row['objectId'] = $objectId;
+ $permissions[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['view', 'edit', 'delete', 'isUser'],
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($permissions) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $permissions;
+ }
+
+ /**
+ * Gets all permissions for a user group
+ * @param string $entity
+ * @param int $groupId
+ * @return Permission[]
+ */
+ public function getByGroupId($entity, $groupId)
+ {
+ $permissions = [];
+
+ $sql = '
+ SELECT `permission`.`permissionId`,
+ `permission`.`groupId`,
+ `permission`.`objectId`,
+ `permission`.`view`,
+ `permission`.`edit`,
+ `permission`.`delete`,
+ `permissionentity`.`entityId`
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :entity
+ AND `permission`.`groupId` = :groupId
+ ';
+ $params = ['entity' => 'Xibo\Entity\\' . $entity, 'groupId' => $groupId];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $row['entity'] = $entity;
+ $permissions[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['view', 'edit', 'delete'],
+ ]);
+ }
+
+ return $permissions;
+ }
+
+ /**
+ * Gets all permissions for a set of user groups
+ * @param string $entity
+ * @param int $userId
+ * @return Permission[]
+ */
+ public function getByUserId($entity, $userId): array
+ {
+ $permissions = [];
+
+ $sql = '
+ SELECT `permission`.`permissionId`,
+ `permission`.`groupId`,
+ `permission`.`objectId`,
+ `permission`.`view`,
+ `permission`.`edit`,
+ `permission`.`delete`,
+ `permissionentity`.`entityId`
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ INNER JOIN `lkusergroup`
+ ON `lkusergroup`.groupId = `group`.groupId
+ INNER JOIN `user`
+ ON lkusergroup.UserID = `user`.UserID
+ WHERE `permissionentity`.entity = :entity
+ AND `user`.userId = :userId
+ UNION
+ SELECT `permission`.`permissionId`,
+ `permission`.`groupId`,
+ `permission`.`objectId`,
+ `permission`.`view`,
+ `permission`.`edit`,
+ `permission`.`delete`,
+ `permissionentity`.entityId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE `permissionentity`.entity = :entity
+ AND `group`.IsEveryone = 1
+ ';
+ $params = ['entity' => $entity, 'userId' => $userId];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $row['entity'] = $entity;
+ $permissions[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['view', 'edit', 'delete'],
+ ]);
+ }
+
+ return $permissions;
+ }
+
+ /**
+ * Get Full Permissions
+ * @return Permission
+ */
+ public function getFullPermissions(): Permission
+ {
+ $permission = $this->createEmpty();
+ $permission->view = 1;
+ $permission->edit = 1;
+ $permission->delete = 1;
+ $permission->modifyPermissions = 1;
+ return $permission;
+ }
+}
diff --git a/lib/Factory/PlayerFaultFactory.php b/lib/Factory/PlayerFaultFactory.php
new file mode 100644
index 0000000..71a8afa
--- /dev/null
+++ b/lib/Factory/PlayerFaultFactory.php
@@ -0,0 +1,123 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\PlayerFault;
+
+class PlayerFaultFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return PlayerFault
+ */
+ public function createEmpty()
+ {
+ return new PlayerFault(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher()
+ );
+ }
+
+ /**
+ * @param int $displayId
+ * @return PlayerFault[]
+ */
+ public function getByDisplayId(int $displayId, $sortOrder = null)
+ {
+ return $this->query($sortOrder, ['disableUserCheck' => 1, 'displayId' => $displayId]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return PlayerFault[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['incidentDt DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT player_faults.playerFaultId,
+ player_faults.displayId,
+ player_faults.incidentDt,
+ player_faults.expires,
+ player_faults.code,
+ player_faults.reason,
+ player_faults.layoutId,
+ player_faults.regionId,
+ player_faults.widgetId,
+ player_faults.scheduleId,
+ player_faults.mediaId
+ ';
+
+ $body = ' FROM player_faults
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('playerFaultId') !== null) {
+ $body .= ' AND `player_faults`.playerFaultId = :playerFaultId ';
+ $params['playerFaultId'] = $sanitizedFilter->getInt('playerFaultId');
+ }
+
+ if ($sanitizedFilter->getInt('displayId') !== null) {
+ $body .= ' AND `player_faults`.displayId = :displayId ';
+ $params['displayId'] = $sanitizedFilter->getInt('displayId');
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $playerFault = $this->createEmpty()->hydrate($row);
+ $entries[] = $playerFault;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/PlayerVersionFactory.php b/lib/Factory/PlayerVersionFactory.php
new file mode 100644
index 0000000..8bc5d99
--- /dev/null
+++ b/lib/Factory/PlayerVersionFactory.php
@@ -0,0 +1,279 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\PlayerVersion;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class PlayerVersionFactory
+ * @package Xibo\Factory
+ */
+class PlayerVersionFactory extends BaseFactory
+{
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ */
+ public function __construct($user, $userFactory, $config)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->config = $config;
+
+ }
+
+ /**
+ * Create Empty
+ * @return PlayerVersion
+ */
+ public function createEmpty()
+ {
+ return new PlayerVersion(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this
+ );
+ }
+
+ /**
+ * Populate Player Version table
+ * @param string $type
+ * @param int $version
+ * @param int $code
+ * @param string $playerShowVersion
+ * @param string $modifiedBy
+ * @param string $fileName
+ * @param int $size
+ * @param string $md5
+ * @return PlayerVersion
+ */
+ public function create(
+ $type,
+ $version,
+ $code,
+ $playerShowVersion,
+ $modifiedBy,
+ $fileName,
+ $size,
+ $md5
+ )
+ {
+ $playerVersion = $this->createEmpty();
+ $playerVersion->type = $type;
+ $playerVersion->version = $version;
+ $playerVersion->code = $code;
+ $playerVersion->playerShowVersion = $playerShowVersion;
+ $playerVersion->modifiedBy = $modifiedBy;
+ $playerVersion->fileName = $fileName;
+ $playerVersion->size = $size;
+ $playerVersion->md5 = $md5;
+ $playerVersion->save();
+
+ return $playerVersion;
+ }
+
+ /**
+ * Get by Version Id
+ * @param int $versionId
+ * @return PlayerVersion
+ * @throws NotFoundException
+ */
+ public function getById($versionId)
+ {
+ $versions = $this->query(null, array('disableUserCheck' => 1, 'versionId' => $versionId));
+
+ if (count($versions) <= 0)
+ throw new NotFoundException(__('Cannot find version'));
+
+ return $versions[0];
+ }
+
+ /**
+ * Get by Type
+ * @param string $type
+ * @return PlayerVersion
+ * @throws NotFoundException
+ */
+ public function getByType(string $type): PlayerVersion
+ {
+ $versions = $this->query(null, array('disableUserCheck' => 1, 'playerType' => $type));
+
+ if (count($versions) <= 0) {
+ throw new NotFoundException(__('Cannot find Player Version'));
+ }
+
+ return $versions[0];
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return PlayerVersion[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['code DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT `player_software`.versionId,
+ `player_software`.player_type AS type,
+ `player_software`.player_version AS version,
+ `player_software`.player_code AS code,
+ `player_software`.playerShowVersion,
+ `player_software`.createdAt,
+ `player_software`.modifiedAt,
+ `player_software`.modifiedBy,
+ `player_software`.fileName,
+ `player_software`.size,
+ `player_software`.md5
+ ';
+
+ $body = ' FROM player_software
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('versionId', ['default' => -1]) != -1) {
+ $body .= " AND player_software.versionId = :versionId ";
+ $params['versionId'] = $sanitizedFilter->getInt('versionId');
+ }
+
+ if ($sanitizedFilter->getString('playerType') != '') {
+ $body .= " AND player_software.player_type = :playerType ";
+ $params['playerType'] = $sanitizedFilter->getString('playerType');
+ }
+
+ if ($sanitizedFilter->getString('playerVersion') != '') {
+ $body .= " AND player_software.player_version = :playerVersion ";
+ $params['playerVersion'] = $sanitizedFilter->getString('playerVersion');
+ }
+
+ if ($sanitizedFilter->getInt('playerCode') != '') {
+ $body .= " AND player_software.player_code = :playerCode ";
+ $params['playerCode'] = $sanitizedFilter->getInt('playerCode');
+ }
+
+ if ($sanitizedFilter->getString('playerShowVersion') !== null) {
+ $terms = explode(',', $sanitizedFilter->getString('playerShowVersion'));
+ $this->nameFilter('player_software', 'playerShowVersion', $terms, $body, $params, ($sanitizedFilter->getCheckbox('useRegexForName') == 1));
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'versionId', 'code', 'size'
+ ]
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['entity']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ public function getDistinctType()
+ {
+ $params = [];
+ $entries = [];
+ $sql = '
+ SELECT DISTINCT player_software.player_type AS type
+ FROM player_software
+ ORDER BY type ASC
+ ';
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entry = $this->createEmpty()->hydrate($row);
+ if ($entry->type === 'sssp') {
+ $entry->setUnmatchedProperty('typeShow', 'Tizen');
+ } else if ($entry->type === 'lg') {
+ $entry->setUnmatchedProperty('typeShow', 'webOS');
+ } else {
+ $entry->setUnmatchedProperty('typeShow', ucfirst($row['type']));
+ }
+
+ $entries[] = $entry;
+ }
+
+ return $entries;
+ }
+
+ public function getDistinctVersion()
+ {
+ $params = [];
+ $entries = [];
+ $sql = '
+ SELECT DISTINCT player_software.player_version AS version
+ FROM player_software
+ ORDER BY version ASC
+ ';
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+
+ public function getSizeAndCount()
+ {
+ return $this->getStore()->select('SELECT IFNULL(SUM(size), 0) AS SumSize, COUNT(*) AS totalCount FROM `player_software`', [])[0];
+ }
+}
diff --git a/lib/Factory/PlaylistFactory.php b/lib/Factory/PlaylistFactory.php
new file mode 100644
index 0000000..03a41a3
--- /dev/null
+++ b/lib/Factory/PlaylistFactory.php
@@ -0,0 +1,512 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use Xibo\Entity\Playlist;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class PlaylistFactory
+ * @package Xibo\Factory
+ */
+class PlaylistFactory extends BaseFactory
+{
+ use TagTrait;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var WidgetFactory
+ */
+ private $widgetFactory;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * Construct a factory
+ * @param ConfigServiceInterface $config
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param PermissionFactory $permissionFactory
+ * @param WidgetFactory $widgetFactory
+ */
+ public function __construct($config, $user, $userFactory, $permissionFactory, $widgetFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->config = $config;
+ $this->permissionFactory = $permissionFactory;
+ $this->widgetFactory = $widgetFactory;
+ }
+
+ /**
+ * @return Playlist
+ */
+ public function createEmpty()
+ {
+ return new Playlist(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this->permissionFactory,
+ $this,
+ $this->widgetFactory
+ );
+ }
+
+ /**
+ * Load Playlists by
+ * @param $regionId
+ * @return Playlist
+ * @throws NotFoundException
+ */
+ public function getByRegionId($regionId)
+ {
+ $playlists = $this->query(null, array('disableUserCheck' => 1, 'regionId' => $regionId));
+
+ if (count($playlists) <= 0) {
+ $this->getLog()->error('Region ' . $regionId . ' does not have a Playlist associated, please try to set a new owner in Permissions.');
+ throw new NotFoundException(__('One of the Regions on this Layout does not have a Playlist, please contact your administrator.'));
+ }
+
+ return $playlists[0];
+ }
+
+ /**
+ * @param $campaignId
+ * @return Playlist[]
+ * @throws NotFoundException
+ */
+ public function getByCampaignId($campaignId): array
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'campaignId' => $campaignId]);
+ }
+
+ /**
+ * Get by Id
+ * @param int $playlistId
+ * @return Playlist
+ * @throws NotFoundException
+ */
+ public function getById($playlistId)
+ {
+ $playlists = $this->query(null, array('disableUserCheck' => 1, 'playlistId' => $playlistId));
+
+ if (count($playlists) <= 0)
+ throw new NotFoundException(__('Cannot find playlist'));
+
+ return $playlists[0];
+ }
+
+ /**
+ * Get by OwnerId
+ * @param int $ownerId
+ * @return Playlist[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['userId' => $ownerId, 'regionSpecific' => 0]);
+ }
+
+ /**
+ * @param $folderId
+ * @return Playlist[]
+ * @throws NotFoundException
+ */
+ public function getByFolderId($folderId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'folderId' => $folderId]);
+ }
+
+ /**
+ * Create a Playlist
+ * @param string $name
+ * @param int $ownerId
+ * @param int|null $regionId
+ * @return Playlist
+ */
+ public function create($name, $ownerId, $regionId = null)
+ {
+ $playlist = $this->createEmpty();
+ $playlist->name = $name;
+ $playlist->ownerId = $ownerId;
+ $playlist->regionId = $regionId;
+ $playlist->isDynamic = 0;
+ $playlist->requiresDurationUpdate = 1;
+
+ return $playlist;
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return Playlist[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+
+ $params = [];
+ $select = '
+ SELECT `playlist`.playlistId,
+ `playlist`.ownerId,
+ `playlist`.name,
+ `user`.UserName AS owner,
+ `playlist`.regionId,
+ `playlist`.createdDt,
+ `playlist`.modifiedDt,
+ `playlist`.duration,
+ `playlist`.isDynamic,
+ `playlist`.filterMediaName,
+ `playlist`.filterMediaNameLogicalOperator,
+ `playlist`.filterMediaTags,
+ `playlist`.filterExactTags,
+ `playlist`.filterMediaTagsLogicalOperator,
+ `playlist`.filterFolderId,
+ `playlist`.maxNumberOfItems,
+ `playlist`.requiresDurationUpdate,
+ `playlist`.enableStat,
+ `playlist`.folderId,
+ `playlist`.permissionsFolderId,
+ (
+ SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :permissionEntityForGroup
+ AND objectId = playlist.playlistId
+ AND view = 1
+ ) AS groupsWithPermissions
+ ';
+
+ $params['permissionEntityForGroup'] = 'Xibo\\Entity\\Playlist';
+
+ $body = '
+ FROM `playlist`
+ LEFT OUTER JOIN `user`
+ ON `user`.userId = `playlist`.ownerId
+ WHERE 1 = 1
+ ';
+
+ if ($parsedFilter->getInt('playlistId') !== null) {
+ $body .= ' AND `playlist`.playlistId = :playlistId ';
+ $params['playlistId'] = $parsedFilter->getInt('playlistId');
+ }
+
+ if ($parsedFilter->getInt('notPlaylistId') !== null) {
+ $body .= ' AND `playlist`.playlistId <> :notPlaylistId ';
+ $params['notPlaylistId'] = $parsedFilter->getInt('notPlaylistId');
+ }
+
+ if ($parsedFilter->getInt('userId') !== null) {
+ $body .= ' AND `playlist`.ownerId = :ownerId ';
+ $params['ownerId'] = $parsedFilter->getInt('userId');
+ }
+
+ // User Group filter
+ if ($parsedFilter->getInt('ownerUserGroupId',['default' => 0]) != 0) {
+ $body .= ' AND `playlist`.ownerId IN (SELECT DISTINCT userId FROM `lkusergroup` WHERE groupId = :ownerUserGroupId) ';
+ $params['ownerUserGroupId'] = $parsedFilter->getInt('ownerUserGroupId',['default' => 0]);
+ }
+
+ if ($parsedFilter->getInt('regionId') !== null) {
+ $body .= ' AND `playlist`.regionId = :regionId ';
+ $params['regionId'] = $parsedFilter->getInt('regionId');
+ }
+
+ if ($parsedFilter->getInt('requiresDurationUpdate') !== null) {
+ // Either 1, or 0
+ if ($parsedFilter->getInt('requiresDurationUpdate') == 1) {
+ // Not 0 and behind now.
+ $body .= ' AND `playlist`.requiresDurationUpdate <= :requiresDurationUpdate ';
+ $body .= ' AND `playlist`.requiresDurationUpdate <> 0 ';
+ $params['requiresDurationUpdate'] = Carbon::now()->format('U');
+ } else {
+ // Ahead of now means we don't need to update yet, or we are set to 0 and we never update
+ $body .= ' AND (`playlist`.requiresDurationUpdate > :requiresDurationUpdate OR `playlist`.requiresDurationUpdate = 0)';
+ $params['requiresDurationUpdate'] = Carbon::now()->format('U');
+ }
+ }
+
+ if ($parsedFilter->getInt('isDynamic') !== null) {
+ $body .= ' AND `playlist`.isDynamic = :isDynamic ';
+ $params['isDynamic'] = $parsedFilter->getInt('isDynamic');
+ }
+
+ if ($parsedFilter->getInt('childId') !== null) {
+ $body .= '
+ AND `playlist`.playlistId IN (
+ SELECT parentId
+ FROM `lkplaylistplaylist`
+ WHERE childId = :childId
+ ';
+
+ if ($parsedFilter->getInt('depth') !== null) {
+ $body .= ' AND depth = :depth ';
+ $params['depth'] = $parsedFilter->getInt('depth');
+ }
+
+ $body .= '
+ )
+ ';
+ $params['childId'] = $parsedFilter->getInt('childId');
+ }
+
+ if ($parsedFilter->getInt('regionSpecific') !== null) {
+ if ($parsedFilter->getInt('regionSpecific') === 1)
+ $body .= ' AND `playlist`.regionId IS NOT NULL ';
+ else
+ $body .= ' AND `playlist`.regionId IS NULL ';
+ }
+
+ if ($parsedFilter->getInt('layoutId', $filterBy) !== null) {
+ $body .= '
+ AND playlist.playlistId IN (
+ SELECT lkplaylistplaylist.childId
+ FROM region
+ INNER JOIN playlist
+ ON playlist.regionId = region.regionId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ WHERE region.layoutId = :layoutId
+ )';
+ $params['layoutId'] = $parsedFilter->getInt('layoutId', $filterBy);
+ }
+
+ if ($parsedFilter->getInt('campaignId') !== null) {
+ $body .= '
+ AND `playlist`.playlistId IN (
+ SELECT `lkplaylistplaylist`.childId
+ FROM region
+ INNER JOIN playlist
+ ON `playlist`.regionId = `region`.regionId
+ INNER JOIN lkplaylistplaylist
+ ON `lkplaylistplaylist`.parentId = `playlist`.playlistId
+ INNER JOIN widget
+ ON `widget`.playlistId = `lkplaylistplaylist`.childId
+ INNER JOIN lkwidgetmedia
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = region.layoutid
+ AND lkcl.CampaignID = :campaignId
+ )';
+ $params['campaignId'] = $parsedFilter->getInt('campaignId');
+ }
+
+ // Playlist Like
+ if ($parsedFilter->getString('name') != '') {
+ $terms = explode(',', $parsedFilter->getString('name'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'playlist',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ // Playlist exact name
+ if ($parsedFilter->getString('playlistExact') != '') {
+ $body.= " AND playlist.name = :exact ";
+ $params['exact'] = $parsedFilter->getString('playlistExact');
+ }
+
+ // Not PlaylistId
+ if ($parsedFilter->getInt('notPlaylistId', ['default' => 0]) != 0) {
+ $body .= " AND playlist.playlistId <> :notPlaylistId ";
+ $params['notPlaylistId'] = $parsedFilter->getInt('notPlaylistId',['default' => 0]);
+ }
+
+ // Tags
+ if ($parsedFilter->getString('tags') != '') {
+ $tagFilter = $parsedFilter->getString('tags', $filterBy);
+
+ if (trim($tagFilter) === '--no-tag') {
+ $body .= ' AND `playlist`.playlistID NOT IN (
+ SELECT `lktagplaylist`.playlistId
+ FROM `tag`
+ INNER JOIN `lktagplaylist`
+ ON `lktagplaylist`.tagId = `tag`.tagId
+ )
+ ';
+ } else {
+ $operator = $parsedFilter->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $parsedFilter->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $body .= ' AND `playlist`.playlistID NOT IN (
+ SELECT lktagplaylist.playlistId
+ FROM tag
+ INNER JOIN lktagplaylist
+ ON lktagplaylist.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $notTags,
+ 'lktagplaylist',
+ 'lkTagPlaylistId',
+ 'playlistId',
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $body .= ' AND `playlist`.playlistID IN (
+ SELECT lktagplaylist.playlistId
+ FROM tag
+ INNER JOIN lktagplaylist
+ ON lktagplaylist.tagId = tag.tagId
+ ';
+
+ $this->tagFilter(
+ $tags,
+ 'lktagplaylist',
+ 'lkTagPlaylistId',
+ 'playlistId',
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+ }
+ }
+ }
+
+ // MediaID
+ if ($parsedFilter->getInt('mediaId') !== null) {
+ // TODO: sub-playlists
+ $body .= ' AND `playlist`.playlistId IN (
+ SELECT DISTINCT `widget`.playlistId
+ FROM `lkwidgetmedia`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ WHERE `lkwidgetmedia`.mediaId = :mediaId
+ )
+ ';
+
+ $params['mediaId'] = $parsedFilter->getInt('mediaId',['default' => 0]);
+ }
+
+ // Media Like
+ if (!empty($parsedFilter->getString('mediaLike'))) {
+ // TODO: sub-playlists
+ $body .= ' AND `playlist`.playlistId IN (
+ SELECT DISTINCT `widget`.playlistId
+ FROM `lkwidgetmedia`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ INNER JOIN `media`
+ ON `lkwidgetmedia`.mediaId = `media`.mediaId
+ WHERE `media`.name LIKE :mediaLike
+ )
+ ';
+
+ $params['mediaLike'] = '%' . $parsedFilter->getString('mediaLike') . '%';
+ }
+
+ if ($parsedFilter->getInt('filterFolderId') !== null) {
+ $body .= " AND `playlist`.filterFolderId = :filterFolderId ";
+ $params['filterFolderId'] = $parsedFilter->getInt('filterFolderId');
+ }
+
+ if ($parsedFilter->getInt('folderId') !== null) {
+ $body .= " AND `playlist`.folderId = :folderId ";
+ $params['folderId'] = $parsedFilter->getInt('folderId');
+ }
+
+ // Logged in user view permissions
+ $this->viewPermissionSql('Xibo\Entity\Playlist', $body, $params, 'playlist.playlistId', 'playlist.ownerId', $filterBy, '`playlist`.permissionsFolderId');
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ } else {
+ $order .= 'ORDER BY `playlist`.name ';
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $parsedFilter->getInt('start') !== null && $parsedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+ $playlistIds = [];
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $playlist = $this->createEmpty()->hydrate($row, ['intProperties' => ['requiresDurationUpdate', 'isDynamic', 'maxNumberOfItems', 'duration']]);
+ $playlistIds[] = $playlist->playlistId;
+ $entries[] = $playlist;
+ }
+
+ // decorate with TagLinks
+ if (count($entries) > 0) {
+ $this->decorateWithTagLinks('lktagplaylist', 'playlistId', $playlistIds, $entries);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['permissionEntityForGroup']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/RegionFactory.php b/lib/Factory/RegionFactory.php
new file mode 100644
index 0000000..1eff3a1
--- /dev/null
+++ b/lib/Factory/RegionFactory.php
@@ -0,0 +1,289 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\Region;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class RegionFactory
+ * @package Xibo\Factory
+ */
+class RegionFactory extends BaseFactory
+{
+ /**
+ * @var RegionOptionFactory
+ */
+ private $regionOptionFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var PlaylistFactory
+ */
+ private $playlistFactory;
+
+ /** @var ActionFactory */
+ private $actionFactory;
+
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ /**
+ * Construct a factory
+ * @param PermissionFactory $permissionFactory
+ * @param RegionOptionFactory $regionOptionFactory
+ * @param PlaylistFactory $playlistFactory
+ * @param ActionFactory $actionFactory
+ */
+ public function __construct($permissionFactory, $regionOptionFactory, $playlistFactory, $actionFactory, $campaignFactory)
+ {
+ $this->permissionFactory = $permissionFactory;
+ $this->regionOptionFactory = $regionOptionFactory;
+ $this->playlistFactory = $playlistFactory;
+ $this->actionFactory = $actionFactory;
+ $this->campaignFactory = $campaignFactory;
+ }
+
+ /**
+ * @return Region
+ */
+ public function createEmpty()
+ {
+ return new Region(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this,
+ $this->permissionFactory,
+ $this->regionOptionFactory,
+ $this->playlistFactory,
+ $this->actionFactory,
+ $this->campaignFactory
+ );
+ }
+
+ /**
+ * Create a new region
+ * @param string $type
+ * @param int $ownerId
+ * @param string $name
+ * @param int $width
+ * @param int $height
+ * @param int $top
+ * @param int $left
+ * @param int $zIndex
+ * @param int $isDrawer
+ * @return Region
+ * @throws InvalidArgumentException
+ */
+ public function create(string $type, $ownerId, $name, $width, $height, $top, $left, $zIndex = 0, $isDrawer = 0)
+ {
+ if (!in_array($type, ['playlist', 'canvas', 'frame', 'drawer', 'zone'])) {
+ throw new InvalidArgumentException(__('Incorrect type'), 'type');
+ }
+
+ if (!is_numeric($width) || !is_numeric($height) || !is_numeric($top) || !is_numeric($left)) {
+ throw new InvalidArgumentException(__('Size and coordinates must be generic'));
+ }
+
+ if ($width <= 0) {
+ throw new InvalidArgumentException(__('Width must be greater than 0'));
+ }
+
+ if ($height <= 0) {
+ throw new InvalidArgumentException(__('Height must be greater than 0'));
+ }
+
+ return $this->hydrate($this->createEmpty(), [
+ 'type' => $type,
+ 'ownerId' => $ownerId,
+ 'name' => $name,
+ 'width' => $width,
+ 'height' => $height,
+ 'top' => $top,
+ 'left' => $left,
+ 'zIndex' => $zIndex,
+ 'isDrawer' => $isDrawer,
+ ]);
+ }
+
+ /**
+ * Get the regions for a layout
+ * @param int $layoutId
+ * @return array[\Xibo\Entity\Region]
+ */
+ public function getByLayoutId($layoutId)
+ {
+ // Get all regions for this layout
+ return $this->query([], ['disableUserCheck' => 1, 'layoutId' => $layoutId, 'isDrawer' => 0]);
+ }
+
+ /**
+ * Get the drawer regions for a layout
+ * @param int $layoutId
+ * @return array[\Xibo\Entity\Region]
+ */
+ public function getDrawersByLayoutId($layoutId)
+ {
+ // Get all regions for this layout
+ return $this->query([], ['disableUserCheck' => 1, 'layoutId' => $layoutId, 'isDrawer' => 1]);
+ }
+
+ /**
+ * Get the regions for a playlist
+ * @param int $playlistId
+ * @return array[\Xibo\Entity\Region]
+ */
+ public function getByPlaylistId($playlistId)
+ {
+ // Get all regions for this layout
+ return $this->query([], ['disableUserCheck' => 1, 'playlistId' => $playlistId]);
+ }
+
+ /**
+ * Load a region
+ * @param int $regionId
+ * @return Region
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function loadByRegionId($regionId)
+ {
+ $region = $this->getById($regionId);
+ $region->load();
+ return $region;
+ }
+
+ /**
+ * Get by RegionId
+ * @param int $regionId
+ * @return Region
+ * @throws NotFoundException
+ */
+ public function getById($regionId)
+ {
+ // Get a region by its ID
+ $regions = $this->query(array(), array('disableUserCheck' => 1, 'regionId' => $regionId));
+
+ if (count($regions) <= 0)
+ throw new NotFoundException(__('Region not found'));
+
+ return $regions[0];
+ }
+
+ /**
+ * @param $ownerId
+ * @return Region[]
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Region[]
+ */
+ public function query($sortOrder = [], $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $params = [];
+ $sql = '
+ SELECT `region`.regionId,
+ `region`.layoutId,
+ `region`.ownerId,
+ `region`.name,
+ `region`.width,
+ `region`.height,
+ `region`.top,
+ `region`.left,
+ `region`.zIndex,
+ `region`.type,
+ `region`.duration,
+ `region`.isDrawer,
+ `region`.syncKey
+ ';
+
+ $sql .= '
+ FROM `region`
+ ';
+
+ $sql .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('regionId') != 0) {
+ $sql .= ' AND regionId = :regionId ';
+ $params['regionId'] = $sanitizedFilter->getInt('regionId');
+ }
+
+ if ($sanitizedFilter->getInt('layoutId') != 0) {
+ $sql .= ' AND layoutId = :layoutId ';
+ $params['layoutId'] = $sanitizedFilter->getInt('layoutId');
+ }
+
+ if ($sanitizedFilter->getInt('playlistId') !== null) {
+ $sql .= ' AND regionId IN (SELECT regionId FROM playlist WHERE playlistId = :playlistId) ';
+ $params['playlistId'] = $sanitizedFilter->getInt('playlistId');
+ }
+
+ if ($sanitizedFilter->getInt('isDrawer') !== null) {
+ $sql .= ' AND region.isDrawer = :isDrawer ';
+ $params['isDrawer'] = $sanitizedFilter->getInt('isDrawer');
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $sql .= ' AND region.ownerId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // Order by Name
+ $sql .= ' ORDER BY `region`.name ';
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->hydrate($this->createEmpty(), $row);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @param Region $region
+ * @param array $row
+ * @return Region
+ */
+ private function hydrate($region, $row)
+ {
+ return $region->hydrate($row, [
+ 'intProperties' => ['zIndex', 'duration', 'isDrawer'],
+ 'doubleProperties' => ['width', 'height', 'top', 'left']
+ ]);
+ }
+}
diff --git a/lib/Factory/RegionOptionFactory.php b/lib/Factory/RegionOptionFactory.php
new file mode 100644
index 0000000..09786b3
--- /dev/null
+++ b/lib/Factory/RegionOptionFactory.php
@@ -0,0 +1,89 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\RegionOption;
+
+/**
+ * Class RegionOptionFactory
+ * @package Xibo\Factory
+ */
+class RegionOptionFactory extends BaseFactory
+{
+ /**
+ * @return RegionOption
+ */
+ public function createEmpty()
+ {
+ return new RegionOption($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Load by Region Id
+ * @param int $regionId
+ * @return array[RegionOption]
+ */
+ public function getByRegionId($regionId)
+ {
+ return $this->query(null, array('regionId' => $regionId));
+ }
+
+ /**
+ * Create a region option
+ * @param int $regionId
+ * @param string $option
+ * @param mixed $value
+ * @return RegionOption
+ */
+ public function create($regionId, $option, $value)
+ {
+ $regionOption = $this->createEmpty();
+ $regionOption->regionId = $regionId;
+ $regionOption->option = $option;
+ $regionOption->value = $value;
+
+ return $regionOption;
+ }
+
+ /**
+ * Query Region options
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[RegionOption]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $sql = 'SELECT * FROM `regionoption` WHERE regionId = :regionId';
+
+ foreach ($this->getStore()->select($sql, array('regionId' => $sanitizedFilter->getInt('regionId'))) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/ReportScheduleFactory.php b/lib/Factory/ReportScheduleFactory.php
new file mode 100644
index 0000000..7b686be
--- /dev/null
+++ b/lib/Factory/ReportScheduleFactory.php
@@ -0,0 +1,205 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\ReportSchedule;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ReportScheduleFactory
+ * @package Xibo\Factory
+ */
+class ReportScheduleFactory extends BaseFactory
+{
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ /**
+ * Create Empty
+ * @return ReportSchedule
+ */
+ public function createEmpty()
+ {
+ return new ReportSchedule(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher()
+ );
+ }
+
+ /**
+ * Loads only the reportSchedule information
+ * @param int $reportScheduleId
+ * @param int $disableUserCheck
+ * @return ReportSchedule
+ * @throws NotFoundException
+ */
+ public function getById($reportScheduleId, $disableUserCheck = 0)
+ {
+
+ if ($reportScheduleId == 0) {
+ throw new NotFoundException();
+ }
+
+ $reportSchedules = $this->query(null, ['reportScheduleId' => $reportScheduleId, 'disableUserCheck' => $disableUserCheck]);
+
+ if (count($reportSchedules) <= 0) {
+ throw new NotFoundException(\__('Report Schedule not found'));
+ }
+
+ // Set our reportSchedule
+ return $reportSchedules[0];
+ }
+
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return ReportSchedule[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder == null) {
+ $sortOrder = ['name'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+ $select = '
+ SELECT
+ reportschedule.reportScheduleId,
+ reportschedule.name,
+ reportschedule.lastSavedReportId,
+ reportschedule.reportName,
+ reportschedule.filterCriteria,
+ reportschedule.schedule,
+ reportschedule.lastRunDt,
+ reportschedule.previousRunDt,
+ reportschedule.createdDt,
+ reportschedule.userId,
+ reportschedule.fromDt,
+ reportschedule.toDt,
+ reportschedule.isActive,
+ reportschedule.message,
+ `user`.UserName AS owner
+ ';
+
+ $body = ' FROM `reportschedule` ';
+
+ $body .= ' LEFT OUTER JOIN `user` ON `user`.userId = `reportschedule`.userId ';
+
+ $body .= ' WHERE 1 = 1 ';
+
+ // Like
+ if ($sanitizedFilter->getString('name') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('name'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'reportschedule',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getInt('reportScheduleId', ['default' => 0]) != 0) {
+ $params['reportScheduleId'] = $sanitizedFilter->getInt('reportScheduleId', ['default' => 0]);
+ $body .= ' AND reportschedule.reportScheduleId = :reportScheduleId ';
+ }
+
+ // Owner filter
+ if ($sanitizedFilter->getInt('userId', ['default' => 0]) != 0) {
+ $body .= ' AND reportschedule.userid = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId', ['default' => 0]);
+ }
+
+ if ($sanitizedFilter->getCheckbox('onlyMySchedules') == 1) {
+ $body .= ' AND reportschedule.userid = :currentUserId ';
+ $params['currentUserId'] = $this->getUser()->userId;
+ }
+
+ // Report Name
+ if ($sanitizedFilter->getString('reportName') != '') {
+ $body .= ' AND reportschedule.reportName = :reportName ';
+ $params['reportName'] = $sanitizedFilter->getString('reportName');
+ }
+
+ // isActive
+ if ($sanitizedFilter->getInt('isActive') !== null) {
+ $body .= ' AND reportschedule.isActive = :isActive ';
+ $params['isActive'] = $sanitizedFilter->getInt('isActive');
+ }
+
+ // View Permissions
+ if ($this->getUser()->userTypeId != 1) {
+ $this->viewPermissionSql('Xibo\Entity\ReportSchedule', $body, $params, '`reportschedule`.reportScheduleId', '`reportschedule`.userId', $filterBy);
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'reportScheduleId', 'lastRunDt', 'previousRunDt', 'lastSavedReportId', 'isActive', 'fromDt', 'toDt'
+ ]
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/RequiredFileFactory.php b/lib/Factory/RequiredFileFactory.php
new file mode 100644
index 0000000..3d6eb3d
--- /dev/null
+++ b/lib/Factory/RequiredFileFactory.php
@@ -0,0 +1,419 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\RequiredFile;
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Xmds\Entity\Dependency;
+
+/**
+ * Class RequiredFileFactory
+ * @package Xibo\Factory
+ */
+class RequiredFileFactory extends BaseFactory
+{
+ private $statement = null;
+
+ private $hydrate = [
+ 'intProperties' => ['bytesRequested', 'complete'],
+ 'stringProperties' => ['realId'],
+ ];
+
+ /**
+ * @return RequiredFile
+ */
+ public function createEmpty()
+ {
+ return new RequiredFile($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * @param array $params
+ * @return RequiredFile[]
+ */
+ private function query($params)
+ {
+ $files = [];
+
+ if ($this->statement === null) {
+ $this->statement = $this->getStore()->getConnection()->prepare('
+ SELECT *
+ FROM `requiredfile`
+ WHERE `displayId` = :displayId
+ AND `type` = :type
+ AND `itemId` = :itemId
+ ');
+ }
+
+ $this->statement->execute($params);
+
+ foreach ($this->statement->fetchAll(\PDO::FETCH_ASSOC) as $item) {
+ $files[] = $this->createEmpty()->hydrate($item, $this->hydrate);
+ }
+
+ return $files;
+ }
+
+ /**
+ * @param int $displayId
+ * @param int $layoutId
+ * @return RequiredFile
+ * @throws NotFoundException
+ */
+ public function getByDisplayAndLayout($displayId, $layoutId)
+ {
+ $result = $this->query(['displayId' => $displayId, 'type' => 'L', 'itemId' => $layoutId]);
+
+ if (count($result) <= 0)
+ throw new NotFoundException(__('Required file not found for Display and Layout Combination'));
+
+ return $result[0];
+ }
+
+ /**
+ * @param int $displayId
+ * @param int $mediaId
+ * @return RequiredFile
+ * @throws NotFoundException
+ */
+ public function getByDisplayAndMedia($displayId, $mediaId, $type = 'M')
+ {
+ $result = $this->query(['displayId' => $displayId, 'type' => $type, 'itemId' => $mediaId]);
+
+ if (count($result) <= 0)
+ throw new NotFoundException(__('Required file not found for Display and Media Combination'));
+
+ return $result[0];
+ }
+
+ /**
+ * @param int $displayId
+ * @param int $widgetId
+ * @param string $type The type of widget, either W (widget html) or D (data)
+ * @return RequiredFile
+ * @throws NotFoundException
+ */
+ public function getByDisplayAndWidget($displayId, $widgetId, $type = 'W')
+ {
+ $result = $this->query(['displayId' => $displayId, 'type' => $type, 'itemId' => $widgetId]);
+
+ if (count($result) <= 0) {
+ throw new NotFoundException(__('Required file not found for Display and Layout Widget'));
+ }
+
+ return $result[0];
+ }
+
+ /**
+ * @param int $displayId
+ * @param string $fileType The file type of this dependency
+ * @param int $id The ID of this dependency
+ * @param bool $isUseRealId Should we use the realId as a lookup?
+ * @return RequiredFile
+ * @throws NotFoundException
+ */
+ public function getByDisplayAndDependency($displayId, $fileType, $id, bool $isUseRealId = true): RequiredFile
+ {
+ if (!$isUseRealId && $id < 0) {
+ $fileType = self::getLegacyFileType($id);
+ }
+
+ $result = $this->getStore()->select('
+ SELECT *
+ FROM `requiredfile`
+ WHERE `displayId` = :displayId
+ AND `type` = :type
+ AND `fileType` = :fileType
+ AND `' . ($isUseRealId ? 'realId' : 'itemId') . '` = :itemId
+ ', [
+ 'displayId' => $displayId,
+ 'type' => 'P',
+ 'fileType' => $fileType,
+ 'itemId' => $id,
+ ]);
+
+ if (count($result) <= 0) {
+ throw new NotFoundException(__('Required file not found for Display and Dependency'));
+ }
+
+ return $this->createEmpty()->hydrate($result[0], $this->hydrate);
+ }
+
+ /**
+ * Return the fileType depending on the legacyId range
+ * @param $id
+ * @return string
+ */
+ private static function getLegacyFileType($id): string
+ {
+ return match (true) {
+ $id < 0 && $id > Dependency::LEGACY_ID_OFFSET_FONT * -1 => 'bundle',
+ $id === Dependency::LEGACY_ID_OFFSET_FONT * -1 => 'fontCss',
+ $id < Dependency::LEGACY_ID_OFFSET_FONT * -1
+ && $id > Dependency::LEGACY_ID_OFFSET_PLAYER_SOFTWARE * -1 => 'font',
+ $id < Dependency::LEGACY_ID_OFFSET_PLAYER_SOFTWARE * -1
+ && $id > Dependency::LEGACY_ID_OFFSET_ASSET * -1 => 'playersoftware',
+ $id < Dependency::LEGACY_ID_OFFSET_PLAYER_SOFTWARE * -1 => 'asset',
+ };
+ }
+
+ /**
+ * @param int $displayId
+ * @param string $path The path of this dependency
+ * @return RequiredFile
+ * @throws NotFoundException
+ */
+ public function getByDisplayAndDependencyPath($displayId, $path)
+ {
+ $result = $this->getStore()->select('
+ SELECT *
+ FROM `requiredfile`
+ WHERE `displayId` = :displayId
+ AND `type` = :type
+ AND `path` = :path
+ ', [
+ 'displayId' => $displayId,
+ 'type' => 'P',
+ 'path' => $path
+ ]);
+
+ if (count($result) <= 0) {
+ throw new NotFoundException(__('Required file not found for Display and Path'));
+ }
+
+ return $this->createEmpty()->hydrate($result[0], $this->hydrate);
+ }
+
+ /**
+ * @param int $displayId
+ * @param string $id The itemId of this dependency
+ * @return RequiredFile
+ * @throws NotFoundException
+ */
+ public function getByDisplayAndDependencyId($displayId, $id)
+ {
+ $result = $this->getStore()->select('
+ SELECT *
+ FROM `requiredfile`
+ WHERE `displayId` = :displayId
+ AND `type` = :type
+ AND `itemId` = :itemId
+ ', [
+ 'displayId' => $displayId,
+ 'type' => 'P',
+ 'itemId' => $id
+ ]);
+
+ if (count($result) <= 0) {
+ throw new NotFoundException(__('Required file not found for Display and Dependency ID'));
+ }
+
+ return $this->createEmpty()->hydrate($result[0], $this->hydrate);
+ }
+
+ /**
+ * Create for layout
+ * @param $displayId
+ * @param $layoutId
+ * @param $size
+ * @param $path
+ * @return RequiredFile
+ */
+ public function createForLayout($displayId, $layoutId, $size, $path)
+ {
+ try {
+ $requiredFile = $this->getByDisplayAndLayout($displayId, $layoutId);
+ }
+ catch (NotFoundException $e) {
+ $requiredFile = $this->createEmpty();
+ }
+
+ $requiredFile->displayId = $displayId;
+ $requiredFile->type = 'L';
+ $requiredFile->itemId = $layoutId;
+ $requiredFile->size = $size;
+ $requiredFile->path = $path;
+ return $requiredFile;
+ }
+
+ /**
+ * Create for Get Resource
+ * @param $displayId
+ * @param $widgetId
+ * @return RequiredFile
+ */
+ public function createForGetResource($displayId, $widgetId)
+ {
+ try {
+ $requiredFile = $this->getByDisplayAndWidget($displayId, $widgetId);
+ }
+ catch (NotFoundException $e) {
+ $requiredFile = $this->createEmpty();
+ }
+
+ $requiredFile->displayId = $displayId;
+ $requiredFile->type = 'W';
+ $requiredFile->itemId = $widgetId;
+ return $requiredFile;
+ }
+
+ /**
+ * Create for Get Data
+ * @param $displayId
+ * @param $widgetId
+ * @return RequiredFile
+ */
+ public function createForGetData($displayId, $widgetId): RequiredFile
+ {
+ try {
+ $requiredFile = $this->getByDisplayAndWidget($displayId, $widgetId, 'D');
+ } catch (NotFoundException $e) {
+ $requiredFile = $this->createEmpty();
+ }
+
+ $requiredFile->displayId = $displayId;
+ $requiredFile->type = 'D';
+ $requiredFile->itemId = $widgetId;
+ return $requiredFile;
+ }
+
+ /**
+ * Create for Get Dependency
+ * @param $displayId
+ * @param $fileType
+ * @param $id
+ * @param string|int $realId
+ * @param $path
+ * @param int $size
+ * @param bool $isUseRealId
+ * @return RequiredFile
+ */
+ public function createForGetDependency(
+ $displayId,
+ $fileType,
+ $id,
+ $realId,
+ $path,
+ int $size,
+ bool $isUseRealId = true
+ ): RequiredFile {
+ try {
+ $requiredFile = $this->getByDisplayAndDependency(
+ $displayId,
+ $fileType,
+ $isUseRealId ? $realId : $id,
+ $isUseRealId,
+ );
+ } catch (NotFoundException) {
+ $requiredFile = $this->createEmpty();
+ }
+
+ $requiredFile->displayId = $displayId;
+ $requiredFile->type = 'P';
+ $requiredFile->itemId = $id;
+ $requiredFile->fileType = $fileType;
+ $requiredFile->realId = $realId;
+ $requiredFile->path = $path;
+ $requiredFile->size = $size;
+ return $requiredFile;
+ }
+
+ /**
+ * Create for Media
+ * @param $displayId
+ * @param $mediaId
+ * @param $size
+ * @param $path
+ * @param $released
+ * @return RequiredFile
+ */
+ public function createForMedia($displayId, $mediaId, $size, $path, $released)
+ {
+ try {
+ $requiredFile = $this->getByDisplayAndMedia($displayId, $mediaId);
+ } catch (NotFoundException $e) {
+ $requiredFile = $this->createEmpty();
+ }
+
+ $requiredFile->displayId = $displayId;
+ $requiredFile->type = 'M';
+ $requiredFile->itemId = $mediaId;
+ $requiredFile->size = $size;
+ $requiredFile->path = $path;
+ $requiredFile->released = $released;
+ return $requiredFile;
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function resolveRequiredFileFromRequest($request): RequiredFile
+ {
+ $params = $this->getSanitizer($request);
+ $displayId = $params->getInt('displayId');
+
+ switch ($params->getString('type')) {
+ case 'L':
+ $itemId = $params->getInt('itemId');
+ $file = $this->getByDisplayAndLayout($displayId, $itemId);
+ break;
+
+ case 'M':
+ $itemId = $params->getInt('itemId');
+ $file = $this->getByDisplayAndMedia($displayId, $itemId);
+ break;
+
+ case 'P':
+ $itemId = $params->getString('itemId');
+ $fileType = $params->getString('fileType');
+ if (empty($fileType)) {
+ throw new NotFoundException(__('Missing fileType'));
+ }
+
+ // HTTP links will always have the realId
+ $file = $this->getByDisplayAndDependency(
+ $displayId,
+ $fileType,
+ $itemId,
+ );
+
+ // Update $file->path with the path on disk (likely /assets/$itemId)
+ $event = new XmdsDependencyRequestEvent($file);
+ $this->getDispatcher()->dispatch($event, XmdsDependencyRequestEvent::$NAME);
+
+ // Path should be set - we only want the relative path here.
+ $file->path = $event->getRelativePath();
+ if (empty($file->path)) {
+ throw new NotFoundException(__('File not found'));
+ }
+ break;
+
+ default:
+ throw new NotFoundException(__('Unknown type'));
+ }
+
+ return $file;
+ }
+}
diff --git a/lib/Factory/ResolutionFactory.php b/lib/Factory/ResolutionFactory.php
new file mode 100644
index 0000000..bf42887
--- /dev/null
+++ b/lib/Factory/ResolutionFactory.php
@@ -0,0 +1,266 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\Resolution;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ResolutionFactory
+ * @package Xibo\Factory
+ */
+class ResolutionFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return Resolution
+ */
+ public function createEmpty()
+ {
+ return new Resolution($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create Resolution
+ * @param $resolutionName
+ * @param $width
+ * @param $height
+ * @return Resolution
+ */
+ public function create($resolutionName, $width, $height)
+ {
+ $resolution = $this->createEmpty();
+ $resolution->resolution = $resolutionName;
+ $resolution->width = $width;
+ $resolution->height = $height;
+
+ return $resolution;
+ }
+
+ /**
+ * Load the Resolution by ID
+ * @param int $resolutionId
+ * @return Resolution
+ * @throws NotFoundException
+ */
+ public function getById($resolutionId)
+ {
+ $resolutions = $this->query(null, array('disableUserCheck' => 1, 'resolutionId' => $resolutionId));
+
+ if (count($resolutions) <= 0)
+ throw new NotFoundException(null, 'Resolution');
+
+ return $resolutions[0];
+ }
+
+ /**
+ * Get Resolution by Dimensions
+ * @param double $width
+ * @param double $height
+ * @return Resolution
+ * @throws NotFoundException
+ */
+ public function getByDimensions($width, $height)
+ {
+ $resolutions = $this->query(null, array('disableUserCheck' => 1, 'width' => $width, 'height' => $height));
+
+ if (count($resolutions) <= 0)
+ throw new NotFoundException(__('Resolution not found'));
+
+ return $resolutions[0];
+ }
+
+ /**
+ * @param $width
+ * @param $height
+ * @return Resolution
+ * @throws NotFoundException
+ */
+ public function getClosestMatchingResolution($width, $height): Resolution
+ {
+ $area = $width * $height;
+ $sort = ['ABS(' . $area . ' - (`intended_width` * `intended_height`))'];
+ $sort[] = $width > $height ? '`intended_width` DESC' : '`intended_height` DESC';
+
+ $resolutions = $this->query(
+ $sort,
+ [
+ 'disableUserCheck' => 1,
+ 'enabled' => 1,
+ 'start' => 0,
+ 'length' => 1
+ ]
+ );
+
+ if (count($resolutions) <= 0) {
+ throw new NotFoundException(__('Resolution not found'));
+ }
+
+ return $resolutions[0];
+ }
+
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ /**
+ * Get Resolution by Dimensions
+ * @param double $width
+ * @param double $height
+ * @return Resolution
+ * @throws NotFoundException
+ */
+ public function getByDesignerDimensions($width, $height)
+ {
+ $resolutions = $this->query(null, array('disableUserCheck' => 1, 'designerWidth' => $width, 'designerHeight' => $height));
+
+ if (count($resolutions) <= 0)
+ throw new NotFoundException(__('Resolution not found'));
+
+ return $resolutions[0];
+ }
+
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder === null) {
+ $sortOrder = ['resolution'];
+ }
+
+ $entities = [];
+
+ $params = [];
+ $select = '
+ SELECT `resolution`.resolutionId,
+ `resolution`.resolution,
+ `resolution`.intended_width AS width,
+ `resolution`.intended_height AS height,
+ `resolution`.width AS designerWidth,
+ `resolution`.height AS designerHeight,
+ `resolution`.version,
+ `resolution`.enabled,
+ `resolution`.userId
+ ';
+
+ $body = '
+ FROM `resolution`
+ WHERE 1 = 1
+ ';
+
+ if ($parsedFilter->getInt('enabled', ['default' => -1]) != -1
+ && $parsedFilter->getInt('withCurrent') !== null
+ ) {
+ $body .= ' AND ( enabled = :enabled OR `resolution`.resolutionId = :withCurrent) ';
+ $params['enabled'] = $parsedFilter->getInt('enabled');
+ $params['withCurrent'] = $parsedFilter->getInt('withCurrent');
+ }
+
+ if ($parsedFilter->getInt('enabled', ['default' => -1]) != -1
+ && $parsedFilter->getInt('withCurrent') === null
+ ) {
+ $body .= ' AND enabled = :enabled ';
+ $params['enabled'] = $parsedFilter->getInt('enabled');
+ }
+
+ if ($parsedFilter->getInt('resolutionId') !== null) {
+ $body .= ' AND resolutionId = :resolutionId ';
+ $params['resolutionId'] = $parsedFilter->getInt('resolutionId');
+ }
+
+ if ($parsedFilter->getString('resolution') != null) {
+ $body .= ' AND resolution = :resolution ';
+ $params['resolution'] = $parsedFilter->getString('resolution');
+ }
+
+ if ($parsedFilter->getString('partialResolution') != null) {
+ $body .= ' AND resolution LIKE :partialResolution ';
+ $params['partialResolution'] = '%' . $parsedFilter->getString('partialResolution') . '%';
+ }
+
+ if ($parsedFilter->getDouble('width') !== null) {
+ $body .= ' AND intended_width = :width ';
+ $params['width'] = $parsedFilter->getDouble('width');
+ }
+
+ if ($parsedFilter->getDouble('height') !== null) {
+ $body .= ' AND intended_height = :height ';
+ $params['height'] = $parsedFilter->getDouble('height');
+ }
+
+ if ($parsedFilter->getDouble('designerWidth') !== null) {
+ $body .= ' AND width = :designerWidth ';
+ $params['designerWidth'] = $parsedFilter->getDouble('designerWidth');
+ }
+
+ if ($parsedFilter->getDouble('designerHeight') !== null) {
+ $body .= ' AND height = :designerHeight ';
+ $params['designerHeight'] = $parsedFilter->getDouble('designerHeight');
+ }
+
+ if ($parsedFilter->getString('orientation') !== null) {
+ if ($parsedFilter->getString('orientation') === 'portrait') {
+ $body .= ' AND intended_width <= intended_height ';
+ } else {
+ $body .= ' AND intended_width > intended_height ';
+ }
+ }
+
+ if ($parsedFilter->getInt('userId') !== null) {
+ $body .= ' AND `resolution`.userId = :userId ';
+ $params['userId'] = $parsedFilter->getInt('userId');
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $parsedFilter->getInt('start') !== null && $parsedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0]) . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+
+
+ foreach($this->getStore()->select($sql, $params) as $record) {
+ $entities[] = $this->createEmpty()->hydrate($record, ['intProperties' => ['width', 'height', 'version', 'enabled']]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entities) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entities;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/SavedReportFactory.php b/lib/Factory/SavedReportFactory.php
new file mode 100644
index 0000000..20df365
--- /dev/null
+++ b/lib/Factory/SavedReportFactory.php
@@ -0,0 +1,270 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\SavedReport;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class SavedReportFactory
+ * @package Xibo\Factory
+ */
+class SavedReportFactory extends BaseFactory
+{
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ * @param MediaFactory $mediaFactory
+ */
+ public function __construct($user, $userFactory, $config, $mediaFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->config = $config;
+ $this->mediaFactory = $mediaFactory;
+ }
+
+ /**
+ * Create Empty
+ * @return SavedReport
+ */
+ public function createEmpty()
+ {
+ return new SavedReport(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this->mediaFactory,
+ $this
+ );
+ }
+
+ /**
+ * Populate Saved Report table
+ * @param string $saveAs
+ * @param int $reportScheduleId
+ * @param int $generatedOn
+ * @param int $userId
+ * @return SavedReport
+ */
+ public function create(
+ $saveAs,
+ $reportScheduleId,
+ $generatedOn,
+ $userId,
+ $fileName,
+ $size,
+ $md5)
+ {
+ $savedReport = $this->createEmpty();
+ $savedReport->saveAs = $saveAs;
+ $savedReport->reportScheduleId = $reportScheduleId;
+ $savedReport->generatedOn = $generatedOn;
+ $savedReport->userId = $userId;
+ $savedReport->fileName = $fileName;
+ $savedReport->size = $size;
+ $savedReport->md5 = $md5;
+ $savedReport->save();
+
+ return $savedReport;
+ }
+
+ /**
+ * Get by Version Id
+ * @param int $savedReportId
+ * @return SavedReport
+ * @throws NotFoundException
+ */
+ public function getById($savedReportId)
+ {
+ $savedReports = $this->query(null, array('disableUserCheck' => 1, 'savedReportId' => $savedReportId));
+
+ if (count($savedReports) <= 0) {
+ throw new NotFoundException(__('Cannot find saved report'));
+ }
+
+ return $savedReports[0];
+ }
+
+ /**
+ * @param $ownerId
+ * @return SavedReport[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return SavedReport[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['generatedOn DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT
+ saved_report.reportScheduleId,
+ saved_report.savedReportId,
+ saved_report.saveAs,
+ saved_report.userId,
+ saved_report.schemaVersion,
+ saved_report.fileName,
+ saved_report.size,
+ saved_report.md5,
+ reportschedule.name AS reportScheduleName,
+ reportschedule.reportName,
+ saved_report.generatedOn,
+ `user`.UserName AS owner
+ ';
+
+ $body = ' FROM saved_report
+ INNER JOIN reportschedule
+ ON saved_report.reportScheduleId = reportschedule.reportScheduleId
+ ';
+
+ // Media might be linked to the system user (userId 0)
+ $body .= " LEFT OUTER JOIN `user` ON `user`.userId = `saved_report`.userId ";
+
+ $body .= " WHERE 1 = 1 ";
+
+ // Like
+ if ($sanitizedFilter->getString('saveAs') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('saveAs'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'saved_report',
+ 'saveAs',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getInt('savedReportId', ['default' => -1]) != -1) {
+ $body .= " AND saved_report.savedReportId = :savedReportId ";
+ $params['savedReportId'] = $sanitizedFilter->getInt('savedReportId');
+ }
+
+ if ($sanitizedFilter->getInt('reportScheduleId') != '') {
+ $body .= " AND saved_report.reportScheduleId = :reportScheduleId ";
+ $params['reportScheduleId'] = $sanitizedFilter->getInt('reportScheduleId');
+ }
+
+ if ($sanitizedFilter->getInt('generatedOn') != '') {
+ $body .= " AND saved_report.generatedOn = :generatedOn ";
+ $params['generatedOn'] = $sanitizedFilter->getInt('generatedOn');
+ }
+
+ // Owner filter
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `saved_report`.userId = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // Report name
+ if ($sanitizedFilter->getString('reportName') != '') {
+ $body .= " AND reportschedule.reportName = :reportName ";
+ $params['reportName'] = $sanitizedFilter->getString('reportName');
+ }
+
+ // User Group filter
+ if ($sanitizedFilter->getInt('ownerUserGroupId', ['default' => 0]) != 0) {
+ $body .= ' AND `saved_report`.userId IN (SELECT DISTINCT userId FROM `lkusergroup` WHERE groupId = :ownerUserGroupId) ';
+ $params['ownerUserGroupId'] = $sanitizedFilter->getInt('ownerUserGroupId', ['default' => 0]);
+ }
+
+ if ($sanitizedFilter->getCheckbox('onlyMyReport') == 1) {
+ $body .= ' AND `saved_report`.userId = :currentUserId ';
+ $params['currentUserId'] = $this->getUser()->userId;
+ }
+
+ // View Permissions
+ $this->viewPermissionSql('Xibo\Entity\SavedReport', $body, $params, '`saved_report`.savedReportId', '`saved_report`.userId', $filterBy);
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $version = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'reportScheduleId', 'generatedOn', 'schemaVersion', 'size'
+ ]
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Calculate the sum of the size column for all rows and count the total number of rows in the table.
+ */
+ public function getSizeAndCount()
+ {
+ return $this->getStore()->select('SELECT IFNULL(SUM(size), 0) AS SumSize, COUNT(*) AS totalCount FROM `saved_report`', [])[0];
+ }
+}
diff --git a/lib/Factory/ScheduleCriteriaFactory.php b/lib/Factory/ScheduleCriteriaFactory.php
new file mode 100644
index 0000000..253c50e
--- /dev/null
+++ b/lib/Factory/ScheduleCriteriaFactory.php
@@ -0,0 +1,54 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\ScheduleCriteria;
+
+/**
+ * Factory to return schedule criteria entities
+ */
+class ScheduleCriteriaFactory extends BaseFactory
+{
+ public function createEmpty(): ScheduleCriteria
+ {
+ return new ScheduleCriteria($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get all criteria for an event
+ * @param int $eventId
+ * @return ScheduleCriteria[]
+ */
+ public function getByEventId(int $eventId): array
+ {
+ $entries = [];
+
+ foreach ($this->getStore()->select('SELECT * FROM `schedule_criteria` WHERE `eventId` = :eventId', [
+ 'eventId' => $eventId,
+ ]) as $row) {
+ // Create and hydrate
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+ return $entries;
+ }
+}
diff --git a/lib/Factory/ScheduleExclusionFactory.php b/lib/Factory/ScheduleExclusionFactory.php
new file mode 100644
index 0000000..e322ee6
--- /dev/null
+++ b/lib/Factory/ScheduleExclusionFactory.php
@@ -0,0 +1,88 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\ScheduleExclusion;
+
+/**
+ * Class ScheduleExclusionFactory
+ * @package Xibo\Factory
+ */
+class ScheduleExclusionFactory extends BaseFactory
+{
+ /**
+ * Load by Event Id
+ * @param int $eventId
+ * @return array[ScheduleExclusion]
+ */
+ public function getByEventId($eventId)
+ {
+ return $this->query(null, array('eventId' => $eventId));
+ }
+
+ /**
+ * Create Empty
+ * @return ScheduleExclusion
+ */
+ public function createEmpty()
+ {
+ return new ScheduleExclusion($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create a schedule exclusion
+ * @param int $eventId
+ * @param int $fromDt
+ * @param int $toDt
+ * @return ScheduleExclusion
+ */
+ public function create($eventId, $fromDt, $toDt)
+ {
+ $scheduleExclusion = $this->createEmpty();
+ $scheduleExclusion->eventId = $eventId;
+ $scheduleExclusion->fromDt = $fromDt;
+ $scheduleExclusion->toDt = $toDt;
+
+ return $scheduleExclusion;
+ }
+
+ /**
+ * Query Schedule exclusions
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[ScheduleExclusion]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $sql = 'SELECT * FROM `scheduleexclusions` WHERE eventId = :eventId';
+
+ foreach ($this->getStore()->select($sql, array('eventId' => $sanitizedFilter->getInt('eventId'))) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/ScheduleFactory.php b/lib/Factory/ScheduleFactory.php
new file mode 100644
index 0000000..f06092a
--- /dev/null
+++ b/lib/Factory/ScheduleFactory.php
@@ -0,0 +1,822 @@
+.
+ */
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Schedule;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ScheduleFactory
+ * @package Xibo\Factory
+ */
+class ScheduleFactory extends BaseFactory
+{
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var ScheduleReminderFactory */
+ private $scheduleReminderFactory;
+
+ /** @var ScheduleExclusionFactory */
+ private $scheduleExclusionFactory;
+
+ /**
+ * Construct a factory
+ * @param ConfigServiceInterface $config
+ * @param PoolInterface $pool
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param DayPartFactory $dayPartFactory
+ * @param UserFactory $userFactory
+ * @param ScheduleReminderFactory $scheduleReminderFactory
+ * @param ScheduleExclusionFactory $scheduleExclusionFactory
+ * @param User $user
+ */
+ public function __construct(
+ $config,
+ $pool,
+ $displayGroupFactory,
+ $dayPartFactory,
+ $userFactory,
+ $scheduleReminderFactory,
+ $scheduleExclusionFactory,
+ $user,
+ private readonly ScheduleCriteriaFactory $scheduleCriteriaFactory
+ ) {
+ $this->setAclDependencies($user, $userFactory);
+ $this->config = $config;
+ $this->pool = $pool;
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ $this->userFactory = $userFactory;
+ $this->scheduleReminderFactory = $scheduleReminderFactory;
+ $this->scheduleExclusionFactory = $scheduleExclusionFactory;
+ }
+
+ /**
+ * Create Empty
+ * @return Schedule
+ */
+ public function createEmpty()
+ {
+ return new Schedule(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->config,
+ $this->pool,
+ $this->displayGroupFactory,
+ $this->dayPartFactory,
+ $this->userFactory,
+ $this->scheduleReminderFactory,
+ $this->scheduleExclusionFactory,
+ $this->scheduleCriteriaFactory
+ );
+ }
+
+ /**
+ * @param int $eventId
+ * @return Schedule
+ * @throws NotFoundException
+ */
+ public function getById($eventId)
+ {
+ $events = $this->query(null, ['disableUserCheck' => 1, 'eventId' => $eventId]);
+
+ if (count($events) <= 0)
+ throw new NotFoundException();
+
+ return $events[0];
+ }
+
+ /**
+ * @param int $displayGroupId
+ * @return array[Schedule]
+ */
+ public function getByDisplayGroupId($displayGroupId)
+ {
+ if ($displayGroupId == null) {
+ return [];
+ }
+
+ return $this->query(null, ['disableUserCheck' => 1, 'displayGroupIds' => [$displayGroupId]]);
+ }
+
+ /**
+ * Get by Campaign ID
+ * @param int $campaignId
+ * @return array[Schedule]
+ */
+ public function getByCampaignId($campaignId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'campaignId' => $campaignId]);
+ }
+
+ public function getByParentCampaignId($campaignId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'parentCampaignId' => $campaignId]);
+ }
+
+ /**
+ * Get by OwnerId
+ * @param int $ownerId
+ * @return array[Schedule]
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'ownerId' => $ownerId]);
+ }
+
+ /**
+ * Get by DayPartId
+ * @param int $dayPartId
+ * @return Schedule[]
+ */
+ public function getByDayPartId($dayPartId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'dayPartId' => $dayPartId]);
+ }
+
+ /**
+ * @param $syncGroupId
+ * @return Schedule[]
+ */
+ public function getBySyncGroupId($syncGroupId): array
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'syncGroupId' => $syncGroupId]);
+ }
+
+ /**
+ * @param int $displayId
+ * @param Carbon $fromDt
+ * @param Carbon $toDt
+ * @param array $options
+ * @return array
+ */
+ public function getForXmds($displayId, $fromDt, $toDt, $options = [])
+ {
+ $options = array_merge(['useGroupId' => false], $options);
+
+ // We dial the fromDt back to the top of the day, so that we include dayPart events that start on this
+ // day
+ $params = array(
+ 'fromDt' => $fromDt->copy()->startOfDay()->format('U'),
+ 'toDt' => $toDt->format('U')
+ );
+
+ $this->getLog()->debug('Get events for XMDS: fromDt[' . $params['fromDt'] . '], toDt[' . $params['toDt'] . '], with options: ' . json_encode($options));
+
+ // Add file nodes to the $fileElements
+ // Firstly get all the scheduled layouts
+ $SQL = '
+ SELECT `schedule`.eventTypeId,
+ `layoutLinks`.layoutId,
+ `layoutLinks`.status,
+ `layoutLinks`.duration,
+ `command`.code,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.recurrenceMonthlyRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.eventId,
+ schedule.is_priority AS isPriority,
+ `schedule`.displayOrder,
+ schedule.dayPartId,
+ `schedule`.campaignId,
+ `schedule`.commandId,
+ schedule.syncTimezone,
+ schedule.syncEvent,
+ schedule.shareOfVoice,
+ schedule.maxPlaysPerHour,
+ schedule.isGeoAware,
+ schedule.geoLocation,
+ schedule.actionTriggerCode,
+ schedule.actionType,
+ schedule.actionLayoutCode,
+ schedule.syncGroupId,
+ `campaign`.campaign,
+ `campaign`.campaignId as groupKey,
+ `campaign`.cyclePlaybackEnabled as cyclePlayback,
+ `campaign`.playCount,
+ `command`.command,
+ `lkscheduledisplaygroup`.displayGroupId,
+ `daypart`.isAlways,
+ `daypart`.isCustom,
+ `syncLayout`.layoutId AS syncLayoutId,
+ `syncLayout`.status AS syncLayoutStatus,
+ `syncLayout`.duration AS syncLayoutDuration,
+ `schedule`.dataSetId,
+ `schedule`.dataSetParams
+ FROM `schedule`
+ INNER JOIN `daypart`
+ ON `daypart`.dayPartId = `schedule`.dayPartId
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ ';
+
+ if (!$options['useGroupId']) {
+ // Only join in the display/display group link table if we are requesting this data for a display
+ // otherwise the group we are looking for might not have any displays, and this join would therefore
+ // remove any records.
+ $SQL .= '
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ ';
+ }
+
+ $SQL .= '
+ LEFT OUTER JOIN `campaign`
+ ON `schedule`.CampaignID = campaign.CampaignID
+ LEFT OUTER JOIN (
+ SELECT `layout`.layoutId,
+ `layout`.status,
+ `layout`.duration,
+ `lkcampaignlayout`.campaignId,
+ `lkcampaignlayout`.displayOrder
+ FROM `layout`
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.LayoutID = `layout`.layoutId
+ WHERE `layout`.retired = 0
+ AND `layout`.parentId IS NULL
+ ) layoutLinks
+ ON `campaign`.CampaignID = `layoutLinks`.campaignId
+ LEFT OUTER JOIN `command`
+ ON `command`.commandId = `schedule`.commandId
+ LEFT OUTER JOIN `schedule_sync`
+ ON `schedule_sync`.eventId = `schedule`.eventId';
+
+ // do this only if we have a Display.
+ if (!$options['useGroupId']) {
+ $SQL .= ' AND `schedule_sync`.displayId = :displayId';
+ }
+
+ $SQL .= ' LEFT OUTER JOIN `layout` syncLayout
+ ON `syncLayout`.layoutId = `schedule_sync`.layoutId
+ AND `syncLayout`.retired = 0
+ AND `syncLayout`.parentId IS NULL
+ ';
+
+ if ($options['useGroupId']) {
+ $SQL .= ' WHERE `lkdgdg`.childId = :displayGroupId ';
+ $params['displayGroupId'] = $options['displayGroupId'];
+ } else {
+ $SQL .= ' WHERE `lkdisplaydg`.DisplayID = :displayId ';
+ $params['displayId'] = $displayId;
+ }
+
+ // Are we requesting a range or a single date/time?
+ // only the inclusive range changes, but it is clearer to have the whole statement reprinted.
+ // Ranged request
+ $SQL .= '
+ AND (
+ (schedule.FromDT <= :toDt AND IFNULL(`schedule`.toDt, `schedule`.fromDt) >= :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+
+ ORDER BY `schedule`.DisplayOrder,
+ CASE WHEN `campaign`.listPlayOrder = \'block\' THEN `schedule`.FromDT ELSE 0 END,
+ CASE WHEN `campaign`.listPlayOrder = \'block\' THEN `campaign`.campaignId ELSE 0 END,
+ IFNULL(`layoutLinks`.displayOrder, 0),
+ `schedule`.FromDT,
+ `schedule`.eventId
+ ';
+
+ return $this->getStore()->select($SQL, $params);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Schedule[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+
+ if (is_array($sortOrder)) {
+ $newSortOrder = [];
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`recurringEvent`') {
+ $newSortOrder[] = '`recurrence_type`';
+ continue;
+ }
+
+ if ($sort == '`recurringEvent` DESC') {
+ $newSortOrder[] = '`recurrence_type` DESC';
+ continue;
+ }
+
+ if ($sort == '`icon`') {
+ $newSortOrder[] = '`eventTypeId`';
+ continue;
+ }
+
+ if ($sort == '`icon` DESC') {
+ $newSortOrder[] = '`eventTypeId` DESC';
+ continue;
+ }
+
+ $newSortOrder[] = $sort;
+ }
+ $sortOrder = $newSortOrder;
+ }
+
+ $select = '
+ SELECT `schedule`.eventId,
+ `schedule`.eventTypeId,
+ `schedule`.fromDt,
+ `schedule`.toDt,
+ `schedule`.userId,
+ `schedule`.displayOrder,
+ `schedule`.is_priority AS isPriority,
+ `schedule`.recurrence_type AS recurrenceType,
+ `schedule`.recurrence_detail AS recurrenceDetail,
+ `schedule`.recurrence_range AS recurrenceRange,
+ `schedule`.recurrenceRepeatsOn,
+ `schedule`.recurrenceMonthlyRepeatsOn,
+ `schedule`.lastRecurrenceWatermark,
+ campaign.campaignId,
+ campaign.campaign,
+ parentCampaign.campaign AS parentCampaignName,
+ parentCampaign.type AS parentCampaignType,
+ `command`.commandId,
+ `command`.command,
+ `schedule`.dayPartId,
+ `schedule`.syncTimezone,
+ `schedule`.syncEvent,
+ `schedule`.shareOfVoice,
+ `schedule`.maxPlaysPerHour,
+ `schedule`.isGeoAware,
+ `schedule`.geoLocation,
+ `schedule`.actionTriggerCode,
+ `schedule`.actionType,
+ `schedule`.actionLayoutCode,
+ `schedule`.parentCampaignId,
+ `schedule`.syncGroupId,
+ `daypart`.isAlways,
+ `daypart`.isCustom,
+ `syncgroup`.name AS syncGroupName,
+ `schedule`.modifiedBy,
+ `user`.userName as modifiedByName,
+ `schedule`.createdOn,
+ `schedule`.updatedOn,
+ `schedule`.name,
+ `schedule`.dataSetId,
+ `schedule`.dataSetParams,
+ `sc`.eventId AS criteria
+ ';
+
+ $body = ' FROM `schedule`
+ INNER JOIN `daypart`
+ ON `daypart`.dayPartId = `schedule`.dayPartId
+ LEFT OUTER JOIN `campaign`
+ ON campaign.CampaignID = `schedule`.CampaignID
+ LEFT OUTER JOIN `campaign` parentCampaign
+ ON parentCampaign.campaignId = `schedule`.parentCampaignId
+ LEFT OUTER JOIN `command`
+ ON `command`.commandId = `schedule`.commandId
+ LEFT OUTER JOIN `syncgroup`
+ ON `syncgroup`.syncGroupId = `schedule`.syncGroupId
+ LEFT OUTER JOIN `user`
+ ON `user`.userId = `schedule`.modifiedBy
+ LEFT OUTER JOIN (
+ SELECT DISTINCT `eventId` FROM schedule_criteria
+ ) AS sc ON `schedule`.eventId = sc.eventId
+ WHERE 1 = 1';
+
+ if ($parsedFilter->getInt('eventId') !== null) {
+ $body .= ' AND `schedule`.eventId = :eventId ';
+ $params['eventId'] = $parsedFilter->getInt('eventId');
+ }
+
+ if ($parsedFilter->getInt('eventTypeId') !== null) {
+ $body .= ' AND `schedule`.eventTypeId = :eventTypeId ';
+ $params['eventTypeId'] = $parsedFilter->getInt('eventTypeId');
+ }
+
+ if ($parsedFilter->getInt('campaignId') !== null) {
+ $body .= ' AND `schedule`.campaignId = :campaignId ';
+ $params['campaignId'] = $parsedFilter->getInt('campaignId');
+ }
+
+ if ($parsedFilter->getInt('parentCampaignId') !== null) {
+ $body .= ' AND `schedule`.parentCampaignId = :parentCampaignId ';
+ $params['parentCampaignId'] = $parsedFilter->getInt('parentCampaignId');
+ }
+
+ if ($parsedFilter->getInt('adCampaignsOnly') === 1) {
+ $body .= ' AND `schedule`.parentCampaignId IS NOT NULL AND `schedule`.eventTypeId = :eventTypeId ';
+ $params['eventTypeId'] = Schedule::$INTERRUPT_EVENT;
+ }
+
+ if ($parsedFilter->getInt('recurring') !== null) {
+ if ($parsedFilter->getInt('recurring') === 1) {
+ $body .= ' AND `schedule`.recurrence_type IS NOT NULL ';
+ } else if ($parsedFilter->getInt('recurring') === 0) {
+ $body .= ' AND `schedule`.recurrence_type IS NULL ';
+ }
+ }
+
+ if ($parsedFilter->getInt('geoAware') !== null) {
+ $body .= ' AND `schedule`.isGeoAware = :geoAware ';
+ $params['geoAware'] = $parsedFilter->getInt('geoAware');
+ }
+
+ if ($parsedFilter->getInt('ownerId') !== null) {
+ $body .= ' AND `schedule`.userId = :ownerId ';
+ $params['ownerId'] = $parsedFilter->getInt('ownerId');
+ }
+
+ if ($parsedFilter->getInt('dayPartId') !== null) {
+ $body .= ' AND `schedule`.dayPartId = :dayPartId ';
+ $params['dayPartId'] = $parsedFilter->getInt('dayPartId');
+ }
+
+ // Only 1 date
+ if ($parsedFilter->getInt('fromDt') !== null && $parsedFilter->getInt('toDt') === null) {
+ $body .= ' AND schedule.fromDt > :fromDt ';
+ $params['fromDt'] = $parsedFilter->getInt('fromDt');
+ }
+
+ if ($parsedFilter->getInt('toDt') !== null && $parsedFilter->getInt('fromDt') === null) {
+ $body .= ' AND IFNULL(schedule.toDt, schedule.fromDt) <= :toDt ';
+ $params['toDt'] = $parsedFilter->getInt('toDt');
+ }
+ // End only 1 date
+
+ // Both dates
+ if ($parsedFilter->getInt('fromDt') !== null && $parsedFilter->getInt('toDt') !== null) {
+ $body .= ' AND schedule.fromDt < :toDt ';
+ $body .= ' AND IFNULL(schedule.toDt, schedule.fromDt) >= :fromDt ';
+ $params['fromDt'] = $parsedFilter->getInt('fromDt');
+ $params['toDt'] = $parsedFilter->getInt('toDt');
+ }
+ // End both dates
+
+ if ($parsedFilter->getIntArray('displayGroupIds') != null) {
+ // parameterize the selected display/groups and number of selected display/groups
+ $selectedDisplayGroupIds = implode(',', $parsedFilter->getIntArray('displayGroupIds'));
+ $numberOfSelectedDisplayGroups = count($parsedFilter->getIntArray('displayGroupIds'));
+
+ // build date filter for sub-queries for shared schedules
+ $sharedScheduleDateFilter = '';
+ if ($parsedFilter->getInt('futureSchedulesFrom') !== null
+ && $parsedFilter->getInt('futureSchedulesTo') === null
+ ) {
+ // Get schedules that end after this date, or that recur after this date
+ $sharedScheduleDateFilter .= ' AND (IFNULL(`schedule`.toDt, `schedule`.fromDt) >= :futureSchedulesFrom
+ OR `schedule`.recurrence_range >= :futureSchedulesFrom OR (IFNULL(`schedule`.recurrence_range, 0) = 0)
+ AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\') ';
+ $params['futureSchedulesFrom'] = $parsedFilter->getInt('futureSchedulesFrom');
+ }
+
+ if ($parsedFilter->getInt('futureSchedulesFrom') !== null
+ && $parsedFilter->getInt('futureSchedulesTo') !== null
+ ) {
+ // Get schedules that end after this date, or that recur after this date
+ $sharedScheduleDateFilter .= ' AND ((schedule.fromDt < :futureSchedulesTo
+ AND IFNULL(`schedule`.toDt, `schedule`.fromDt) >= :futureSchedulesFrom)
+ OR `schedule`.recurrence_range >= :futureSchedulesFrom OR (IFNULL(`schedule`.recurrence_range, 0) = 0
+ AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\') ) ';
+ $params['futureSchedulesFrom'] = $parsedFilter->getInt('futureSchedulesFrom');
+ $params['futureSchedulesTo'] = $parsedFilter->getInt('futureSchedulesTo');
+ }
+
+ // non Schedule grid filter, keep it the way it was.
+ if ($parsedFilter->getInt('sharedSchedule') === null &&
+ $parsedFilter->getInt('directSchedule') === null
+ ) {
+ $body .= ' AND `schedule`.eventId IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ WHERE displayGroupId IN (' . $selectedDisplayGroupIds . ')
+ ) ';
+ } else {
+ // Schedule grid query
+ // check what options we were provided with and adjust query accordingly.
+ $sharedSchedule = ($parsedFilter->getInt('sharedSchedule') === 1);
+ $directSchedule = ($parsedFilter->getInt('directSchedule') === 1);
+
+ // shared and direct
+ // events scheduled directly on the selected displays/groups
+ // and scheduled on all selected displays/groups
+ // Example : Two Displays selected, return only events scheduled directly to both of them
+ if ($sharedSchedule && $directSchedule) {
+ $body .= ' AND `schedule`.eventId IN (
+ SELECT `lkscheduledisplaygroup`.eventId
+ FROM `lkscheduledisplaygroup`
+ INNER JOIN `schedule` ON `schedule`.eventId = `lkscheduledisplaygroup`.eventId
+ WHERE displayGroupId IN (' . $selectedDisplayGroupIds . ')' .
+ $sharedScheduleDateFilter . '
+ GROUP BY eventId
+ HAVING COUNT(DISTINCT displayGroupId) >= ' .
+ $numberOfSelectedDisplayGroups .
+ ') ';
+ }
+
+ // shared and not direct
+ // 1 - events scheduled on the selected display/groups
+ // 2 - events scheduled on a display group selected display is a member of
+ // 3 - events scheduled on a parent display group of selected display group
+ // and scheduled on all selected displays/groups
+ // Example : Two Displays selected, return only events scheduled directly to both of them
+ if ($sharedSchedule && !$directSchedule) {
+ $body .= ' AND (
+ ( `schedule`.eventId IN (
+ SELECT `lkscheduledisplaygroup`.eventId
+ FROM `lkscheduledisplaygroup`
+ INNER JOIN `schedule` ON `schedule`.eventId = `lkscheduledisplaygroup`.eventId
+ WHERE displayGroupId IN (' . $selectedDisplayGroupIds . ')' .
+ $sharedScheduleDateFilter . '
+ GROUP BY eventId
+ HAVING COUNT(DISTINCT displayGroupId) >= ' . $numberOfSelectedDisplayGroups . '
+ ))
+ OR `schedule`.eventID IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ INNER JOIN `schedule` ON `schedule`.eventId = `lkscheduledisplaygroup`.eventId
+ INNER JOIN `lkdgdg` ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg` ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ WHERE `lkdisplaydg`.DisplayID IN (
+ SELECT lkdisplaydg.displayId FROM lkdisplaydg
+ INNER JOIN displaygroup ON lkdisplaydg.displayGroupId = displaygroup.displayGroupId
+ WHERE lkdisplaydg.displayGroupId IN (' . $selectedDisplayGroupIds . ')
+ AND displaygroup.isDisplaySpecific = 1 ) ' .
+ $sharedScheduleDateFilter . '
+ GROUP BY eventId
+ HAVING COUNT(DISTINCT `lkdisplaydg`.displayId) >= ' .
+ $numberOfSelectedDisplayGroups . '
+ )
+ OR `schedule`.eventID IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ INNER JOIN `schedule` ON `schedule`.eventId = `lkscheduledisplaygroup`.eventId
+ INNER JOIN `lkdgdg` ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ WHERE `lkscheduledisplaygroup`.displayGroupId IN (
+ SELECT lkdgdg.childId FROM lkdgdg
+ WHERE lkdgdg.parentId IN (' . $selectedDisplayGroupIds .') AND lkdgdg.depth > 0)' .
+ $sharedScheduleDateFilter . '
+ GROUP BY eventId
+ HAVING COUNT(DISTINCT `lkscheduledisplaygroup`.displayGroupId) >= ' .
+ $numberOfSelectedDisplayGroups . '
+ )
+ ) ';
+ }
+
+ // not shared and direct (old default)
+ // events scheduled directly on selected displays/groups
+ if (!$sharedSchedule && $directSchedule) {
+ $body .= ' AND `schedule`.eventId IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ WHERE displayGroupId IN (' . $selectedDisplayGroupIds . ')
+ ) ';
+ }
+
+ // not shared and not direct (new default)
+ // 1 - events scheduled on the selected display/groups
+ // 2 - events scheduled on display members of the selected display group
+ // 2 - events scheduled on a display group selected display is a member of
+ // 3 - events scheduled on a parent display group of selected display group
+ if (!$sharedSchedule && !$directSchedule) {
+ $body .= ' AND (
+ ( `schedule`.eventId IN (SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ WHERE displayGroupId IN (' . $selectedDisplayGroupIds . ')) )
+ OR `schedule`.eventID IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ INNER JOIN `lkdgdg` ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg` ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN displaygroup ON lkdisplaydg.displayGroupId = displaygroup.displayGroupId
+ WHERE `lkdisplaydg`.DisplayID IN (
+ SELECT lkdisplaydg.displayId FROM lkdisplaydg
+ WHERE lkdisplaydg.displayGroupId IN (' . $selectedDisplayGroupIds . ')
+ ) AND displaygroup.isDisplaySpecific = 1
+ )
+ OR `schedule`.eventID IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ INNER JOIN
+ `lkdisplaydg` ON lkdisplaydg.DisplayGroupID = `lkscheduledisplaygroup`.displayGroupId
+ WHERE `lkdisplaydg`.DisplayID IN (
+ SELECT lkdisplaydg.displayId FROM lkdisplaydg
+ INNER JOIN displaygroup ON lkdisplaydg.displayGroupId = displaygroup.displayGroupId
+ WHERE lkdisplaydg.displayGroupId IN (' . $selectedDisplayGroupIds . ')
+ AND displaygroup.isDisplaySpecific = 1 )
+ )
+ OR `schedule`.eventID IN (
+ SELECT `lkscheduledisplaygroup`.eventId FROM `lkscheduledisplaygroup`
+ WHERE `lkscheduledisplaygroup`.displayGroupId IN (
+ SELECT lkdgdg.childId FROM lkdgdg
+ WHERE lkdgdg.parentId IN (' . $selectedDisplayGroupIds .') AND lkdgdg.depth > 0)
+ )
+ ) ';
+ }
+ }
+ }
+
+ // Future schedules FROM a date?
+ if ($parsedFilter->getInt('futureSchedulesFrom') !== null
+ && $parsedFilter->getInt('futureSchedulesTo') === null
+ ) {
+ // Get schedules that end after this date, or that recur after this date
+ $body .= ' AND (IFNULL(`schedule`.toDt, `schedule`.fromDt) >= :futureSchedulesFrom
+ OR `schedule`.recurrence_range >= :futureSchedulesFrom OR (IFNULL(`schedule`.recurrence_range, 0) = 0)
+ AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\') ';
+ $params['futureSchedulesFrom'] = $parsedFilter->getInt('futureSchedulesFrom');
+ }
+
+ // Future schedules BETWEEN two dates?
+ if ($parsedFilter->getInt('futureSchedulesFrom') !== null
+ && $parsedFilter->getInt('futureSchedulesTo') !== null
+ ) {
+ // Get schedules that overlap the range OR recur within the range
+ $body .= ' AND (
+ (
+ schedule.fromDt < :futureSchedulesTo
+ AND IFNULL(`schedule`.toDt, `schedule`.fromDt) >= :futureSchedulesFrom
+ )
+ OR (
+ IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ AND `schedule`.fromDt < :futureSchedulesTo
+ AND (
+ `schedule`.recurrence_range >= :futureSchedulesFrom
+ OR IFNULL(`schedule`.recurrence_range, 0) = 0
+ )
+ )
+ ) ';
+ $params['futureSchedulesFrom'] = $parsedFilter->getInt('futureSchedulesFrom');
+ $params['futureSchedulesTo'] = $parsedFilter->getInt('futureSchedulesTo');
+ }
+
+ // Future schedules UNTIL a date?
+ if ($parsedFilter->getInt('futureSchedulesFrom') === null
+ && $parsedFilter->getInt('futureSchedulesTo') !== null
+ ) {
+ // Get schedules that start before this date.
+ $body .= ' AND `schedule`.fromDt < :futureSchedulesTo ';
+ $params['futureSchedulesTo'] = $parsedFilter->getInt('futureSchedulesTo');
+ }
+
+ // Restrict to mediaId - meaning layout schedules of which the layouts contain the selected mediaId
+ if ($parsedFilter->getInt('mediaId') !== null) {
+ $body .= '
+ AND schedule.campaignId IN (
+ SELECT `lkcampaignlayout`.campaignId
+ FROM `lkwidgetmedia`
+ INNER JOIN `widget`
+ ON `widget`.widgetId = `lkwidgetmedia`.widgetId
+ INNER JOIN `lkplaylistplaylist`
+ ON `widget`.playlistId = `lkplaylistplaylist`.childId
+ INNER JOIN `playlist`
+ ON `lkplaylistplaylist`.parentId = `playlist`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN layout
+ ON layout.LayoutID = region.layoutId
+ INNER JOIN `lkcampaignlayout`
+ ON lkcampaignlayout.layoutId = layout.layoutId
+ WHERE lkwidgetmedia.mediaId = :mediaId
+ UNION
+ SELECT `lkcampaignlayout`.campaignId
+ FROM `layout`
+ INNER JOIN `lkcampaignlayout`
+ ON lkcampaignlayout.layoutId = layout.layoutId
+ WHERE `layout`.backgroundImageId = :mediaId
+ )
+ ';
+ $params['mediaId'] = $parsedFilter->getInt('mediaId');
+ }
+
+ // Restrict to playlistId - meaning layout schedules of which the layouts contain the selected playlistId
+ if ($parsedFilter->getInt('playlistId') !== null) {
+ $body .= '
+ AND schedule.campaignId IN (
+ SELECT `lkcampaignlayout`.campaignId
+ FROM `lkplaylistplaylist`
+ INNER JOIN `playlist`
+ ON `lkplaylistplaylist`.parentId = `playlist`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN layout
+ ON layout.LayoutID = region.layoutId
+ INNER JOIN `lkcampaignlayout`
+ ON lkcampaignlayout.layoutId = layout.layoutId
+ WHERE `lkplaylistplaylist`.childId = :playlistId
+
+ )
+ ';
+
+ $params['playlistId'] = $parsedFilter->getInt('playlistId');
+
+ }
+
+ if ($parsedFilter->getInt('syncGroupId') !== null) {
+ $body .= ' AND `schedule`.syncGroupId = :syncGroupId ';
+ $params['syncGroupId'] = $parsedFilter->getInt('syncGroupId');
+ }
+
+ if ($parsedFilter->getString('name') != null) {
+ $terms = explode(',', $parsedFilter->getString('name'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'schedule',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ // Sorting?
+ $order = '';
+ if ($parsedFilter->getInt('gridFilter') === 1 && $sortOrder === null) {
+ $order = ' ORDER BY
+ CASE WHEN `schedule`.fromDt = 0 THEN 0
+ WHEN `schedule`.recurrence_type <> \'\' THEN 1
+ ELSE 2 END,
+ eventId';
+ } else if (is_array($sortOrder) && !empty($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ // Paging
+ $limit = '';
+ if ($parsedFilter->hasParam('start') && $parsedFilter->hasParam('length')) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0])
+ . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'isPriority',
+ 'syncTimezone',
+ 'isAlways',
+ 'isCustom',
+ 'syncEvent',
+ 'recurrenceMonthlyRepeatsOn',
+ 'isGeoAware',
+ 'maxPlaysPerHour',
+ 'modifiedBy',
+ 'dataSetId',
+ ]
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/ScheduleReminderFactory.php b/lib/Factory/ScheduleReminderFactory.php
new file mode 100644
index 0000000..a98fb57
--- /dev/null
+++ b/lib/Factory/ScheduleReminderFactory.php
@@ -0,0 +1,219 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\ScheduleReminder;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ScheduleReminderFactory
+ * @package Xibo\Factory
+ */
+class ScheduleReminderFactory extends BaseFactory
+{
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param ConfigServiceInterface $config
+ */
+ public function __construct($user, $userFactory, $config)
+ {
+ $this->setAclDependencies($user, $userFactory);
+
+ $this->config = $config;
+ }
+
+ /**
+ * Create Empty
+ * @return ScheduleReminder
+ */
+ public function createEmpty()
+ {
+ return new ScheduleReminder($this->getStore(), $this->getLog(), $this->getDispatcher(), $this->config, $this);
+ }
+
+ /**
+ * Populate Schedule Reminder table
+ * @param int $eventId
+ * @param int $type
+ * @param int $option
+ * @param int $value
+ * @param int $reminderDt
+ * @param int $isEmail
+ * @param int $lastReminderDt
+ * @return ScheduleReminder
+ */
+ public function create($eventId, $value, $type, $option, $reminderDt, $isEmail, $lastReminderDt)
+ {
+ $scheduleReminder = $this->createEmpty();
+ $scheduleReminder->eventId = $eventId;
+ $scheduleReminder->value = $value;
+ $scheduleReminder->type = $type;
+ $scheduleReminder->option = $option;
+ $scheduleReminder->reminderDt = $reminderDt;
+ $scheduleReminder->isEmail = $isEmail;
+ $scheduleReminder->lastReminderDt = $lastReminderDt;
+ $scheduleReminder->save();
+
+ return $scheduleReminder;
+ }
+
+ /**
+ * Get by Schedule Reminder Id
+ * @param int $scheduleReminderId
+ * @return ScheduleReminder
+ * @throws NotFoundException
+ */
+ public function getById($scheduleReminderId)
+ {
+ $scheduleReminders = $this->query(null, ['scheduleReminderId' => $scheduleReminderId]);
+
+ if (count($scheduleReminders) <= 0)
+ throw new NotFoundException(__('Cannot find schedule reminder'));
+
+ return $scheduleReminders[0];
+ }
+
+ /**
+ * Get due reminders
+ * @param Date $nextRunDate
+ * @return array[ScheduleReminder]
+ */
+ public function getDueReminders($nextRunDate)
+ {
+ return $this->query(null, ['nextRunDate' => $nextRunDate]);
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return ScheduleReminder[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder === null) {
+ $sortOrder = ['scheduleReminderId '];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $params = [];
+ $entries = [];
+
+ $select = '
+ SELECT
+ schedulereminder.scheduleReminderId,
+ schedulereminder.eventId,
+ schedulereminder.value,
+ schedulereminder.type,
+ schedulereminder.option,
+ schedulereminder.reminderDt,
+ schedulereminder.isEmail,
+ schedulereminder.lastReminderDt
+ ';
+
+ $body = ' FROM schedulereminder ';
+
+ $body .= " WHERE 1 = 1 ";
+
+ if ($sanitizedFilter->getInt('scheduleReminderId', ['default' => -1]) != -1) {
+ $body .= " AND schedulereminder.scheduleReminderId = :scheduleReminderId ";
+ $params['scheduleReminderId'] = $sanitizedFilter->getInt('scheduleReminderId');
+ }
+
+ if ($sanitizedFilter->getInt('eventId') !== null) {
+ $body .= " AND schedulereminder.eventId = :eventId ";
+ $params['eventId'] = $sanitizedFilter->getInt('eventId');
+ }
+
+ if ($sanitizedFilter->getInt('value') !== null) {
+ $body .= " AND schedulereminder.value = :value ";
+ $params['value'] = $sanitizedFilter->getInt('value');
+ }
+
+ if ($sanitizedFilter->getInt('type') !== null) {
+ $body .= " AND schedulereminder.type = :type ";
+ $params['type'] = $sanitizedFilter->getInt('type');
+ }
+
+ if ($sanitizedFilter->getInt('option') !== null) {
+ $body .= " AND schedulereminder.option = :option ";
+ $params['option'] = $sanitizedFilter->getInt('option');
+ }
+
+ if ($sanitizedFilter->getInt('reminderDt') !== null) {
+ $body .= ' AND `schedulereminder`.reminderDt = :reminderDt ';
+ $params['reminderDt'] = $sanitizedFilter->getInt('reminderDt');
+ }
+
+ if ($sanitizedFilter->getInt('nextRunDate') !== null) {
+ $body .= ' AND `schedulereminder`.reminderDt <= :nextRunDate AND `schedulereminder`.reminderDt > `schedulereminder`.lastReminderDt ';
+ $params['nextRunDate'] = $sanitizedFilter->getInt('nextRunDate');
+ }
+
+ if ($sanitizedFilter->getInt('isEmail') !== null) {
+ $body .= ' AND `schedulereminder`.isEmail = :isEmail ';
+ $params['isEmail'] = $sanitizedFilter->getInt('isEmail');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $version = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'value', 'type', 'option', 'reminderDt', 'isEmail', 'lastReminderDt'
+ ]
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+
+
+ }
+
+
+}
\ No newline at end of file
diff --git a/lib/Factory/SessionFactory.php b/lib/Factory/SessionFactory.php
new file mode 100644
index 0000000..fe30232
--- /dev/null
+++ b/lib/Factory/SessionFactory.php
@@ -0,0 +1,159 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\Session;
+use Xibo\Helper\DateFormatHelper;
+
+/**
+ * Class SessionFactory
+ * @package Xibo\Factory
+ */
+class SessionFactory extends BaseFactory
+{
+ /**
+ * @return Session
+ */
+ public function createEmpty()
+ {
+ return new Session($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * @param int $userId
+ */
+ public function expireByUserId(int $userId): void
+ {
+ $this->getStore()->update(
+ 'UPDATE `session` SET IsExpired = 1 WHERE userID = :userId ',
+ ['userId' => $userId]
+ );
+ }
+
+ /**
+ * @param int $userId
+ * @return int loggedIn
+ */
+ public function getActiveSessionsForUser($userId)
+ {
+ $userSession = $this->query(null, ['userId' => $userId, 'type' => 'active']);
+
+ return (count($userSession) > 0) ? 1 : 0;
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return Session[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $select = '
+ SELECT `session`.session_id AS sessionId,
+ session.userId,
+ user.userName,
+ isExpired,
+ session.lastAccessed,
+ remoteAddr AS remoteAddress,
+ userAgent,
+ user.userId AS userId,
+ `session`.session_expiration AS expiresAt
+ ';
+
+ $body = '
+ FROM `session`
+ LEFT OUTER JOIN user ON user.userID = session.userID
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getString('sessionId') != null) {
+ $body .= ' AND session.session_id = :sessionId ';
+ $params['sessionId'] = $sanitizedFilter->getString('sessionId');
+ }
+
+ if ($sanitizedFilter->getString('fromDt') != null) {
+ $body .= ' AND session.LastAccessed >= :lastAccessed ';
+ $params['lastAccessed'] = $sanitizedFilter->getDate('fromDt')->setTime(0, 0, 0)->format(DateFormatHelper::getSystemFormat());
+ }
+
+ if ($sanitizedFilter->getString('type') != null) {
+
+ if ($sanitizedFilter->getString('type') == 'active') {
+ $body .= ' AND IsExpired = 0 ';
+ }
+
+ if ($sanitizedFilter->getString('type') == 'expired') {
+ $body .= ' AND IsExpired = 1 ';
+ }
+
+ if ($sanitizedFilter->getString('type') == 'guest') {
+ $body .= ' AND IFNULL(session.userID, 0) = 0 ';
+ }
+ }
+
+ if ($sanitizedFilter->getInt('userId') != null) {
+ $body .= ' AND user.userID = :userId ';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $session = $this->createEmpty()->hydrate($row, [
+ 'stringProperties' => ['sessionId'],
+ 'intProperties' => ['isExpired'],
+ ]);
+ $session->userAgent = htmlspecialchars($session->userAgent);
+ $session->remoteAddress = filter_var($session->remoteAddress, FILTER_VALIDATE_IP);
+ $session->excludeProperty('sessionId');
+ $entries[] = $session;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/SyncGroupFactory.php b/lib/Factory/SyncGroupFactory.php
new file mode 100755
index 0000000..322a7eb
--- /dev/null
+++ b/lib/Factory/SyncGroupFactory.php
@@ -0,0 +1,243 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Display;
+use Xibo\Entity\SyncGroup;
+use Xibo\Entity\User;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class SyncGroupFactory
+ * @package Xibo\Factory
+ */
+class SyncGroupFactory extends BaseFactory
+{
+ private DisplayFactory $displayFactory;
+ private PermissionFactory $permissionFactory;
+ private ScheduleFactory $scheduleFactory;
+
+ public function __construct(
+ User $user,
+ UserFactory $userFactory,
+ PermissionFactory $permissionFactory,
+ DisplayFactory $displayFactory,
+ ScheduleFactory $scheduleFactory
+ ) {
+ $this->setAclDependencies($user, $userFactory);
+ $this->displayFactory = $displayFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ }
+
+ /**
+ * @return SyncGroup
+ */
+ public function createEmpty(): SyncGroup
+ {
+ return new SyncGroup(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this,
+ $this->displayFactory,
+ $this->permissionFactory,
+ $this->scheduleFactory
+ );
+ }
+
+ /**
+ * @param int $id
+ * @return SyncGroup
+ * @throws NotFoundException
+ */
+ public function getById(int $id): SyncGroup
+ {
+ $syncGroups = $this->query(null, ['syncGroupId' => $id]);
+
+ if (count($syncGroups) <= 0) {
+ Throw new NotFoundException(__('Sync Group not found'));
+ }
+
+ return $syncGroups[0];
+ }
+
+ /**
+ * @param int $userId
+ * @return SyncGroup[]
+ */
+ public function getByOwnerId(int $userId): array
+ {
+ return $this->query(null, ['ownerId' => $userId]);
+ }
+
+ /**
+ * @param int $folderId
+ * @return SyncGroup[]
+ */
+ public function getByFolderId(int $folderId): array
+ {
+ return $this->query(null, ['folderId' => $folderId]);
+ }
+
+ /**
+ * @param int $id
+ * @return Display
+ * @throws NotFoundException
+ */
+ public function getLeadDisplay(int $id): \Xibo\Entity\Display
+ {
+ return $this->displayFactory->getById($id);
+ }
+
+ /**
+ * @param array|null $sortOrder
+ * @param array $filterBy
+ * @return SyncGroup[]
+ */
+ public function query($sortOrder = null, $filterBy = []): array
+ {
+ $parsedBody = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null) {
+ $sortOrder = ['name'];
+ }
+
+ $entries = [];
+ $params = [];
+
+ $select = 'SELECT
+ `syncgroup`.syncGroupId,
+ `syncgroup`.name,
+ `syncgroup`.createdDt,
+ `syncgroup`.modifiedDt,
+ `syncgroup`.ownerId,
+ `syncgroup`.modifiedBy,
+ `syncgroup`.syncPublisherPort,
+ `syncgroup`.syncSwitchDelay,
+ `syncgroup`.syncVideoPauseDelay,
+ `syncgroup`.leadDisplayId,
+ `syncgroup`.folderId,
+ `syncgroup`.permissionsFolderId,
+ `user`.userName as owner,
+ modifiedBy.userName AS modifiedByName,
+ (
+ SELECT GROUP_CONCAT(DISTINCT `group`.group)
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ INNER JOIN `group`
+ ON `group`.groupId = `permission`.groupId
+ WHERE entity = :entity
+ AND objectId = `syncgroup`.syncGroupId
+ AND view = 1
+ ) AS groupsWithPermissions
+ ';
+
+ $params['entity'] = 'Xibo\\Entity\\SyncGroup';
+
+ $body = '
+ FROM `syncgroup`
+ INNER JOIN `user`
+ ON `user`.userId = `syncgroup`.ownerId
+ LEFT OUTER JOIN `user` modifiedBy
+ ON modifiedBy.userId = `syncgroup`.modifiedBy
+ WHERE 1 = 1
+ ';
+
+ if ($parsedBody->getInt('syncGroupId') !== null) {
+ $body .= ' AND `syncgroup`.syncGroupId = :syncGroupId ';
+ $params['syncGroupId'] = $parsedBody->getInt('syncGroupId');
+ }
+
+ if ($parsedBody->getInt('ownerId') !== null) {
+ $body .= ' AND `syncgroup`.ownerId = :ownerId ';
+ $params['ownerId'] = $parsedBody->getInt('ownerId');
+ }
+
+ // Filter by SyncGroup Name?
+ if ($parsedBody->getString('name') != null) {
+ $terms = explode(',', $parsedBody->getString('name'));
+ $logicalOperator = $parsedBody->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'syncgroup',
+ 'name',
+ $terms,
+ $body,
+ $params,
+ ($parsedBody->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($parsedBody->getInt('folderId') !== null) {
+ $body .= ' AND `syncgroup`.folderId = :folderId ';
+ $params['folderId'] = $parsedBody->getInt('folderId');
+ }
+
+ if ($parsedBody->getInt('leadDisplayId') !== null) {
+ $body .= ' AND `syncgroup`.leadDisplayId = :leadDisplayId ';
+ $params['leadDisplayId'] = $parsedBody->getInt('leadDisplayId');
+ }
+
+ // View Permissions
+ $this->viewPermissionSql(
+ 'Xibo\Entity\SyncGroup',
+ $body,
+ $params,
+ '`syncgroup`.syncGroupId',
+ '`syncgroup`.ownerId',
+ $filterBy,
+ '`syncgroup`.permissionsFolderId'
+ );
+
+ // Sorting?
+ $order = '';
+
+ if (is_array($sortOrder)) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($parsedBody->hasParam('start') && $parsedBody->hasParam('length')) {
+ $limit = ' LIMIT ' . $parsedBody->getInt('start', ['default' => 0])
+ . ', ' . $parsedBody->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ unset($params['entity']);
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/TagFactory.php b/lib/Factory/TagFactory.php
new file mode 100644
index 0000000..d177e7a
--- /dev/null
+++ b/lib/Factory/TagFactory.php
@@ -0,0 +1,404 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\Tag;
+use Xibo\Entity\TagLink;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class TagFactory
+ * @package Xibo\Factory
+ */
+class TagFactory extends BaseFactory
+{
+ use TagTrait;
+ /**
+ * @return Tag
+ */
+ public function createEmpty()
+ {
+ return new Tag($this->getStore(), $this->getLog(), $this->getDispatcher(), $this);
+ }
+
+ /**
+ * @return TagLink
+ */
+ public function createEmptyLink()
+ {
+ return new TagLink($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * @param $name
+ * @return Tag
+ */
+ public function create($name)
+ {
+ $tag = $this->createEmpty();
+ $tag->tag = trim($name);
+
+ return $tag;
+ }
+
+ public function createTagLink($tagId, $tag, $value)
+ {
+ $tagLink = $this->createEmptyLink();
+ $tagLink->tag = trim($tag);
+ $tagLink->tagId = $tagId;
+ $tagLink->value = trim($value ?? '');
+
+ return $tagLink;
+ }
+
+ /**
+ * Get tags from a string
+ * @param string $tagString
+ * @return array[Tag]
+ * @throws InvalidArgumentException
+ */
+ public function tagsFromString($tagString)
+ {
+ $tags = [];
+
+ if ($tagString == '') {
+ return $tags;
+ }
+
+ // Parse the tag string, create tags
+ foreach (explode(',', $tagString) as $tagName) {
+ $tagName = trim($tagName);
+
+ $tags[] = $this->tagFromString($tagName);
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Get Tag from String
+ * @param string $tagString
+ * @return TagLink
+ * @throws InvalidArgumentException
+ */
+ public function tagFromString($tagString)
+ {
+ // Trim the tag
+ $tagString = trim($tagString);
+ $explode = explode('|', $tagString);
+
+ // Add to the list
+ try {
+ $tag = $this->getByTag($explode[0]);
+ $tagLink = $this->createTagLink($tag->tagId, $tag->tag, $explode[1] ?? null);
+ $tagLink->validateOptions($tag);
+ }
+ catch (NotFoundException $e) {
+ // New tag
+ $tag = $this->createEmpty();
+ $tag->tag = $explode[0];
+ $tag->save();
+ $tagLink = $this->createTagLink($tag->tagId, $tag->tag, $explode[1] ?? null);
+ }
+
+ return $tagLink;
+ }
+
+ public function tagsFromJson($tagArray)
+ {
+ $tagLinks = [];
+ foreach ($tagArray as $tag) {
+ if (!is_array($tag)) {
+ $tag = json_decode($tag);
+ }
+ try {
+ $tagCheck = $this->getByTag($tag->tag);
+ $tagLink = $this->createTagLink($tagCheck->tagId, $tag->tag, $tag->value ?? null);
+ $tagLink->validateOptions($tag);
+ } catch (NotFoundException $exception) {
+ $newTag = $this->createEmpty();
+ $newTag->tag = $tag->tag;
+ $newTag->save();
+ $tagLink = $this->createTagLink($newTag->tagId, $tag->tag, $tag->value ?? null);
+ }
+ $tagLinks[] = $tagLink;
+ }
+
+ return $tagLinks;
+ }
+
+ /**
+ * Load tag by Tag Name
+ * @param string $tagName
+ * @return Tag
+ * @throws NotFoundException
+ */
+ public function getByTag($tagName)
+ {
+ $sql = 'SELECT tag.tagId, tag.tag, tag.isSystem, tag.isRequired, tag.options FROM `tag` WHERE tag.tag = :tag';
+
+ $tags = $this->getStore()->select($sql, ['tag' => $tagName]);
+
+ if (count($tags) <= 0) {
+ throw new NotFoundException(sprintf(__('Unable to find Tag %s'), $tagName));
+ }
+
+ $row = $tags[0];
+ $tag = $this->createEmpty();
+ $sanitizedRow = $this->getSanitizer($row);
+
+ $tag->tagId = $sanitizedRow->getInt('tagId');
+ $tag->tag = $sanitizedRow->getString('tag');
+ $tag->isSystem = $sanitizedRow->getInt('isSystem');
+ $tag->isRequired = $sanitizedRow->getInt('isRequired');
+ $tag->options = $sanitizedRow->getString('options');
+
+ return $tag;
+ }
+
+ /**
+ * Get Tag by ID
+ * @param int $tagId
+ * @return Tag
+ * @throws NotFoundException
+ */
+ public function getById($tagId)
+ {
+ $this->getLog()->debug('TagFactory getById(%d)', $tagId);
+
+ $tags = $this->query(null, ['tagId' => $tagId]);
+
+ if (count($tags) <= 0) {
+ $this->getLog()->debug('Tag not found with ID %d', $tagId);
+ throw new NotFoundException(\__('Tag not found'));
+ }
+
+ return $tags[0];
+ }
+
+ /**
+ * Get the system tags
+ * @return array|Tag
+ * @throws NotFoundException
+ */
+ public function getSystemTags()
+ {
+ $tags = $this->query(null, ['isSystem' => 1]);
+
+ if (count($tags) <= 0)
+ throw new NotFoundException();
+
+ return $tags;
+ }
+
+ /**
+ * Query
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[\Xibo\Entity\Log]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder == null) {
+ $sortOrder = ['tagId DESC'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+ $order = '';
+ $limit = '';
+
+ $select = 'SELECT tagId, tag, isSystem, isRequired, options ';
+
+ $body = '
+ FROM `tag`
+ ';
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('tagId') != null) {
+ $body .= " AND `tag`.tagId = :tagId ";
+ $params['tagId'] = $sanitizedFilter->getInt('tagId');
+ }
+
+ if ($sanitizedFilter->getInt('notTagId', ['default' => 0]) != 0) {
+ $body .= " AND tag.tagId <> :notTagId ";
+ $params['notTagId'] = $sanitizedFilter->getInt('notTagId');
+ }
+
+ if ($sanitizedFilter->getString('tag') != null) {
+ $terms = explode(',', $sanitizedFilter->getString('tag'));
+ $logicalOperator = $sanitizedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'tag',
+ 'tag',
+ $terms,
+ $body,
+ $params,
+ ($sanitizedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($sanitizedFilter->getString('tagExact') != null) {
+ $body.= " AND tag.tag = :exact ";
+ $params['exact'] = $sanitizedFilter->getString('tagExact');
+ }
+
+ //isSystem filter, by default hide tags with isSystem flag
+ if ($sanitizedFilter->getInt('allTags') !== 1) {
+ $body .= ' AND `tag`.isSystem = :isSystem ';
+ $params['isSystem'] = $sanitizedFilter->getCheckbox('isSystem');
+ }
+
+ // isRequired filter, by default hide tags with isSystem flag
+ if ($sanitizedFilter->getCheckbox('isRequired') != 0) {
+ $body .= " AND `tag`.isRequired = :isRequired ";
+ $params['isRequired'] = $sanitizedFilter->getCheckbox('isRequired');
+ }
+
+ if ($sanitizedFilter->getCheckbox('haveOptions') === 1) {
+ $body .= " AND `tag`.options IS NOT NULL";
+ }
+
+ // Sorting?
+ if (is_array($sortOrder)) {
+ $order = ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $tag = $this->createEmpty()->hydrate($row, ['intProperties' => ['isSystem', 'isRequired']]);
+ $tag->excludeProperty('value');
+
+ $entries[] = $tag;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+ return $entries;
+ }
+
+
+ public function getAllLinks($sortOrder, $filterBy)
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null) {
+ $sortOrder = ['type ASC'];
+ }
+
+ $params['tagId'] = $sanitizedFilter->getInt('tagId');
+
+ $body = 'SELECT
+ `lktagmedia`.mediaId AS entityId,
+ `media`.name AS name,
+ `lktagmedia`.value,
+ \'Media\' AS type
+ FROM `lktagmedia` INNER JOIN `media` ON `lktagmedia`.mediaId = `media`.mediaId
+ WHERE `lktagmedia`.tagId = :tagId
+ UNION ALL
+ SELECT
+ `lktaglayout`.layoutId AS entityId,
+ `layout`.layout AS name,
+ `lktaglayout`.value,
+ \'Layout\' AS type
+ FROM `lktaglayout` INNER JOIN `layout` ON `lktaglayout`.layoutId = `layout`.layoutId
+ WHERE `lktaglayout`.tagId = :tagId
+ UNION ALL
+ SELECT
+ `lktagcampaign`.campaignId AS entityId,
+ `campaign`.campaign AS name,
+ `lktagcampaign`.value,
+ \'Campaign\' AS type
+ FROM `lktagcampaign` INNER JOIN `campaign` ON `lktagcampaign`.campaignId = `campaign`.campaignId
+ WHERE `lktagcampaign`.tagId = :tagId
+ UNION ALL
+ SELECT
+ `lktagdisplaygroup`.displayGroupId AS entityId,
+ `displaygroup`.displayGroup AS name,
+ `lktagdisplaygroup`.value,
+ \'Display Group\' AS type
+ FROM `lktagdisplaygroup` INNER JOIN `displaygroup` ON `lktagdisplaygroup`.displayGroupId = `displaygroup`.displayGroupId AND `displaygroup`.isDisplaySpecific = 0
+ WHERE `lktagdisplaygroup`.tagId = :tagId
+ UNION ALL
+ SELECT
+ `lktagdisplaygroup`.displayGroupId AS entityId,
+ `display`.display AS name,
+ `lktagdisplaygroup`.value,
+ \'Display\' AS type
+ FROM `display` INNER JOIN `lkdisplaydg` ON `lkdisplaydg`.displayId = `display`.displayId
+ INNER JOIN `displaygroup` ON `displaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId AND `displaygroup`.isDisplaySpecific = 1
+ INNER JOIN lktagdisplaygroup ON `lktagdisplaygroup`.displayGroupId = `displaygroup`.displayGroupId
+ WHERE `lktagdisplaygroup`.tagId = :tagId
+ UNION ALL
+ SELECT
+ `lktagplaylist`.playListId AS entityId,
+ `playlist`.name AS name,
+ `lktagplaylist`.value,
+ \'Playlist\' AS type
+ FROM `lktagplaylist` INNER JOIN `playlist` ON `lktagplaylist`.playlistId = `playlist`.playlistId
+ WHERE `lktagplaylist`.tagId = :tagId
+ ';
+
+ // Sorting?
+ $sort = '';
+ if (is_array($sortOrder)) {
+ $sort .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ // Paging
+ $limit = '';
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $body . $sort . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $row;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total FROM (' . $body .') x', $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/TagTrait.php b/lib/Factory/TagTrait.php
new file mode 100644
index 0000000..11a4aef
--- /dev/null
+++ b/lib/Factory/TagTrait.php
@@ -0,0 +1,102 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\TagLink;
+
+trait TagTrait
+{
+ /**
+ * Gets tags for entityId
+ * @param string $table
+ * @param string $column
+ * @param int $entityId
+ * @return TagLink[]
+ */
+ public function loadTagsByEntityId(string $table, string $column, int $entityId)
+ {
+ $tags = [];
+
+ $sql = 'SELECT tag.tagId, tag.tag, `'. $table .'`.value FROM `tag` INNER JOIN `'.$table.'` ON `'.$table.'`.tagId = tag.tagId WHERE `'.$table.'`.'.$column.' = :entityId';
+
+ foreach ($this->getStore()->select($sql, ['entityId' => $entityId]) as $row) {
+ $sanitizedRow = $this->getSanitizer($row);
+
+ $tagLink = new TagLink($this->getStore(), $this->getLog(), $this->getDispatcher());
+ $tagLink->tagId = $sanitizedRow->getInt('tagId');
+ $tagLink->tag = $sanitizedRow->getString('tag');
+ $tagLink->value = $sanitizedRow->getString('value');
+
+ $tags[] = $tagLink;
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Gets tags for entity
+ * @param string $table
+ * @param string $column
+ * @param array $entityIds
+ * @param \Xibo\Entity\EntityTrait[] $entries
+ */
+ public function decorateWithTagLinks(string $table, string $column, array $entityIds, array $entries): void
+ {
+ // Query to get all tags from a tag link table for a set of entityIds
+ $sql = 'SELECT `tag`.`tagId`, `tag`.`tag`, `' . $table . '`.`value`, `' . $table . '`.`' . $column . '`'
+ . ' FROM `tag` '
+ . ' INNER JOIN `' . $table . '` ON `' . $table . '`.`tagId` = `tag`.`tagId` '
+ . ' WHERE `' . $table . '`.`' . $column . '` IN(' . implode(',', $entityIds) .')';
+
+ foreach ($this->getStore()->select($sql, []) as $row) {
+ // Add each tag returned above to its respective entity
+ $sanitizedRow = $this->getSanitizer($row);
+
+ $tagLink = new TagLink($this->getStore(), $this->getLog(), $this->getDispatcher());
+ $tagLink->tagId = $sanitizedRow->getInt('tagId');
+ $tagLink->tag = $sanitizedRow->getString('tag');
+ $tagLink->value = $sanitizedRow->getString('value', ['defaultOnEmptyString' => true]);
+
+ foreach ($entries as $entry) {
+ if ($entry->$column === $sanitizedRow->getInt($column)) {
+ $entry->tags[] = $tagLink;
+ }
+ }
+ }
+
+ // Set the original value on the entity.
+ foreach ($entries as $entry) {
+ $entry->setOriginalValue('tags', $entry->tags);
+ }
+ }
+
+ public function getTagUsageByEntity(string $tagLinkTable, string $idColumn, string $nameColumn, string $entity, int $tagId, &$entries)
+ {
+ $sql = 'SELECT `'.$tagLinkTable.'`.'.$idColumn.' AS entityId, `'.$entity.'`.'.$nameColumn.' AS name, `'. $tagLinkTable .'`.value, \''.$entity.'\' AS type FROM `'.$tagLinkTable.'` INNER JOIN `'.$entity.'` ON `'.$tagLinkTable.'`.'.$idColumn.' = `'.$entity.'`.'.$idColumn.' WHERE `'.$tagLinkTable.'`.tagId = :tagId ';
+ foreach ($this->getStore()->select($sql, ['tagId' => $tagId]) as $row) {
+ $entries[] = $row;
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/TaskFactory.php b/lib/Factory/TaskFactory.php
new file mode 100644
index 0000000..ddab457
--- /dev/null
+++ b/lib/Factory/TaskFactory.php
@@ -0,0 +1,163 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+use Xibo\Entity\Task;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class TaskFactory
+ * @package Xibo\Factory
+ */
+class TaskFactory extends BaseFactory
+{
+ /**
+ * Create empty
+ * @return Task
+ */
+ public function create()
+ {
+ return new Task($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get by ID
+ * @param int $taskId
+ * @return Task
+ * @throws NotFoundException if the task cannot be resolved from the provided route
+ */
+ public function getById($taskId)
+ {
+ $tasks = $this->query(null, array('taskId' => $taskId));
+
+ if (count($tasks) <= 0)
+ throw new NotFoundException();
+
+ return $tasks[0];
+ }
+
+ /**
+ * Get by Name
+ * @param string $task
+ * @return Task
+ * @throws NotFoundException if the task cannot be resolved from the provided route
+ */
+ public function getByName($task)
+ {
+ $tasks = $this->query(null, array('name' => $task));
+
+ if (count($tasks) <= 0)
+ throw new NotFoundException();
+
+ return $tasks[0];
+ }
+
+ /**
+ * Get by Class
+ * @param string $class
+ * @return Task
+ * @throws NotFoundException if the task cannot be resolved from the provided route
+ */
+ public function getByClass($class)
+ {
+ $tasks = $this->query(null, array('class' => $class));
+
+ if (count($tasks) <= 0)
+ throw new NotFoundException();
+
+ return $tasks[0];
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return array
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder == null) {
+ $sortOrder = ['name'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+ $select = '
+ SELECT `taskId`, `name`, `status`, `pid`, `configFile`, `class`, `options`, `schedule`,
+ `lastRunDt`, `lastRunStatus`, `lastRunMessage`, `lastRunDuration`, `lastRunExitCode`,
+ `isActive`, `runNow`, `lastRunStartDt`
+ ';
+
+ $body = ' FROM `task`
+ WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getString('name') != null) {
+ $params['name'] = $sanitizedFilter->getString('name');
+ $body .= ' AND `name` = :name ';
+ }
+
+ if ($sanitizedFilter->getString('class') != null) {
+ $params['class'] = $sanitizedFilter->getString('class');
+ $body .= ' AND `class` = :class ';
+ }
+
+ if ($sanitizedFilter->getInt('taskId') !== null) {
+ $params['taskId'] = $sanitizedFilter->getInt('taskId');
+ $body .= ' AND `taskId` = :taskId ';
+ }
+
+ // Sorting?
+ $body .= 'ORDER BY ' . implode(',', $sortOrder);
+
+ // Paging
+ $limit = '';
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $task = $this->create()->hydrate($row, [
+ 'intProperties' => [
+ 'status', 'lastRunStatus', 'nextRunDt', 'lastRunDt', 'lastRunStartDt', 'lastRunExitCode', 'runNow', 'isActive', 'pid'
+ ]
+ ]);
+
+ if ($task->options != null)
+ $task->options = json_decode($task->options, true);
+ else
+ $task->options = [];
+
+ $entries[] = $task;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
diff --git a/lib/Factory/TransitionFactory.php b/lib/Factory/TransitionFactory.php
new file mode 100644
index 0000000..d461cf4
--- /dev/null
+++ b/lib/Factory/TransitionFactory.php
@@ -0,0 +1,149 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\Transition;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class TransitionFactory
+ * @package Xibo\Factory
+ */
+class TransitionFactory extends BaseFactory
+{
+ /**
+ * @return Transition
+ */
+ public function createEmpty()
+ {
+ return new Transition($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * @param int $transitionId
+ * @return Transition
+ * @throws NotFoundException
+ */
+ public function getById($transitionId)
+ {
+ $transitions = $this->query(null, ['transitionId' => $transitionId]);
+
+ if (count($transitions) <= 0)
+ throw new NotFoundException();
+
+ return $transitions[0];
+ }
+
+ /**
+ * Get by Code
+ * @param string $code
+ * @return Transition
+ * @throws NotFoundException
+ */
+ public function getByCode($code)
+ {
+ $transitions = $this->query(null, ['code' => $code]);
+
+ if (count($transitions) <= 0)
+ throw new NotFoundException();
+
+ return $transitions[0];
+ }
+
+ /**
+ * Get enabled by type
+ * @param string $type
+ * @return array[Transition]
+ */
+ public function getEnabledByType($type)
+ {
+ $filter = [];
+
+ if ($type == 'in') {
+ $filter['availableAsIn'] = 1;
+ } else {
+ $filter['availableAsOut'] = 1;
+ }
+
+ return $this->query(null, $filter);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[Transition]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $sql = '
+ SELECT transitionId,
+ transition,
+ `code`,
+ hasDuration,
+ hasDirection,
+ availableAsIn,
+ availableAsOut
+ FROM `transition`
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('transitionId') !== null) {
+ $sql .= ' AND transition.transitionId = :transitionId ';
+ $params['transitionId'] = $sanitizedFilter->getInt('transitionId');
+ }
+
+ if ($sanitizedFilter->getInt('availableAsIn') !== null) {
+ $sql .= ' AND transition.availableAsIn = :availableAsIn ';
+ $params['availableAsIn'] = $sanitizedFilter->getInt('availableAsIn');
+ }
+
+ if ($sanitizedFilter->getInt('availableAsOut') !== null) {
+ $sql .= ' AND transition.availableAsOut = :availableAsOut ';
+ $params['availableAsOut'] = $sanitizedFilter->getInt('availableAsOut');
+ }
+
+ if ($sanitizedFilter->getString('code') != null) {
+ $sql .= ' AND transition.code = :code ';
+ $params['code'] = $sanitizedFilter->getString('code');
+ }
+
+ // Sorting?
+ if (is_array($sortOrder)) {
+ $sql .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/UserFactory.php b/lib/Factory/UserFactory.php
new file mode 100644
index 0000000..cbff4d3
--- /dev/null
+++ b/lib/Factory/UserFactory.php
@@ -0,0 +1,489 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class UserFactory
+ *
+ * @package Xibo\Factory
+ */
+class UserFactory extends BaseFactory
+{
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $configService;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /**
+ * @var UserOptionFactory
+ */
+ private $userOptionFactory;
+
+ /** @var ApplicationScopeFactory */
+ private $applicationScopeFactory;
+
+ /**
+ * Construct a factory
+ * @param ConfigServiceInterface $configService
+ * @param PermissionFactory $permissionFactory
+ * @param UserOptionFactory $userOptionFactory
+ * @param ApplicationScopeFactory $applicationScopeFactory
+ */
+ public function __construct($configService, $permissionFactory, $userOptionFactory, $applicationScopeFactory)
+ {
+ $this->configService = $configService;
+ $this->permissionFactory = $permissionFactory;
+ $this->userOptionFactory = $userOptionFactory;
+ $this->applicationScopeFactory = $applicationScopeFactory;
+ }
+
+ /**
+ * Create a user
+ * @return User
+ */
+ public function create()
+ {
+ return new User($this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->configService,
+ $this,
+ $this->permissionFactory,
+ $this->userOptionFactory,
+ $this->applicationScopeFactory
+ );
+ }
+
+ /**
+ * Get User by ID
+ * @param int $userId
+ * @return User
+ * @throws NotFoundException if the user cannot be found
+ */
+ public function getById($userId)
+ {
+ $users = $this->query(null, array('disableUserCheck' => 1, 'userId' => $userId));
+
+ if (count($users) <= 0) {
+ throw new NotFoundException(__('User not found'));
+ }
+
+ return $users[0];
+ }
+
+ /**
+ * Load by client Id
+ * @param string $clientId
+ * @return mixed
+ * @throws NotFoundException
+ */
+ public function loadByClientId($clientId)
+ {
+ $users = $this->query(null, array('disableUserCheck' => 1, 'clientId' => $clientId));
+
+ if (count($users) <= 0) {
+ throw new NotFoundException(sprintf('User not found'));
+ }
+
+ return $users[0];
+ }
+
+ /**
+ * Get User by Name
+ * @param string $userName
+ * @return User
+ * @throws NotFoundException if the user cannot be found
+ */
+ public function getByName($userName)
+ {
+ $users = $this->query(null, array('disableUserCheck' => 1, 'exactUserName' => $userName));
+
+ if (count($users) <= 0) {
+ throw new NotFoundException(__('User not found'));
+ }
+
+ return $users[0];
+ }
+
+ /**
+ * Get by email
+ * @param string $email
+ * @return User
+ * @throws NotFoundException if the user cannot be found
+ */
+ public function getByEmail($email) {
+ $users = $this->query(null, array('disableUserCheck' => 1, 'email' => $email));
+
+ if (count($users) <= 0) {
+ throw new NotFoundException(__('User not found'));
+ }
+
+ return $users[0];
+ }
+
+ /**
+ * Get by groupId
+ * @param int $groupId
+ * @return array[User]
+ */
+ public function getByGroupId($groupId)
+ {
+ return $this->query(null, array('disableUserCheck' => 1, 'groupIds' => [$groupId]));
+ }
+
+ /**
+ * Get Super Admins
+ * @return User[]
+ */
+ public function getSuperAdmins()
+ {
+ return $this->query(null, array('disableUserCheck' => 1, 'userTypeId' => 1));
+ }
+
+ /**
+ * Get system user
+ * @return User
+ * @throws NotFoundException
+ */
+ public function getSystemUser()
+ {
+ return $this->getById($this->configService->getSetting('SYSTEM_USER'));
+ }
+
+ /**
+ * @param int $homeFolderId
+ * @return User[]
+ */
+ public function getByHomeFolderId(int $homeFolderId)
+ {
+ return $this->query(null, ['homeFolderId' => $homeFolderId]);
+ }
+
+ /**
+ * Query for users
+ * @param array[mixed] $sortOrder
+ * @param array[mixed] $filterBy
+ * @return array[User]
+ */
+ public function query($sortOrder = [], $filterBy = [])
+ {
+ $entries = [];
+ $parsedFilter = $this->getSanitizer($filterBy);
+
+ // Default sort order
+ if ($sortOrder === null || count($sortOrder) <= 0) {
+ $sortOrder = ['userName'];
+ }
+
+ $params = [];
+ $select = '
+ SELECT `user`.userId,
+ userName,
+ userTypeId,
+ email,
+ lastAccessed,
+ newUserWizard,
+ retired,
+ CSPRNG,
+ UserPassword AS password,
+ group.groupId,
+ group.group,
+ `user`.homePageId,
+ `user`.homeFolderId,
+ `folder`.folderName AS homeFolder,
+ `user`.firstName,
+ `user`.lastName,
+ `user`.phone,
+ `user`.ref1,
+ `user`.ref2,
+ `user`.ref3,
+ `user`.ref4,
+ `user`.ref5,
+ IFNULL(group.libraryQuota, 0) AS libraryQuota,
+ `group`.isSystemNotification,
+ `group`.isDisplayNotification,
+ `group`.isDataSetNotification,
+ `group`.isLayoutNotification,
+ `group`.isLibraryNotification,
+ `group`.isReportNotification,
+ `group`.isScheduleNotification,
+ `group`.isCustomNotification,
+ `user`.isPasswordChangeRequired,
+ `user`.twoFactorTypeId,
+ `user`.twoFactorSecret,
+ `user`.twoFactorRecoveryCodes
+ ';
+
+ $body = '
+ FROM `user`
+ INNER JOIN lkusergroup
+ ON lkusergroup.userId = user.userId
+ INNER JOIN `folder`
+ ON `folder`.folderId = `user`.homeFolderId
+ INNER JOIN `group`
+ ON `group`.groupId = lkusergroup.groupId
+ AND isUserSpecific = 1
+ WHERE 1 = 1
+ ';
+
+ if ($parsedFilter->getCheckbox('disableUserCheck') == 0) {
+ // Normal users can only see themselves
+ if ($this->getUser()->userTypeId == 3) {
+ $params['userId'] = $this->getUser()->userId;
+ }
+ // Group admins can only see users from their groups.
+ else if ($this->getUser()->userTypeId == 2) {
+ $body .= '
+ AND user.userId IN (
+ SELECT `otherUserLinks`.userId
+ FROM `lkusergroup`
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 0
+ INNER JOIN `lkusergroup` `otherUserLinks`
+ ON `otherUserLinks`.groupId = `group`.groupId
+ WHERE `lkusergroup`.userId = :currentUserId
+ )
+ ';
+ $params['currentUserId'] = $this->getUser()->userId;
+ }
+ }
+
+ if ($parsedFilter->getInt('notUserId') !== null) {
+ $body .= ' AND user.userId <> :notUserId ';
+ $params['notUserId'] = $parsedFilter->getInt('notUserId');
+ }
+
+ // User Id Provided?
+ if (isset($params['userId'])) {
+ $body .= ' AND user.userId = :userId ';
+ } else if ($parsedFilter->getInt('userId') !== null) {
+ $body .= " AND user.userId = :userId ";
+ $params['userId'] = $parsedFilter->getInt('userId');
+ }
+
+ // Groups Provided
+ $groups = $parsedFilter->getIntArray('groupIds');
+
+ if ($groups !== null && count($groups) > 0) {
+ $body .= ' AND user.userId IN (SELECT userId FROM `lkusergroup` WHERE groupId IN (' . implode(',', $groups) . ')) ';
+ }
+
+ // User Type Provided
+ if ($parsedFilter->getInt('userTypeId') !== null) {
+ $body .= " AND user.userTypeId = :userTypeId ";
+ $params['userTypeId'] = $parsedFilter->getInt('userTypeId');
+ }
+
+ // Home Folder Id
+ if ($parsedFilter->getInt('homeFolderId') !== null) {
+ $body .= ' AND `user`.homeFolderId = :homeFolderId ';
+ $params['homeFolderId'] = $parsedFilter->getInt('homeFolderId');
+ }
+
+ // User Name Provided
+ if ($parsedFilter->getString('exactUserName') != null) {
+ $body .= " AND user.userName = :exactUserName ";
+ $params['exactUserName'] = $parsedFilter->getString('exactUserName');
+ }
+
+ if ($parsedFilter->getString('userName') != null) {
+ $terms = explode(',', $parsedFilter->getString('userName'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'user',
+ 'userName',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ // Email Provided
+ if ($parsedFilter->getString('email') != null) {
+ $body .= " AND user.email = :email ";
+ $params['email'] = $parsedFilter->getString('email');
+ }
+
+ // First Name Provided
+ if ($parsedFilter->getString('firstName') != null) {
+ $body .= " AND user.firstName LIKE :firstName ";
+ $params['firstName'] = '%' . $parsedFilter->getString('firstName') . '%';
+ }
+
+ // Last Name Provided
+ if ($parsedFilter->getString('lastName') != null) {
+ $body .= " AND user.lastName LIKE :lastName ";
+ $params['lastName'] = '%' . $parsedFilter->getString('lastName') . '%';
+ }
+
+ // Retired users?
+ if ($parsedFilter->getInt('retired') !== null) {
+ $body .= " AND user.retired = :retired ";
+ $params['retired'] = $parsedFilter->getInt('retired');
+ }
+
+ if ($parsedFilter->getString('clientId') != null) {
+ $body .= ' AND user.userId = (SELECT userId FROM `oauth_clients` WHERE id = :clientId) ';
+ $params['clientId'] = $parsedFilter->getString('clientId');
+ }
+
+ // Home folderId
+ if ($parsedFilter->getInt('homeFolderId') !== null) {
+ $body .= ' AND user.homeFolderId = :homeFolderId ';
+ $params['homeFolderId'] = $parsedFilter->getInt('homeFolderId');
+ }
+
+ if (in_array('`member`', $sortOrder) || in_array('`member` DESC', $sortOrder)) {
+ $members = [];
+
+ // DisplayGroup members with provided Display Group ID
+ if ($parsedFilter->getInt('userGroupIdMembers') !== null) {
+ foreach ($this->getStore()->select($select . $body, $params) as $row) {
+ $userId = $this->getSanitizer($row)->getInt('userId');
+
+ if ($this->getStore()->exists(
+ 'SELECT userId FROM `lkusergroup` WHERE groupId = :groupId AND userId = :userId ',
+ [
+ 'userId' => $userId,
+ 'groupId' => $parsedFilter->getInt('userGroupIdMembers')
+ ]
+ )) {
+ $members[] = $userId;
+ }
+ }
+ }
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (isset($members) && $members != []) {
+ $sqlOrderMembers = 'ORDER BY FIELD(user.userId,' . implode(',', $members) . ')';
+
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`member`') {
+ $order .= $sqlOrderMembers;
+ continue;
+ }
+
+ if ($sort == '`member` DESC') {
+ $order .= $sqlOrderMembers . ' DESC';
+ continue;
+ }
+ }
+ }
+
+ if (is_array($sortOrder) && (!in_array('`member`', $sortOrder) && !in_array('`member` DESC', $sortOrder))) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($parsedFilter->hasParam('start') && $parsedFilter->hasParam('length')) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0])
+ . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->create()->hydrate($row, [
+ 'intProperties' => [
+ 'libraryQuota',
+ 'isPasswordChangeRequired',
+ 'retired',
+ 'isSystemNotification',
+ 'isDisplayNotification',
+ 'isDataSetNotification',
+ 'isLayoutNotification',
+ 'isReportNotification',
+ 'isScheduleNotification',
+ 'isCustomNotification',
+ ],
+ 'stringProperties' => ['homePageId']
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Get a count of users, with respect to the logged-in user
+ * @return int
+ */
+ public function count(): int
+ {
+ $params = [];
+ $sql = '
+ SELECT COUNT(DISTINCT `user`.userId) AS countOf
+ FROM `user`
+ INNER JOIN `lkusergroup`
+ ON `lkusergroup`.userId = `user`.userId
+ INNER JOIN `folder`
+ ON `folder`.folderId = `user`.homeFolderId
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 1
+ ';
+
+ // Super admins should get a count of all users in the system.
+ if (!$this->getUser()->isSuperAdmin()) {
+ // Non-super admins should only get a count of users in their group
+ $sql .= '
+ WHERE `user`.userId IN (
+ SELECT `otherUserLinks`.userId
+ FROM `lkusergroup`
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 0
+ INNER JOIN `lkusergroup` `otherUserLinks`
+ ON `otherUserLinks`.groupId = `group`.groupId
+ WHERE `lkusergroup`.userId = :currentUserId
+ )
+ ';
+ $params['currentUserId'] = $this->getUser()->userId;
+ }
+
+ // Run the query
+ $results = $this->getStore()->select($sql, $params);
+ return intval($results[0]['countOf'] ?? 0);
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/UserGroupFactory.php b/lib/Factory/UserGroupFactory.php
new file mode 100644
index 0000000..f893190
--- /dev/null
+++ b/lib/Factory/UserGroupFactory.php
@@ -0,0 +1,1070 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Homepage;
+use Xibo\Entity\User;
+use Xibo\Entity\UserGroup;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class UserGroupFactory
+ * @package Xibo\Factory
+ */
+class UserGroupFactory extends BaseFactory
+{
+ /** @var array */
+ private $features = null;
+
+ /** @var array */
+ private $homepages = null;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ /**
+ * Create Empty User Group Object
+ * @return UserGroup
+ */
+ public function createEmpty()
+ {
+ return new UserGroup($this->getStore(), $this->getLog(), $this->getDispatcher(), $this, $this->getUserFactory());
+ }
+
+ /**
+ * Create User Group
+ * @param $userGroup
+ * @param $libraryQuota
+ * @return UserGroup
+ */
+ public function create($userGroup, $libraryQuota)
+ {
+ $group = $this->createEmpty();
+ $group->group = $userGroup;
+ $group->libraryQuota = $libraryQuota;
+
+ return $group;
+ }
+
+ /**
+ * Get by Group Id
+ * @param int $groupId
+ * @return UserGroup
+ * @throws NotFoundException
+ */
+ public function getById($groupId)
+ {
+ $groups = $this->query(null, ['disableUserCheck' => 1, 'groupId' => $groupId, 'isUserSpecific' => -1]);
+
+ if (count($groups) <= 0)
+ throw new NotFoundException(__('Group not found'));
+
+ return $groups[0];
+ }
+
+ /**
+ * Get by Group Name
+ * @param string $group
+ * @param int $isUserSpecific
+ * @return UserGroup
+ * @throws NotFoundException
+ */
+ public function getByName($group, $isUserSpecific = 0)
+ {
+ $groups = $this->query(null, ['disableUserCheck' => 1, 'exactGroup' => $group, 'isUserSpecific' => $isUserSpecific]);
+
+ if (count($groups) <= 0)
+ throw new NotFoundException(__('Group not found'));
+
+ return $groups[0];
+ }
+
+ /**
+ * Get Everyone Group
+ * @return UserGroup
+ * @throws NotFoundException
+ */
+ public function getEveryone()
+ {
+ $groups = $this->query(null, ['disableUserCheck' => 1, 'isEveryone' => 1]);
+
+ if (count($groups) <= 0)
+ throw new NotFoundException(__('Group not found'));
+
+ return $groups[0];
+ }
+
+ /**
+ * Get isSystemNotification Group
+ * @return UserGroup[]
+ */
+ public function getSystemNotificationGroups()
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'isSystemNotification' => 1, 'isUserSpecific' => -1, 'checkRetired' => 1]);
+ }
+
+ /**
+ * Get isDisplayNotification Group
+ * @param int|null $displayGroupId Optionally provide a displayGroupId to restrict to view permissions.
+ * @return UserGroup[]
+ */
+ public function getDisplayNotificationGroups($displayGroupId = null)
+ {
+ return $this->query(null, [
+ 'disableUserCheck' => 1,
+ 'isDisplayNotification' => 1,
+ 'isUserSpecific' => -1,
+ 'displayGroupId' => $displayGroupId,
+ 'checkRetired' => 1
+ ]);
+ }
+
+ /**
+ * Get by User Id
+ * @param int $userId
+ * @return \Xibo\Entity\UserGroup[]
+ */
+ public function getByUserId($userId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $userId, 'isUserSpecific' => 0]);
+ }
+
+ /**
+ * Get User Groups assigned to Notifications
+ * @param int $notificationId
+ * @return array[UserGroup]
+ */
+ public function getByNotificationId($notificationId)
+ {
+ return $this->query(
+ null,
+ ['disableUserCheck' => 1, 'notificationId' => $notificationId, 'isUserSpecific' => -1]
+ );
+ }
+
+ /**
+ * Get by Display Group
+ * @param int $displayGroupId
+ * @return UserGroup[]
+ */
+ public function getByDisplayGroupId($displayGroupId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'displayGroupId' => $displayGroupId]);
+ }
+
+ /**
+ * @param int $userId
+ * @param string $type
+ * @return bool
+ */
+ public function checkNotificationEmailPreferences(int $userId, string $type): bool
+ {
+ // We should include all groups this user is in (including their user specific one).
+ // therefore isUserSpecific should be -1 otherwise it defaults to 0.
+ $groups = $this->query(null, [
+ 'disableUserCheck' => 1,
+ 'isUserSpecific' => -1,
+ 'userId' => $userId,
+ 'notificationType' => $type,
+ ]);
+
+ return count($groups) > 0;
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return UserGroup[]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+ $params = [];
+
+ if ($sortOrder === null) {
+ $sortOrder = ['`group`'];
+ }
+
+ $select = '
+ SELECT `group`.group,
+ `group`.description,
+ `group`.groupId,
+ `group`.isUserSpecific,
+ `group`.isEveryone,
+ `group`.libraryQuota,
+ `group`.isSystemNotification,
+ `group`.isDisplayNotification,
+ `group`.isDataSetNotification,
+ `group`.isLayoutNotification,
+ `group`.isLibraryNotification,
+ `group`.isReportNotification,
+ `group`.isScheduleNotification,
+ `group`.isCustomNotification,
+ `group`.isShownForAddUser,
+ `group`.defaultHomepageId,
+ `group`.features
+ ';
+
+ $body = '
+ FROM `group`
+ WHERE 1 = 1
+ ';
+
+ // Permissions
+ if ($parsedFilter->getCheckbox('disableUserCheck') == 0) {
+ // Normal users can only see their group
+ if ($this->getUser()->userTypeId != 1) {
+ $body .= '
+ AND `group`.groupId IN (
+ SELECT `group`.groupId
+ FROM `lkusergroup`
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 0
+ WHERE `lkusergroup`.userId = :currentUserId
+ )
+ ';
+ $params['currentUserId'] = $this->getUser()->userId;
+ }
+ }
+
+ if ($parsedFilter->getInt('checkRetired') === 1) {
+ $body .= '
+ AND `group`.groupId NOT IN (
+ SELECT `group`.groupId
+ FROM `user`
+ INNER JOIN `lkusergroup`
+ ON `lkusergroup`.userId = `user`.userId
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND isUserSpecific = 1
+ WHERE user.retired = 1
+ )
+ ';
+ }
+
+ // Filter by Group Id
+ if ($parsedFilter->getInt('groupId') !== null) {
+ $body .= ' AND `group`.groupId = :groupId ';
+ $params['groupId'] = $parsedFilter->getInt('groupId');
+ }
+
+ // Filter by Group Name
+ if ($parsedFilter->getString('group') != null) {
+ $terms = explode(',', $parsedFilter->getString('group'));
+ $logicalOperator = $parsedFilter->getString('logicalOperatorName', ['default' => 'OR']);
+ $this->nameFilter(
+ 'group',
+ 'group',
+ $terms,
+ $body,
+ $params,
+ ($parsedFilter->getCheckbox('useRegexForName') == 1),
+ $logicalOperator
+ );
+ }
+
+ if ($parsedFilter->getString('exactGroup') != null) {
+ $body .= ' AND `group`.group = :exactGroup ';
+ $params['exactGroup'] = $parsedFilter->getString('exactGroup');
+ }
+
+ // Filter by User Id
+ if ($parsedFilter->getInt('userId') !== null) {
+ $body .= ' AND `group`.groupId IN (SELECT groupId FROM `lkusergroup` WHERE userId = :userId) ';
+ $params['userId'] = $parsedFilter->getInt('userId');
+ }
+
+ if ($parsedFilter->getInt('isUserSpecific') !== -1) {
+ $body .= ' AND isUserSpecific = :isUserSpecific ';
+ $params['isUserSpecific'] = $parsedFilter->getInt('isUserSpecific', ['default' => 0]);
+ }
+
+ // Always apply isEveryone=0 unless its been provided otherwise.
+ $body .= ' AND isEveryone = :isEveryone ';
+ $params['isEveryone'] = $parsedFilter->getInt('isEveryone', ['default' => 0]);
+
+ if ($parsedFilter->getInt('isSystemNotification') !== null) {
+ $body .= ' AND isSystemNotification = :isSystemNotification ';
+ $params['isSystemNotification'] = $parsedFilter->getInt('isSystemNotification');
+ }
+
+ if ($parsedFilter->getInt('isDisplayNotification') !== null) {
+ $body .= ' AND isDisplayNotification = :isDisplayNotification ';
+ $params['isDisplayNotification'] = $parsedFilter->getInt('isDisplayNotification');
+ }
+
+ if (!empty($parsedFilter->getString('notificationType'))) {
+ $body .= ' AND ' . $parsedFilter->getString('notificationType') . ' = 1 ';
+ }
+
+ if ($parsedFilter->getInt('notificationId') !== null) {
+ $body .= ' AND `group`.groupId IN (
+ SELECT groupId FROM `lknotificationgroup` WHERE notificationId = :notificationId
+ ) ';
+ $params['notificationId'] = $parsedFilter->getInt('notificationId');
+ }
+
+ if ($parsedFilter->getInt('isShownForAddUser') !== null) {
+ $body .= ' AND `group`.isShownForAddUser = :isShownForAddUser ';
+ $params['isShownForAddUser'] = $parsedFilter->getInt('isShownForAddUser');
+ }
+
+ if ($parsedFilter->getInt('displayGroupId') !== null) {
+ $body .= '
+ AND `group`.groupId IN (
+ SELECT DISTINCT `permission`.groupId
+ FROM `permission`
+ INNER JOIN `permissionentity`
+ ON `permissionentity`.entityId = permission.entityId
+ AND `permissionentity`.entity = \'Xibo\\Entity\\DisplayGroup\'
+ WHERE `permission`.objectId = :displayGroupId
+ AND `permission`.view = 1
+ )
+ ';
+ $params['displayGroupId'] = $parsedFilter->getInt('displayGroupId');
+ }
+
+ if (in_array('`member`', $sortOrder) || in_array('`member` DESC', $sortOrder)) {
+ $members = [];
+
+ // DisplayGroup members with provided Display Group ID
+ if ($parsedFilter->getInt('userIdMember') !== null) {
+ foreach ($this->getStore()->select($select . $body, $params) as $row) {
+ $userGroupId = $this->getSanitizer($row)->getInt('groupId');
+
+ if ($this->getStore()->exists(
+ 'SELECT groupId FROM `lkusergroup` WHERE userId = :userId AND groupId = :groupId ',
+ [
+ 'groupId' => $userGroupId,
+ 'userId' => $parsedFilter->getInt('userIdMember')
+ ]
+ )) {
+ $members[] = $userGroupId;
+ }
+ }
+ }
+ }
+
+ // Sorting?
+ $order = '';
+
+ if (isset($members) && $members != []) {
+ $sqlOrderMembers = 'ORDER BY FIELD(group.groupId,' . implode(',', $members) . ')';
+
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`member`') {
+ $order .= $sqlOrderMembers;
+ continue;
+ }
+
+ if ($sort == '`member` DESC') {
+ $order .= $sqlOrderMembers . ' DESC';
+ continue;
+ }
+ }
+ }
+
+ if (is_array($sortOrder) && (!in_array('`member`', $sortOrder) && !in_array('`member` DESC', $sortOrder))) {
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($parsedFilter->hasParam('start') && $parsedFilter->hasParam('length')) {
+ $limit = ' LIMIT ' . $parsedFilter->getInt('start', ['default' => 0])
+ . ', ' . $parsedFilter->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $group = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => [
+ 'isUserSpecific',
+ 'isEveryone',
+ 'libraryQuota',
+ 'isSystemNotification',
+ 'isDisplayNotification',
+ 'isDataSetNotification',
+ 'isLayoutNotification',
+ 'isReportNotification',
+ 'isScheduleNotification',
+ 'isCustomNotification',
+ 'isShownForAddUser'
+ ],
+ 'stringProperties' => [
+ 'defaultHomepageId'
+ ]
+ ]);
+
+ // Parse the features JSON string stored in database
+ $group->features = ($group->features === null) ? [] : json_decode($group->features, true);
+
+ $entries[] = $group;
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @param \Xibo\Entity\User $user The User
+ * @param bool $includeIsUser
+ * @return array
+ */
+ public function getGroupFeaturesForUser($user, $includeIsUser = true)
+ {
+ $features = [];
+
+ foreach ($this->getStore()->select('
+ SELECT `group`.groupId, `group`.features
+ FROM `group`
+ WHERE `group`.groupId = :groupId
+ OR `group`.groupId IN (SELECT groupId FROM lkusergroup WHERE userId = :userId)
+ ', [
+ 'userId' => $user->userId,
+ 'groupId' => $user->groupId
+ ]) as $featureString
+ ) {
+ if (!$includeIsUser && $user->groupId == $featureString['groupId']) {
+ continue;
+ }
+
+ $feature = ($featureString['features'] == null) ? [] : json_decode($featureString['features'], true);
+ $features = array_merge($feature, $features);
+ }
+
+ return $features;
+ }
+
+ /**
+ * @param string $group
+ * @return array
+ */
+ public function getFeaturesByGroup(string $group)
+ {
+ $groupFeatures = [];
+ foreach ($this->getFeatures() as $feature) {
+ if ($feature['group'] === $group) {
+ $groupFeatures[] = $feature;
+ }
+ }
+ return $groupFeatures;
+ }
+
+ /**
+ * Populate the core system features and homepages
+ * @return array
+ */
+ public function getFeatures()
+ {
+ if ($this->features === null) {
+ $this->features = [
+ 'schedule.view' => [
+ 'feature' => 'schedule.view',
+ 'group' => 'scheduling',
+ 'title' => __('Page which shows all Events added to the Calendar for the purposes of Schedule Management')
+ ],
+ 'schedule.agenda' => [
+ 'feature' => 'schedule.agenda',
+ 'group' => 'scheduling',
+ 'title' => __('Include the Agenda View on the Calendar')
+ ],
+ 'schedule.add' => [
+ 'feature' => 'schedule.add',
+ 'group' => 'scheduling',
+ 'title' => __('Include "Add Event" button to allow for the creation of new Scheduled Events')
+ ],
+ 'schedule.modify' => [
+ 'feature' => 'schedule.modify',
+ 'group' => 'scheduling',
+ 'title' => __('Allow edits including deletion of existing Scheduled Events')
+ ],
+ 'schedule.sync' => [
+ 'feature' => 'schedule.sync',
+ 'group' => 'scheduling',
+ 'title' => __('Allow creation of Synchronised Schedules')
+ ],
+ 'schedule.dataConnector' => [
+ 'feature' => 'schedule.dataConnector',
+ 'group' => 'scheduling',
+ 'title' => __('Allow creation of Data Connector Schedules')
+ ],
+ 'schedule.geoLocation' => [
+ 'feature' => 'schedule.geoLocation',
+ 'group' => 'scheduling',
+ 'title' => __('Geo Schedule - allow events to be location aware when scheduling')
+ ],
+ 'schedule.reminders' => [
+ 'feature' => 'schedule.reminders',
+ 'group' => 'scheduling',
+ 'title' => __('Reminders - allow reminders to be added to events when scheduling')
+ ],
+ 'schedule.criteria' => [
+ 'feature' => 'schedule.criteria',
+ 'group' => 'scheduling',
+ 'title' => __('Criteria - allow criteria to be set to determine when the event is active when scheduling')
+ ],
+ 'daypart.view' => [
+ 'feature' => 'daypart.view',
+ 'group' => 'scheduling',
+ 'title' => __('Page which shows all Dayparts that have been created')
+ ],
+ 'daypart.add' => [
+ 'feature' => 'daypart.add',
+ 'group' => 'scheduling',
+ 'title' => __('Include "Add Daypart" button to allow for the creation of new Dayparts')
+ ],
+ 'daypart.modify' => [
+ 'feature' => 'daypart.modify',
+ 'group' => 'scheduling',
+ 'title' => __('Allow edits including deletion to be made to all created Dayparts')
+ ],
+ 'library.view' => [
+ 'feature' => 'library.view',
+ 'group' => 'library',
+ 'title' => __('Page which shows all items that have been uploaded to the Library for the purposes of Media Management')
+ ],
+ 'library.add' => [
+ 'feature' => 'library.add',
+ 'group' => 'library',
+ 'title' => __('Include "Add Media" buttons to allow for additional content to be uploaded to the Media Library')
+ ],
+ 'library.modify' => [
+ 'feature' => 'library.modify',
+ 'group' => 'library',
+ 'title' => __('Allow edits including deletion to all items uploaded to the Media Library')
+ ],
+ 'dataset.view' => [
+ 'feature' => 'dataset.view',
+ 'group' => 'library',
+ 'title' => __('Page which shows all DataSets that have been created which can be used in multiple Layouts')
+ ],
+ 'dataset.add' => [
+ 'feature' => 'dataset.add',
+ 'group' => 'library',
+ 'title' => __('Include "Add DataSet" button to allow for additional DataSets to be created independently to Layouts')
+ ],
+ 'dataset.modify' => [
+ 'feature' => 'dataset.modify',
+ 'group' => 'library',
+ 'title' => __('Allow edits including deletion to all created DataSets independently to Layouts')
+ ],
+ 'dataset.data' => [
+ 'feature' => 'dataset.data',
+ 'group' => 'library',
+ 'title' => __('Allow edits including deletion to all data contained within a DataSet independently to Layouts')
+ ],
+ 'dataset.dataConnector' => [
+ 'feature' => 'dataset.realtime',
+ 'group' => 'library',
+ 'title' => __('Create and update real time DataSets')
+ ],
+ 'layout.view' => [
+ 'feature' => 'layout.view',
+ 'group' => 'layout-design',
+ 'title' => __('Page which shows all Layouts that have been created for the purposes of Layout Management')
+ ],
+ 'layout.add' => [
+ 'feature' => 'layout.add',
+ 'group' => 'layout-design',
+ 'title' => __('Include "Add Layout" button to allow for additional Layouts to be created')
+ ],
+ 'layout.modify' => [
+ 'feature' => 'layout.modify',
+ 'group' => 'layout-design',
+ 'title' => __('Allow edits including deletion to be made to all created Layouts')
+ ],
+ 'layout.export' => [
+ 'feature' => 'layout.export',
+ 'group' => 'layout-design',
+ 'title' => __('Include the Export function for all editable Layouts to allow a User to export a Layout and its contents regardless of the share options that have been set')
+ ],
+ 'campaign.view' => [
+ 'feature' => 'campaign.view',
+ 'group' => 'campaigns',
+ 'title' => __('Page which shows all Campaigns that have been created for the purposes of Campaign Management')
+ ],
+ 'campaign.add' => [
+ 'feature' => 'campaign.add',
+ 'group' => 'campaigns',
+ 'title' => __('Include "Add Campaign" button to allow for additional Campaigns to be created')
+ ],
+ 'campaign.modify' => [
+ 'feature' => 'campaign.modify',
+ 'group' => 'campaigns',
+ 'title' => __('Allow edits including deletion to all created Campaigns')
+ ],
+ 'ad.campaign' => [
+ 'feature' => 'ad.campaign',
+ 'group' => 'campaigns',
+ 'title' => __('Access to Ad Campaigns')
+ ],
+ 'template.view' => [
+ 'feature' => 'template.view',
+ 'group' => 'layout-design',
+ 'title' => __('Page which shows all Templates that have been saved')
+ ],
+ 'template.add' => [
+ 'feature' => 'template.add',
+ 'group' => 'layout-design',
+ 'title' => __('Add "Save Template" function for all Layouts')
+ ],
+ 'template.modify' => [
+ 'feature' => 'template.modify',
+ 'group' => 'layout-design',
+ 'title' => __('Allow edits to be made to all saved Templates')
+ ],
+ 'resolution.view' => [
+ 'feature' => 'resolution.view',
+ 'group' => 'layout-design',
+ 'title' => __('Page which shows all Resolutions that have been added to the platform')
+ ],
+ 'resolution.add' => [
+ 'feature' => 'resolution.add',
+ 'group' => 'layout-design',
+ 'title' => __('Add Resolution button to allow for additional Resolutions to be added')
+ ],
+ 'resolution.modify' => [
+ 'feature' => 'resolution.modify',
+ 'group' => 'layout-design',
+ 'title' => __('Allow edits including deletion to all added Resolutions')
+ ],
+ 'tag.view' => [
+ 'feature' => 'tag.view',
+ 'group' => 'tagging',
+ 'title' => __('Page which shows all Tags that have been added for the purposes of Tag Management')
+ ],
+ 'tag.tagging' => [
+ 'feature' => 'tag.tagging',
+ 'group' => 'tagging',
+ 'title' => __('Ability to add and edit Tags when assigning to items')
+ ],
+ 'playlist.view' => [
+ 'feature' => 'playlist.view',
+ 'group' => 'playlist-design',
+ 'title' => __('Page which shows all Playlists that have been created which can be used in multiple Layouts')
+ ],
+ 'playlist.add' => [
+ 'feature' => 'playlist.add',
+ 'group' => 'playlist-design',
+ 'title' => __('Include "Add Playlist" button to allow for additional Playlists to be created independently to Layouts')
+ ],
+ 'playlist.modify' => [
+ 'feature' => 'playlist.modify',
+ 'group' => 'playlist-design',
+ 'title' => __('Allow edits including deletion to all created Playlists independently to Layouts')
+ ],
+ 'user.profile' => [
+ 'feature' => 'user.profile',
+ 'group' => 'users',
+ 'title' => __('Ability to update own Profile, including changing passwords and authentication preferences')
+ ],
+ 'drawer' => [
+ 'feature' => 'drawer',
+ 'group' => 'users',
+ 'title' => __('Notifications appear in the navigation bar')
+ ],
+ 'notification.centre' => [
+ 'feature' => 'notification.centre',
+ 'group' => 'notifications',
+ 'title' => __('Access to the Notification Centre to view past notifications')
+ ],
+ 'application.view' => [
+ 'feature' => 'application.view',
+ 'group' => 'users',
+ 'title' => __('Access to API applications')
+ ],
+ 'user.sharing' => [
+ 'feature' => 'user.sharing',
+ 'group' => 'users',
+ 'title' => __('Allow Sharing capabilities for all User objects')
+ ],
+ 'notification.add' => [
+ 'feature' => 'notification.add',
+ 'group' => 'notifications',
+ 'title' => __('Include "Add Notification" button to allow for the creation of new notifications')
+ ],
+ 'notification.modify' => [
+ 'feature' => 'notification.modify',
+ 'group' => 'notifications',
+ 'title' => __('Allow edits including deletion for all notifications in the Notification Centre')
+ ],
+ 'users.view' => [
+ 'feature' => 'users.view',
+ 'group' => 'users-management',
+ 'title' => __('Page which shows all Users in the platform for the purposes of User Management')
+ ],
+ 'users.add' => [
+ 'feature' => 'users.add',
+ 'group' => 'users-management',
+ 'title' => __('Include "Add User" button to allow for additional Users to be added to the platform')
+ ],
+ 'users.modify' => [
+ 'feature' => 'users.modify',
+ 'group' => 'users-management',
+ 'title' => __('Allow Group Admins to edit including deletion, for all added Users within their group')
+ ],
+ 'usergroup.view' => [
+ 'feature' => 'usergroup.view',
+ 'group' => 'users-management',
+ 'title' => __('Page which shows all User Groups that have been created')
+ ],
+ 'usergroup.modify' => [
+ 'feature' => 'usergroup.modify',
+ 'group' => 'users-management',
+ 'title' => __('Allow edits including deletion for all created User Groups')
+ ],
+ 'dashboard.status' => [
+ 'feature' => 'dashboard.status',
+ 'group' => 'dashboards',
+ 'title' => __('Status Dashboard showing key platform metrics, suitable for an Administrator.')
+ ],
+ 'dashboard.media.manager' => [
+ 'feature' => 'dashboard.media.manager',
+ 'group' => 'dashboards',
+ 'title' => __('Media Manager Dashboard showing only the Widgets the user has access to modify.')
+ ],
+ 'dashboard.playlist' => [
+ 'feature' => 'dashboard.playlist',
+ 'group' => 'dashboards',
+ 'title' => __('Playlist Dashboard showing only the Playlists configured in Layouts the user has access to modify.')
+ ],
+ 'displays.view' => [
+ 'feature' => 'displays.view',
+ 'group' => 'displays',
+ 'title' => __('Page which shows all Displays added to the platform for the purposes of Display Management')
+ ],
+ 'displays.add' => [
+ 'feature' => 'displays.add',
+ 'group' => 'displays',
+ 'title' => __('Include "Add Display" button to allow additional Displays to be added to the platform')
+ ],
+ 'displays.modify' => [
+ 'feature' => 'displays.modify',
+ 'group' => 'displays',
+ 'title' => __('Allow edits including deletion for all added Displays')
+ ],
+ 'displays.limitedView' => [
+ 'feature' => 'displays.limitedView',
+ 'group' => 'displays',
+ 'title' => __('Allow access to non-destructive edit-only features')
+ ],
+ 'displaygroup.view' => [
+ 'feature' => 'displaygroup.view',
+ 'group' => 'displays',
+ 'title' => __('Page which shows all Display Groups that have been created')
+ ],
+ 'displaygroup.add' => [
+ 'feature' => 'displaygroup.add',
+ 'group' => 'displays',
+ 'title' => __('Include "Add Display Group" button to allow for the creation of additional Display Groups')
+ ],
+ 'displaygroup.modify' => [
+ 'feature' => 'displaygroup.modify',
+ 'group' => 'displays',
+ 'title' => __('Allow edits including deletion for all created Display Groups')
+ ],
+ 'displaygroup.limitedView' => [
+ 'feature' => 'displaygroup.limitedView',
+ 'group' => 'displays',
+ 'title' => __('Allow access to non-destructive edit-only features in a Display Group')
+ ],
+ 'displayprofile.view' => [
+ 'feature' => 'displayprofile.view',
+ 'group' => 'displays',
+ 'title' => __('Page which shows all Display Setting Profiles that have been added')
+ ],
+ 'displayprofile.add' => [
+ 'feature' => 'displayprofile.add',
+ 'group' => 'displays',
+ 'title' => __('Include "Add Profile" button to allow for additional Display Setting Profiles to be added to the platform')
+ ],
+ 'displayprofile.modify' => [
+ 'feature' => 'displayprofile.modify',
+ 'group' => 'displays',
+ 'title' => __('Allow edits including deletion for all created Display Setting Profiles')
+ ],
+ 'playersoftware.view' => [
+ 'feature' => 'playersoftware.view',
+ 'group' => 'displays',
+ 'title' => __('Page to view/add/edit/delete/download Player Software Versions')
+ ],
+ 'command.view' => [
+ 'feature' => 'command.view',
+ 'group' => 'displays',
+ 'title' => __('Page to view/add/edit/delete Commands')
+ ],
+ 'display.syncView' => [
+ 'feature' => 'display.syncView',
+ 'group' => 'displays',
+ 'title' => __('Page which shows all Sync Groups added to the platform for the purposes of Sync Group Management')
+ ],
+ 'display.syncAdd' => [
+ 'feature' => 'display.syncAdd',
+ 'group' => 'displays',
+ 'title' => __('Allow creation of Synchronised Groups')
+ ],
+ 'display.syncModify' => [
+ 'feature' => 'display.syncModify',
+ 'group' => 'displays',
+ 'title' => __('Allow edits of Synchronised Groups')
+ ],
+ 'fault.view' => [
+ 'feature' => 'fault.view',
+ 'group' => 'troubleshooting',
+ 'title' => __('Access to a Report Fault wizard for collecting reports to forward to the support team for analysis, which may contain sensitive data.')
+ ],
+ 'log.view' => [
+ 'feature' => 'log.view',
+ 'group' => 'troubleshooting',
+ 'title' => __('Page to show debug and error logging which may contain sensitive data')
+ ],
+ 'session.view' => [
+ 'feature' => 'session.view',
+ 'group' => 'troubleshooting',
+ 'title' => __('Page to show all User Sessions throughout the platform')
+ ],
+ 'auditlog.view' => [
+ 'feature' => 'auditlog.view',
+ 'group' => 'troubleshooting',
+ 'title' => __('Page to show the Audit Trail for all created/modified and removed items throughout the platform')
+ ],
+ 'module.view' => [
+ 'feature' => 'module.view',
+ 'group' => 'system',
+ 'title' => __('Page which allows for Module Management for the platform')
+ ],
+ 'developer.edit' => [
+ 'feature' => 'developer.edit',
+ 'group' => 'system',
+ 'title' => __('Add/Edit custom modules and templates'),
+ ],
+ 'developer.delete' => [
+ 'feature' => 'developer.delete',
+ 'group' => 'system',
+ 'title' => __('Delete custom modules and templates'),
+ ],
+ 'transition.view' => [
+ 'feature' => 'transition.view',
+ 'group' => 'system',
+ 'title' => __('Page which allows for Transition Management for the platform')
+ ],
+ 'task.view' => [
+ 'feature' => 'task.view',
+ 'group' => 'system',
+ 'title' => __('Page which allows for Task Management for the platform')
+ ],
+ 'report.view' => [
+ 'feature' => 'report.view',
+ 'group' => 'reporting',
+ 'title' => __('Dashboard which shows all available Reports')
+ ],
+ 'displays.reporting' => [
+ 'feature' => 'displays.reporting',
+ 'group' => 'reporting',
+ 'title' => __('Display Reports to show bandwidth usage and time connected / disconnected')
+ ],
+ 'proof-of-play' => [
+ 'feature' => 'proof-of-play',
+ 'group' => 'reporting',
+ 'title' => __('Proof of Play Reports which include summary and distribution by Layout, Media or Event')
+ ],
+ 'report.scheduling' => [
+ 'feature' => 'report.scheduling',
+ 'group' => 'reporting',
+ 'title' => __('Page which shows all Reports that have been Scheduled')
+ ],
+ 'report.saving' => [
+ 'feature' => 'report.saving',
+ 'group' => 'reporting',
+ 'title' => __('Page which shows all Reports that have been Saved')
+ ],
+ 'folder.view' => [
+ 'feature' => 'folder.view',
+ 'group' => 'folders',
+ 'title' => __('View Folder Tree on Grids and Forms')
+ ],
+ 'folder.add' => [
+ 'feature' => 'folder.add',
+ 'group' => 'folders',
+ 'title' => __('Allow users to create Sub-Folders under Folders they have access to. (Except the Root Folder)')
+ ],
+ 'folder.modify' => [
+ 'feature' => 'folder.modify',
+ 'group' => 'folders',
+ 'title' => __('Rename and Delete existing Folders')
+ ],
+ 'folder.userHome' => [
+ 'feature' => 'folder.userHome',
+ 'group' => 'folders',
+ 'title' => __('Set a home folder for a user')
+ ],
+ 'menuBoard.view' => [
+ 'feature' => 'menuBoard.view',
+ 'group' => 'menuboard-design',
+ 'title' => __('View the Menu Board page')
+ ],
+ 'menuBoard.add' => [
+ 'feature' => 'menuBoard.add',
+ 'group' => 'menuboard-design',
+ 'title' => __('Include "Add Menu Board" button to allow for additional Menu Boards to be added to the platform')
+ ],
+ 'menuBoard.modify' => [
+ 'feature' => 'menuBoard.modify',
+ 'group' => 'menuboard-design',
+ 'title' => __('Allow edits, creation of Menu Board Categories and Products including deletion for all created Menu Board content')
+ ],
+ 'font.view' => [
+ 'feature' => 'font.view',
+ 'group' => 'fonts',
+ 'title' => __('View the Fonts page')
+ ],
+ 'font.add' => [
+ 'feature' => 'font.add',
+ 'group' => 'fonts',
+ 'title' => __('Upload new Fonts')
+ ],
+ 'font.delete' => [
+ 'feature' => 'font.delete',
+ 'group' => 'fonts',
+ 'title' => __('Delete existing Fonts')
+ ]
+ ];
+ }
+ return $this->features;
+ }
+
+ /**
+ * @param string|null $homepage The home page id
+ * @return \Xibo\Entity\Homepage
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getHomepageByName(?string $homepage): Homepage
+ {
+ if (empty($homepage)) {
+ throw new NotFoundException(__('Homepage has not been set'));
+ }
+
+ $homepages = $this->getHomepages();
+
+ if (!array_key_exists($homepage, $homepages)) {
+ throw new NotFoundException(sprintf(__('Homepage %s not found.'), $homepage));
+ }
+
+ return $homepages[$homepage];
+ }
+
+ /**
+ * @return \Xibo\Entity\Homepage[]
+ */
+ public function getHomepages()
+ {
+ if ($this->homepages === null) {
+ $this->homepages = [
+ 'statusdashboard.view' => new Homepage(
+ 'statusdashboard.view',
+ 'dashboard.status',
+ __('Status Dashboard'),
+ __('Status Dashboard showing key platform metrics, usually for an administrator.')
+ ),
+ 'icondashboard.view' => new Homepage(
+ 'icondashboard.view',
+ '',
+ __('Icon Dashboard'),
+ __('Icon Dashboard showing an easy access set of feature icons the user can access.')
+ ),
+ 'mediamanager.view' => new Homepage(
+ 'mediamanager.view',
+ 'dashboard.media.manager',
+ __('Media Manager Dashboard'),
+ __('Media Manager Dashboard showing all Widgets the user has access to modify.')
+ ),
+ 'playlistdashboard.view' => new Homepage(
+ 'playlistdashboard.view',
+ 'dashboard.playlist',
+ __('Playlist Dashboard'),
+ __('Playlist Dashboard showing all Playlists configured in Layouts the user has access to modify.')
+ ),
+ ];
+ }
+
+ return $this->homepages;
+ }
+
+ /**
+ * @param string $feature
+ * @param string $title
+ * @return $this
+ */
+ public function registerCustomFeature(string $feature, string $title)
+ {
+ $this->getFeatures();
+
+ if (!array_key_exists($feature, $this->features)) {
+ $this->features[$feature] = [
+ 'feature' => $feature,
+ 'group' => 'custom',
+ 'title' => $title
+ ];
+ }
+ return $this;
+ }
+
+ /**
+ * @param string $homepage
+ * @param string $title
+ * @param string $description
+ * @param string $feature
+ * @return $this
+ */
+ public function registerCustomHomepage(string $homepage, string $title, string $description, string $feature)
+ {
+ $this->getHomepages();
+
+ if (!array_key_exists($homepage, $this->homepages)) {
+ $this->homepages[$homepage] = new Homepage(
+ $homepage,
+ $feature,
+ $title,
+ $description
+ );
+ }
+ return $this;
+ }
+}
diff --git a/lib/Factory/UserNotificationFactory.php b/lib/Factory/UserNotificationFactory.php
new file mode 100644
index 0000000..4a392a6
--- /dev/null
+++ b/lib/Factory/UserNotificationFactory.php
@@ -0,0 +1,222 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use Xibo\Entity\User;
+use Xibo\Entity\UserNotification;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class UserGroupNotificationFactory
+ * @package Xibo\Factory
+ */
+class UserNotificationFactory extends BaseFactory
+{
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ */
+ public function __construct($user, $userFactory)
+ {
+ $this->setAclDependencies($user, $userFactory);
+ }
+
+ /**
+ * @return UserNotification
+ */
+ public function createEmpty()
+ {
+ return new UserNotification($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create User Notification
+ * @param $subject
+ * @param $body
+ * @return UserNotification
+ */
+ public function create($subject, $body = '')
+ {
+ $notification = $this->createEmpty();
+ $notification->subject = $subject;
+ $notification->body = $body;
+ $notification->userId = $this->getUser()->userId;
+ $notification->releaseDt = Carbon::now()->format('U');
+
+ return $notification;
+ }
+
+ /**
+ * Get by NotificationId
+ * @param int $notificationId
+ * @return UserNotification
+ * @throws AccessDeniedException
+ */
+ public function getByNotificationId($notificationId)
+ {
+ $notifications = $this->query(null, ['userId' => $this->getUser()->userId, 'notificationId' => $notificationId]);
+
+ if (count($notifications) <= 0)
+ throw new AccessDeniedException();
+
+ return $notifications[0];
+ }
+
+ /**
+ * Get my notifications
+ * @param int $length
+ * @return UserNotification[]
+ */
+ public function getMine($length = 5)
+ {
+ return $this->query(null, ['userId' => $this->getUser()->userId, 'start' => 0, 'length' => $length]);
+ }
+
+ /**
+ * Get email notification queue
+ * @return UserNotification[]
+ */
+ public function getEmailQueue()
+ {
+ return $this->query(null, ['isEmailed' => 0, 'checkRetired' => 1]);
+ }
+
+ /**
+ * Count My Unread
+ * @return int
+ */
+ public function countMyUnread()
+ {
+ return $this->getStore()->select('
+ SELECT COUNT(*) AS Cnt
+ FROM `lknotificationuser`
+ INNER JOIN `notification`
+ ON `notification`.notificationId = `lknotificationuser`.notificationId
+ WHERE `lknotificationuser`.`userId` = :userId
+ AND `lknotificationuser`.`read` = 0
+ AND `notification`.releaseDt < :now
+ ', [
+ 'now' => Carbon::now()->format('U'), 'userId' => $this->getUser()->userId
+ ])[0]['Cnt'];
+ }
+
+ /**
+ * @param array[Optional] $sortOrder
+ * @param array[Optional] $filterBy
+ * @return array[UserNotification]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $parsedBody = $this->getSanitizer($filterBy);
+
+ if ($sortOrder == null) {
+ $sortOrder = ['releaseDt DESC'];
+ }
+
+ $params = ['now' => Carbon::now()->format('U')];
+ $select = 'SELECT `lknotificationuser`.lknotificationuserId,
+ `lknotificationuser`.notificationId,
+ `lknotificationuser`.userId,
+ `lknotificationuser`.read,
+ `lknotificationuser`.readDt,
+ `lknotificationuser`.emailDt,
+ `notification`.subject,
+ `notification`.body,
+ `notification`.releaseDt,
+ `notification`.isInterrupt,
+ `notification`.isSystem,
+ `notification`.filename,
+ `notification`.originalFileName,
+ `notification`.nonusers,
+ `notification`.type,
+ `user`.email,
+ `user`.retired
+ ';
+
+ $body = ' FROM `lknotificationuser`
+ INNER JOIN `notification`
+ ON `notification`.notificationId = `lknotificationuser`.notificationId
+ LEFT OUTER JOIN `user`
+ ON `user`.userId = `lknotificationuser`.userId
+ ';
+
+ $body .= ' WHERE `notification`.releaseDt < :now ';
+
+ if ($parsedBody->getInt('notificationId') !== null) {
+ $body .= ' AND `lknotificationuser`.notificationId = :notificationId ';
+ $params['notificationId'] = $parsedBody->getInt('notificationId');
+ }
+
+ if ($parsedBody->getInt('userId') !== null) {
+ $body .= ' AND `lknotificationuser`.userId = :userId ';
+ $params['userId'] = $parsedBody->getInt('userId');
+ }
+
+ if ($parsedBody->getInt('read') !== null) {
+ $body .= ' AND `lknotificationuser`.read = :read ';
+ $params['read'] = $parsedBody->getInt('read');
+ }
+
+ if ($parsedBody->getInt('isEmailed') !== null) {
+ if ($parsedBody->getInt('isEmailed') == 0) {
+ $body .= ' AND `lknotificationuser`.emailDt = 0 ';
+ } else {
+ $body .= ' AND `lknotificationuser`.emailDt <> 0 ';
+ }
+ }
+
+ if ($parsedBody->getInt('checkRetired') === 1) {
+ $body .= ' AND `user`.retired = 0 ';
+ }
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $parsedBody->getInt('start') !== null && $parsedBody->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $parsedBody->getInt('start', ['default' => 0]) . ', ' . $parsedBody->getInt('length');
+ }
+
+ $sql = $select . $body . $order . $limit;
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/UserOptionFactory.php b/lib/Factory/UserOptionFactory.php
new file mode 100644
index 0000000..f02fc11
--- /dev/null
+++ b/lib/Factory/UserOptionFactory.php
@@ -0,0 +1,90 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\UserOption;
+
+/**
+ * Class UserOptionFactory
+ * @package Xibo\Factory
+ */
+class UserOptionFactory extends BaseFactory
+{
+ /**
+ * Load by User Id
+ * @param int $userId
+ * @return array[UserOption]
+ */
+ public function getByUserId($userId)
+ {
+ return $this->query(null, array('userId' => $userId));
+ }
+
+ /**
+ * Create Empty
+ * @return UserOption
+ */
+ public function createEmpty()
+ {
+ return new UserOption($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create a user option
+ * @param int $userId
+ * @param string $option
+ * @param mixed $value
+ * @return UserOption
+ */
+ public function create($userId, $option, $value)
+ {
+ $userOption = $this->createEmpty();
+ $userOption->userId = $userId;
+ $userOption->option = $option;
+ $userOption->value = $value;
+
+ return $userOption;
+ }
+
+ /**
+ * Query User options
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[UserOption]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $parsedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+
+ $sql = 'SELECT * FROM `useroption` WHERE userId = :userId';
+
+ foreach ($this->getStore()->select($sql,['userId' => $parsedFilter->getInt('userId')]) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/UserTypeFactory.php b/lib/Factory/UserTypeFactory.php
new file mode 100644
index 0000000..909f50d
--- /dev/null
+++ b/lib/Factory/UserTypeFactory.php
@@ -0,0 +1,110 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\User;
+use Xibo\Entity\UserType;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class UserTypeFactory
+ * @package Xibo\Factory
+ */
+class UserTypeFactory extends BaseFactory
+{
+ /**
+ * @return UserType
+ */
+ public function createEmpty()
+ {
+ return new UserType($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * @return User[]
+ * @throws NotFoundException
+ */
+ public function getAllRoles()
+ {
+ return $this->query();
+ }
+
+ /**
+ * @return User[]
+ * @throws NotFoundException
+ */
+ public function getNonAdminRoles()
+ {
+ return $this->query(null, ['userOnly' => 1]);
+ }
+
+ /**
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[Transition]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = ['userType'], $filterBy = [])
+ {
+ $entries = [];
+ $params = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ try {
+ $sql = '
+ SELECT userTypeId, userType
+ FROM `usertype`
+ WHERE 1 = 1
+ ';
+
+ if ($sanitizedFilter->getInt('userOnly') !== null) {
+ $sql .= ' AND `userTypeId` = 3 ';
+ }
+
+ if ($sanitizedFilter->getString('userType') !== null) {
+ $sql .= ' AND userType = :userType ';
+ $params['userType'] = $sanitizedFilter->getString('userType');
+ }
+
+ // Sorting?
+ if (is_array($sortOrder))
+ $sql .= 'ORDER BY ' . implode(',', $sortOrder);
+
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+
+ } catch (\Exception $e) {
+
+ $this->getLog()->error($e);
+
+ throw new NotFoundException();
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/WidgetAudioFactory.php b/lib/Factory/WidgetAudioFactory.php
new file mode 100644
index 0000000..c1ff290
--- /dev/null
+++ b/lib/Factory/WidgetAudioFactory.php
@@ -0,0 +1,73 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\WidgetAudio;
+
+/**
+ * Class WidgetAudioFactory
+ * @package Xibo\Factory
+ */
+class WidgetAudioFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return WidgetAudio
+ */
+ public function createEmpty()
+ {
+ return new WidgetAudio($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Media Linked to Widgets by WidgetId
+ * @param int $widgetId
+ * @return array[int]
+ */
+ public function getByWidgetId($widgetId)
+ {
+ return $this->query(null, array('widgetId' => $widgetId));
+ }
+
+ /**
+ * Query Media Linked to Widgets
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[int]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $entries = [];
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $sql = 'SELECT `mediaId`, `widgetId`, `volume`, `loop` FROM `lkwidgetaudio` WHERE widgetId = :widgetId AND mediaId <> 0 ';
+
+ foreach ($this->getStore()->select($sql, ['widgetId' => $sanitizedFilter->getInt('widgetId')]) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => ['duration']]);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Factory/WidgetDataFactory.php b/lib/Factory/WidgetDataFactory.php
new file mode 100644
index 0000000..bb1c5bd
--- /dev/null
+++ b/lib/Factory/WidgetDataFactory.php
@@ -0,0 +1,153 @@
+.
+ */
+
+namespace Xibo\Factory;
+
+use Carbon\Carbon;
+use Xibo\Entity\WidgetData;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Factory for returning Widget Data
+ */
+class WidgetDataFactory extends BaseFactory
+{
+ public function create(
+ int $widgetId,
+ array $data,
+ int $displayOrder
+ ): WidgetData {
+ $widgetData = $this->createEmpty();
+ $widgetData->widgetId = $widgetId;
+ $widgetData->data = $data;
+ $widgetData->displayOrder = $displayOrder;
+ return $widgetData;
+ }
+
+ private function createEmpty(): WidgetData
+ {
+ return new WidgetData($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Get Widget Data by its ID
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function getById(int $id): WidgetData
+ {
+ if (empty($id)) {
+ throw new InvalidArgumentException(__('Missing ID'), 'id');
+ }
+
+ $sql = 'SELECT * FROM `widgetdata` WHERE `id` = :id';
+ foreach ($this->getStore()->select($sql, ['id' => $id]) as $row) {
+ return $this->hydrate($row);
+ };
+
+ throw new NotFoundException();
+ }
+
+ /**
+ * Get Widget Data for a Widget
+ * @param int $widgetId
+ * @return WidgetData[]
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function getByWidgetId(int $widgetId): array
+ {
+ if (empty($widgetId)) {
+ throw new InvalidArgumentException(__('Missing Widget ID'), 'widgetId');
+ }
+
+ $entries = [];
+ $sql = 'SELECT * FROM `widgetdata` WHERE `widgetId` = :widgetId';
+ foreach ($this->getStore()->select($sql, ['widgetId' => $widgetId]) as $row) {
+ $entries[] = $this->hydrate($row);
+ };
+
+ return $entries;
+ }
+
+ /**
+ * Get modified date for Widget Data for a Widget
+ * @param int $widgetId
+ * @return ?\Carbon\Carbon
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function getModifiedDtForWidget(int $widgetId): ?Carbon
+ {
+ if (empty($widgetId)) {
+ throw new InvalidArgumentException(__('Missing Widget ID'), 'widgetId');
+ }
+
+ $sql = '
+ SELECT MAX(`createdDt`) AS createdDt, MAX(`modifiedDt`) AS modifiedDt
+ FROM `widgetdata`
+ WHERE `widgetId` = :widgetId
+ ';
+ $result = $this->getStore()->select($sql, ['widgetId' => $widgetId]);
+ $modifiedDt = $result[0]['modifiedDt'] ?? ($result[0]['createdDt'] ?? null);
+ if (empty($modifiedDt)) {
+ return null;
+ } else {
+ return Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $modifiedDt);
+ }
+ }
+
+ /**
+ * Copy data from one widget to another
+ * primarily used during checkout
+ * @param int $fromWidgetId
+ * @param int $toWidgetId
+ * @return void
+ */
+ public function copyByWidgetId(int $fromWidgetId, int $toWidgetId): void
+ {
+ $this->getStore()->update('
+ INSERT INTO `widgetdata` (`widgetId`, `data`, `displayOrder`, `createdDt`, `modifiedDt`)
+ SELECT :toWidgetId, `data`, `displayOrder`, `createdDt`, `modifiedDt`
+ FROM `widgetdata`
+ WHERE `widgetId` = :widgetId
+ ', [
+ 'widgetId' => $fromWidgetId,
+ 'toWidgetId' => $toWidgetId
+ ]);
+ }
+
+ /**
+ * Helper function for
+ * @param array $row
+ * @return \Xibo\Entity\WidgetData
+ */
+ private function hydrate(array $row): WidgetData
+ {
+ if (!empty($row['data'])) {
+ $row['data'] = json_decode($row['data'], true);
+ } else {
+ $row['data'] = [];
+ }
+ return $this->createEmpty()->hydrate($row);
+ }
+}
diff --git a/lib/Factory/WidgetFactory.php b/lib/Factory/WidgetFactory.php
new file mode 100644
index 0000000..5f804c4
--- /dev/null
+++ b/lib/Factory/WidgetFactory.php
@@ -0,0 +1,524 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Entity\Module;
+use Xibo\Entity\User;
+use Xibo\Entity\Widget;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class WidgetFactory
+ * @package Xibo\Factory
+ */
+class WidgetFactory extends BaseFactory
+{
+
+ /**
+ * @var WidgetOptionFactory
+ */
+ private $widgetOptionFactory;
+
+ /**
+ * @var WidgetMediaFactory
+ */
+ private $widgetMediaFactory;
+
+ /** @var WidgetAudioFactory */
+ private $widgetAudioFactory;
+
+ /**
+ * @var PermissionFactory
+ */
+ private $permissionFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /** @var ActionFactory */
+ private $actionFactory;
+
+ /** @var \Xibo\Factory\ModuleTemplateFactory */
+ private $moduleTemplateFactory;
+
+ /**
+ * Construct a factory
+ * @param User $user
+ * @param UserFactory $userFactory
+ * @param WidgetOptionFactory $widgetOptionFactory
+ * @param WidgetMediaFactory $widgetMediaFactory
+ * @param WidgetAudioFactory $widgetAudioFactory
+ * @param PermissionFactory $permissionFactory
+ * @param DisplayNotifyServiceInterface $displayNotifyService
+ * @param ActionFactory $actionFactory
+ * @param \Xibo\Factory\ModuleTemplateFactory $moduleTemplateFactory
+ */
+ public function __construct(
+ $user,
+ $userFactory,
+ $widgetOptionFactory,
+ $widgetMediaFactory,
+ $widgetAudioFactory,
+ $permissionFactory,
+ $displayNotifyService,
+ $actionFactory,
+ $moduleTemplateFactory
+ ) {
+ $this->setAclDependencies($user, $userFactory);
+ $this->widgetOptionFactory = $widgetOptionFactory;
+ $this->widgetMediaFactory = $widgetMediaFactory;
+ $this->widgetAudioFactory = $widgetAudioFactory;
+ $this->permissionFactory = $permissionFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ $this->actionFactory = $actionFactory;
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ }
+
+ /**
+ * Create Empty
+ * @return Widget
+ */
+ public function createEmpty()
+ {
+ return new Widget(
+ $this->getStore(),
+ $this->getLog(),
+ $this->getDispatcher(),
+ $this->widgetOptionFactory,
+ $this->widgetMediaFactory,
+ $this->widgetAudioFactory,
+ $this->permissionFactory,
+ $this->displayNotifyService,
+ $this->actionFactory
+ );
+ }
+
+ /**
+ * Load widgets by Playlist ID
+ * @param int $playlistId
+ * @return array[Widget]
+ * @throws NotFoundException
+ */
+ public function getByPlaylistId($playlistId)
+ {
+ return $this->query(null, array('disableUserCheck' => 1, 'playlistId' => $playlistId));
+ }
+
+ /**
+ * Load widgets by MediaId
+ * @param int $mediaId
+ * @param int|null $isDynamicPlaylist
+ * @return Widget[]
+ * @throws NotFoundException
+ */
+ public function getByMediaId($mediaId, $isDynamicPlaylist = null)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'mediaId' => $mediaId, 'isDynamicPlaylist' => $isDynamicPlaylist]);
+ }
+
+ /**
+ * Get Widget by its ID
+ * first check the lkwidgetmedia table for any active links
+ * if that fails, check the widgethistory table
+ * in either case, if we find a widget that isn't a media record, then still throw not found
+ * @param int $widgetId
+ * @return int|null
+ * @throws NotFoundException
+ */
+ public function getMediaByWidgetId($widgetId)
+ {
+ // Try getting the widget directly
+ $row = $this->getStore()->select('
+ SELECT `widget`.widgetId, `lkwidgetmedia`.mediaId
+ FROM `widget`
+ LEFT OUTER JOIN `lkwidgetmedia`
+ ON `lkwidgetmedia`.widgetId = `widget`.widgetId
+ WHERE `widget`.widgetId = :widgetId
+ ', [
+ 'widgetId' => $widgetId
+ ]);
+
+ // The widget doesn't exist
+ if (count($row) <= 0) {
+ // Try and get the same from the widget history table
+ $row = $this->getStore()->select('
+ SELECT widgetId, mediaId
+ FROM `widgethistory`
+ WHERE widgetId = :widgetId
+ ', [
+ 'widgetId' => $widgetId
+ ]);
+
+ // The widget didn't ever exist
+ if (count($row) <= 0) {
+ throw new NotFoundException();
+ }
+ }
+
+ return ($row[0]['mediaId'] == null) ? null : intval($row[0]['mediaId']);
+ }
+
+ /**
+ * Get widget by widget id
+ * @param $widgetId
+ * @return Widget
+ * @throws NotFoundException
+ */
+ public function getById($widgetId): Widget
+ {
+ $widgets = $this->query(null, array('disableUserCheck' => 1, 'widgetId' => $widgetId));
+
+ if (count($widgets) <= 0) {
+ throw new NotFoundException(__('Widget not found'));
+ }
+
+ return $widgets[0];
+ }
+
+ /**
+ * Load widget by widget id
+ * @param $widgetId
+ * @return Widget
+ * @throws NotFoundException
+ */
+ public function loadByWidgetId($widgetId): Widget
+ {
+ $widgets = $this->query(null, array('disableUserCheck' => 1, 'widgetId' => $widgetId));
+
+ if (count($widgets) <= 0) {
+ throw new NotFoundException(__('Widget not found'));
+ }
+
+ $widget = $widgets[0];
+ /* @var Widget $widget */
+ $widget->load();
+ return $widget;
+ }
+
+ /**
+ * @param $ownerId
+ * @return Widget[]
+ * @throws NotFoundException
+ */
+ public function getByOwnerId($ownerId)
+ {
+ return $this->query(null, ['disableUserCheck' => 1, 'userId' => $ownerId]);
+ }
+
+ /**
+ * Create a new widget
+ * @param int $ownerId
+ * @param int $playlistId
+ * @param string $type
+ * @param int $duration
+ * @param int $schemaVersion
+ * @return Widget
+ */
+ public function create($ownerId, $playlistId, $type, $duration, $schemaVersion)
+ {
+ $widget = $this->createEmpty();
+ $widget->ownerId = $ownerId;
+ $widget->playlistId = $playlistId;
+ $widget->type = $type;
+ $widget->duration = $duration;
+ $widget->schemaVersion = $schemaVersion;
+ $widget->displayOrder = 1;
+ $widget->useDuration = 0;
+
+ return $widget;
+ }
+
+ /**
+ * @param null $sortOrder
+ * @param array $filterBy
+ * @return Widget[]
+ * @throws NotFoundException
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ if ($sortOrder == null) {
+ $sortOrder = ['displayOrder'];
+ }
+
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ $entries = [];
+
+ $params = [];
+ $select = '
+ SELECT widget.widgetId,
+ widget.playlistId,
+ widget.ownerId,
+ widget.type,
+ widget.duration,
+ widget.displayOrder,
+ widget.schemaVersion,
+ `widget`.useDuration,
+ `widget`.calculatedDuration,
+ `widget`.fromDt,
+ `widget`.toDt,
+ `widget`.createdDt,
+ `widget`.modifiedDt,
+ `widget`.calculatedDuration,
+ `playlist`.name AS playlist,
+ `playlist`.folderId,
+ `playlist`.permissionsFolderId,
+ `playlist`.isDynamic
+ ';
+
+ if (is_array($sortOrder) && (in_array('`widget`', $sortOrder) || in_array('`widget` DESC', $sortOrder))) {
+ // output a pseudo column for the widget name
+ $select .= '
+ , IFNULL((
+ SELECT `value` AS name
+ FROM `widgetoption`
+ WHERE `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'name\'
+ ), `widget`.type) AS widget
+ ';
+ }
+
+ $body = '
+ FROM `widget`
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `widget`.playlistId
+ ';
+
+ if ($sanitizedFilter->getInt('showWidgetsFrom') === 1) {
+ $body .= '
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN `layout`
+ ON `layout`.layoutId = `region`.layoutId
+ ';
+ }
+
+ if ($sanitizedFilter->getInt('mediaId') !== null) {
+ $body .= '
+ INNER JOIN `lkwidgetmedia`
+ ON `lkwidgetmedia`.widgetId = widget.widgetId
+ AND `lkwidgetmedia`.mediaId = :mediaId
+ ';
+ $params['mediaId'] = $sanitizedFilter->getInt('mediaId');
+ }
+
+ $body .= ' WHERE 1 = 1 ';
+
+ if ($sanitizedFilter->getInt('showWidgetsFrom') === 1) {
+ $body .= ' AND layout.parentId IS NOT NULL ';
+ }
+
+ if ($sanitizedFilter->getInt('showWidgetsFrom') === 2) {
+ $body .= ' AND playlist.regionId IS NULL ';
+ }
+
+ if ($sanitizedFilter->getInt('playlistId') !== null) {
+ $body .= ' AND `widget`.playlistId = :playlistId';
+ $params['playlistId'] = $sanitizedFilter->getInt('playlistId');
+ }
+
+ if ($sanitizedFilter->getInt('widgetId') !== null) {
+ $body .= ' AND `widget`.widgetId = :widgetId';
+ $params['widgetId'] = $sanitizedFilter->getInt('widgetId');
+ }
+
+ if ($sanitizedFilter->getInt('schemaVersion') !== null) {
+ $body .= ' AND `widget`.schemaVersion = :schemaVersion';
+ $params['schemaVersion'] = $sanitizedFilter->getInt('schemaVersion');
+ }
+
+ if ($sanitizedFilter->getString('type') !== null) {
+ $body .= ' AND `widget`.type = :type';
+ $params['type'] = $sanitizedFilter->getString('type');
+ }
+
+ if ($sanitizedFilter->getString('layout') !== null) {
+ $body .= ' AND widget.widgetId IN (
+ SELECT widgetId
+ FROM `widget`
+ INNER JOIN `playlist`
+ ON `widget`.playlistId = `playlist`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ INNER JOIN `layout`
+ ON `layout`.layoutId = `region`.layoutId
+ WHERE layout.layout LIKE :layout
+ )';
+ $params['layout'] = '%' . $sanitizedFilter->getString('layout') . '%';
+ }
+
+ if ($sanitizedFilter->getString('region') !== null) {
+ $body .= ' AND widget.widgetId IN (
+ SELECT widgetId
+ FROM `widget`
+ INNER JOIN `playlist`
+ ON `widget`.playlistId = `playlist`.playlistId
+ INNER JOIN `region`
+ ON `region`.regionId = `playlist`.regionId
+ WHERE region.name LIKE :region
+ )';
+ $params['region'] = '%' . $sanitizedFilter->getString('region') . '%';
+ }
+
+ if ($sanitizedFilter->getString('media') !== null) {
+ $body .= ' AND widget.widgetId IN (
+ SELECT widgetId
+ FROM `lkwidgetmedia`
+ INNER JOIN `media`
+ ON `media`.mediaId = `lkwidgetmedia`.mediaId
+ WHERE media.name LIKE :media
+ )';
+ $params['media'] = '%' . $sanitizedFilter->getString('media') . '%';
+ }
+
+ if ($sanitizedFilter->getInt('userId') !== null) {
+ $body .= ' AND `widget`.ownerId = :userId';
+ $params['userId'] = $sanitizedFilter->getInt('userId');
+ }
+
+ // Playlist Like
+ if ($sanitizedFilter->getString('playlist') != '') {
+ $terms = explode(',', $sanitizedFilter->getString('playlist'));
+ $this->nameFilter('playlist', 'name', $terms, $body, $params, ($sanitizedFilter->getCheckbox('useRegexForName') == 1));
+ }
+
+ if ($sanitizedFilter->getInt('isDynamicPlaylist') !== null) {
+ $body .= ' AND `playlist`.isDynamic = :isDynamicPlaylist';
+ $params['isDynamicPlaylist'] = $sanitizedFilter->getInt('isDynamicPlaylist');
+ }
+
+ // Permissions
+ $this->viewPermissionSql('Xibo\Entity\Widget', $body, $params, 'widget.widgetId', 'widget.ownerId', $filterBy, 'playlist.permissionsFolderId');
+
+ // Sorting?
+ $order = '';
+ if (is_array($sortOrder))
+ $order .= ' ORDER BY ' . implode(',', $sortOrder);
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null && $sanitizedFilter->getInt('start') !== null && $sanitizedFilter->getInt('length') !== null) {
+ $limit = ' LIMIT ' . $sanitizedFilter->getInt('start', ['default' => 0]) . ', ' . $sanitizedFilter->getInt('length', ['default' => 10]);
+ }
+
+ // The final statements
+ $sql = $select . $body . $order . $limit;
+
+
+
+ foreach ($this->getStore()->select($sql, $params) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => [
+ 'duration', 'useDuration', 'schemaVersion', 'calculatedDuration', 'fromDt', 'toDt', 'createdDt', 'modifiedDt']
+ ]);
+ }
+
+ // Paging
+ if ($limit != '' && count($entries) > 0) {
+ $results = $this->getStore()->select('SELECT COUNT(*) AS total ' . $body, $params);
+ $this->_countLast = intval($results[0]['total']);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Get all templates for a set of widgets.
+ * @param \Xibo\Entity\Module $module The lead module we're rendering for
+ * @param Widget[] $widgets
+ * @return \Xibo\Entity\ModuleTemplate[]
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getTemplatesForWidgets(Module $module, array $widgets): array
+ {
+ $this->getLog()->debug('getTemplatesForWidgets: ' . count($widgets) . ' widgets, module: '
+ . $module->type . ', dataType: ' . $module->dataType);
+
+ $templates = [];
+ foreach ($widgets as $widget) {
+ if (!empty($module->dataType)) {
+ // Do we have a static one?
+ $templateId = $widget->getOptionValue('templateId', null);
+ if ($templateId !== null && $templateId !== 'elements') {
+ $templates[] = $this->moduleTemplateFactory->getByDataTypeAndId(
+ $module->dataType,
+ $templateId
+ );
+ }
+ }
+
+ // Does this widget have elements?
+ $widgetElements = $widget->getOptionValue('elements', null);
+ if (!empty($widgetElements)) {
+ $this->getLog()->debug('getTemplatesForWidgets: there are elements to include');
+
+ // Elements will be JSON
+ $widgetElements = json_decode($widgetElements, true);
+
+ // Get all templates used by this widget
+ $uniqueElements = [];
+
+ foreach ($widgetElements as $widgetElement) {
+ foreach ($widgetElement['elements'] ?? [] as $element) {
+ if (!array_key_exists($element['id'], $uniqueElements)) {
+ $uniqueElements[$element['id']] = $element;
+ }
+ }
+
+ foreach ($uniqueElements as $templateId => $element) {
+ try {
+ $template = $this->moduleTemplateFactory->getByTypeAndId(
+ 'element',
+ $templateId
+ );
+
+ // Does this template extend a global template
+ if (!empty($template->extends)) {
+ try {
+ $templates[] = $this->moduleTemplateFactory->getByDataTypeAndId(
+ 'global',
+ $template->extends->template
+ );
+ } catch (\Exception $e) {
+ $this->getLog()->error('getTemplatesForWidgets: ' . $templateId
+ . ' extends another template which does not exist.');
+ }
+ }
+
+ $templates[] = $template;
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error('getTemplatesForWidgets: templateId ' . $templateId
+ . ' not found');
+ }
+ }
+ }
+ }
+ }
+
+ $this->getLog()->debug('getTemplatesForWidgets: ' . count($templates) . ' templates returned.');
+
+ return $templates;
+ }
+}
diff --git a/lib/Factory/WidgetMediaFactory.php b/lib/Factory/WidgetMediaFactory.php
new file mode 100644
index 0000000..f6c4884
--- /dev/null
+++ b/lib/Factory/WidgetMediaFactory.php
@@ -0,0 +1,110 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+use Xibo\Support\Exception\NotFoundException;
+
+class WidgetMediaFactory extends BaseFactory
+{
+ /**
+ * Media Linked to Widgets by WidgetId
+ * @param int $widgetId
+ * @return array[int]
+ */
+ public function getByWidgetId($widgetId)
+ {
+ return $this->query(null, array('widgetId' => $widgetId));
+ }
+
+ /**
+ * @param int $mediaId
+ * @return int
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getDurationForMediaId(int $mediaId): int
+ {
+ $results = $this->getStore()->select('SELECT `duration` FROM `media` WHERE `mediaId` = :mediaId', [
+ 'mediaId' => $mediaId
+ ]);
+
+ if (count($results) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return intval($results[0]['duration'] ?? 0);
+ }
+
+ /**
+ * @return string
+ */
+ public function getLibraryTempPath(): string
+ {
+ return $this->getConfig()->getSetting('LIBRARY_LOCATION') . '/temp';
+ }
+
+ /**
+ * @param int $mediaId
+ * @return string
+ * @throws NotFoundException
+ */
+ public function getPathForMediaId(int $mediaId): string
+ {
+ $results = $this->getStore()->select('SELECT `storedAs` FROM `media` WHERE `mediaId` = :mediaId', [
+ 'mediaId' => $mediaId
+ ]);
+
+ if (count($results) <= 0) {
+ throw new NotFoundException();
+ }
+
+ return $this->getConfig()->getSetting('LIBRARY_LOCATION') . $results[0]['storedAs'];
+ }
+
+ /**
+ * Query Media Linked to Widgets
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[int]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+
+ if ($sanitizedFilter->getInt('moduleOnly') === 1) {
+ $sql = '
+ SELECT lkwidgetmedia.mediaId
+ FROM `lkwidgetmedia`
+ INNER JOIN `media`
+ ON `media`.mediaId = `lkwidgetmedia`.mediaId
+ WHERE widgetId = :widgetId
+ AND `lkwidgetmedia`.mediaId <> 0
+ AND `media`.type = \'module\'
+ ';
+ } else {
+ $sql = 'SELECT mediaId FROM `lkwidgetmedia` WHERE widgetId = :widgetId AND mediaId <> 0 ';
+ }
+
+ return array_map(function($element) { return $element['mediaId']; }, $this->getStore()->select($sql, array('widgetId' => $sanitizedFilter->getInt('widgetId'))));
+ }
+}
diff --git a/lib/Factory/WidgetOptionFactory.php b/lib/Factory/WidgetOptionFactory.php
new file mode 100644
index 0000000..02e73f5
--- /dev/null
+++ b/lib/Factory/WidgetOptionFactory.php
@@ -0,0 +1,90 @@
+.
+ */
+
+
+namespace Xibo\Factory;
+
+
+use Xibo\Entity\WidgetOption;
+
+class WidgetOptionFactory extends BaseFactory
+{
+ /**
+ * Create Empty
+ * @return WidgetOption
+ */
+ public function createEmpty()
+ {
+ return new WidgetOption($this->getStore(), $this->getLog(), $this->getDispatcher());
+ }
+
+ /**
+ * Create a Widget Option
+ * @param int $widgetId
+ * @param string $type
+ * @param string $option
+ * @param mixed $value
+ * @return WidgetOption
+ */
+ public function create($widgetId, $type, $option, $value)
+ {
+ $widgetOption = $this->createEmpty();
+ $widgetOption->widgetId = $widgetId;
+ $widgetOption->type = $type;
+ $widgetOption->option = $option;
+ $widgetOption->value = $value;
+
+ return $widgetOption;
+ }
+
+ /**
+ * Load by Widget Id
+ * @param int $widgetId
+ * @return array[WidgetOption]
+ */
+ public function getByWidgetId($widgetId)
+ {
+ return $this->query(null, array('widgetId' => $widgetId));
+ }
+
+ /**
+ * Query Widget options
+ * @param array $sortOrder
+ * @param array $filterBy
+ * @return array[WidgetOption]
+ */
+ public function query($sortOrder = null, $filterBy = [])
+ {
+ $sanitizedFilter = $this->getSanitizer($filterBy);
+ $entries = [];
+
+ $sql = 'SELECT * FROM `widgetoption` WHERE widgetId = :widgetId';
+
+ foreach ($this->getStore()->select($sql, [
+ 'widgetId' => $sanitizedFilter->getInt('widgetId')
+ ]) as $row) {
+ $entries[] = $this->createEmpty()->hydrate($row);
+ }
+
+ return $entries;
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/ApplicationState.php b/lib/Helper/ApplicationState.php
new file mode 100644
index 0000000..eea7e60
--- /dev/null
+++ b/lib/Helper/ApplicationState.php
@@ -0,0 +1,188 @@
+.
+ */
+namespace Xibo\Helper;
+
+/**
+ * Class ApplicationState
+ * @package Xibo\Helper
+ */
+class ApplicationState
+{
+ public $httpStatus = 200;
+ public $template;
+ public $message;
+ public $success;
+ public $html;
+ public $buttons;
+ public $fieldActions;
+ public $dialogTitle;
+ public $callBack;
+ public $autoSubmit;
+
+ public $login;
+ public $clockUpdate;
+
+ public $id;
+ private $data;
+ public $extra;
+ public $recordsTotal;
+ public $recordsFiltered;
+ private $commit = true;
+
+ public function __construct()
+ {
+ // Assume success
+ $this->success = true;
+ $this->buttons = '';
+ $this->fieldActions = '';
+ $this->extra = array();
+ }
+
+ /**
+ * Sets the Default response if for a login box
+ */
+ public static function asRequiresLogin()
+ {
+ return [
+ 'login' => true,
+ 'success' => false
+ ];
+ }
+
+ /**
+ * Add a Field Action to a Field
+ * @param string $field The field name
+ * @param string $action The action name
+ * @param string $value The value to trigger on
+ * @param string $actions The actions (field => action)
+ * @param string $operation The Operation (optional)
+ */
+ public function addFieldAction($field, $action, $value, $actions, $operation = "equals")
+ {
+ $this->fieldActions[] = array(
+ 'field' => $field,
+ 'trigger' => $action,
+ 'value' => $value,
+ 'operation' => $operation,
+ 'actions' => $actions
+ );
+ }
+
+ /**
+ * Response JSON
+ * @return array
+ */
+ public function asArray()
+ {
+ // Construct the Response
+ $response = array();
+
+ // General
+ $response['html'] = $this->html;
+ $response['buttons'] = $this->buttons;
+ $response['fieldActions'] = $this->fieldActions;
+ $response['dialogTitle'] = $this->dialogTitle;
+ $response['callBack'] = $this->callBack;
+ $response['autoSubmit'] = $this->autoSubmit;
+
+ $response['success'] = $this->success;
+ $response['message'] = $this->message;
+ $response['clockUpdate'] = $this->clockUpdate;
+
+ // Login
+ $response['login'] = $this->login;
+
+ // Extra
+ $response['id'] = intval($this->id);
+ $response['extra'] = $this->extra;
+ $response['data'] = $this->data;
+
+ return $response;
+ }
+
+ /**
+ * @return false|string
+ */
+ public function asJson()
+ {
+ return json_encode($this->asArray());
+ }
+
+ /**
+ * Set Data
+ * @param array $data
+ */
+ public function setData($data)
+ {
+ $this->data = $data;
+ }
+
+ /**
+ * Get Data
+ * @return array|mixed
+ */
+ public function getData()
+ {
+ if ($this->data == null) {
+ $this->data = [];
+ }
+
+ return $this->data;
+ }
+
+ /**
+ * Hydrate with properties
+ *
+ * @param array $properties
+ *
+ * @return self
+ */
+ public function hydrate(array $properties)
+ {
+ foreach ($properties as $prop => $val) {
+ if (property_exists($this, $prop)) {
+ $this->{$prop} = $val;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Called in the Storage Middleware to determine whether or not we should commit this transaction.
+ * @return bool
+ */
+ public function getCommitState()
+ {
+ return $this->commit;
+ }
+
+ /**
+ * Set the commit state
+ * @param bool $state
+ * @return bool
+ */
+ public function setCommitState(bool $state)
+ {
+ return $this->commit = $state;
+ }
+}
diff --git a/lib/Helper/AttachmentUploadHandler.php b/lib/Helper/AttachmentUploadHandler.php
new file mode 100644
index 0000000..e6df75a
--- /dev/null
+++ b/lib/Helper/AttachmentUploadHandler.php
@@ -0,0 +1,42 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+/**
+ * Class AttachmentUploadHandler
+ * @package Xibo\Helper
+ */
+class AttachmentUploadHandler extends BlueImpUploadHandler
+{
+ /**
+ * @param $file
+ * @param $index
+ */
+ protected function handleFormData($file, $index)
+ {
+ $controller = $this->options['controller'];
+ /* @var \Xibo\Controller\Notification $controller */
+
+ $controller->getLog()->debug('Upload complete for name: ' . $file->name);
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/BlueImpUploadHandler.php b/lib/Helper/BlueImpUploadHandler.php
new file mode 100644
index 0000000..54f71da
--- /dev/null
+++ b/lib/Helper/BlueImpUploadHandler.php
@@ -0,0 +1,500 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Heavily modified BlueImp Upload handler, stripped out image processing, downloads, etc.
+ * jQuery File Upload Plugin PHP Class 6.4.2
+ * https://github.com/blueimp/jQuery-File-Upload
+ *
+ * Copyright 2010, Sebastian Tschan
+ * https://blueimp.net
+ *
+ * Licensed under the MIT license:
+ * http://www.opensource.org/licenses/MIT
+ */
+class BlueImpUploadHandler
+{
+ protected array $options;
+
+ // PHP File Upload error message codes:
+ // http://php.net/manual/en/features.file-upload.errors.php
+ private array $errorMessages = [
+ 1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
+ 2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
+ 3 => 'The uploaded file was only partially uploaded',
+ 4 => 'No file was uploaded',
+ 6 => 'Missing a temporary folder',
+ 7 => 'Failed to write file to disk',
+ 8 => 'A PHP extension stopped the file upload',
+ 'post_max_size' => 'The uploaded file exceeds the post_max_size directive in php.ini',
+ 'accept_file_types' => 'Filetype not allowed',
+ ];
+
+ /**
+ * @param string $uploadDir
+ * @param \Psr\Log\LoggerInterface $logger
+ * @param array $options
+ * @param bool $initialize
+ */
+ public function __construct(
+ string $uploadDir,
+ private readonly LoggerInterface $logger,
+ array $options = [],
+ bool $initialize = true,
+ ) {
+ $this->options = array_merge([
+ 'upload_dir' => $uploadDir,
+ 'access_control_allow_origin' => '*',
+ 'access_control_allow_methods' => array(
+ 'OPTIONS',
+ 'HEAD',
+ 'GET',
+ 'POST',
+ 'PUT',
+ 'PATCH',
+ 'DELETE'
+ ),
+ 'access_control_allow_headers' => array(
+ 'Content-Type',
+ 'Content-Range',
+ 'Content-Disposition'
+ ),
+ // Defines which files can be displayed inline when downloaded:
+ 'inline_file_types' => '/\.(gif|jpe?g|png)$/i',
+ // Defines which files (based on their names) are accepted for upload:
+ 'accept_file_types' => '/.+$/i',
+ // Set the following option to false to enable resumable uploads:
+ 'discard_aborted_uploads' => true,
+ ], $options);
+
+ if ($initialize) {
+ $this->initialize();
+ }
+ }
+
+ protected function getLogger(): LoggerInterface
+ {
+ return $this->logger;
+ }
+
+ private function initialize(): void
+ {
+ switch ($this->getServerVar('REQUEST_METHOD')) {
+ case 'OPTIONS':
+ case 'HEAD':
+ $this->head();
+ break;
+ case 'PATCH':
+ case 'PUT':
+ case 'POST':
+ $this->post();
+ break;
+ default:
+ $this->header('HTTP/1.1 405 Method Not Allowed');
+ }
+ }
+
+ /**
+ * Get the upload directory
+ * @return string
+ */
+ protected function getUploadDir(): string
+ {
+ return $this->options['upload_dir'];
+ }
+
+ /**
+ * @param $fileName
+ * @param $version
+ * @return string
+ */
+ private function getUploadPath($fileName = null, $version = null): string
+ {
+ $this->getLogger()->debug('getUploadPath: ' . $fileName);
+
+ $fileName = $fileName ?: '';
+ $versionPath = empty($version) ? '' : $version . '/';
+ return $this->options['upload_dir'] . $versionPath . $fileName;
+ }
+
+ /**
+ * Fix for overflowing signed 32-bit integers,
+ * works for sizes up to 2^32-1 bytes (4 GiB - 1):
+ * @param $size
+ * @return int
+ */
+ private function fixIntegerOverflow($size): int
+ {
+ if ($size < 0) {
+ $size += 2.0 * (PHP_INT_MAX + 1);
+ }
+ return $size;
+ }
+
+ /**
+ * @param string $filePath
+ * @param bool $clearStatCache
+ * @return int
+ */
+ private function getFileSize(string $filePath, bool $clearStatCache = false): int
+ {
+ if ($clearStatCache) {
+ clearstatcache(true, $filePath);
+ }
+ return $this->fixIntegerOverflow(filesize($filePath));
+ }
+
+ /**
+ * @param $error
+ * @return string
+ */
+ private function getErrorMessage($error): string
+ {
+ return $this->errorMessages[$error] ?? $error;
+ }
+
+ /**
+ * @param $val
+ * @return float|int
+ */
+ private function getConfigBytes($val): float|int
+ {
+ return $this->fixIntegerOverflow(ByteFormatter::toBytes($val));
+ }
+
+ /**
+ * @param $file
+ * @param $error
+ * @return bool
+ */
+ private function validate($file, $error): bool
+ {
+ if ($error) {
+ $file->error = $this->getErrorMessage($error);
+ return false;
+ }
+
+ // Make sure the content length isn't greater than the max size
+ $contentLength = $this->fixIntegerOverflow(intval($this->getServerVar('CONTENT_LENGTH')));
+ $postMaxSize = $this->getConfigBytes(ini_get('post_max_size'));
+ if ($postMaxSize && ($contentLength > $postMaxSize)) {
+ $file->error = $this->getErrorMessage('post_max_size');
+ return false;
+ }
+
+ // Max sure the we are an accepted file type
+ if (!preg_match($this->options['accept_file_types'], $file->name)) {
+ $file->error = $this->getErrorMessage('accept_file_types');
+ return false;
+ }
+ return true;
+ }
+
+ private function upcountName(string $name): string
+ {
+ $this->getLogger()->debug('upcountName: ' . $name);
+ return preg_replace_callback(
+ '/(?:(?: \(([\d]+)\))?(\.[^.]+))?$/',
+ function ($matches): string {
+ $this->getLogger()->debug('upcountName: callback, matches: ' . var_export($matches, true));
+ $index = isset($matches[1]) ? intval($matches[1]) + 1 : 1;
+ $ext = $matches[2] ?? '';
+ return ' (' . $index . ')' . $ext;
+ },
+ $name,
+ 1
+ );
+ }
+
+ /**
+ * @param $name
+ * @param $contentRange
+ * @return string
+ */
+ private function getUniqueFilename($name, $contentRange): string
+ {
+ $uploadPath = $this->getUploadPath($name);
+
+ $this->getLogger()->debug('getUniqueFilename: ' . $name . ', uploadPath: ' . $uploadPath
+ . ', contentRange: ' . $contentRange);
+
+ $attempts = 0;
+ while (is_dir($uploadPath) && $attempts < 100) {
+ $name = $this->upcountName($name);
+ $attempts++;
+ }
+
+ $this->getLogger()->debug('getUniqueFilename: resolved file path: ' . $name);
+
+ $contentRange = $contentRange === null ? 0 : $contentRange[1];
+
+ // Keep an existing filename if this is part of a chunked upload:
+ $uploaded_bytes = $this->fixIntegerOverflow($contentRange);
+ while (is_file($this->getUploadPath($name))) {
+ if ($uploaded_bytes === $this->getFileSize($this->getUploadPath($name))) {
+ break;
+ }
+ $name = $this->upcountName($name);
+ }
+ return $name;
+ }
+
+ /**
+ * @param $name
+ * @param $type
+ * @return string
+ */
+ private function trimFileName($name, $type): string
+ {
+ // Remove path information and dots around the filename, to prevent uploading
+ // into different directories or replacing hidden system files.
+ // Also remove control characters and spaces (\x00..\x20) around the filename:
+ $name = trim(basename(stripslashes($name)), ".\x00..\x20");
+ // Use a timestamp for empty filenames:
+ if (!$name) {
+ $name = str_replace('.', '-', microtime(true));
+ }
+ // Add missing file extension for known image types:
+ if (!str_contains($name, '.')
+ && preg_match('/^image\/(gif|jpe?g|png)/', $type, $matches)
+ ) {
+ $name .= '.' . $matches[1];
+ }
+ return $name;
+ }
+
+ /**
+ * @param string $name
+ * @param string $type
+ * @param int|null $contentRange
+ * @return string
+ */
+ private function getFileName(string $name, string $type, ?int $contentRange): string
+ {
+ $this->getLogger()->debug('getFileName: ' . $name . ', type: ' . $type);
+
+ return $this->getUniqueFilename(
+ $this->trimFileName($name, $type),
+ $contentRange
+ );
+ }
+
+ /**
+ * @param $uploadedFile
+ * @param $name
+ * @param $size
+ * @param $type
+ * @param $error
+ * @param $index
+ * @param $contentRange
+ * @return \stdClass
+ */
+ private function handleFileUpload(
+ $uploadedFile,
+ $name,
+ $size,
+ $type,
+ $error,
+ $index = null,
+ $contentRange = null
+ ) {
+ $this->getLogger()->debug('handleFileUpload: ' . $uploadedFile);
+
+ // Build a file object to return.
+ $file = new \stdClass();
+ $file->name = $this->getFileName($name, $type, $contentRange);
+ $file->size = $this->fixIntegerOverflow(intval($size));
+ $file->type = $type;
+
+ if ($this->validate($file, $error)) {
+ $uploadPath = $this->getUploadPath();
+ if (!is_dir($uploadPath)) {
+ mkdir($uploadPath, 0755, true);
+ }
+ $filePath = $this->getUploadPath($file->name);
+
+ // Are we appending?
+ $appendFile = $contentRange && is_file($filePath) && $file->size > $this->getFileSize($filePath);
+
+ if ($uploadedFile && is_uploaded_file($uploadedFile)) {
+ // multipart/formdata uploads (POST method uploads)
+ if ($appendFile) {
+ file_put_contents(
+ $filePath,
+ fopen($uploadedFile, 'r'),
+ FILE_APPEND
+ );
+ } else {
+ move_uploaded_file($uploadedFile, $filePath);
+ }
+ } else {
+ // Non-multipart uploads (PUT method support)
+ file_put_contents(
+ $filePath,
+ fopen('php://input', 'r'),
+ $appendFile ? FILE_APPEND : 0
+ );
+ }
+ $fileSize = $this->getFileSize($filePath, $appendFile);
+
+ if ($fileSize === $file->size) {
+ $this->handleFormData($file, $index);
+ } else {
+ $file->size = $fileSize;
+ if (!$contentRange && $this->options['discard_aborted_uploads']) {
+ unlink($filePath);
+ $file->error = 'abort';
+ }
+ }
+ }
+ return $file;
+ }
+
+ /**
+ * @param $file
+ * @param $index
+ * @return void
+ */
+ protected function handleFormData($file, $index)
+ {
+ }
+
+ /**
+ * @param string $str
+ * @return void
+ */
+ private function header(string $str): void
+ {
+ header($str);
+ }
+
+ /**
+ * @param $id
+ * @return mixed|string
+ */
+ private function getServerVar($id): mixed
+ {
+ return $_SERVER[$id] ?? '';
+ }
+
+ private function sendContentTypeHeader(): void
+ {
+ $this->header('Vary: Accept');
+ if (str_contains($this->getServerVar('HTTP_ACCEPT'), 'application/json')) {
+ $this->header('Content-type: application/json');
+ } else {
+ $this->header('Content-type: text/plain');
+ }
+ }
+
+ private function sendAccessControlHeaders(): void
+ {
+ $this->header('Access-Control-Allow-Origin: ' . $this->options['access_control_allow_origin']);
+ $this->header('Access-Control-Allow-Methods: '
+ . implode(', ', $this->options['access_control_allow_methods']));
+ $this->header('Access-Control-Allow-Headers: '
+ . implode(', ', $this->options['access_control_allow_headers']));
+ }
+
+ private function head(): void
+ {
+ $this->header('Pragma: no-cache');
+ $this->header('Cache-Control: no-store, no-cache, must-revalidate');
+ $this->header('Content-Disposition: inline; filename="files.json"');
+ // Prevent Internet Explorer from MIME-sniffing the content-type:
+ $this->header('X-Content-Type-Options: nosniff');
+ if ($this->options['access_control_allow_origin']) {
+ $this->sendAccessControlHeaders();
+ }
+ $this->sendContentTypeHeader();
+ }
+
+ /**
+ * @return void
+ */
+ public function post(): void
+ {
+ $upload = $_FILES['files'] ?? null;
+
+ // Parse the Content-Disposition header, if available:
+ $fileName = $this->getServerVar('HTTP_CONTENT_DISPOSITION') ?
+ rawurldecode(preg_replace(
+ '/(^[^"]+")|("$)/',
+ '',
+ $this->getServerVar('HTTP_CONTENT_DISPOSITION')
+ )) : null;
+
+ // Parse the Content-Range header, which has the following form:
+ // Content-Range: bytes 0-524287/2000000
+ $contentRange = $this->getServerVar('HTTP_CONTENT_RANGE')
+ ? preg_split('/[^0-9]+/', $this->getServerVar('HTTP_CONTENT_RANGE'))
+ : null;
+ $size = $contentRange ? $contentRange[3] : null;
+
+ $this->getLogger()->debug('post: contentRange: ' . var_export($contentRange, true));
+
+ $files = [];
+ if ($upload && is_array($upload['tmp_name'])) {
+ // param_name is an array identifier like "files[]",
+ // $_FILES is a multi-dimensional array:
+ foreach ($upload['tmp_name'] as $index => $value) {
+ $files[] = $this->handleFileUpload(
+ $upload['tmp_name'][$index],
+ $fileName ?: $upload['name'][$index],
+ $size ?: $upload['size'][$index],
+ $upload['type'][$index],
+ $upload['error'][$index],
+ $index,
+ $contentRange
+ );
+ }
+ } else {
+ // param_name is a single object identifier like "file",
+ // $_FILES is a one-dimensional array:
+ $files[] = $this->handleFileUpload(
+ $upload['tmp_name'] ?? null,
+ $fileName ?: ($upload['name'] ?? null),
+ $size ?: ($upload['size'] ?? $this->getServerVar('CONTENT_LENGTH')),
+ $upload['type'] ?? $this->getServerVar('CONTENT_TYPE'),
+ $upload['error'] ?? null,
+ null,
+ $contentRange
+ );
+ }
+
+ // Output response
+ $json = json_encode(['files' => $files]);
+ $this->head();
+ if ($this->getServerVar('HTTP_CONTENT_RANGE')) {
+ if ($files && is_array($files) && is_object($files[0]) && $files[0]->size) {
+ $this->header('Range: 0-' . (
+ $this->fixIntegerOverflow(intval($files[0]->size)) - 1
+ ));
+ }
+ }
+ echo $json;
+ }
+}
diff --git a/lib/Helper/ByteFormatter.php b/lib/Helper/ByteFormatter.php
new file mode 100644
index 0000000..1d169dc
--- /dev/null
+++ b/lib/Helper/ByteFormatter.php
@@ -0,0 +1,68 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Carbon\Carbon;
+use Exception;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class DataSetUploadHandler
+ * @package Xibo\Helper
+ */
+class DataSetUploadHandler extends BlueImpUploadHandler
+{
+ /**
+ * @param $file
+ * @param $index
+ */
+ protected function handleFormData($file, $index)
+ {
+ /* @var \Xibo\Controller\DataSet $controller */
+ $controller = $this->options['controller'];
+
+ /* @var SanitizerInterface $sanitizer */
+ $sanitizer = $this->options['sanitizer'];
+
+ // Handle form data, e.g. $_REQUEST['description'][$index]
+ $fileName = $file->name;
+
+ $controller->getLog()->debug('Upload complete for ' . $fileName . '.');
+
+ // Upload and Save
+ try {
+
+ // Authenticate
+ $controller = $this->options['controller'];
+ $dataSet = $controller->getDataSetFactory()->getById($this->options['dataSetId']);
+
+ if (!$controller->getUser()->checkEditable($dataSet)) {
+ throw new AccessDeniedException();
+ }
+
+ // Get all columns
+ $columns = $dataSet->getColumn();
+
+ // Filter columns where dataSetColumnType is "Value"
+ $filteredColumns = array_filter($columns, function ($column) {
+ return $column->dataSetColumnTypeId == '1';
+ });
+
+ // Check if there are any value columns defined in the dataset
+ if (count($filteredColumns) === 0) {
+ $controller->getLog()->error('Import failed: No value columns defined in the dataset.');
+ throw new InvalidArgumentException(__('Import failed: No value columns defined in the dataset.'));
+ }
+
+ // We are allowed to edit - pull all required parameters from the request object
+ $overwrite = $sanitizer->getCheckbox('overwrite');
+ $ignoreFirstRow = $sanitizer->getCheckbox('ignorefirstrow');
+
+ $controller->getLog()->debug('Options provided - overwrite = %d, ignore first row = %d', $overwrite, $ignoreFirstRow);
+
+ // Enumerate over the columns in the DataSet and set a row value for each
+ $spreadSheetMapping = [];
+
+ foreach ($dataSet->getColumn() as $column) {
+ /* @var \Xibo\Entity\DataSetColumn $column */
+ if ($column->dataSetColumnTypeId == 1) {
+ // Has this column been provided in the mappings?
+
+ $spreadSheetColumn = 0;
+ if (isset($_REQUEST['csvImport_' . $column->dataSetColumnId]))
+ $spreadSheetColumn = (($index === null) ? $_REQUEST['csvImport_' . $column->dataSetColumnId] : $_REQUEST['csvImport_' . $column->dataSetColumnId][$index]);
+
+ // If it has been left blank, then skip
+ if ($spreadSheetColumn != 0)
+ $spreadSheetMapping[($spreadSheetColumn - 1)] = $column->heading;
+ }
+ }
+
+ // Delete the data?
+ if ($overwrite == 1)
+ $dataSet->deleteData();
+
+ $firstRow = true;
+ $i = 0;
+ $handle = fopen($controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName, 'r');
+ while (($data = fgetcsv($handle)) !== FALSE ) {
+ $i++;
+
+ // remove any elements that doesn't contain any value from the array
+ $filteredData = array_filter($data, function($value) {
+ return !empty($value);
+ });
+
+ // Skip empty lines without any delimiters or data
+ if (empty($filteredData)) {
+ continue;
+ }
+
+ $row = [];
+
+ // The CSV file might have headings, so ignore the first row.
+ if ($firstRow) {
+ $firstRow = false;
+
+ if ($ignoreFirstRow == 1)
+ continue;
+ }
+
+ for ($cell = 0; $cell < count($data); $cell++) {
+
+ // Insert the data into the correct column
+ if (isset($spreadSheetMapping[$cell])) {
+ // Sanitize the data a bit
+ $item = $data[$cell];
+
+ if ($item == '')
+ $item = null;
+
+ $row[$spreadSheetMapping[$cell]] = $item;
+ }
+ }
+
+ try {
+ $dataSet->addRow($row);
+ } catch (\PDOException $PDOException) {
+ $controller->getLog()->error('Error importing row ' . $i . '. E = ' . $PDOException->getMessage());
+ $controller->getLog()->debug($PDOException->getTraceAsString());
+
+ throw new InvalidArgumentException(__('Unable to import row %d', $i), 'row');
+ }
+ }
+
+ // Close the file
+ fclose($handle);
+
+ // TODO: update list content definitions
+
+ // Save the dataSet
+ $dataSet->lastDataEdit = Carbon::now()->format('U');
+ $dataSet->save(['validate' => false, 'saveColumns' => false]);
+
+ // Tidy up the temporary file
+ @unlink($controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName);
+
+ } catch (Exception $e) {
+ $file->error = $e->getMessage();
+
+ // Don't commit
+ $controller->getState()->setCommitState(false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/DatabaseLogHandler.php b/lib/Helper/DatabaseLogHandler.php
new file mode 100644
index 0000000..c2bdb7b
--- /dev/null
+++ b/lib/Helper/DatabaseLogHandler.php
@@ -0,0 +1,186 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Monolog\Handler\AbstractProcessingHandler;
+use Monolog\Logger;
+use Xibo\Storage\PdoStorageService;
+
+/**
+ * Class DatabaseLogHandler
+ * @package Xibo\Helper
+ */
+class DatabaseLogHandler extends AbstractProcessingHandler
+{
+ /** @var \PDO */
+ private static $pdo;
+
+ /** @var \PDOStatement|null */
+ private static $statement;
+
+ /** @var int Log Level */
+ protected $level = Logger::ERROR;
+
+ /** @var int Track the number of failures since a success */
+ private $failureCount = 0;
+
+ /**
+ * @param int $level The minimum logging level at which this handler will be triggered
+ */
+ public function __construct($level = Logger::ERROR)
+ {
+ parent::__construct($level);
+ }
+
+ /**
+ * Gets minimum logging level at which this handler will be triggered.
+ *
+ * @return int
+ */
+ public function getLevel(): int
+ {
+ return $this->level;
+ }
+
+ /**
+ * @param int|string $level
+ * @return $this|\Monolog\Handler\AbstractHandler
+ */
+ public function setLevel($level): \Monolog\Handler\AbstractHandler
+ {
+ $this->level = Logger::toMonologLevel($level);
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ * @throws \Exception
+ */
+ protected function write(array $record): void
+ {
+ if (self::$statement == null) {
+ self::$pdo = PdoStorageService::newConnection('log');
+
+ $SQL = '
+ INSERT INTO `log` (
+ `runNo`,
+ `logdate`,
+ `channel`,
+ `type`,
+ `page`,
+ `function`,
+ `message`,
+ `userid`,
+ `displayid`,
+ `sessionHistoryId`,
+ `requestId`
+ ) VALUES (
+ :runNo,
+ :logdate,
+ :channel,
+ :type,
+ :page,
+ :function,
+ :message,
+ :userid,
+ :displayid,
+ :sessionHistoryId,
+ :requestId
+ )
+ ';
+
+ self::$statement = self::$pdo->prepare($SQL);
+ }
+
+ $params = [
+ 'runNo' => $record['extra']['uid'] ?? '',
+ 'logdate' => $record['datetime']->format('Y-m-d H:i:s'),
+ 'type' => $record['level_name'],
+ 'channel' => $record['channel'],
+ 'page' => $record['extra']['route'] ?? '',
+ 'function' => $record['extra']['method'] ?? '',
+ 'message' => $record['message'],
+ 'userid' => $record['extra']['userId'] ?? 0,
+ 'displayid' => $record['extra']['displayId'] ?? 0,
+ 'sessionHistoryId' => $record['extra']['sessionHistoryId'] ?? 0,
+ 'requestId' => $record['extra']['requestId'] ?? 0,
+ ];
+
+ try {
+ // Insert
+ self::$statement->execute($params);
+
+ // Reset failure count
+ $this->failureCount = 0;
+
+ // Successful write
+ PdoStorageService::incrementStat('log', 'insert');
+ } catch (\Exception $e) {
+ // Increment failure count
+ $this->failureCount++;
+
+ // Try to create a new statement
+ if ($this->failureCount <= 1) {
+ // Clear the stored statement, and try again
+ // this will rebuild the connection
+ self::$statement = null;
+
+ // Try again.
+ $this->write($record);
+ }
+ // If the failureCount is > 1, then we ignore the error.
+ }
+ }
+
+ /**
+ * Deleting logs must happen on the same DB connection as the log handler writes logs
+ * otherwise we can end up with a deadlock where the log handler has written things, locked the table
+ * and, we're then trying to get the same lock.
+ * @param string $cutOff
+ */
+ public static function tidyLogs(string $cutOff): void
+ {
+ try {
+ if (self::$pdo === null) {
+ self::$pdo = PdoStorageService::newConnection('log');
+ }
+
+ $statement = self::$pdo->prepare('DELETE FROM `log` WHERE logdate < :maxage LIMIT 10000');
+
+ do {
+ // Execute statement
+ $statement->execute(['maxage' => $cutOff]);
+
+ // initialize number of rows deleted
+ $rowsDeleted = $statement->rowCount();
+
+ PdoStorageService::incrementStat('log', 'delete');
+
+ // pause for a second
+ sleep(2);
+ } while ($rowsDeleted > 0);
+ } catch (\PDOException) {
+ }
+ }
+}
diff --git a/lib/Helper/DateFormatHelper.php b/lib/Helper/DateFormatHelper.php
new file mode 100644
index 0000000..117e095
--- /dev/null
+++ b/lib/Helper/DateFormatHelper.php
@@ -0,0 +1,265 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Carbon\Carbon;
+
+/**
+ * Class Environment
+ * @package Xibo\Helper
+ */
+class DateFormatHelper
+{
+ private static $timezones = null;
+
+ /**
+ * Get the default date format
+ * @return string
+ */
+ public static function getSystemFormat()
+ {
+ return 'Y-m-d H:i:s';
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function extractTimeFormat($format)
+ {
+ $replacements = [
+ 'd' => '',
+ 'D' => '',
+ 'j' => '',
+ 'l' => '',
+ 'N' => '',
+ 'S' => '',
+ 'w' => '',
+ 'z' => '',
+ 'W' => '',
+ 'F' => '',
+ 'm' => '',
+ 'M' => '',
+ 'n' => '',
+ 't' => '', // no equivalent
+ 'L' => '', // no equivalent
+ 'o' => '',
+ 'Y' => '',
+ 'y' => '',
+ 'a' => 'a',
+ 'A' => 'A',
+ 'B' => '', // no equivalent
+ 'g' => 'g',
+ 'G' => 'G',
+ 'h' => 'h',
+ 'H' => 'H',
+ 'i' => 'i',
+ 's' => 's',
+ 'u' => '',
+ 'e' => '', // deprecated since version 1.6.0 of moment.js
+ 'I' => '', // no equivalent
+ 'O' => '', // no equivalent
+ 'P' => '', // no equivalent
+ 'T' => '', // no equivalent
+ 'Z' => '', // no equivalent
+ 'c' => '', // no equivalent
+ 'r' => '', // no equivalent
+ 'U' => '',
+ '-' => '',
+ '/' => '',
+ '.' => ''
+ ];
+ $timeOnly = strtr($format, $replacements);
+ return trim($timeOnly);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function extractDateOnlyFormat($format)
+ {
+ $replacements = [
+ 'd' => 'd',
+ 'D' => 'D',
+ 'j' => '',
+ 'l' => '',
+ 'N' => '',
+ 'S' => '',
+ 'w' => '',
+ 'z' => '',
+ 'W' => '',
+ 'F' => '',
+ 'm' => 'm',
+ 'M' => 'M',
+ 'n' => '',
+ 't' => '', // no equivalent
+ 'L' => '', // no equivalent
+ 'o' => '',
+ 'Y' => 'Y',
+ 'y' => 'y',
+ 'a' => '',
+ 'A' => '',
+ 'B' => '', // no equivalent
+ 'g' => '',
+ 'G' => '',
+ 'h' => '',
+ 'H' => '',
+ 'i' => '',
+ 's' => '',
+ 'u' => '',
+ 'e' => '', // deprecated since version 1.6.0 of moment.js
+ 'I' => '', // no equivalent
+ 'O' => '', // no equivalent
+ 'P' => '', // no equivalent
+ 'T' => '', // no equivalent
+ 'Z' => '', // no equivalent
+ 'c' => '', // no equivalent
+ 'r' => '', // no equivalent
+ 'U' => '',
+ '-' => '-',
+ '/' => '/',
+ '.' => '.',
+ ':' => ''
+ ];
+ $timeOnly = strtr($format, $replacements);
+ return trim($timeOnly);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function convertPhpToMomentFormat($format)
+ {
+ $replacements = [
+ 'd' => 'DD',
+ 'D' => 'ddd',
+ 'j' => 'D',
+ 'l' => 'dddd',
+ 'N' => 'E',
+ 'S' => 'o',
+ 'w' => 'e',
+ 'z' => 'DDD',
+ 'W' => 'W',
+ 'F' => 'MMMM',
+ 'm' => 'MM',
+ 'M' => 'MMM',
+ 'n' => 'M',
+ 't' => '', // no equivalent
+ 'L' => '', // no equivalent
+ 'o' => 'YYYY',
+ 'Y' => 'YYYY',
+ 'y' => 'YY',
+ 'a' => 'a',
+ 'A' => 'A',
+ 'B' => '', // no equivalent
+ 'g' => 'h',
+ 'G' => 'H',
+ 'h' => 'hh',
+ 'H' => 'HH',
+ 'i' => 'mm',
+ 's' => 'ss',
+ 'u' => 'SSS',
+ 'e' => 'zz', // deprecated since version 1.6.0 of moment.js
+ 'I' => '', // no equivalent
+ 'O' => '', // no equivalent
+ 'P' => '', // no equivalent
+ 'T' => '', // no equivalent
+ 'Z' => '', // no equivalent
+ 'c' => '', // no equivalent
+ 'r' => '', // no equivalent
+ 'U' => 'X',
+ ];
+ $momentFormat = strtr($format, $replacements);
+ return $momentFormat;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function convertMomentToJalaliFormat($format)
+ {
+ $replacements = [
+ 'DD' => 'jDD',
+ 'ddd' => 'ddd',
+ 'D' => 'jD',
+ 'dddd' => 'dddd',
+ 'E' => 'E',
+ 'e' => 'e',
+ 'DDD' => 'jDDD',
+ 'W' => '',
+ 'MMMM' => 'jMMMM',
+ 'MM' => 'jMM',
+ 'MMM' => 'jMMM',
+ 'M' => 'jM',
+ 'YYYY' => 'jYYYY',
+ 'YY' => 'jYY',
+ 'a' => 'a',
+ 'A' => 'A',
+ 'h' => 'h',
+ 'H' => 'H',
+ 'hh' => 'hh',
+ 'HH' => 'HH',
+ 'mm' => 'mm',
+ 'ss' => 'ss',
+ 'SSS' => 'SSS',
+ 'X' => 'X'
+ ];
+ $timeOnly = strtr($format, $replacements);
+ return trim($timeOnly);
+ }
+
+ /**
+ * Timezone identifiers
+ * @return array
+ */
+ public static function timezoneList()
+ {
+ if (self::$timezones === null) {
+ self::$timezones = [];
+ $offsets = [];
+ $now = new Carbon('now');
+
+ foreach (\DateTimeZone::listIdentifiers() as $timezone) {
+ $now->setTimezone(new \DateTimeZone($timezone));
+ $offsets[] = $offset = $now->getOffset();
+ self::$timezones[$timezone] = '(' . self::formatGmtOffset($offset) . ') ' . self::formatTimezoneName($timezone);
+ }
+
+ array_multisort($offsets, self::$timezones);
+ }
+
+ return self::$timezones;
+ }
+
+ private static function formatGmtOffset($offset) {
+ $hours = intval($offset / 3600);
+ $minutes = abs(intval($offset % 3600 / 60));
+ return 'GMT' . ($offset ? sprintf('%+03d:%02d', $hours, $minutes) : '');
+ }
+
+ private static function formatTimezoneName($name) {
+ $name = str_replace('/', ', ', $name);
+ $name = str_replace('_', ' ', $name);
+ $name = str_replace('St ', 'St. ', $name);
+ return $name;
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/Environment.php b/lib/Helper/Environment.php
new file mode 100644
index 0000000..cd99dd5
--- /dev/null
+++ b/lib/Helper/Environment.php
@@ -0,0 +1,356 @@
+.
+ */
+namespace Xibo\Helper;
+
+use Phinx\Console\PhinxApplication;
+use Phinx\Wrapper\TextWrapper;
+
+/**
+ * Class Environment
+ * @package Xibo\Helper
+ */
+class Environment
+{
+ public static $WEBSITE_VERSION_NAME = '4.4.0-alpha2';
+ public static $XMDS_VERSION = '7';
+ public static $XLF_VERSION = 4;
+ public static $VERSION_REQUIRED = '8.1.0';
+ public static $VERSION_UNSUPPORTED = '9.0';
+ public static $PLAYER_SUPPORT = 300;
+
+ /** @var null cache migration status for the whole request */
+ private static $migrationStatus = null;
+
+ /** @var string the git commit ref */
+ private static $gitCommit = null;
+
+ /**
+ * Is there a migration pending?
+ * @return bool
+ */
+ public static function migrationPending()
+ {
+ // Allow missing migrations in dev mode.
+ if (self::isDevMode()) {
+ return self::getMigrationStatus() > 2;
+ } else {
+ return self::getMigrationStatus() != 0;
+ }
+ }
+
+ /**
+ * Get Migration Status
+ * @return int
+ */
+ private static function getMigrationStatus()
+ {
+ if (self::$migrationStatus === null) {
+ // Use a Phinx text wrapper to work out what the current status is
+ // make sure this does not output anything to our output buffer
+ ob_start();
+ $phinx = new TextWrapper(new PhinxApplication(), ['configuration' => PROJECT_ROOT . '/phinx.php']);
+ $phinx->getStatus();
+
+ self::$migrationStatus = $phinx->getExitCode();
+ ob_end_clean();
+ }
+
+ return self::$migrationStatus;
+ }
+
+ /**
+ * Get Git Commit
+ * @return string
+ */
+ public static function getGitCommit()
+ {
+ if (self::$gitCommit === null) {
+ if (isset($_SERVER['GIT_COMMIT']) && $_SERVER['GIT_COMMIT'] === 'dev') {
+ $out = [];
+ exec('cat /var/www/cms/.git/$(cat /var/www/cms/.git/HEAD | cut -d\' \' -f2)', $out);
+ self::$gitCommit = $out[0] ?? null;
+ } else {
+ self::$gitCommit = $_SERVER['GIT_COMMIT'] ?? null;
+ }
+
+ if (self::$gitCommit === null && file_exists(PROJECT_ROOT . '/commit.sha')) {
+ self::$gitCommit = trim(file_get_contents(PROJECT_ROOT . '/commit.sha'));
+ }
+ }
+
+ return self::$gitCommit ?? 'unknown';
+ }
+
+ /**
+ * Check FileSystem Permissions
+ * @return bool
+ */
+ public static function checkSettingsFileSystemPermissions()
+ {
+ $settingsPath = PROJECT_ROOT . '/web/settings.php';
+ return (file_exists($settingsPath)) ? is_writable($settingsPath) : is_writable(PROJECT_ROOT . '/web');
+ }
+
+ /**
+ * Check FileSystem Permissions
+ * @return bool
+ */
+ public static function checkCacheFileSystemPermissions()
+ {
+ return is_writable(PROJECT_ROOT . '/cache');
+ }
+
+ /**
+ * Check PHP version is within the preset parameters
+ * @return bool
+ */
+ public static function checkPHP()
+ {
+ return (version_compare(phpversion(), self::$VERSION_REQUIRED) != -1) && (version_compare(phpversion(), self::$VERSION_UNSUPPORTED) != 1);
+ }
+
+ /**
+ * Check PHP has the PDO module installed (with MySQL driver)
+ */
+ public static function checkPDO()
+ {
+ return extension_loaded("pdo_mysql");
+ }
+
+ /**
+ * Check PHP has the GetText module installed
+ * @return bool
+ */
+ public static function checkGettext()
+ {
+ return extension_loaded("gettext");
+ }
+
+ /**
+ * Check PHP has JSON module installed
+ * @return bool
+ */
+ public static function checkJson()
+ {
+ return extension_loaded("json");
+ }
+
+ /**
+ *
+ * Check PHP has SOAP module installed
+ * @return bool
+ */
+ public static function checkSoap()
+ {
+ return extension_loaded("soap");
+ }
+
+ /**
+ * Check PHP has GD module installed
+ * @return bool
+ */
+ public static function checkGd()
+ {
+ return extension_loaded("gd");
+ }
+
+ /**
+ * Check PHP has the DOM XML functionality installed
+ * @return bool
+ */
+ public static function checkDomXml()
+ {
+ return extension_loaded("dom");
+ }
+
+ /**
+ * Check PHP has the DOM functionality installed
+ * @return bool
+ */
+ public static function checkDom()
+ {
+ return class_exists("DOMDocument");
+ }
+
+ /**
+ * Check PHP has session functionality installed
+ * @return bool
+ */
+ public static function checkSession()
+ {
+ return extension_loaded("session");
+ }
+
+ /**
+ * Check PHP has PCRE functionality installed
+ * @return bool
+ */
+ public static function checkPCRE()
+ {
+ return extension_loaded("pcre");
+ }
+
+ /**
+ * Check PHP has FileInfo functionality installed
+ * @return bool
+ */
+ public static function checkFileInfo()
+ {
+ return extension_loaded("fileinfo");
+ }
+
+ public static function checkZip()
+ {
+ return extension_loaded('zip');
+ }
+
+ public static function checkIntlDateFormat()
+ {
+ return class_exists('IntlDateFormatter');
+ }
+
+
+ /**
+ * Check to see if curl is installed
+ */
+ public static function checkCurlInstalled()
+ {
+ return function_exists('curl_version');
+ }
+
+ /**
+ * Check PHP is setup for large file uploads
+ * @return bool
+ */
+ public static function checkPHPUploads()
+ {
+ # Consider 0 - 128M warning / < 120 seconds
+ # Variables to check:
+ # post_max_size
+ # upload_max_filesize
+ # max_execution_time
+
+ $minSize = ByteFormatter::toBytes('128M');
+
+ if (ByteFormatter::toBytes(ini_get('post_max_size')) < $minSize)
+ return false;
+
+ if (ByteFormatter::toBytes(ini_get('upload_max_filesize')) < $minSize)
+ return false;
+
+ if (ini_get('max_execution_time') < 120)
+ return false;
+
+ // All passed
+ return true;
+ }
+
+ public static function getMaxUploadSize()
+ {
+ return ini_get('upload_max_filesize');
+ }
+
+ /**
+ * Check open ssl is available
+ * @return bool
+ */
+ public static function checkOpenSsl()
+ {
+ return extension_loaded('openssl');
+ }
+
+ /**
+ * @inheritdoc
+ * https://stackoverflow.com/a/45767760
+ */
+ public static function getMemoryLimitBytes()
+ {
+ return intval(str_replace(array('G', 'M', 'K'), array('000000000', '000000', '000'), ini_get('memory_limit')));
+ }
+
+ /**
+ * @return bool
+ */
+ public static function checkTimezoneIdentifiers()
+ {
+ return function_exists('timezone_identifiers_list');
+ }
+
+ /**
+ * @return bool
+ */
+ public static function checkAllowUrlFopen()
+ {
+ return ini_get('allow_url_fopen');
+ }
+
+ /**
+ * @return bool
+ */
+ public static function checkCurl()
+ {
+ return extension_loaded('curl');
+ }
+
+ /**
+ * @return bool
+ */
+ public static function checkSimpleXml()
+ {
+ return extension_loaded('simplexml');
+ }
+
+ /**
+ * @return bool
+ */
+ public static function checkGnu()
+ {
+ return extension_loaded('gnupg');
+ }
+
+ /**
+ * @param $url
+ * @return bool
+ */
+ public static function checkUrl($url)
+ {
+ return (stripos($url, '/web/') === false);
+ }
+
+ /**
+ * Is the CMS in DEV mode?
+ * @return bool
+ */
+ public static function isDevMode()
+ {
+ return (isset($_SERVER['CMS_DEV_MODE']) && $_SERVER['CMS_DEV_MODE'] === 'true');
+ }
+
+ /**
+ * Is debugging forced ON for this request?
+ * @return bool
+ */
+ public static function isForceDebugging()
+ {
+ return (isset($_SERVER['CMS_FORCE_DEBUG']) && $_SERVER['CMS_FORCE_DEBUG'] === 'true');
+ }
+}
diff --git a/lib/Helper/HttpCacheProvider.php b/lib/Helper/HttpCacheProvider.php
new file mode 100644
index 0000000..fcac8b4
--- /dev/null
+++ b/lib/Helper/HttpCacheProvider.php
@@ -0,0 +1,122 @@
+withHeader('Cache-Control', $headerValue);
+ }
+
+ /**
+ * Disable client-side HTTP caching
+ *
+ * @param ResponseInterface $response PSR7 response object
+ *
+ * @return ResponseInterface A new PSR7 response object with `Cache-Control` header
+ */
+ public static function denyCache(ResponseInterface $response)
+ {
+ return $response->withHeader('Cache-Control', 'no-store,no-cache');
+ }
+
+ /**
+ * Add `Expires` header to PSR7 response object
+ *
+ * @param ResponseInterface $response A PSR7 response object
+ * @param int|string $time A UNIX timestamp or a valid `strtotime()` string
+ *
+ * @return ResponseInterface A new PSR7 response object with `Expires` header
+ * @throws InvalidArgumentException if the expiration date cannot be parsed
+ */
+ public static function withExpires(ResponseInterface $response, $time)
+ {
+ if (!is_integer($time)) {
+ $time = strtotime($time);
+ if ($time === false) {
+ throw new InvalidArgumentException('Expiration value could not be parsed with `strtotime()`.');
+ }
+ }
+
+ return $response->withHeader('Expires', gmdate('D, d M Y H:i:s T', $time));
+ }
+
+ /**
+ * Add `ETag` header to PSR7 response object
+ *
+ * @param ResponseInterface $response A PSR7 response object
+ * @param string $value The ETag value
+ * @param string $type ETag type: "strong" or "weak"
+ *
+ * @return ResponseInterface A new PSR7 response object with `ETag` header
+ * @throws InvalidArgumentException if the etag type is invalid
+ */
+ public static function withEtag(ResponseInterface $response, $value, $type = 'strong')
+ {
+ if (!in_array($type, ['strong', 'weak'])) {
+ throw new InvalidArgumentException('Invalid etag type. Must be "strong" or "weak".');
+ }
+ $value = '"' . $value . '"';
+ if ($type === 'weak') {
+ $value = 'W/' . $value;
+ }
+
+ return $response->withHeader('ETag', $value);
+ }
+
+ /**
+ * Add `Last-Modified` header to PSR7 response object
+ *
+ * @param ResponseInterface $response A PSR7 response object
+ * @param int|string $time A UNIX timestamp or a valid `strtotime()` string
+ *
+ * @return ResponseInterface A new PSR7 response object with `Last-Modified` header
+ * @throws InvalidArgumentException if the last modified date cannot be parsed
+ */
+ public static function withLastModified(ResponseInterface $response, $time)
+ {
+ if (!is_integer($time)) {
+ $time = strtotime($time);
+ if ($time === false) {
+ throw new InvalidArgumentException('Last Modified value could not be parsed with `strtotime()`.');
+ }
+ }
+
+ return $response->withHeader('Last-Modified', gmdate('D, d M Y H:i:s T', $time));
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/HttpsDetect.php b/lib/Helper/HttpsDetect.php
new file mode 100644
index 0000000..eab95e0
--- /dev/null
+++ b/lib/Helper/HttpsDetect.php
@@ -0,0 +1,205 @@
+.
+ */
+
+
+namespace Xibo\Helper;
+
+use Slim\Http\ServerRequest;
+
+/**
+ * Class HttpsDetect
+ * @package Xibo\Helper
+ */
+class HttpsDetect
+{
+ /**
+ * Get the root of the web server
+ * this should only be used if you're planning to append the path
+ * @return string
+ */
+ public function getRootUrl(): string
+ {
+ $url = $this->getScheme() . '://' . $this->getHost();
+ if (($this->getScheme() === 'https' && $this->getPort() !== 443)
+ || ($this->getScheme() === 'http' && $this->getPort() !== 80)
+ ) {
+ $url .= sprintf(':%s', $this->getPort());
+ }
+
+ return $url;
+ }
+
+ /**
+ * @deprecated use getRootUrl
+ * @return string
+ */
+ public function getUrl(): string
+ {
+ return $this->getRootUrl();
+ }
+
+ /**
+ * Get the base URL for the instance
+ * this should give us the CMS URL including alias and file
+ * @param \Slim\Http\ServerRequest|null $request
+ * @return string
+ */
+ public function getBaseUrl(?ServerRequest $request = null): string
+ {
+ // Check REQUEST_URI is set. IIS doesn't set it, so we need to build it
+ // Attribution:
+ // Code snippet from http://support.ecenica.com/web-hosting/scripting/troubleshooting-scripting-errors/how-to-fix-server-request_uri-php-error-on-windows-iis/
+ // Released under BSD License
+ // Copyright (c) 2009, Ecenica Limited All rights reserved.
+ if (!isset($_SERVER['REQUEST_URI'])) {
+ $_SERVER['REQUEST_URI'] = $_SERVER['PHP_SELF'];
+ if (isset($_SERVER['QUERY_STRING'])) {
+ $_SERVER['REQUEST_URI'] .= '?' . $_SERVER['QUERY_STRING'];
+ }
+ }
+ // End Code Snippet
+
+ // The request URL should be everything after the host, i.e:
+ // /xmds.php?file=
+ // /xibo/xmds.php?file=
+ // /playersoftware
+ // /xibo/playersoftware
+ $requestUri = explode('?', htmlentities($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8'));
+ $baseUrl = $this->getRootUrl() . '/' . ltrim($requestUri[0], '/');
+
+ // We use the path, if provided, to remove any known path information
+ // i.e. if we're running in a sub-folder we might be on /xibo/playersoftware
+ // in which case we want to remove /playersoftware to get to /xibo which is the base path.
+ $path = $request?->getUri()?->getPath() ?? '';
+ if (!empty($path)) {
+ $baseUrl = str_replace($path, '', $baseUrl);
+ }
+
+ return $baseUrl;
+ }
+
+ /**
+ * @return string
+ */
+ public function getScheme(): string
+ {
+ return ($this->isHttps()) ? 'https' : 'http';
+ }
+
+ /**
+ * Get Host
+ * @return string
+ */
+ public function getHost(): string
+ {
+ if (isset($_SERVER['HTTP_HOST'])) {
+ $httpHost = htmlentities($_SERVER['HTTP_HOST'], ENT_QUOTES, 'UTF-8');
+ if (str_contains($httpHost, ':')) {
+ $hostParts = explode(':', $httpHost);
+
+ return $hostParts[0];
+ }
+
+ return $httpHost;
+ }
+
+ return $_SERVER['SERVER_NAME'];
+ }
+
+ /**
+ * Get Port
+ * @return int
+ */
+ public function getPort(): int
+ {
+ if (isset($_SERVER['HTTP_HOST']) && str_contains($_SERVER['HTTP_HOST'], ':')) {
+ $hostParts = explode(':', htmlentities($_SERVER['HTTP_HOST'], ENT_QUOTES, 'UTF-8'));
+ return $hostParts[1];
+ }
+
+ return ($this->isHttps() ? 443 : 80);
+ }
+
+ /**
+ * Is HTTPs?
+ * @return bool
+ */
+ public static function isHttps(): bool
+ {
+ return (
+ (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') ||
+ (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https')
+ );
+ }
+
+ /**
+ * @param \Xibo\Service\ConfigServiceInterface $config
+ * @param \Psr\Http\Message\RequestInterface $request
+ * @return bool
+ */
+ public static function isShouldIssueSts($config, $request): bool
+ {
+ // We might need to issue STS headers
+ $whiteListLoadBalancers = $config->getSetting('WHITELIST_LOAD_BALANCERS');
+ $originIp = $_SERVER['REMOTE_ADDR'] ?? '';
+ $forwardedProtoHttps = (
+ strtolower($request->getHeaderLine('HTTP_X_FORWARDED_PROTO')) === 'https'
+ && $originIp != ''
+ && (
+ $whiteListLoadBalancers === '' || in_array($originIp, explode(',', $whiteListLoadBalancers))
+ )
+ );
+
+ return (
+ ($request->getUri()->getScheme() == 'https' || $forwardedProtoHttps)
+ && $config->getSetting('ISSUE_STS', 0) == 1
+ );
+ }
+
+ /**
+ * @param \Xibo\Service\ConfigServiceInterface $config
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * @return \Psr\Http\Message\ResponseInterface
+ */
+ public static function decorateWithSts($config, $response)
+ {
+ return $response->withHeader(
+ 'strict-transport-security',
+ 'max-age=' . $config->getSetting('STS_TTL', 600)
+ );
+ }
+
+ /**
+ * @param \Xibo\Service\ConfigServiceInterface $config
+ * @param \Psr\Http\Message\RequestInterface $request
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * @return \Psr\Http\Message\ResponseInterface
+ */
+ public static function decorateWithStsIfNecessary($config, $request, $response)
+ {
+ if (self::isShouldIssueSts($config, $request)) {
+ return self::decorateWithSts($config, $response);
+ } else {
+ return $response;
+ }
+ }
+}
diff --git a/lib/Helper/Install.php b/lib/Helper/Install.php
new file mode 100644
index 0000000..77f0085
--- /dev/null
+++ b/lib/Helper/Install.php
@@ -0,0 +1,576 @@
+.
+ */
+namespace Xibo\Helper;
+
+use Phinx\Console\PhinxApplication;
+use Phinx\Wrapper\TextWrapper;
+use Psr\Container\ContainerInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InstallationError;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class Install
+ * @package Xibo\Helper
+ */
+class Install
+{
+ // DB Details
+ public $db_create;
+ public $db_admin_user;
+ public $db_admin_pass;
+ public $new_db_host;
+ public $new_db_user;
+ public $new_db_pass;
+ public $new_db_name;
+ public $new_ssl_ca;
+ public $new_ssl_verify;
+ public $existing_db_host;
+ public $existing_db_user;
+ public $existing_db_pass;
+ public $existing_db_name;
+ public $existing_ssl_ca;
+ public $existing_ssl_verify;
+
+ /** @var ContainerInterface */
+ private $container;
+
+ /** @var SanitizerService */
+ private $sanitizerService;
+
+ /**
+ * Install constructor.
+ * @param ContainerInterface $container
+ */
+ public function __construct(ContainerInterface $container)
+ {
+ $this->container = $container;
+ $this->sanitizerService = $container->get('sanitizerService');
+ }
+
+ /**
+ * @param $array
+ * @return SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ return $this->sanitizerService->getSanitizer($array);
+ }
+
+ /**
+ * @return array
+ */
+ public function step1(): array
+ {
+ return [
+ 'config' => $this->container->get('configService'),
+ 'isSettingsPathWriteable' => Environment::checkSettingsFileSystemPermissions()
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function step2(): array
+ {
+ return [];
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @throws InstallationError
+ */
+ public function step3(Request $request, Response $response) : Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ /** @var StorageServiceInterface $store */
+ $store = $this->container->get('store');
+
+ // Have we been told to create a new database
+ $this->db_create = $sanitizedParams->getInt('db_create');
+
+ // Check all parameters have been specified
+ $this->db_admin_user = $sanitizedParams->getString('admin_username');
+ $this->db_admin_pass = $sanitizedParams->getString('admin_password');
+
+ $this->new_db_host = $sanitizedParams->getString('host');
+ $this->new_db_user = $sanitizedParams->getString('db_username');
+ $this->new_db_pass = $sanitizedParams->getString('db_password');
+ $this->new_db_name = $sanitizedParams->getString('db_name');
+ $this->new_ssl_ca = $sanitizedParams->getString('ssl_ca');
+ $this->new_ssl_verify = $sanitizedParams->getCheckbox('ssl_verify') == 1;
+
+ $this->existing_db_host = $sanitizedParams->getString('existing_host');
+ $this->existing_db_user = $sanitizedParams->getString('existing_db_username');
+ $this->existing_db_pass = $sanitizedParams->getString('existing_db_password');
+ $this->existing_db_name = $sanitizedParams->getString('existing_db_name');
+ $this->existing_ssl_ca = $sanitizedParams->getString('existing_ssl_ca');
+ $this->existing_ssl_verify = $sanitizedParams->getCheckbox('existing_ssl_verify') == 1;
+
+ // If an administrator user name / password has been specified then we should create a new DB
+ if ($this->db_create == 1) {
+ // Check details for a new database
+ if ($this->new_db_host == '') {
+ throw new InstallationError(__('Please provide a database host. This is usually localhost.'));
+ }
+
+ if ($this->new_db_user == '') {
+ throw new InstallationError(__('Please provide a user for the new database.'));
+ }
+
+ if ($this->new_db_pass == '') {
+ throw new InstallationError(__('Please provide a password for the new database.'));
+ }
+
+ if ($this->new_db_name == '') {
+ throw new InstallationError(__('Please provide a name for the new database.'));
+ }
+
+ if ($this->db_admin_user == '') {
+ throw new InstallationError(__('Please provide an admin user name.'));
+ }
+
+ // Try to create the new database
+ // Try and connect using these details and create the new database
+ try {
+ $store->connect(
+ $this->new_db_host,
+ $this->db_admin_user,
+ $this->db_admin_pass,
+ null,
+ empty($this->new_ssl_ca) ? null : $this->new_ssl_ca,
+ $this->new_ssl_verify
+ );
+ } catch (\PDOException $e) {
+ throw new InstallationError(sprintf(
+ __('Could not connect to MySQL with the administrator details. Please check and try again. Error Message = [%s]'),
+ $e->getMessage()
+ ));
+ }
+
+ // Try to create the new database
+ try {
+ $dbh = $store->getConnection();
+ $dbh->exec(sprintf('CREATE DATABASE `%s` CHARACTER SET utf8 COLLATE utf8_general_ci', $this->new_db_name));
+ } catch (\PDOException $e) {
+ throw new InstallationError(sprintf(__('Could not create a new database with the administrator details [%s]. Please check and try again. Error Message = [%s]'), $this->db_admin_user, $e->getMessage()));
+ }
+
+ // Try to create the new user
+ $sql = null;
+ try {
+ $dbh = $store->getConnection();
+
+ // Create the user and grant privileges
+ if ($this->new_db_host == 'localhost') {
+ $sql = sprintf(
+ 'GRANT ALL PRIVILEGES ON `%s`.* to %s@%s IDENTIFIED BY %s',
+ $this->new_db_name,
+ $dbh->quote($this->new_db_user),
+ $dbh->quote($this->new_db_host),
+ $dbh->quote($this->new_db_pass)
+ );
+ } else {
+ $sql = sprintf(
+ 'GRANT ALL PRIVILEGES ON `%s`.* to %s@\'%%\' IDENTIFIED BY %s',
+ $this->new_db_name,
+ $dbh->quote($this->new_db_user),
+ $dbh->quote($this->new_db_pass)
+ );
+ }
+ $dbh->exec($sql);
+
+ // Flush
+ $dbh->exec('FLUSH PRIVILEGES');
+ } catch (\PDOException $e) {
+ throw new InstallationError(sprintf(
+ __('Could not create a new user with the administrator details. Please check and try again. Error Message = [%s]. SQL = [%s].'),
+ $e->getMessage(),
+ $sql
+ ));
+ }
+
+ // Set our DB details
+ $this->existing_db_host = $this->new_db_host;
+ $this->existing_db_user = $this->new_db_user;
+ $this->existing_db_pass = $this->new_db_pass;
+ $this->existing_db_name = $this->new_db_name;
+ $this->existing_ssl_ca = $this->new_ssl_ca;
+ $this->existing_ssl_verify = $this->new_ssl_verify;
+
+ // Close the connection
+ $store->close();
+ } else {
+ // Check details for a new database
+ if ($this->existing_db_host == '') {
+ throw new InstallationError(__('Please provide a database host. This is usually localhost.'));
+ }
+
+ if ($this->existing_db_user == '') {
+ throw new InstallationError(__('Please provide a user for the existing database.'));
+ }
+
+ if ($this->existing_db_pass == '') {
+ throw new InstallationError(__('Please provide a password for the existing database.'));
+ }
+
+ if ($this->existing_db_name == '') {
+ throw new InstallationError(__('Please provide a name for the existing database.'));
+ }
+ }
+
+ // Try and make a connection with this database
+ try {
+ $store->connect(
+ $this->existing_db_host,
+ $this->existing_db_user,
+ $this->existing_db_pass,
+ $this->existing_db_name,
+ empty($this->existing_ssl_ca) ? null : $this->existing_ssl_ca,
+ $this->existing_ssl_verify
+ );
+ } catch (\PDOException $e) {
+ throw new InstallationError(sprintf(
+ __('Could not connect to MySQL with the administrator details. Please check and try again. Error Message = [%s]'),
+ $e->getMessage()
+ ));
+ }
+
+ // Write out a new settings.php
+ $fh = fopen(PROJECT_ROOT . '/web/settings.php', 'wt');
+
+ if (!$fh) {
+ throw new InstallationError(
+ __('Unable to write to settings.php. We already checked this was possible earlier, so something changed.')
+ );
+ }
+
+ // Get the settings template and issue replacements
+ $settings = $this->getSettingsTemplate();
+
+ // Replace instances of $_SERVER vars with our own
+ $settings = str_replace('$_SERVER[\'MYSQL_HOST\'] . \':\' . $_SERVER[\'MYSQL_PORT\']', '\'' . $this->existing_db_host . '\'', $settings);
+ $settings = str_replace('$_SERVER[\'MYSQL_USER\']', '\'' . $this->existing_db_user . '\'', $settings);
+ $settings = str_replace('$_SERVER[\'MYSQL_PASSWORD\']', '\'' . addslashes($this->existing_db_pass) . '\'', $settings);
+ $settings = str_replace('$_SERVER[\'MYSQL_DATABASE\']', '\'' . $this->existing_db_name . '\'', $settings);
+ $settings = str_replace('$_SERVER[\'MYSQL_ATTR_SSL_CA\']', '\'' . $this->existing_ssl_ca . '\'', $settings);
+ $settings = str_replace('$_SERVER[\'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT\']', '\'' . $this->existing_ssl_verify . '\'', $settings);
+ $settings = str_replace('define(\'SECRET_KEY\',\'\')', 'define(\'SECRET_KEY\',\'' . Install::generateSecret() . '\');', $settings);
+
+ if (!fwrite($fh, $settings)) {
+ throw new InstallationError(__('Unable to write to settings.php. We already checked this was possible earlier, so something changed.'));
+ }
+
+ fclose($fh);
+
+ // Run phinx migrate
+ $phinx = new TextWrapper(new PhinxApplication(), ['configuration' => PROJECT_ROOT . '/phinx.php']);
+ $phinx->getMigrate();
+
+ // If we get here, we want to move on to the next step.
+ // This is handled by the calling function (i.e. there is no output from this call, we just reload and move on)
+ return $response;
+ }
+
+ /**
+ * @return array
+ */
+ public function step4(): array
+ {
+ return [];
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @throws InstallationError
+ */
+ public function step5(Request $request, Response $response) : Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+ /** @var StorageServiceInterface $store */
+ $store = $this->container->get('store');
+ // Configure the user account
+ $username = $sanitizedParams->getString('admin_username');
+ $password = $sanitizedParams->getString('admin_password');
+
+ if ($username == '') {
+ throw new InstallationError(__('Missing the admin username.'));
+ }
+
+ if ($password == '') {
+ throw new InstallationError(__('Missing the admin password.'));
+ }
+
+ // Update user id 1 with these details.
+ try {
+ $dbh = $store->getConnection();
+
+ $sth = $dbh->prepare('UPDATE `user` SET UserName = :username, UserPassword = :password WHERE UserID = 1 LIMIT 1');
+ $sth->execute(array(
+ 'username' => $username,
+ 'password' => md5($password)
+ ));
+
+ // Update group ID 3 with the user name
+ $sth = $dbh->prepare('UPDATE `group` SET `group` = :username WHERE groupId = 3 LIMIT 1');
+ $sth->execute(array(
+ 'username' => $username
+ ));
+
+ } catch (\PDOException $e) {
+ throw new InstallationError(sprintf(__('Unable to set the user details. This is an unexpected error, please contact support. Error Message = [%s]'), $e->getMessage()));
+ }
+
+ return $response;
+ }
+
+ /**
+ * @return array
+ */
+ public function step6(): array
+ {
+ return [
+ 'serverKey' => Install::generateSecret(6)
+ ];
+ }
+
+ /**
+ * @param Request $request
+ * @param Response $response
+ * @throws InstallationError
+ */
+ public function step7(Request $request, Response $response) : Response
+ {
+ $sanitizedParams = $this->getSanitizer($request->getParams());
+
+ /** @var StorageServiceInterface $store */
+ $store = $this->container->get('store');
+
+ $server_key = $sanitizedParams->getString('server_key');
+ $library_location = $sanitizedParams->getString('library_location');
+ $stats = $sanitizedParams->getCheckbox('stats');
+
+ if ($server_key == '') {
+ throw new InstallationError(__('Missing the server key.'));
+ }
+
+ if ($library_location == '') {
+ throw new InstallationError(__('Missing the library location.'));
+ }
+
+ // Remove trailing white space from the path given.
+ $library_location = trim($library_location);
+
+ if (!is_dir($library_location)) {
+ // Make sure they haven't given a file as the library location
+ if (is_file($library_location)) {
+ throw new InstallationError(__('A file exists with the name you gave for the Library Location. Please choose another location'));
+ }
+
+ // Directory does not exist. Attempt to make it
+ // Using mkdir recursively, so it will attempt to make any
+ // intermediate folders required.
+ if (!mkdir($library_location, 0755, true)) {
+ throw new InstallationError(__('Could not create the Library Location directory for you. Please ensure the webserver has permission to create a folder in this location, or create the folder manually and grant permission for the webserver to write to the folder.'));
+ }
+ }
+
+ // Is library_location writable?
+ if (!is_writable($library_location)) {
+ throw new InstallationError(__('The Library Location you gave is not writable by the webserver. Please fix the permissions and try again.'));
+ }
+
+ // Is library_location empty?
+ if (count(Install::ls("*", $library_location, true)) > 0) {
+ throw new InstallationError(__('The Library Location you gave is not empty. Please give the location of an empty folder'));
+ }
+
+ // Check if the user has added a trailing slash. If not, add one.
+ if (!((substr($library_location, -1) == '/') || (substr($library_location, -1) == '\\'))) {
+ $library_location = $library_location . '/';
+ }
+
+ // Attempt to create fonts sub-folder in Library location
+ if (!mkdir($library_location . 'fonts', 0777, true)) {
+ throw new InstallationError(__('Could not create the fonts sub-folder under Library Location directory for you. Please ensure the webserver has permission to create a folder in this location, or create the folder manually and grant permission for the webserver to write to the folder.'));//phpcs:ignore
+ }
+
+ try {
+ $dbh = $store->getConnection();
+
+ // Library Location
+ $sth = $dbh->prepare('UPDATE `setting` SET `value` = :value WHERE `setting`.`setting` = \'LIBRARY_LOCATION\' LIMIT 1');
+ $sth->execute(array('value' => $library_location));
+
+ // Server Key
+ $sth = $dbh->prepare('UPDATE `setting` SET `value` = :value WHERE `setting`.`setting` = \'SERVER_KEY\' LIMIT 1');
+ $sth->execute(array('value' => $server_key));
+
+ // Default Time zone
+ $sth = $dbh->prepare('UPDATE `setting` SET `value` = :value WHERE `setting`.`setting` = \'defaultTimezone\' LIMIT 1');
+ $sth->execute(array('value' => date_default_timezone_get()));
+
+ // Phone Home
+ $sth = $dbh->prepare('UPDATE `setting` SET `value` = :value WHERE `setting`.`setting` = \'PHONE_HOME\' LIMIT 1');
+ $sth->execute([
+ 'value' => $stats
+ ]);
+ } catch (\PDOException $e) {
+ throw new InstallationError(sprintf(__('An error occurred updating these settings. This is an unexpected error, please contact support. Error Message = [%s]'), $e->getMessage()));
+ }
+
+ // Delete install
+ if (!@unlink('index.php')) {
+ throw new InstallationError(__("Unable to delete install/index.php. Please ensure the web server has permission to unlink this file and retry"));
+ }
+
+ return $response;
+ }
+
+ /**
+ * This function will take a pattern and a folder as the argument and go thru it(recursively if needed)and return the list of
+ * all files in that folder.
+ * Link : http://www.bin-co.com/php/scripts/filesystem/ls/
+ * License : BSD
+ * Arguments : $pattern - The pattern to look out for [OPTIONAL]
+ * $folder - The path of the directory of which's directory list you want [OPTIONAL]
+ * $recursivly - The funtion will traverse the folder tree recursivly if this is true. Defaults to false. [OPTIONAL]
+ * $options - An array of values 'return_files' or 'return_folders' or both
+ * Returns : A flat list with the path of all the files(no folders) that matches the condition given.
+ */
+ public static function ls($pattern = '*', $folder = '', $recursivly = false, $options = ['return_files', 'return_folders']): array
+ {
+ if ($folder) {
+ $current_folder = realpath('.');
+ if (in_array('quiet', $options)) { // If quiet is on, we will suppress the 'no such folder' error
+ if (!file_exists($folder)) return array();
+ }
+
+ if (!chdir($folder)) return array();
+ }
+
+
+ $get_files = in_array('return_files', $options);
+ $get_folders = in_array('return_folders', $options);
+ $both = array();
+ $folders = array();
+
+ // Get the all files and folders in the given directory.
+ if ($get_files) $both = glob($pattern, GLOB_BRACE + GLOB_MARK);
+ if ($recursivly or $get_folders) $folders = glob("*", GLOB_ONLYDIR + GLOB_MARK);
+
+ //If a pattern is specified, make sure even the folders match that pattern.
+ $matching_folders = array();
+ if ($pattern !== '*') $matching_folders = glob($pattern, GLOB_ONLYDIR + GLOB_MARK);
+
+ //Get just the files by removing the folders from the list of all files.
+ $all = array_values(array_diff($both, $folders));
+
+ if ($recursivly or $get_folders) {
+ foreach ($folders as $this_folder) {
+ if ($get_folders) {
+ //If a pattern is specified, make sure even the folders match that pattern.
+ if ($pattern !== '*') {
+ if (in_array($this_folder, $matching_folders)) array_push($all, $this_folder);
+ } else array_push($all, $this_folder);
+ }
+
+ if ($recursivly) {
+ // Continue calling this function for all the folders
+ $deep_items = Install::ls($pattern, $this_folder, $recursivly, $options); # :RECURSION:
+ foreach ($deep_items as $item) {
+ array_push($all, $this_folder . $item);
+ }
+ }
+ }
+ }
+
+ if ($folder) chdir($current_folder);
+ return $all;
+ }
+
+ /**
+ * @param int $length
+ * @return string
+ */
+ public static function generateSecret($length = 12): string
+ {
+ # Generates a random 12 character alphanumeric string to use as a salt
+ mt_srand((double)microtime() * 1000000);
+ $key = "";
+ for ($i = 0; $i < $length; $i++) {
+ $c = mt_rand(0, 2);
+ if ($c == 0) {
+ $key .= chr(mt_rand(65, 90));
+ } elseif ($c == 1) {
+ $key .= chr(mt_rand(97, 122));
+ } else {
+ $key .= chr(mt_rand(48, 57));
+ }
+ }
+
+ return $key;
+ }
+
+ private function getSettingsTemplate()
+ {
+ return <<" . __("Please press the back button in your browser."));
+
+global \$dbhost;
+global \$dbuser;
+global \$dbpass;
+global \$dbname;
+global \$dbssl;
+global \$dbsslverify;
+
+\$dbhost = \$_SERVER['MYSQL_HOST'] . ':' . \$_SERVER['MYSQL_PORT'];
+\$dbuser = \$_SERVER['MYSQL_USER'];
+\$dbpass = \$_SERVER['MYSQL_PASSWORD'];
+\$dbname = \$_SERVER['MYSQL_DATABASE'];
+\$dbssl = \$_SERVER['MYSQL_ATTR_SSL_CA'];
+\$dbsslverify = \$_SERVER['MYSQL_ATTR_SSL_VERIFY_SERVER_CERT'];
+
+if (!defined('SECRET_KEY'))
+ define('SECRET_KEY','');
+
+if (file_exists('/var/www/cms/custom/settings-custom.php'))
+ include_once('/var/www/cms/custom/settings-custom.php');
+
+END;
+ }
+}
diff --git a/lib/Helper/LayoutUploadHandler.php b/lib/Helper/LayoutUploadHandler.php
new file mode 100644
index 0000000..55c7773
--- /dev/null
+++ b/lib/Helper/LayoutUploadHandler.php
@@ -0,0 +1,143 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Exception;
+use Xibo\Support\Exception\LibraryFullException;
+
+/**
+ * Class LayoutUploadHandler
+ * @package Xibo\Helper
+ */
+class LayoutUploadHandler extends BlueImpUploadHandler
+{
+ /**
+ * @param $file
+ * @param $index
+ */
+ protected function handleFormData($file, $index)
+ {
+ /* @var \Xibo\Controller\Layout $controller */
+ $controller = $this->options['controller'];
+
+ /* @var SanitizerService $sanitizerService */
+ $sanitizerService = $this->options['sanitizerService'];
+
+ // Handle form data, e.g. $_REQUEST['description'][$index]
+ $fileName = $file->name;
+
+ $controller->getLog()->debug('Upload complete for ' . $fileName . '.');
+
+ // Upload and Save
+ try {
+ // Check Library
+ if ($this->options['libraryQuotaFull']) {
+ throw new LibraryFullException(sprintf(
+ __('Your library is full. Library Limit: %s K'),
+ $this->options['libraryLimit']
+ ));
+ }
+
+ // Check for a user quota
+ $controller->getUser()->isQuotaFullByUser();
+ $params = $sanitizerService->getSanitizer($_REQUEST);
+
+ // Parse parameters
+ $name = htmlspecialchars($params->getArray('name')[$index]);
+ $tags = $controller->getUser()->featureEnabled('tag.tagging')
+ ? htmlspecialchars($params->getArray('tags')[$index])
+ : '';
+ $template = $params->getCheckbox('template', ['default' => 0]);
+ $replaceExisting = $params->getCheckbox('replaceExisting', ['default' => 0]);
+ $importTags = $params->getCheckbox('importTags', ['default' => 0]);
+ $useExistingDataSets = $params->getCheckbox('useExistingDataSets', ['default' => 0]);
+ $importDataSetData = $params->getCheckbox('importDataSetData', ['default' => 0]);
+ $importFallback = $params->getCheckbox('importFallback', ['default' => 0]);
+
+ $layout = $controller->getLayoutFactory()->createFromZip(
+ $controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName,
+ $name,
+ $this->options['userId'],
+ $template,
+ $replaceExisting,
+ $importTags,
+ $useExistingDataSets,
+ $importDataSetData,
+ $this->options['dataSetFactory'],
+ $tags,
+ $this->options['mediaService'],
+ $this->options['folderId'],
+ );
+
+ // set folderId, permissionFolderId is handled on Layout specific Campaign record.
+ $layout->folderId = $this->options['folderId'];
+
+ $layout->save(['saveActions' => false, 'import' => true]);
+
+ if (!empty($layout->getUnmatchedProperty('thumbnail'))) {
+ rename($layout->getUnmatchedProperty('thumbnail'), $layout->getThumbnailUri());
+ }
+
+ $layout->managePlaylistClosureTable();
+
+ // When importing a layout, skip action validation
+ $layout->manageActions(false);
+
+ // Handle widget data
+ $fallback = $layout->getUnmatchedProperty('fallback');
+ if ($importFallback == 1 && $fallback !== null) {
+ /** @var \Xibo\Factory\WidgetDataFactory $widgetDataFactory */
+ $widgetDataFactory = $this->options['widgetDataFactory'];
+ foreach ($layout->getAllWidgets() as $widget) {
+ // Did this widget have fallback data included in its export?
+ if (array_key_exists($widget->tempWidgetId, $fallback)) {
+ foreach ($fallback[$widget->tempWidgetId] as $item) {
+ // We create the widget data with the new widgetId
+ $widgetDataFactory
+ ->create(
+ $widget->widgetId,
+ $item['data'] ?? [],
+ intval($item['displayOrder'] ?? 1),
+ )
+ ->save();
+ }
+ }
+ }
+ }
+
+ @unlink($controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName);
+
+ // Set the name for the return
+ $file->name = $layout->layout;
+ $file->id = $layout->layoutId;
+ } catch (Exception $e) {
+ $controller->getLog()->error(sprintf('Error importing Layout: %s', $e->getMessage()));
+ $controller->getLog()->debug($e->getTraceAsString());
+
+ $file->error = $e->getMessage();
+
+ // Don't commit
+ $controller->getState()->setCommitState(false);
+ }
+ }
+}
diff --git a/lib/Helper/LinkSigner.php b/lib/Helper/LinkSigner.php
new file mode 100644
index 0000000..c7b9e13
--- /dev/null
+++ b/lib/Helper/LinkSigner.php
@@ -0,0 +1,167 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Xibo\Entity\Display;
+
+/**
+ * S3 style links
+ * inspired by https://gist.github.com/kelvinmo/d78be66c4f36415a6b80
+ */
+class LinkSigner
+{
+ /**
+ * @param \Xibo\Entity\Display $display
+ * @param string $encryptionKey
+ * @param string|null $cdnUrl
+ * @param $type
+ * @param $itemId
+ * @param string $storedAs
+ * @param string|null $fileType
+ * @return string
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public static function generateSignedLink(
+ Display $display,
+ string $encryptionKey,
+ ?string $cdnUrl,
+ $type,
+ $itemId,
+ string $storedAs,
+ string $fileType = null,
+ bool $isRequestFromPwa = false,
+ ): string {
+ // Start with the base url, which should correctly account for running with a CMS_ALIAS
+ $xmdsRoot = (new HttpsDetect())->getBaseUrl();
+
+ // PWA requests resources via `/pwa/getResource`, but the link should be served from `/xmds.php`
+ if ($isRequestFromPwa) {
+ $xmdsRoot = str_replace('/pwa/getResource', '/xmds.php', $xmdsRoot);
+ }
+
+ // Build the rest of the URL
+ $saveAsPath = $xmdsRoot
+ . '?file=' . $storedAs
+ . '&displayId=' . $display->displayId
+ . '&type=' . $type
+ . '&itemId=' . $itemId;
+
+ if ($fileType !== null) {
+ $saveAsPath .= '&fileType=' . $fileType;
+ }
+
+ $saveAsPath .= '&' . LinkSigner::getSignature(
+ parse_url($xmdsRoot, PHP_URL_HOST),
+ $storedAs,
+ time() + ($display->getSetting('collectionInterval', 300) * 2),
+ $encryptionKey,
+ );
+
+ // CDN?
+ if (!empty($cdnUrl)) {
+ // Serve a link to the CDN
+ // CDN_URL has a `?dl=` parameter on the end already, so we just encode our string and concatenate it
+ return 'http' . (
+ (
+ (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') ||
+ (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])
+ && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https')
+ ) ? 's' : '')
+ . '://' . $cdnUrl . urlencode($saveAsPath);
+ } else {
+ // Serve a HTTP link to XMDS
+ return $saveAsPath;
+ }
+ }
+
+ /**
+ * Get a S3 compatible signature
+ */
+ public static function getSignature(
+ string $host,
+ string $uri,
+ int $expires,
+ string $secretKey,
+ ?string $timeText = null,
+ ?bool $isReturnSignature = false
+ ): string {
+ $encodedUri = str_replace('%2F', '/', rawurlencode($uri));
+ $headerString = 'host:' . $host . "\n";
+ $signedHeadersString = 'host';
+
+ if ($timeText === null) {
+ $timestamp = time();
+ $dateText = gmdate('Ymd', $timestamp);
+ $timeText = $dateText . 'T000000Z';
+ } else {
+ $dateText = explode('T', $timeText)[0];
+ }
+
+ $algorithm = 'AWS4-HMAC-SHA256';
+ $scope = $dateText . '/all/s3/aws4_request';
+
+ $amzParams = [
+ 'X-Amz-Algorithm' => $algorithm,
+ 'X-Amz-Date' => $timeText,
+ 'X-Amz-SignedHeaders' => $signedHeadersString
+ ];
+ if ($expires > 0) {
+ $amzParams['X-Amz-Expires'] = $expires;
+ }
+ ksort($amzParams);
+
+ $queryStringItems = [];
+ foreach ($amzParams as $key => $value) {
+ $queryStringItems[] = rawurlencode($key) . '=' . rawurlencode($value);
+ }
+ $queryString = implode('&', $queryStringItems);
+
+ $request = 'GET' . "\n" . $encodedUri . "\n" . $queryString . "\n" . $headerString . "\n"
+ . $signedHeadersString . "\nUNSIGNED-PAYLOAD";
+ $stringToSign = $algorithm . "\n" . $timeText . "\n" . $scope . "\n" . hash('sha256', $request);
+ $signingKey = hash_hmac(
+ 'sha256',
+ 'aws4_request',
+ hash_hmac(
+ 'sha256',
+ 's3',
+ hash_hmac(
+ 'sha256',
+ 'all',
+ hash_hmac(
+ 'sha256',
+ $dateText,
+ 'AWS4' . $secretKey,
+ true
+ ),
+ true
+ ),
+ true
+ ),
+ true
+ );
+ $signature = hash_hmac('sha256', $stringToSign, $signingKey);
+
+ return ($isReturnSignature) ? $signature : $queryString . '&X-Amz-Signature=' . $signature;
+ }
+}
diff --git a/lib/Helper/LogoutTrait.php b/lib/Helper/LogoutTrait.php
new file mode 100644
index 0000000..e55568c
--- /dev/null
+++ b/lib/Helper/LogoutTrait.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\User;
+use Xibo\Service\LogServiceInterface;
+
+trait LogoutTrait
+{
+ public function completeLogoutFlow(User $user, Session $session, LogServiceInterface $log, Request $request)
+ {
+ $user->touch();
+
+ unset($_SESSION['userid']);
+ unset($_SESSION['username']);
+ unset($_SESSION['password']);
+ $session->setIsExpired(1);
+
+ $log->audit('User', $user->userId, 'User logout', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ }
+}
diff --git a/lib/Helper/NatoAlphabet.php b/lib/Helper/NatoAlphabet.php
new file mode 100644
index 0000000..4402022
--- /dev/null
+++ b/lib/Helper/NatoAlphabet.php
@@ -0,0 +1,62 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+class NatoAlphabet
+{
+ public static function convertToNato($word) {
+
+ $replacement = [
+ "a"=>"Alpha", "b"=>"Bravo", "c"=>"Charlie",
+ "d"=>"Delta", "e"=>"Echo", "f"=>"Foxtrot",
+ "g"=>"Golf", "h"=>"Hotel", "i"=>"India",
+ "j"=>"Juliet", "k"=>"Kilo", "l"=>"Lima",
+ "m"=>"Mike", "n"=>"November", "o"=>"Oscar",
+ "p"=>"Papa", "q"=>"Quebec", "r"=>"Romeo",
+ "s"=>"Sierra", "t"=>"Tango", "u"=>"Uniform",
+ "v"=>"Victor", "w"=>"Whiskey", "x"=>"X-Ray",
+ "y"=>"Yankee", "z"=>"Zulu", "0"=>"Zero",
+ "1"=>"One", "2"=>"Two", "3"=>"Three",
+ "4"=>"Four", "5"=>"Five", "6"=>"Six",
+ "7"=>"Seven", "8"=>"Eight", "9"=>"Nine",
+ "-"=>"Dash", " "=>"(Space)"
+ ];
+
+ $converted = [];
+
+ for ($i=0; $i < strlen($word); $i++) {
+ $currentLetter = substr($word, $i, 1);
+
+ if (!empty($replacement[$currentLetter])) {
+ $convertedWord = strtolower($replacement[$currentLetter]);
+ } elseif (!empty($replacement[strtolower($currentLetter)])) {
+ $convertedWord = $replacement[strtolower($currentLetter)];
+ } else {
+ $convertedWord = $currentLetter;
+ }
+ $converted[] = $convertedWord;
+ }
+
+ return implode(' ', $converted);
+ }
+}
diff --git a/lib/Helper/NullHelpService.php b/lib/Helper/NullHelpService.php
new file mode 100644
index 0000000..15088a4
--- /dev/null
+++ b/lib/Helper/NullHelpService.php
@@ -0,0 +1,42 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+class NullHelpService
+{
+ /**
+ * @inheritdoc
+ */
+ public function link($topic = null, $category = 'General')
+ {
+ //
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function address($suffix = '')
+ {
+ //
+ }
+}
diff --git a/lib/Helper/NullSanitizer.php b/lib/Helper/NullSanitizer.php
new file mode 100644
index 0000000..b3de1b0
--- /dev/null
+++ b/lib/Helper/NullSanitizer.php
@@ -0,0 +1,41 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+class NullSanitizer extends SanitizerService
+{
+ /**
+ * @param $array
+ */
+ public function getSanitizer($array)
+ {
+ //
+ }
+
+ /**
+ */
+ public function getValidator()
+ {
+ //
+ }
+}
diff --git a/lib/Helper/NullSession.php b/lib/Helper/NullSession.php
new file mode 100644
index 0000000..eba5101
--- /dev/null
+++ b/lib/Helper/NullSession.php
@@ -0,0 +1,66 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+class NullView
+{
+ public function fetch(string $template, array $data = [])
+ {
+ //
+ }
+
+ public function render($response, string $template, array $data = [])
+ {
+ //
+ }
+}
diff --git a/lib/Helper/ObjectVars.php b/lib/Helper/ObjectVars.php
new file mode 100644
index 0000000..bbb3065
--- /dev/null
+++ b/lib/Helper/ObjectVars.php
@@ -0,0 +1,37 @@
+.
+ */
+
+
+namespace Xibo\Helper;
+
+
+class ObjectVars
+{
+ /**
+ * Get Object Properties
+ * @param $object
+ * @return array
+ */
+ public static function getObjectVars($object)
+ {
+ return get_object_vars($object);
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/Pbkdf2Hash.php b/lib/Helper/Pbkdf2Hash.php
new file mode 100644
index 0000000..fcb2438
--- /dev/null
+++ b/lib/Helper/Pbkdf2Hash.php
@@ -0,0 +1,123 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+/**
+ * Class Profiler
+ * @package Xibo\Helper
+ */
+class Profiler
+{
+ private static $profiles = [];
+
+ /**
+ * @param $key
+ * @param null $logger
+ */
+ public static function start($key, $logger = null)
+ {
+ $start = microtime(true);
+ self::$profiles[$key] = $start;
+
+ if ($logger !== null) {
+ $logger->debug('PROFILE: ' . $key . ' - start: ' . $start);
+ }
+ }
+
+ /**
+ * @param $key
+ * @param null $logger
+ */
+ public static function end($key, $logger = null)
+ {
+ $start = self::$profiles[$key] ?? 0;
+ $end = microtime(true);
+ unset(self::$profiles[$key]);
+
+ if ($logger !== null) {
+ $logger->debug('PROFILE: ' . $key . ' - end: ' . $end
+ . ', duration: ' . ($end - $start));
+ }
+ }
+}
diff --git a/lib/Helper/QuickChartQRProvider.php b/lib/Helper/QuickChartQRProvider.php
new file mode 100644
index 0000000..1a180ae
--- /dev/null
+++ b/lib/Helper/QuickChartQRProvider.php
@@ -0,0 +1,104 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use RobThree\Auth\Providers\Qr\BaseHTTPQRCodeProvider;
+use RobThree\Auth\Providers\Qr\QRException;
+
+class QuickChartQRProvider extends BaseHTTPQRCodeProvider
+{
+ public $url;
+ public $errorCorrectionLevel;
+ public $margin;
+ public $backgroundColor;
+ public $color;
+ public $format;
+
+ /**
+ * QuickChartQRProvider constructor.
+ * @param string $url URL to a Quick Chart service
+ * @param bool $verifyssl
+ * @param string $errorCorrectionLevel valid values L, M, Q, H
+ * @param int $margin
+ * @param string $backgroundColor Hex color code - background colour
+ * @param string $color Hex color code - QR colour
+ * @param string $format Valid values: png, svg
+ * @throws QRException
+ */
+ public function __construct(
+ $url,
+ $verifyssl = false,
+ $errorCorrectionLevel = 'L',
+ $margin = 4,
+ $backgroundColor = 'ffffff',
+ $color = '000000',
+ $format = 'png'
+ ) {
+ if (!is_bool($verifyssl)) {
+ throw new QRException('VerifySSL must be bool');
+ }
+
+ $this->verifyssl = $verifyssl;
+
+ $this->url = $url;
+ $this->errorCorrectionLevel = $errorCorrectionLevel;
+ $this->margin = $margin;
+ $this->backgroundColor = $backgroundColor;
+ $this->color = $color;
+ $this->format = $format;
+ }
+
+ /**
+ * @return string
+ * @throws QRException
+ */
+ public function getMimeType()
+ {
+ switch (strtolower($this->format)) {
+ case 'png':
+ return 'image/png';
+ case 'svg':
+ return 'image/svg+xml';
+ case 'webp':
+ return 'image/webp';
+ }
+ throw new QRException(sprintf('Unknown MIME-type: %s', $this->format));
+ }
+
+ public function getQRCodeImage($qrText, $size)
+ {
+ return $this->getContent($this->getUrl($qrText, $size));
+ }
+
+ public function getUrl($qrText, $size)
+ {
+ return $this->url . '/qr'
+ . '?size=' . $size
+ . '&ecLevel=' . strtoupper($this->errorCorrectionLevel)
+ . '&margin=' . $this->margin
+ . '&light=' . $this->backgroundColor
+ . '&dark=' . $this->color
+ . '&format=' . strtolower($this->format)
+ . '&text=' . rawurlencode($qrText);
+ }
+}
diff --git a/lib/Helper/Random.php b/lib/Helper/Random.php
new file mode 100644
index 0000000..1eac380
--- /dev/null
+++ b/lib/Helper/Random.php
@@ -0,0 +1,51 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+/**
+ * Class Random
+ * @package Xibo\Helper
+ */
+class Random
+{
+ /**
+ * @param int $length
+ * @param string $prefix
+ * @return string
+ * @throws \Exception
+ */
+ public static function generateString($length = 10, $prefix = '')
+ {
+ if (function_exists('random_bytes')) {
+ return substr($prefix . bin2hex(random_bytes($length)), 0, $length + strlen($prefix));
+ } else {
+ $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ $charactersLength = strlen($characters);
+ $randomString = '';
+ for ($i = 0; $i < $length; $i++) {
+ $randomString .= $characters[rand(0, $charactersLength - 1)];
+ }
+ return $prefix . $randomString;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/RouteLogProcessor.php b/lib/Helper/RouteLogProcessor.php
new file mode 100644
index 0000000..cf2aeee
--- /dev/null
+++ b/lib/Helper/RouteLogProcessor.php
@@ -0,0 +1,54 @@
+.
+ */
+
+
+namespace Xibo\Helper;
+
+/**
+ * Class RouteLogProcessor
+ * a process to add route/method information to the log record
+ * @package Xibo\Helper
+ */
+class RouteLogProcessor
+{
+ /**
+ * Log Processor
+ * @param string $route
+ * @param string $method
+ */
+ public function __construct(
+ private readonly string $route,
+ private readonly string $method
+ ) {
+ }
+
+ /**
+ * @param array $record
+ * @return array
+ */
+ public function __invoke(array $record): array
+ {
+ $record['extra']['method'] = $this->method;
+ $record['extra']['route'] = $this->route;
+ return $record;
+ }
+}
diff --git a/lib/Helper/SanitizerService.php b/lib/Helper/SanitizerService.php
new file mode 100644
index 0000000..2a264dd
--- /dev/null
+++ b/lib/Helper/SanitizerService.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+
+use Xibo\Support\Sanitizer\RespectSanitizer;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+use Xibo\Support\Validator\RespectValidator;
+use Xibo\Support\Validator\ValidatorInterface;
+
+class SanitizerService
+{
+ /**
+ * @param $array
+ * @return SanitizerInterface
+ */
+ public function getSanitizer($array)
+ {
+ return (new RespectSanitizer())
+ ->setCollection($array)
+ ->setDefaultOptions([
+ 'checkboxReturnInteger' => true
+ ]);
+ }
+
+ /**
+ * @return ValidatorInterface
+ */
+ public function getValidator()
+ {
+ return new RespectValidator();
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/SendFile.php b/lib/Helper/SendFile.php
new file mode 100644
index 0000000..e027487
--- /dev/null
+++ b/lib/Helper/SendFile.php
@@ -0,0 +1,67 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use GuzzleHttp\Psr7\Stream;
+use Slim\Http\Response;
+
+/**
+ * Class SendFile
+ * @package Xibo\Helper
+ */
+class SendFile
+{
+ /**
+ * @param \Slim\Http\Response $response
+ * @param string $sendFile
+ * @param string $filePath
+ * @param string|null $name
+ * @param bool $zlibOff
+ * @return \Slim\Http\Response
+ */
+ public static function decorateResponse($response, $sendFile, $filePath, $name = null, $zlibOff = true):? Response
+ {
+ if ($zlibOff && ini_get('zlib.output_compression')) {
+ ini_set('zlib.output_compression', 'Off');
+ }
+
+ $baseName = basename($filePath);
+ $response = $response
+ ->withHeader('Content-Type', 'application/octet-stream')
+ ->withHeader('Content-Disposition', 'attachment; filename=' . ($name === null ? $baseName : $name))
+ ->withHeader('Content-Transfer-Encoding', 'Binary')
+ ->withHeader('Content-Length', filesize($filePath));
+
+ // Send via Apache X-Sendfile header?
+ if ($sendFile == 'Apache') {
+ $response = $response->withHeader('X-Sendfile', $filePath);
+ } else if ($sendFile == 'Nginx') {
+ // Send via Nginx X-Accel-Redirect?
+ $response = $response->withHeader('X-Accel-Redirect', '/download/temp/' . $baseName);
+ } else {
+ $response = $response->withBody(new Stream(fopen($filePath, 'r')));
+ }
+
+ return $response;
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/Session.php b/lib/Helper/Session.php
new file mode 100644
index 0000000..8bfd3f8
--- /dev/null
+++ b/lib/Helper/Session.php
@@ -0,0 +1,547 @@
+.
+ */
+namespace Xibo\Helper;
+
+use Carbon\Carbon;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\PdoStorageService;
+
+/**
+ * Class Session
+ * @package Xibo\Helper
+ */
+class Session implements \SessionHandlerInterface
+{
+ private $maxLifetime;
+ private $key;
+
+ /**
+ * Refresh expiry
+ * @var bool
+ */
+ public $refreshExpiry = true;
+
+ /**
+ * Expiry time
+ * @var int
+ */
+ private $sessionExpiry = 0;
+
+ /**
+ * Is the session expired?
+ * @var bool
+ */
+ private $expired = true;
+
+ /**
+ * The UserId whom owns this session
+ * @var int
+ */
+ private $userId = 0;
+
+ /**
+ * @var bool Whether gc() has been called
+ */
+ private $gcCalled = false;
+
+ /**
+ * Prune this key?
+ * @var bool
+ */
+ private $pruneKey = false;
+
+ /**
+ * The database connection
+ * @var PdoStorageService
+ */
+ private $pdo = null;
+
+ /**
+ * Log
+ * @var LogServiceInterface
+ */
+ private LogServiceInterface $log;
+
+ /**
+ * Session constructor.
+ * @param LogServiceInterface $log
+ */
+ public function __construct(LogServiceInterface $log)
+ {
+ $this->log = $log;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function open($savePath, $sessionName): bool
+ {
+ //$this->log->debug('Session open');
+ $this->maxLifetime = ini_get('session.gc_maxlifetime');
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function close(): bool
+ {
+ //$this->log->debug('Session close');
+
+ try {
+ // Commit
+ $this->commit();
+ } catch (\PDOException $e) {
+ $this->log->error('Error closing session: %s', $e->getMessage());
+ }
+
+ try {
+ // Prune this session if necessary
+ if ($this->pruneKey || $this->gcCalled) {
+ $db = new PdoStorageService($this->log);
+ $db->setConnection();
+
+ if ($this->pruneKey) {
+ $db->update('DELETE FROM `session` WHERE session_id = :session_id', [
+ 'session_id' => $this->key,
+ ]);
+ }
+
+ if ($this->gcCalled) {
+ // Delete sessions older than 10 times the max lifetime
+ $db->update('DELETE FROM `session` WHERE IsExpired = 1 AND session_expiration < :expiration', [
+ 'expiration' => Carbon::now()->subSeconds($this->maxLifetime * 10)->format('U'),
+ ]);
+
+ // Update expired sessions as expired
+ $db->update('UPDATE `session` SET IsExpired = 1 WHERE session_expiration < :expiration', [
+ 'expiration' => Carbon::now()->format('U'),
+ ]);
+ }
+
+ $db->commitIfNecessary();
+ $db->close();
+ }
+ } catch (\PDOException $e) {
+ $this->log->error('Error closing session: %s', $e->getMessage());
+ }
+
+ // Close
+ $this->getDb()->close();
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function read($key): false|string
+ {
+ //$this->log->debug('Session read');
+
+ $data = '';
+ $this->key = $key;
+
+ $userAgent = substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253);
+
+ try {
+ $dbh = $this->getDb();
+
+ // Start a transaction
+ $this->beginTransaction();
+
+ // Get this session
+ $sth = $dbh->getConnection()->prepare('
+ SELECT `session_data`, `isexpired`, `useragent`, `session_expiration`, `userId`
+ FROM `session`
+ WHERE `session_id` = :session_id
+ ');
+ $sth->execute(['session_id' => $key]);
+
+ $row = $sth->fetch();
+ if (!$row) {
+ // New session.
+ $this->insertSession(
+ $key,
+ '',
+ Carbon::now()->format('U'),
+ Carbon::now()->addSeconds($this->maxLifetime)->format('U'),
+ );
+
+ $this->expired = false;
+ } else {
+ // Existing session
+ // Check the session hasn't expired
+ if ($row['session_expiration'] < Carbon::now()->format('U')) {
+ $this->expired = true;
+ } else {
+ $this->expired = $row['isexpired'];
+ }
+
+ // What happens if the UserAgent has changed?
+ if ($row['useragent'] != $userAgent) {
+ // Force delete this session
+ $this->expired = 1;
+ $this->pruneKey = true;
+ }
+
+ $this->userId = $row['userId'];
+ $this->sessionExpiry = $row['session_expiration'];
+
+ // Set the session data (expired or not)
+ $data = $row['session_data'];
+ }
+
+ return (string)$data;
+ } catch (\Exception $e) {
+ $this->log->error('Error reading session: %s', $e->getMessage());
+
+ return $data;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function write($id, $data): bool
+ {
+ //$this->log->debug('Session write');
+
+ // What should we do with expiry?
+ $expiry = ($this->refreshExpiry)
+ ? Carbon::now()->addSeconds($this->maxLifetime)->format('U')
+ : $this->sessionExpiry;
+
+ try {
+ $this->updateSession($id, $data, Carbon::now()->format('U'), $expiry);
+ } catch (\PDOException $e) {
+ $this->log->error('Error writing session data: %s', $e->getMessage());
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function destroy($id): bool
+ {
+ //$this->log->debug('Session destroy');
+ try {
+ $this->getDb()->update('DELETE FROM `session` WHERE session_id = :session_id', ['session_id' => $id]);
+ } catch (\PDOException $e) {
+ $this->log->error('Error destroying session: %s', $e->getMessage());
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function gc($max_lifetime): false|int
+ {
+ //$this->log->debug('Session gc');
+ $this->gcCalled = true;
+ return true;
+ }
+
+ /**
+ * Sets the User Id
+ * @param $userId
+ */
+ public function setUser($userId): void
+ {
+ //$this->log->debug('Setting user Id to %d', $userId);
+ $_SESSION['userid'] = $userId;
+ $this->userId = $userId;
+ }
+
+ /**
+ * Updates the session ID with a new one
+ */
+ public function regenerateSessionId(): void
+ {
+ //$this->log->debug('Session regenerate');
+ session_regenerate_id(true);
+
+ $this->key = session_id();
+ }
+
+ /**
+ * Set this session to expired
+ * @param $isExpired
+ */
+ public function setIsExpired($isExpired): void
+ {
+ $this->expired = $isExpired;
+ }
+
+ /**
+ * Store a variable in the session
+ * @param string $key
+ * @param mixed $secondKey
+ * @param mixed|null $value
+ * @return mixed
+ */
+ public static function set(string $key, mixed $secondKey, mixed $value = null): mixed
+ {
+ if (func_num_args() == 2) {
+ $_SESSION[$key] = $secondKey;
+ return $secondKey;
+ } else {
+ if (!isset($_SESSION[$key]) || !is_array($_SESSION[$key])) {
+ $_SESSION[$key] = [];
+ }
+
+ $_SESSION[$key][(string) $secondKey] = $value;
+ return $value;
+ }
+ }
+
+ /**
+ * Get the Value from the position denoted by the 2 keys provided
+ * @param string $key
+ * @param string $secondKey
+ * @return bool
+ */
+ public static function get(string $key, ?string $secondKey = null): mixed
+ {
+ if ($secondKey != null) {
+ if (isset($_SESSION[$key][$secondKey])) {
+ return $_SESSION[$key][$secondKey];
+ }
+ } else {
+ if (isset($_SESSION[$key])) {
+ return $_SESSION[$key];
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Is the session expired?
+ * @return bool
+ */
+ public function isExpired(): bool
+ {
+ return $this->expired;
+ }
+
+ /**
+ * Get a Database
+ * @return PdoStorageService
+ */
+ private function getDb(): PdoStorageService
+ {
+ if ($this->pdo == null) {
+ $this->pdo = (new PdoStorageService($this->log))->setConnection();
+ }
+
+ return $this->pdo;
+ }
+
+ /**
+ * Helper method to begin a transaction.
+ *
+ * MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
+ * due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
+ * So we change it to READ COMMITTED.
+ */
+ private function beginTransaction(): void
+ {
+ if (!$this->getDb()->getConnection()->inTransaction()) {
+ try {
+ $this->getDb()->getConnection()->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
+ } catch (\PDOException $e) {
+ // https://github.com/xibosignage/xibo/issues/787
+ // this only works if BINLOG format is set to MIXED or ROW
+ $this->log->error('Unable to set session transaction isolation level, message = ' . $e->getMessage());
+ }
+ $this->getDb()->getConnection()->beginTransaction();
+ }
+ }
+
+ /**
+ * Commit
+ */
+ private function commit(): void
+ {
+ if ($this->getDb()->getConnection()->inTransaction()) {
+ $this->getDb()->getConnection()->commit();
+ }
+ }
+
+ /**
+ * Insert session
+ * @param $key
+ * @param $data
+ * @param $lastAccessed
+ * @param $expiry
+ */
+ private function insertSession($key, $data, $lastAccessed, $expiry): void
+ {
+ //$this->log->debug('Session insert');
+
+ $this->insertSessionHistory();
+
+ $sql = '
+ INSERT INTO `session` (
+ `session_id`,
+ `session_data`,
+ `session_expiration`,
+ `lastaccessed`,
+ `userid`,
+ `isexpired`,
+ `useragent`,
+ `remoteaddr`
+ )
+ VALUES (
+ :session_id,
+ :session_data,
+ :session_expiration,
+ :lastAccessed,
+ :userId,
+ :expired,
+ :useragent,
+ :remoteaddr
+ )
+ ';
+
+ $params = [
+ 'session_id' => $key,
+ 'session_data' => $data,
+ 'session_expiration' => $expiry,
+ 'lastAccessed' => Carbon::createFromTimestamp($lastAccessed)->format(DateFormatHelper::getSystemFormat()),
+ 'userId' => $this->userId,
+ 'expired' => ($this->expired) ? 1 : 0,
+ 'useragent' => substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253),
+ 'remoteaddr' => $this->getIp()
+ ];
+
+ $this->getDb()->update($sql, $params);
+ }
+
+ private function insertSessionHistory(): void
+ {
+ $sql = '
+ INSERT INTO `session_history` (`ipAddress`, `userAgent`, `startTime`, `userId`, `lastUsedTime`)
+ VALUES (:ipAddress, :userAgent, :startTime, :userId, :lastUsedTime)
+ ';
+
+ $params = [
+ 'ipAddress' => $this->getIp(),
+ 'userAgent' => substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253),
+ 'startTime' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'userId' => $this->userId,
+ 'lastUsedTime' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ];
+
+ $id = $this->getDb()->insert($sql, $params);
+
+ $this->set('sessionHistoryId', $id);
+ }
+
+ /**
+ * Update Session
+ * @param $key
+ * @param $data
+ * @param $lastAccessed
+ * @param $expiry
+ */
+ private function updateSession($key, $data, $lastAccessed, $expiry): void
+ {
+ //$this->log->debug('Session update');
+
+ $this->updateSessionHistory();
+
+ $sql = '
+ UPDATE `session` SET
+ session_data = :session_data,
+ session_expiration = :session_expiration,
+ LastAccessed = :lastAccessed,
+ userID = :userId,
+ IsExpired = :expired
+ WHERE session_id = :session_id
+ ';
+
+ $params = [
+ 'session_data' => $data,
+ 'session_expiration' => $expiry,
+ 'lastAccessed' => Carbon::createFromTimestamp($lastAccessed)->format(DateFormatHelper::getSystemFormat()),
+ 'userId' => $this->userId,
+ 'expired' => ($this->expired) ? 1 : 0,
+ 'session_id' => $key
+ ];
+
+ $this->getDb()->update($sql, $params);
+ }
+
+ /**
+ * Updates the session history
+ */
+ private function updateSessionHistory(): void
+ {
+ $sql = '
+ UPDATE `session_history` SET
+ lastUsedTime = :lastUsedTime, userID = :userId
+ WHERE sessionId = :sessionId
+ ';
+
+ $params = [
+ 'lastUsedTime' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'userId' => $this->userId,
+ 'sessionId' => $_SESSION['sessionHistoryId'],
+ ];
+
+ $this->getDb()->update($sql, $params);
+ }
+
+ /**
+ * Get the Client IP Address
+ * @return string
+ */
+ private function getIp(): string
+ {
+ $clientIp = '';
+ $keys = array('X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP', 'REMOTE_ADDR');
+ foreach ($keys as $key) {
+ if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
+ $clientIp = $_SERVER[$key];
+ break;
+ }
+ }
+ return $clientIp;
+ }
+
+ /**
+ * @param $userId
+ */
+ public function expireAllSessionsForUser($userId): void
+ {
+ $this->getDb()->update('UPDATE `session` SET IsExpired = 1 WHERE userID = :userId', [
+ 'userId' => $userId
+ ]);
+ }
+}
diff --git a/lib/Helper/Status.php b/lib/Helper/Status.php
new file mode 100644
index 0000000..373a2bc
--- /dev/null
+++ b/lib/Helper/Status.php
@@ -0,0 +1,35 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+/**
+ * Static class to reference statuses.
+ */
+class Status
+{
+ // Widget statuses.
+ public static $STATUS_VALID = 1;
+ public static $STATUS_PLAYER = 2;
+ public static $STATUS_NOT_BUILT = 3;
+ public static $STATUS_INVALID = 4;
+}
diff --git a/lib/Helper/Translate.php b/lib/Helper/Translate.php
new file mode 100644
index 0000000..8d66d0e
--- /dev/null
+++ b/lib/Helper/Translate.php
@@ -0,0 +1,238 @@
+.
+ */
+namespace Xibo\Helper;
+
+use CachedFileReader;
+use Gettext\Translations;
+use Gettext\Translator;
+use gettext_reader;
+use Illuminate\Support\Str;
+use Xibo\Service\ConfigServiceInterface;
+
+/**
+ * Class Translate
+ * @package Xibo\Helper
+ */
+class Translate
+{
+ private static $requestedLanguage;
+ private static $locale;
+ private static $jsLocale;
+ private static $jsLocaleRequested;
+
+ /**
+ * Gets and Sets the Locale
+ * @param ConfigServiceInterface $config
+ * @param $language string[optional] The Language to Load
+ */
+ public static function InitLocale($config, $language = NULL)
+ {
+ // The default language
+ $default = ($language === null) ? $config->getSetting('DEFAULT_LANGUAGE') : $language;
+
+ // Build an array of supported languages
+ $localeDir = PROJECT_ROOT . '/locale';
+ $supportedLanguages = array_map('basename', glob($localeDir . '/*.mo'));
+
+ // Record any matching languages we find.
+ $foundLanguage = null;
+
+ // Try to get the local firstly from _REQUEST (post then get)
+ if ($language != null) {
+ // Serve only the requested language
+ // Firstly, Sanitize it
+ self::$requestedLanguage = str_replace('-', '_', $language);
+
+ // Check its valid
+ if (in_array(self::$requestedLanguage . '.mo', $supportedLanguages)) {
+ $foundLanguage = self::$requestedLanguage;
+ }
+ }
+ else if ($config->getSetting('DETECT_LANGUAGE') == 1) {
+ // Detect the language, try from HTTP accept
+ // Parse the language header and build a preference array
+ $languagePreferenceArray = Translate::parseHttpAcceptLanguageHeader();
+
+ if (count($languagePreferenceArray) > 0) {
+ // Go through the list until we have a match
+ foreach ($languagePreferenceArray as $languagePreference => $preferenceRating) {
+
+ // We don't ship an en.mo, so fudge in a case where we automatically convert that to en_GB
+ if ($languagePreference == 'en')
+ $languagePreference = 'en_GB';
+
+ // Sanitize
+ $languagePreference = str_replace('-', '_', $languagePreference);
+
+ // Set as requested
+ self::$requestedLanguage = $languagePreference;
+
+ // Check it is valid
+ if (in_array($languagePreference . '.mo', $supportedLanguages)) {
+ $foundLanguage = $languagePreference;
+ break;
+ }
+ }
+ }
+ }
+
+ // Requested language
+ if (self::$requestedLanguage == null)
+ self::$requestedLanguage = $default;
+
+ // Are we still empty, then default language from settings
+ if ($foundLanguage == '') {
+ // Check the default
+ if (!in_array($default . '.mo', $supportedLanguages)) {
+ $default = 'en_GB';
+ }
+
+ // The default is valid
+ $foundLanguage = $default;
+ }
+
+ // Load translations
+ $translator = new Translator();
+ $translator->loadTranslations(Translations::fromMoFile($localeDir . '/' . $foundLanguage . '.mo'));
+ $translator->register();
+
+ // Store our resolved language locales
+ self::$locale = $foundLanguage;
+ self::$jsLocale = str_replace('_', '-', $foundLanguage);
+ self::$jsLocaleRequested = str_replace('_', '-', self::$requestedLanguage);
+ }
+
+ /**
+ * Get translations for user selected language
+ * @param $language
+ * @return Translator|null
+ */
+ public static function getTranslationsFromLocale($language): ?Translator
+ {
+ // Build an array of supported languages
+ $localeDir = PROJECT_ROOT . '/locale';
+ $supportedLanguages = array_map('basename', glob($localeDir . '/*.mo'));
+
+ // Record any matching languages we find.
+ $foundLanguage = null;
+
+ // Try to get the local firstly from _REQUEST (post then get)
+ if ($language != null) {
+ $parsedLanguage = str_replace('-', '_', $language);
+
+ // Check its valid
+ if (in_array($parsedLanguage . '.mo', $supportedLanguages)) {
+ $foundLanguage = $parsedLanguage;
+ } else {
+ return null;
+ }
+ }
+
+ // Are we still empty, then return null
+ if ($foundLanguage == '') {
+ return null;
+ }
+
+ // Load translations
+ $translator = new Translator();
+ $translator->loadTranslations(Translations::fromMoFile($localeDir . '/' . $foundLanguage . '.mo'));
+ $translator->register();
+
+ return $translator;
+ }
+
+ /**
+ * Get the Locale
+ * @param null $characters The number of characters to take from the beginning of the local string
+ * @return mixed
+ */
+ public static function GetLocale($characters = null)
+ {
+ return ($characters == null) ? self::$locale : substr(self::$locale, 0, $characters);
+ }
+
+ public static function GetJsLocale()
+ {
+ return self::$jsLocale;
+ }
+
+ /**
+ * @param array $options
+ * @return string
+ */
+ public static function getRequestedJsLocale($options = [])
+ {
+ $options = array_merge([
+ 'short' => false
+ ], $options);
+
+ if ($options['short'] && (strlen(self::$jsLocaleRequested) > 2) && Str::contains(self::$jsLocaleRequested, '-')) {
+ // Short js-locale requested, and our string is longer than 2 characters and has a splitter (language variant)
+ $variant = explode('-', self::$jsLocaleRequested);
+
+ // The logic here is that if they are the same, i.e. de-DE, then we should only output de, but if they are
+ // different, i.e. de-AT then we should output the whole thing
+ return (strtolower($variant[0]) === strtolower($variant[1])) ? $variant[0] : self::$jsLocaleRequested;
+ } else {
+ return self::$jsLocaleRequested;
+ }
+ }
+
+ public static function getRequestedLanguage()
+ {
+ return self::$requestedLanguage;
+ }
+
+ /**
+ * Parse the HttpAcceptLanguage Header
+ * Inspired by: http://www.thefutureoftheweb.com/blog/use-accept-language-header
+ * @param null $header
+ * @return array Language array where the key is the language identifier and the value is the preference double.
+ */
+ public static function parseHttpAcceptLanguageHeader($header = null)
+ {
+ if ($header == null)
+ $header = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : '';
+
+ $languages = array();
+
+ if ($header != '') {
+ // break up string into pieces (languages and q factors)
+ preg_match_all('/([a-z]{1,8}(-[a-z]{1,8})?)\s*(;\s*q\s*=\s*(1|0\.[0-9]+))?/i', $header, $langParse);
+
+ if (count($langParse[1])) {
+ // create a list like "en" => 0.8
+ $languages = array_combine($langParse[1], $langParse[4]);
+
+ // set default to 1 for any without q factor
+ foreach ($languages as $lang => $val) {
+ if ($val === '')
+ $languages[$lang] = 1;
+ }
+
+ // sort list based on value
+ arsort($languages, SORT_NUMERIC);
+ }
+ }
+
+ return $languages;
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/UploadHandler.php b/lib/Helper/UploadHandler.php
new file mode 100644
index 0000000..e8096ec
--- /dev/null
+++ b/lib/Helper/UploadHandler.php
@@ -0,0 +1,124 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Xibo\Support\Exception\LibraryFullException;
+
+/**
+ * @phpcs:disable PSR1.Methods.CamelCapsMethodName
+ */
+class UploadHandler extends BlueImpUploadHandler
+{
+ /**
+ * @var callable
+ */
+ private $postProcess;
+
+ /** @var ApplicationState */
+ private $state;
+
+ /**
+ * Set post processor
+ * @param callable $function
+ */
+ public function setPostProcessor(callable $function)
+ {
+ $this->postProcess = $function;
+ }
+
+ /**
+ * @param ApplicationState $state
+ * @return $this
+ */
+ public function setState(ApplicationState $state)
+ {
+ $this->state = $state;
+ return $this;
+ }
+
+ /**
+ * Handle form data from BlueImp
+ * @param $file
+ * @param $index
+ */
+ protected function handleFormData($file, $index)
+ {
+ try {
+ $filePath = $this->getUploadDir() . $file->name;
+ $file->fileName = $file->name;
+
+ $name = htmlspecialchars($this->getParam($index, 'name', $file->name));
+ $file->name = $name;
+
+ // Check Library
+ if ($this->options['libraryQuotaFull']) {
+ throw new LibraryFullException(
+ sprintf(
+ __('Your library is full. Library Limit: %s K'),
+ $this->options['libraryLimit']
+ )
+ );
+ }
+
+ $this->getLogger()->debug('Upload complete for name: ' . $name . '. Index is ' . $index);
+
+ if ($this->postProcess !== null) {
+ $file = call_user_func($this->postProcess, $file, $this);
+ }
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('Error uploading file : ' . $exception->getMessage());
+ $this->getLogger()->debug($exception->getTraceAsString());
+
+ // Unlink the temporary file
+ @unlink($filePath);
+ $this->state->setCommitState(false);
+ $file->error = $exception->getMessage();
+ }
+
+ return $file;
+ }
+
+ /**
+ * Get Param from File Input, taking into account multi-upload index if applicable
+ * @param int $index
+ * @param string $param
+ * @param mixed $default
+ * @return mixed
+ */
+ private function getParam($index, $param, $default)
+ {
+ if ($index === null) {
+ if (isset($_REQUEST[$param])) {
+ return $_REQUEST[$param];
+ } else {
+ return $default;
+ }
+ } else {
+ if (isset($_REQUEST[$param][$index])) {
+ return $_REQUEST[$param][$index];
+ } else {
+ return $default;
+ }
+ }
+ }
+}
diff --git a/lib/Helper/UserLogProcessor.php b/lib/Helper/UserLogProcessor.php
new file mode 100644
index 0000000..dee8b66
--- /dev/null
+++ b/lib/Helper/UserLogProcessor.php
@@ -0,0 +1,63 @@
+.
+ */
+
+
+namespace Xibo\Helper;
+
+/**
+ * Class UserLogProcessor
+ * @package Xibo\Helper
+ */
+class UserLogProcessor
+{
+ /**
+ * UserLogProcessor
+ * @param int $userId
+ * @param int|null $sessionHistoryId
+ * @param int|null $requestId
+ */
+ public function __construct(
+ private readonly int $userId,
+ private readonly ?int $sessionHistoryId,
+ private readonly ?int $requestId
+ ) {
+ }
+
+ /**
+ * @param array $record
+ * @return array
+ */
+ public function __invoke(array $record): array
+ {
+ $record['extra']['userId'] = $this->userId;
+
+ if ($this->sessionHistoryId != null) {
+ $record['extra']['sessionHistoryId'] = $this->sessionHistoryId;
+ }
+
+ if ($this->requestId != null) {
+ $record['extra']['requestId'] = $this->requestId;
+ }
+
+ return $record;
+ }
+}
diff --git a/lib/Helper/WakeOnLan.php b/lib/Helper/WakeOnLan.php
new file mode 100644
index 0000000..9352ef6
--- /dev/null
+++ b/lib/Helper/WakeOnLan.php
@@ -0,0 +1,271 @@
+ 32))
+ {
+ throw new \Exception(__('CIDR subnet mask is not a number within the range of 0 till 32.'));
+ }
+
+ // Convert $cidr from one decimal to one inverted binary array
+ $inverted_binary_cidr = "";
+
+ // Build $inverted_binary_cidr by $cidr * zeros (this is the mask)
+ for ($a=0; $a<$cidr; $a++) $inverted_binary_cidr .= "0";
+
+ // Invert the mask (by postfixing ones to $inverted_binary_cidr untill 32 bits are filled/ complete)
+ $inverted_binary_cidr = $inverted_binary_cidr.substr("11111111111111111111111111111111", 0, 32 - strlen($inverted_binary_cidr));
+
+ // Convert $inverted_binary_cidr to an array of bits
+ $inverted_binary_cidr_array = str_split($inverted_binary_cidr);
+
+ // Convert IP address from four decimals to one binary array
+ // Split IP address into an array of (four) decimals
+ $addr_byte = explode('.', $address);
+ $binary_addr = "";
+
+ for ($a=0; $a<4; $a++)
+ {
+ // Prefix zeros
+ $pre = substr("00000000",0,8-strlen(decbin($addr_byte[$a])));
+
+ // Postfix binary decimal
+ $post = decbin($addr_byte[$a]);
+ $binary_addr .= $pre.$post;
+ }
+
+ // Convert $binary_addr to an array of bits
+ $binary_addr_array = str_split($binary_addr);
+
+ // Perform a bitwise OR operation on arrays ($binary_addr_array & $inverted_binary_cidr_array)
+ $binary_broadcast_addr_array="";
+
+ // binary array of 32 bit variables ('|' = logical operator 'or')
+ for ($a=0; $a<32; $a++) $binary_broadcast_addr_array[$a] = ($binary_addr_array[$a] | $inverted_binary_cidr_array[$a]);
+
+ // build binary address of four bundles of 8 bits (= 1 byte)
+ $binary_broadcast_addr = chunk_split(implode("", $binary_broadcast_addr_array), 8, ".");
+
+ // chop off last dot ('.')
+ $binary_broadcast_addr = substr($binary_broadcast_addr,0,strlen($binary_broadcast_addr)-1);
+
+ // binary array of 4 byte variables
+ $binary_broadcast_addr_array = explode(".", $binary_broadcast_addr);
+ $broadcast_addr_array = "";
+
+ // decimal array of 4 byte variables
+ for ($a=0; $a<4; $a++) $broadcast_addr_array[$a] = bindec($binary_broadcast_addr_array[$a]);
+
+ // broadcast address
+ $address = implode(".", $broadcast_addr_array);
+ }
+
+ // Check whether $port is valid
+ if ((!ctype_digit($port)) || ($port < 0) || ($port > 65536))
+ throw new \Exception(__('Port is not a number within the range of 0 till 65536. Port Provided: ' . $port));
+
+ // Check whether UDP is supported
+ if (!array_search('udp', stream_get_transports()))
+ throw new \Exception(__('No magic packet can been sent, since UDP is unsupported (not a registered socket transport)'));
+
+ // Ready to send the packet
+ if (function_exists('fsockopen'))
+ {
+ // Try fsockopen function - To do: handle error 'Permission denied'
+ $socket = fsockopen("udp://" . $address, $port, $errno, $errstr);
+
+ if ($socket)
+ {
+ $socket_data = fwrite($socket, $buf);
+
+ if ($socket_data)
+ {
+ $function = "fwrite";
+ $sent_fsockopen = "A magic packet of ".$socket_data." bytes has been sent via UDP to IP address: ".$address.":".$port.", using the '".$function."()' function.";
+ $content = bin2hex($buf);
+
+ $sent_fsockopen = $sent_fsockopen."Contents of magic packet:".strlen($content)." ".$content;
+ fclose($socket);
+
+ unset($socket);
+
+ $logger->notice($sent_fsockopen, 'display', 'WakeOnLan');
+ return true;
+ }
+ else
+ {
+ unset($socket);
+
+ throw new \Exception(__('Using "fwrite()" failed, due to error: ' . $errstr. ' ("' . $errno . '")'));
+ }
+ }
+ else
+ {
+ unset($socket);
+
+ $logger->notice(__('Using fsockopen() failed, due to denied permission'));
+ }
+ }
+
+ // Try socket_create function
+ if (function_exists('socket_create'))
+ {
+ // create socket based on IPv4, datagram and UDP
+ $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
+
+ if ($socket)
+ {
+ // to enable manipulation of options at the socket level (you may have to change this to 1)
+ $level = SOL_SOCKET;
+
+ // to enable permission to transmit broadcast datagrams on the socket (you may have to change this to 6)
+ $optname = SO_BROADCAST;
+
+ $optval = true;
+ $opt_returnvalue = socket_set_option($socket, $level, $optname, $optval);
+
+ if ($opt_returnvalue < 0)
+ {
+ throw new \Exception(__('Using "socket_set_option()" failed, due to error: ' . socket_strerror($opt_returnvalue)));
+ }
+
+ $flags = 0;
+
+ // To do: handle error 'Operation not permitted'
+ $socket_data = socket_sendto($socket, $buf, strlen($buf), $flags, $address, $port);
+
+ if ($socket_data)
+ {
+ $function = "socket_sendto";
+ $socket_create = "A magic packet of ". $socket_data . " bytes has been sent via UDP to IP address: ".$address.":".$port.", using the '".$function."()' function. ";
+
+ $content = bin2hex($buf);
+ $socket_create = $socket_create . "Contents of magic packet:" . strlen($content) ." " . $content;
+
+ socket_close($socket);
+ unset($socket);
+
+ $logger->notice($socket_create, 'display', 'WakeOnLan');
+ return true;
+ }
+ else
+ {
+ $error = __('Using "socket_sendto()" failed, due to error: ' . socket_strerror(socket_last_error($socket)) . ' (' . socket_last_error($socket) . ')');
+ socket_close($socket);
+ unset($socket);
+
+ throw new \Exception($error);
+ }
+ }
+ else
+ {
+ throw new \Exception(__('Using "socket_sendto()" failed, due to error: ' . socket_strerror(socket_last_error($socket)) . ' (' . socket_last_error($socket) . ')'));
+ }
+ }
+ else
+ {
+ throw new \Exception(__('Wake On Lan Failed as there are no functions available to transmit it'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Helper/XiboUploadHandler.php b/lib/Helper/XiboUploadHandler.php
new file mode 100644
index 0000000..85882f0
--- /dev/null
+++ b/lib/Helper/XiboUploadHandler.php
@@ -0,0 +1,567 @@
+.
+ */
+
+namespace Xibo\Helper;
+
+use Exception;
+use Xibo\Entity\Layout;
+use Xibo\Entity\Permission;
+use Xibo\Event\LibraryReplaceEvent;
+use Xibo\Event\LibraryReplaceWidgetEvent;
+use Xibo\Event\LibraryUploadCompleteEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\LibraryFullException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class XiboUploadHandler
+ * @package Xibo\Helper
+ */
+class XiboUploadHandler extends BlueImpUploadHandler
+{
+ /**
+ * Handle form data from BlueImp
+ * @param $file
+ * @param $index
+ */
+ protected function handleFormData($file, $index)
+ {
+ $controller = $this->options['controller'];
+ /* @var \Xibo\Controller\Library $controller */
+
+ // Handle form data, e.g. $_REQUEST['description'][$index]
+ // Link the file to the module
+ $fileName = $file->name;
+ $filePath = $controller->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $fileName;
+
+ $this->getLogger()->debug('Upload complete for name: ' . $fileName . '. Index is ' . $index);
+
+ // Upload and Save
+ try {
+ // Check Library
+ if ($this->options['libraryQuotaFull']) {
+ throw new LibraryFullException(
+ sprintf(
+ __('Your library is full. Library Limit: %s K'),
+ $this->options['libraryLimit']
+ )
+ );
+ }
+ // Check for a user quota
+ // this method has the ability to reconnect to MySQL in the event that the upload has taken a long time.
+ // OSX-381
+ $controller->getUser()->isQuotaFullByUser(true);
+
+ // Get some parameters
+ $name = htmlspecialchars($this->getParam($index, 'name', $fileName));
+ $tags = $controller->getUser()->featureEnabled('tag.tagging')
+ ? htmlspecialchars($this->getParam($index, 'tags', ''))
+ : '';
+
+ // Guess the type
+ $module = $controller->getModuleFactory()
+ ->getByExtension(strtolower(substr(strrchr($fileName, '.'), 1)));
+
+ $this->getLogger()->debug(sprintf(
+ 'Module Type = %s, Name = %s',
+ $module->type,
+ $module->name
+ ));
+
+ // If we have an oldMediaId then we are replacing that media with new one
+ if ($this->options['oldMediaId'] != 0) {
+ $updateInLayouts = ($this->options['updateInLayouts'] == 1);
+ $deleteOldRevisions = ($this->options['deleteOldRevisions'] == 1);
+
+ $this->getLogger()->debug(sprintf(
+ 'Replacing old with new - updateInLayouts = %d, deleteOldRevisions = %d',
+ $updateInLayouts,
+ $deleteOldRevisions
+ ));
+
+ // Load old media
+ $oldMedia = $controller->getMediaFactory()->getById($this->options['oldMediaId']);
+
+ // Check permissions
+ if (!$controller->getUser()->checkEditable($oldMedia)) {
+ throw new AccessDeniedException(__('Access denied replacing old media'));
+ }
+
+ // Check to see if we are changing the media type
+ if ($oldMedia->mediaType != $module->type && $this->options['allowMediaTypeChange'] == 0) {
+ throw new InvalidArgumentException(
+ __('You cannot replace this media with an item of a different type')
+ );
+ }
+
+ // Set the old record to edited
+ $oldMedia->isEdited = 1;
+
+ $oldMedia->save(['validate' => false]);
+
+ // The media name might be empty here, because the user isn't forced to select it
+ $name = ($name == '') ? $oldMedia->name : $name;
+ $tags = ($tags == '') ? '' : $tags;
+
+ // Add the Media
+ // the userId is either the existing user
+ // (if we are changing media type) or the currently logged-in user otherwise.
+ $media = $controller->getMediaFactory()->create(
+ $name,
+ $fileName,
+ $module->type,
+ $oldMedia->getOwnerId()
+ );
+
+ if ($tags != '') {
+ $concatTags = $oldMedia->getTagString() . ',' . $tags;
+ $media->updateTagLinks($controller->getTagFactory()->tagsFromString($concatTags));
+ }
+
+ // Apply the duration from the old media, unless we're a video
+ if ($module->type === 'video') {
+ $media->duration = $module->fetchDurationOrDefaultFromFile($filePath);
+ } else {
+ $media->duration = $oldMedia->duration;
+ }
+
+ // Raise an event for this media item
+ $controller->getDispatcher()->dispatch(
+ new LibraryReplaceEvent($module, $media, $oldMedia),
+ LibraryReplaceEvent::$NAME
+ );
+
+ $media->enableStat = $oldMedia->enableStat;
+ $media->expires = $this->options['expires'];
+ $media->folderId = $this->options['oldFolderId'];
+ $media->permissionsFolderId = $oldMedia->permissionsFolderId;
+
+ // Save
+ $media->save(['oldMedia' => $oldMedia]);
+
+ // Upload finished
+ $controller->getDispatcher()->dispatch(
+ new LibraryUploadCompleteEvent($media),
+ LibraryUploadCompleteEvent::$NAME
+ );
+
+ $this->getLogger()->debug('Copying permissions to new media');
+
+ foreach ($controller->getPermissionFactory()->getAllByObjectId(
+ $controller->getUser(),
+ get_class($oldMedia),
+ $oldMedia->mediaId
+ ) as $permission) {
+ /* @var Permission $permission */
+ $permission = clone $permission;
+ $permission->objectId = $media->mediaId;
+ $permission->save();
+ }
+
+ // Do we want to replace this in all layouts?
+ if ($updateInLayouts) {
+ $this->getLogger()->debug('Replace in all Layouts selected. Getting associated widgets');
+
+ foreach ($controller->getWidgetFactory()->getByMediaId($oldMedia->mediaId, 0) as $widget) {
+ $this->getLogger()->debug('Found widgetId ' . $widget->widgetId
+ . ' to assess, type is ' . $widget->type);
+
+ if (!$controller->getUser()->checkEditable($widget)) {
+ // Widget that we cannot update,
+ // this means we can't delete the original mediaId when it comes time to do so.
+ $deleteOldRevisions = false;
+
+ $controller
+ ->getLog()->info('Media used on Widget that we cannot edit. Delete Old Revisions has been disabled.'); //phpcs:ignore
+ }
+
+ // Load the module for this widget.
+ $moduleToReplace = $controller->getModuleFactory()->getByType($widget->type);
+
+ // If we are replacing an audio media item,
+ // we should check to see if the widget we've found has any
+ // audio items assigned.
+ if ($module->type == 'audio'
+ && in_array($oldMedia->mediaId, $widget->getAudioIds())
+ ) {
+ $this->getLogger()->debug('Found audio on widget that needs updating. widgetId = ' .
+ $widget->getId() . '. Linking ' . $media->mediaId);
+
+ $widget->unassignAudioById($oldMedia->mediaId);
+ $widget->assignAudioById($media->mediaId);
+ $widget->save();
+ } else if ($widget->type !== 'global'
+ && count($widget->getPrimaryMedia()) > 0
+ && $widget->getPrimaryMediaId() == $oldMedia->mediaId
+ ) {
+ // We're only interested in primary media at this point (no audio)
+ // Check whether this widget is of the same type as our incoming media item
+ // This needs to be applicable only to non region specific Widgets,
+ // otherwise we would not be able to replace Media references in region specific Widgets.
+
+ // If these types are different, and the module we're replacing isn't region specific
+ // then we need to see if we're allowed to change it.
+ if ($widget->type != $module->type && $moduleToReplace->regionSpecific == 0) {
+ // Are we supposed to switch, or should we prevent?
+ if ($this->options['allowMediaTypeChange'] == 1) {
+ $widget->type = $module->type;
+ } else {
+ throw new InvalidArgumentException(__(
+ 'You cannot replace this media with an item of a different type'
+ ));
+ }
+ }
+
+ $this->getLogger()->debug(sprintf(
+ 'Found widget that needs updating. ID = %d. Linking %d',
+ $widget->getId(),
+ $media->mediaId
+ ));
+ $widget->unassignMedia($oldMedia->mediaId);
+ $widget->assignMedia($media->mediaId);
+
+ // calculate duration
+ $widget->calculateDuration($module);
+
+ // replace mediaId references in applicable widgets
+ $controller->getLayoutFactory()->handleWidgetMediaIdReferences(
+ $widget,
+ $media->mediaId,
+ $oldMedia->mediaId
+ );
+
+ // Raise an event for this media item
+ $controller->getDispatcher()->dispatch(
+ new LibraryReplaceWidgetEvent($module, $widget, $media, $oldMedia),
+ LibraryReplaceWidgetEvent::$NAME
+ );
+
+ // Save
+ $widget->save(['alwaysUpdate' => true]);
+ }
+
+ // Does this widget have any elements?
+ if ($moduleToReplace->regionSpecific == 1) {
+ // This is a global widget and will have elements which refer to this media id.
+ $this->getLogger()
+ ->debug('handleFormData: This is a region specific widget, checking for elements.');
+
+ // We need to load options as that is where we store elements
+ $widget->load(false);
+
+ // Parse existing elements.
+ $mediaFoundInElement = false;
+ $elements = json_decode($widget->getOptionValue('elements', '[]'), true);
+ foreach ($elements as $index => $widgetElement) {
+ foreach ($widgetElement['elements'] ?? [] as $elementIndex => $element) {
+ // mediaId on the element, used for things like image element
+ if (!empty($element['mediaId']) && $element['mediaId'] == $oldMedia->mediaId) {
+ // We have found an element which uses the mediaId we are replacing
+ $elements[$index]['elements'][$elementIndex]['mediaId'] = $media->mediaId;
+
+ // Swap the ID on the link record
+ $widget->unassignMedia($oldMedia->mediaId);
+ $widget->assignMedia($media->mediaId);
+
+ $mediaFoundInElement = true;
+ }
+
+ // mediaId on the property, used for mediaSelector properties.
+ foreach ($element['properties'] ?? [] as $propertyIndex => $property) {
+ if (!empty($property['mediaId'])) {
+ // TODO: should we really load in all templates here and replace?
+ // Set the mediaId and value of this property
+ // this only works because mediaSelector is the only property which
+ // uses mediaId and it always has the value set.
+ $elements[$index]['elements'][$elementIndex]['properties']
+ [$propertyIndex]['mediaId'] = $media->mediaId;
+ $elements[$index]['elements'][$elementIndex]['properties']
+ [$propertyIndex]['value'] = $media->mediaId;
+
+ $widget->unassignMedia($oldMedia->mediaId);
+ $widget->assignMedia($media->mediaId);
+
+ $mediaFoundInElement = true;
+ }
+ }
+ }
+ }
+
+ if ($mediaFoundInElement) {
+ $this->getLogger()
+ ->debug('handleFormData: mediaId found in elements, replacing');
+
+ // Save the new elements
+ $widget->setOptionValue('elements', 'raw', json_encode($elements));
+
+ // Raise an event for this media item
+ $controller->getDispatcher()->dispatch(
+ new LibraryReplaceWidgetEvent($module, $widget, $media, $oldMedia),
+ LibraryReplaceWidgetEvent::$NAME
+ );
+
+ // Save
+ $widget->save(['alwaysUpdate' => true]);
+ }
+ }
+ }
+
+ // Update any background images
+ if ($media->mediaType == 'image') {
+ $this->getLogger()->debug(sprintf(
+ 'Updating layouts with the old media %d as the background image.',
+ $oldMedia->mediaId
+ ));
+
+ // Get all Layouts with this as the background image
+ foreach ($controller->getLayoutFactory()->query(
+ null,
+ ['disableUserCheck' => 1, 'backgroundImageId' => $oldMedia->mediaId]
+ ) as $layout) {
+ /* @var Layout $layout */
+
+ if (!$controller->getUser()->checkEditable($layout)) {
+ // Widget that we cannot update,
+ // this means we can't delete the original mediaId when it comes time to do so.
+ $deleteOldRevisions = false;
+
+ $this->getLogger()->info(
+ 'Media used on Widget that we cannot edit. Delete Old Revisions has been disabled.'
+ );
+ }
+
+ $this->getLogger()->debug(sprintf(
+ 'Found layout that needs updating. ID = %d. Setting background image id to %d',
+ $layout->layoutId,
+ $media->mediaId
+ ));
+ $layout->backgroundImageId = $media->mediaId;
+ $layout->save();
+ }
+ }
+ } elseif ($this->options['widgetId'] != 0) {
+ $this->getLogger()->debug('Swapping a specific widget only.');
+ // swap this one
+ $widget = $controller->getWidgetFactory()->getById($this->options['widgetId']);
+
+ if (!$controller->getUser()->checkEditable($widget)) {
+ throw new AccessDeniedException();
+ }
+
+ $widget->unassignMedia($oldMedia->mediaId);
+ $widget->assignMedia($media->mediaId);
+ $widget->save();
+ }
+
+ // We either want to Link the old record to this one, or delete it
+ if ($updateInLayouts && $deleteOldRevisions) {
+ $this->getLogger()->debug('Delete old revisions of ' . $oldMedia->mediaId);
+
+ // Check we have permission to delete this media
+ if (!$controller->getUser()->checkDeleteable($oldMedia)) {
+ throw new AccessDeniedException(
+ __('You do not have permission to delete the old version.')
+ );
+ }
+
+ try {
+ // Join the prior revision up with the new media.
+ $priorMedia = $controller->getMediaFactory()->getParentById($oldMedia->mediaId);
+
+ $this->getLogger()->debug(
+ 'Prior media found, joining ' .
+ $priorMedia->mediaId . ' with ' . $media->mediaId
+ );
+
+ $priorMedia->parentId = $media->mediaId;
+ $priorMedia->save(['validate' => false]);
+ } catch (NotFoundException $e) {
+ // Nothing to do then
+ $this->getLogger()->debug('No prior media found');
+ }
+
+ $controller->getDispatcher()->dispatch(
+ new MediaDeleteEvent($oldMedia),
+ MediaDeleteEvent::$NAME
+ );
+ $oldMedia->delete();
+ } else {
+ $oldMedia->parentId = $media->mediaId;
+ $oldMedia->save(['validate' => false]);
+ }
+ } else {
+ // Not a replacement
+ // Fresh upload
+ // The media name might be empty here, because the user isn't forced to select it
+ $name = ($name == '') ? $fileName : $name;
+ $tags = ($tags == '') ? '' : $tags;
+
+ // Add the Media
+ $media = $controller->getMediaFactory()->create(
+ $name,
+ $fileName,
+ $module->type,
+ $this->options['userId']
+ );
+
+ if ($tags != '') {
+ $media->updateTagLinks($controller->getTagFactory()->tagsFromString($tags));
+ }
+
+ // Set the duration
+ $media->duration = $module->fetchDurationOrDefaultFromFile($filePath);
+
+ if ($media->enableStat == null) {
+ $media->enableStat = $controller->getConfig()->getSetting('MEDIA_STATS_ENABLED_DEFAULT');
+ }
+
+ // Media library expiry.
+ $media->expires = $this->options['expires'];
+ $media->folderId = $this->options['oldFolderId'];
+
+ // Permissions
+ $folder = $controller->getFolderFactory()->getById($this->options['oldFolderId'], 0);
+ $media->permissionsFolderId = $folder->getPermissionFolderIdOrThis();
+
+ // Save
+ $media->save();
+
+ // Upload finished
+ $controller->getDispatcher()->dispatch(
+ new LibraryUploadCompleteEvent($media),
+ LibraryUploadCompleteEvent::$NAME
+ );
+ }
+
+ // Configure the return values according to the media item we've added
+ $file->name = $name;
+ $file->mediaId = $media->mediaId;
+ $file->storedas = $media->storedAs;
+ $file->duration = $media->duration;
+ $file->retired = $media->retired;
+ $file->fileSize = $media->fileSize;
+ $file->md5 = $media->md5;
+ $file->enableStat = $media->enableStat;
+ $file->width = $media->width;
+ $file->height = $media->height;
+ $file->mediaType = $module->type;
+ $file->fileName = $fileName;
+
+ // Test to ensure the final file size is the same as the file size we're expecting
+ if ($file->fileSize != $file->size) {
+ throw new InvalidArgumentException(
+ __('Sorry this is a corrupted upload, the file size doesn\'t match what we\'re expecting.'),
+ 'size'
+ );
+ }
+
+ // Are we assigning to a Playlist?
+ if ($this->options['playlistId'] != 0 && $this->options['widgetId'] == 0) {
+ $this->getLogger()->debug('Assigning uploaded media to playlistId '
+ . $this->options['playlistId']);
+
+ // Get the Playlist
+ $playlist = $controller->getPlaylistFactory()->getById($this->options['playlistId']);
+
+ if (!$playlist->isEditable()) {
+ throw new InvalidArgumentException(
+ __('This Layout is not a Draft, please checkout.'),
+ 'layoutId'
+ );
+ }
+
+ // Create a Widget and add it to our region
+ $widget = $controller->getWidgetFactory()->create(
+ $this->options['userId'],
+ $playlist->playlistId,
+ $module->type,
+ $media->duration,
+ $module->schemaVersion
+ );
+
+ // Default options
+ $widget->setOptionValue(
+ 'enableStat',
+ 'attrib',
+ $controller->getConfig()->getSetting('WIDGET_STATS_ENABLED_DEFAULT')
+ );
+
+ // From/To dates?
+ $widget->fromDt = $this->options['widgetFromDt'];
+ $widget->toDt = $this->options['widgetToDt'];
+ $widget->setOptionValue('deleteOnExpiry', 'attrib', $this->options['deleteOnExpiry']);
+
+ // Assign media
+ $widget->assignMedia($media->mediaId);
+
+ // Calculate the widget duration for new uploaded media widgets
+ $widget->calculateDuration($module);
+
+ // Assign the new widget to the playlist
+ $playlist->assignWidget($widget, $this->options['displayOrder'] ?? null);
+
+ // Save the playlist
+ $playlist->save();
+
+ // Configure widgetId is response
+ $file->widgetId = $widget->widgetId;
+ }
+ } catch (Exception $e) {
+ $this->getLogger()->error('Error uploading media: ' . $e->getMessage());
+ $this->getLogger()->debug($e->getTraceAsString());
+
+ // Unlink the temporary file
+ @unlink($filePath);
+
+ $file->error = $e->getMessage();
+
+ // Don't commit
+ $controller->getState()->setCommitState(false);
+ }
+ }
+
+ /**
+ * Get Param from File Input, taking into account multi-upload index if applicable
+ * @param int $index
+ * @param string $param
+ * @param mixed $default
+ * @return mixed
+ */
+ private function getParam($index, $param, $default)
+ {
+ if ($index === null) {
+ if (isset($_REQUEST[$param])) {
+ return $_REQUEST[$param];
+ } else {
+ return $default;
+ }
+ } else {
+ if (isset($_REQUEST[$param][$index])) {
+ return $_REQUEST[$param][$index];
+ } else {
+ return $default;
+ }
+ }
+ }
+}
diff --git a/lib/Listener/CampaignListener.php b/lib/Listener/CampaignListener.php
new file mode 100644
index 0000000..45433db
--- /dev/null
+++ b/lib/Listener/CampaignListener.php
@@ -0,0 +1,171 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\DayPartDeleteEvent;
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\TagDeleteEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Campaign events
+ */
+class CampaignListener
+{
+ use ListenerLoggerTrait;
+
+ /** @var \Xibo\Factory\CampaignFactory */
+ private $campaignFactory;
+
+ /** @var \Xibo\Storage\StorageServiceInterface */
+ private $storageService;
+
+ public function __construct(
+ CampaignFactory $campaignFactory,
+ StorageServiceInterface $storageService
+ ) {
+ $this->campaignFactory = $campaignFactory;
+ $this->storageService = $storageService;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): CampaignListener
+ {
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'campaign', [$this, 'onParsePermissions']);
+ $dispatcher->addListener(FolderMovingEvent::$NAME, [$this, 'onFolderMoving']);
+ $dispatcher->addListener(UserDeleteEvent::$NAME, [$this, 'onUserDelete']);
+ $dispatcher->addListener(DayPartDeleteEvent::$NAME, [$this, 'onDayPartDelete']);
+ $dispatcher->addListener(TagDeleteEvent::$NAME, [$this, 'onTagDelete']);
+ return $this;
+ }
+
+ /**
+ * Parse permissions
+ * @param \Xibo\Event\ParsePermissionEntityEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onParsePermissions(ParsePermissionEntityEvent $event)
+ {
+ $this->getLogger()->debug('onParsePermissions');
+ $event->setObject($this->campaignFactory->getById($event->getObjectId()));
+ }
+
+ /**
+ * When we're moving a folder, update our folderId/permissions folder id
+ * @param \Xibo\Event\FolderMovingEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onFolderMoving(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->campaignFactory->getByFolderId($folder->getId()) as $campaign) {
+ // update campaign record
+ $campaign->folderId = $newFolder->id;
+ $campaign->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $campaign->updateFolders('campaign');
+ }
+ }
+
+ /**
+ * User is being deleted, tidy up their campaigns
+ * @param \Xibo\Event\UserDeleteEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onUserDelete(UserDeleteEvent $event)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+
+ if ($function === 'delete') {
+ // Delete any Campaigns
+ foreach ($this->campaignFactory->getByOwnerId($user->userId) as $campaign) {
+ $campaign->delete();
+ }
+ } else if ($function === 'reassignAll') {
+ // Reassign campaigns
+ $this->storageService->update('UPDATE `campaign` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ } else if ($function === 'countChildren') {
+ $campaigns = $this->campaignFactory->getByOwnerId($user->userId);
+
+ $count = count($campaigns);
+ $this->getLogger()->debug(
+ sprintf(
+ 'Counted Children Campaign on User ID %d, there are %d',
+ $user->userId,
+ $count
+ )
+ );
+
+ $event->setReturnValue($event->getReturnValue() + $count);
+ }
+ }
+
+ /**
+ * Days parts might be assigned to lkcampaignlayout records.
+ * @param \Xibo\Event\DayPartDeleteEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function onDayPartDelete(DayPartDeleteEvent $event)
+ {
+ // We can't delete dayparts that are in-use on advertising campaigns.
+ if ($this->storageService->exists('
+ SELECT lkCampaignLayoutId
+ FROM `lkcampaignlayout`
+ WHERE dayPartId = :dayPartId
+ LIMIT 1
+ ', [
+ 'dayPartId' => $event->getDayPart()->dayPartId,
+ ])) {
+ throw new InvalidArgumentException(__('This is inuse and cannot be deleted.'), 'dayPartId');
+ }
+ }
+
+ /**
+ * When Tag gets deleted, remove any campaign links from it.
+ * @param TagDeleteEvent $event
+ * @return void
+ */
+ public function onTagDelete(TagDeleteEvent $event)
+ {
+ $this->storageService->update(
+ 'DELETE FROM `lktagcampaign` WHERE `lktagcampaign`.tagId = :tagId',
+ ['tagId' => $event->getTagId()]
+ );
+ }
+}
diff --git a/lib/Listener/DataSetDataProviderListener.php b/lib/Listener/DataSetDataProviderListener.php
new file mode 100644
index 0000000..8a60b73
--- /dev/null
+++ b/lib/Listener/DataSetDataProviderListener.php
@@ -0,0 +1,372 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\DataSet;
+use Xibo\Event\DataSetDataRequestEvent;
+use Xibo\Event\DataSetDataTypeRequestEvent;
+use Xibo\Event\DataSetModifiedDtRequestEvent;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Definition\DataType;
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * Listens to requests for data from DataSets.
+ */
+class DataSetDataProviderListener
+{
+ use ListenerLoggerTrait;
+
+ /** @var \Xibo\Storage\StorageServiceInterface */
+ private $store;
+
+ /** @var \Xibo\Service\ConfigServiceInterface */
+ private $config;
+
+ /** @var \Xibo\Factory\DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var \Xibo\Factory\DisplayFactory */
+ private $displayFactory;
+
+ public function __construct(
+ StorageServiceInterface $store,
+ ConfigServiceInterface $config,
+ DataSetFactory $dataSetFactory,
+ DisplayFactory $displayFactory
+ ) {
+ $this->store = $store;
+ $this->config = $config;
+ $this->dataSetFactory = $dataSetFactory;
+ $this->displayFactory = $displayFactory;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): DataSetDataProviderListener
+ {
+ $dispatcher->addListener(DataSetDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ $dispatcher->addListener(DataSetDataTypeRequestEvent::$NAME, [$this, 'onDataTypeRequest']);
+ $dispatcher->addListener(DataSetModifiedDtRequestEvent::$NAME, [$this, 'onModifiedDtRequest']);
+ return $this;
+ }
+
+ public function onDataRequest(DataSetDataRequestEvent $event)
+ {
+ $this->getLogger()->debug('onDataRequest: data source is ' . $event->getDataProvider()->getDataSource());
+
+ $dataProvider = $event->getDataProvider();
+
+ // We must have a dataSetId configured.
+ $dataSetId = $dataProvider->getProperty('dataSetId', 0);
+ if (empty($dataSetId)) {
+ $this->getLogger()->debug('onDataRequest: no dataSetId.');
+ return;
+ }
+
+ // Get this dataset
+ try {
+ $dataSet = $this->dataSetFactory->getById($dataSetId);
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onDataRequest: dataSetId ' . $dataSetId . ' not found.');
+ return;
+ }
+
+ $this->getData($dataSet, $dataProvider);
+
+ // Cache timeout
+ $dataProvider->setCacheTtl($dataProvider->getProperty('updateInterval', 60) * 60);
+ }
+
+ /**
+ * @param \Xibo\Event\DataSetDataTypeRequestEvent $event
+ * @return void
+ */
+ public function onDataTypeRequest(DataSetDataTypeRequestEvent $event)
+ {
+ // We must have a dataSetId configured.
+ $dataSetId = $event->getDataSetId();
+ if (empty($dataSetId)) {
+ $this->getLogger()->debug('onDataTypeRequest: no dataSetId.');
+ return;
+ }
+
+ $this->getLogger()->debug('onDataTypeRequest: with dataSetId: ' . $dataSetId);
+
+ // Get this dataset
+ try {
+ $dataSet = $this->dataSetFactory->getById($dataSetId);
+
+ // Create a new DataType for this DataSet
+ $dataType = new DataType();
+ $dataType->id = 'dataset';
+ $dataType->name = $dataSet->dataSet;
+
+ // Get the columns for this dataset and return a list of them
+ foreach ($dataSet->getColumn() as $column) {
+ $dataType->addField(
+ $column->heading . '|' . $column->dataSetColumnId,
+ $column->heading,
+ $column->dataType
+ );
+ }
+
+ $event->setDataType($dataType);
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onDataTypeRequest: dataSetId ' . $dataSetId . ' not found.');
+ return;
+ }
+ }
+
+ public function onModifiedDtRequest(DataSetModifiedDtRequestEvent $event)
+ {
+ $this->getLogger()->debug('onModifiedDtRequest: get modifiedDt with dataSetId: ' . $event->getDataSetId());
+
+ try {
+ $dataSet = $this->dataSetFactory->getById($event->getDataSetId());
+ $event->setModifiedDt(Carbon::createFromTimestamp($dataSet->lastDataEdit));
+
+ // Remote dataSets are kept "active" by required files
+ $dataSet->setActive();
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onModifiedDtRequest: dataSetId ' . $event->getDataSetId() . ' not found.');
+ }
+ }
+
+ private function getData(DataSet $dataSet, DataProviderInterface $dataProvider): void
+ {
+ // Load the dataSet
+ $dataSet->load();
+
+ // Columns
+ // Build a list of column mappings we will make available as metadata
+ $mappings = [];
+ $columnIds = $dataProvider->getProperty('columns');
+ $columnIds = empty($columnIds) ? null : explode(',', $columnIds);
+
+ $this->getLogger()->debug('getData: loaded dataSetId ' . $dataSet->dataSetId . ', there are '
+ . count($dataSet->columns) . '. We have selected ' . ($columnIds !== null ? count($columnIds) : 'all')
+ . ' of them');
+
+ foreach ($dataSet->columns as $column) {
+ if ($columnIds === null || in_array($column->dataSetColumnId, $columnIds)) {
+ $mappings[] = [
+ 'dataSetColumnId' => $column->dataSetColumnId,
+ 'heading' => $column->heading,
+ 'dataTypeId' => $column->dataTypeId
+ ];
+ }
+ }
+
+ $this->getLogger()->debug('getData: resolved ' . count($mappings) . ' column mappings');
+
+ // Build filter, order and limit parameters to pass to the DataSet entity
+ // Ordering
+ $ordering = '';
+ if ($dataProvider->getProperty('useOrderingClause', 1) == 1) {
+ $ordering = $dataProvider->getProperty('ordering');
+ } else {
+ // Build an order string
+ foreach (json_decode($dataProvider->getProperty('orderClauses', '[]'), true) as $clause) {
+ $ordering .= $clause['orderClause'] . ' ' . $clause['orderClauseDirection'] . ',';
+ }
+
+ $ordering = rtrim($ordering, ',');
+ }
+
+ // Build a filter to pass to the dataset
+ $filter = [
+ 'filter' => $this->buildFilterClause($dataProvider),
+ 'order' => $ordering,
+ 'displayId' => $dataProvider->getDisplayId(),
+ ];
+
+ // limits?
+ $upperLimit = $dataProvider->getProperty('upperLimit', 0);
+ $lowerLimit = $dataProvider->getProperty('lowerLimit', 0);
+ if ($lowerLimit !== 0 || $upperLimit !== 0) {
+ // Start should be the lower limit
+ // Size should be the distance between upper and lower
+ $filter['start'] = $lowerLimit;
+ $filter['size'] = $upperLimit - $lowerLimit;
+
+ $this->getLogger()->debug('getData: applied limits, start: '
+ . $filter['start'] . ', size: ' . $filter['size']);
+ }
+
+ // Expiry time for any images
+ $expires = Carbon::now()
+ ->addSeconds($dataProvider->getProperty('updateInterval', 3600) * 60)
+ ->format('U');
+
+ try {
+ $this->setTimezone($dataProvider);
+
+ $dataSetResults = $dataSet->getData($filter);
+
+ $this->getLogger()->debug('getData: finished getting data. There are '
+ . count($dataSetResults) . ' records returned');
+
+ foreach ($dataSetResults as $row) {
+ // Add an item containing the columns we have selected
+ $item = [];
+ foreach ($mappings as $mapping) {
+ // This column is selected
+ $cellValue = $row[$mapping['heading']] ?? null;
+ if ($mapping['dataTypeId'] === 4) {
+ // Grab the external image
+ $item[$mapping['heading']] = $dataProvider->addImage(
+ 'dataset_' . md5($dataSet->dataSetId . $mapping['dataSetColumnId'] . $cellValue),
+ str_replace(' ', '%20', htmlspecialchars_decode($cellValue)),
+ $expires
+ );
+ } else if ($mapping['dataTypeId'] === 5) {
+ // Library Image
+ $this->getLogger()->debug('getData: Library media reference found: ' . $cellValue);
+
+ // The content is the ID of the image
+ try {
+ $item[$mapping['heading']] = $dataProvider->addLibraryFile(intval($cellValue));
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('getData: Invalid library media reference: ' . $cellValue);
+ $item[$mapping['heading']] = '';
+ }
+ } else {
+ // Just a normal column
+ $item[$mapping['heading']] = $cellValue;
+ }
+ }
+ $dataProvider->addItem($item);
+ }
+
+ // Add the mapping we've generated to the metadata
+ $dataProvider->addOrUpdateMeta('mapping', $mappings);
+ $dataProvider->setIsHandled();
+ } catch (\Exception $exception) {
+ $this->getLogger()->debug('onDataRequest: ' . $exception->getTraceAsString());
+ $this->getLogger()->error('onDataRequest: unable to get data for dataSetId ' . $dataSet->dataSetId
+ . ' e: ' . $exception->getMessage());
+
+ $dataProvider->addError(__('DataSet Invalid'));
+ }
+ }
+
+ private function buildFilterClause(DataProviderInterface $dataProvider): ?string
+ {
+ $filter = '';
+
+ if ($dataProvider->getProperty('useFilteringClause', 1) == 1) {
+ $filter = $dataProvider->getProperty('filter');
+ } else {
+ // Build
+ $i = 0;
+ foreach (json_decode($dataProvider->getProperty('filterClauses', '[]'), true) as $clause) {
+ $i++;
+
+ switch ($clause['filterClauseCriteria']) {
+ case 'starts-with':
+ $criteria = 'LIKE \'' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'ends-with':
+ $criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'contains':
+ $criteria = 'LIKE \'%' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'equals':
+ $criteria = '= \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'not-contains':
+ $criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'not-starts-with':
+ $criteria = 'NOT LIKE \'' . $clause['filterClauseValue'] . '%\'';
+ break;
+
+ case 'not-ends-with':
+ $criteria = 'NOT LIKE \'%' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'not-equals':
+ $criteria = '<> \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'greater-than':
+ $criteria = '> \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ case 'less-than':
+ $criteria = '< \'' . $clause['filterClauseValue'] . '\'';
+ break;
+
+ default:
+ continue 2;
+ }
+
+ if ($i > 1) {
+ $filter .= ' ' . $clause['filterClauseOperator'] . ' ';
+ }
+
+ $filter .= $clause['filterClause'] . ' ' . $criteria;
+ }
+ }
+
+ return $filter;
+ }
+
+ /**
+ * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function setTimezone(DataProviderInterface $dataProvider)
+ {
+ // Set the timezone for SQL
+ $dateNow = Carbon::now();
+ if ($dataProvider->getDisplayId() != 0) {
+ $display = $this->displayFactory->getById($dataProvider->getDisplayId());
+ $timeZone = $display->getSetting('displayTimeZone', '');
+ $timeZone = ($timeZone == '') ? $this->config->getSetting('defaultTimezone') : $timeZone;
+ $dateNow->timezone($timeZone);
+ $this->logger->debug(sprintf(
+ 'Display Timezone Resolved: %s. Time: %s.',
+ $timeZone,
+ $dateNow->toDateTimeString()
+ ));
+ }
+
+ // Run this command on a new connection so that we do not interfere with any other queries on this connection.
+ $this->store->setTimeZone($dateNow->format('P'), 'dataset');
+
+ $this->getLogger()->debug('setTimezone: finished setting timezone');
+ }
+}
diff --git a/lib/Listener/DisplayGroupListener.php b/lib/Listener/DisplayGroupListener.php
new file mode 100644
index 0000000..4113d07
--- /dev/null
+++ b/lib/Listener/DisplayGroupListener.php
@@ -0,0 +1,277 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Event\MediaFullLoadEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\TagDeleteEvent;
+use Xibo\Event\TagEditEvent;
+use Xibo\Event\TriggerTaskEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * DisplayGroup events
+ */
+class DisplayGroupListener
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+
+ /**
+ * @param DisplayGroupFactory $displayGroupFactory
+ * @param DisplayFactory $displayFactory
+ * @param StorageServiceInterface $storageService
+ */
+ public function __construct(
+ DisplayGroupFactory $displayGroupFactory,
+ DisplayFactory $displayFactory,
+ StorageServiceInterface $storageService
+ ) {
+ $this->displayGroupFactory = $displayGroupFactory;
+ $this->displayFactory = $displayFactory;
+ $this->storageService = $storageService;
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): DisplayGroupListener
+ {
+ $dispatcher->addListener(MediaDeleteEvent::$NAME, [$this, 'onMediaDelete']);
+ $dispatcher->addListener(UserDeleteEvent::$NAME, [$this, 'onUserDelete']);
+ $dispatcher->addListener(MediaFullLoadEvent::$NAME, [$this, 'onMediaLoad']);
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'displayGroup', [$this, 'onParsePermissions']);
+ $dispatcher->addListener(FolderMovingEvent::$NAME, [$this, 'onFolderMoving']);
+ $dispatcher->addListener(TagDeleteEvent::$NAME, [$this, 'onTagDelete']);
+ $dispatcher->addListener(TagEditEvent::$NAME, [$this, 'onTagEdit']);
+
+ return $this;
+ }
+
+ /**
+ * @param MediaDeleteEvent $event
+ * @param string $eventName
+ * @param EventDispatcherInterface $dispatcher
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onMediaDelete(MediaDeleteEvent $event, string $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $media = $event->getMedia();
+ $parentMedia = $event->getParentMedia();
+
+ foreach ($this->displayGroupFactory->getByMediaId($media->mediaId) as $displayGroup) {
+ $dispatcher->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->load();
+ $displayGroup->unassignMedia($media);
+ if ($parentMedia != null) {
+ $displayGroup->assignMedia($parentMedia);
+ }
+
+ $displayGroup->save(['validate' => false]);
+ }
+ }
+
+ /**
+ * @param UserDeleteEvent $event
+ * @param $eventName
+ * @param EventDispatcherInterface $dispatcher
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onUserDelete(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ // we do not want to delete Display specific Display Groups, reassign to systemUser instead.
+ foreach ($this->displayGroupFactory->getByOwnerId($user->userId, -1) as $displayGroup) {
+ if ($displayGroup->isDisplaySpecific === 1) {
+ $displayGroup->setOwner($systemUser->userId);
+ $displayGroup->save(['saveTags' => false, 'manageDynamicDisplayLinks' => false]);
+ } else {
+ $displayGroup->load();
+ $dispatcher->dispatch(new DisplayGroupLoadEvent($displayGroup), DisplayGroupLoadEvent::$NAME);
+ $displayGroup->delete();
+ }
+ }
+ } else if ($function === 'reassignAll') {
+ foreach ($this->displayGroupFactory->getByOwnerId($user->userId, -1) as $displayGroup) {
+ ($displayGroup->isDisplaySpecific === 1) ? $displayGroup->setOwner($systemUser->userId) : $displayGroup->setOwner($newUser->getOwnerId());
+ $displayGroup->save(['saveTags' => false, 'manageDynamicDisplayLinks' => false]);
+ }
+ } else if ($function === 'countChildren') {
+ $displayGroups = $this->displayGroupFactory->getByOwnerId($user->userId, -1);
+
+ $count = count($displayGroups);
+ $this->getLogger()->debug(
+ sprintf(
+ 'Counted Children Display Groups on User ID %d, there are %d',
+ $user->userId,
+ $count
+ )
+ );
+
+ $event->setReturnValue($event->getReturnValue() + $count);
+ }
+ }
+
+ /**
+ * @param MediaFullLoadEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onMediaLoad(MediaFullLoadEvent $event)
+ {
+ $media = $event->getMedia();
+
+ $media->displayGroups = $this->displayGroupFactory->getByMediaId($media->mediaId);
+ }
+
+ /**
+ * @param ParsePermissionEntityEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onParsePermissions(ParsePermissionEntityEvent $event)
+ {
+ $this->getLogger()->debug('onParsePermissions');
+ $event->setObject($this->displayGroupFactory->getById($event->getObjectId()));
+ }
+
+ /**
+ * @param FolderMovingEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onFolderMoving(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->displayGroupFactory->getbyFolderId($folder->getId()) as $displayGroup) {
+ $displayGroup->folderId = $newFolder->getId();
+ $displayGroup->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $displayGroup->updateFolders('displaygroup');
+ }
+ }
+
+ /**
+ * @param TagDeleteEvent $event
+ * @param $eventName
+ * @param EventDispatcherInterface $dispatcher
+ * @return void
+ */
+ public function onTagDelete(TagDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher): void
+ {
+ $displays = $this->storageService->select('
+ SELECT lktagdisplaygroup.displayGroupId
+ FROM `lktagdisplaygroup`
+ INNER JOIN `displaygroup`
+ ON `lktagdisplaygroup`.displayGroupId = `displaygroup`.displayGroupId
+ AND `displaygroup`.isDisplaySpecific = 1
+ WHERE `lktagdisplaygroup`.tagId = :tagId', [
+ 'tagId' => $event->getTagId()
+ ]);
+
+ $this->storageService->update(
+ 'DELETE FROM `lktagdisplaygroup` WHERE `lktagdisplaygroup`.tagId = :tagId',
+ ['tagId' => $event->getTagId()]
+ );
+
+ if (count($displays) > 0) {
+ $dispatcher->dispatch(
+ new TriggerTaskEvent('\Xibo\XTR\MaintenanceRegularTask', 'DYNAMIC_DISPLAY_GROUP_ASSESSED'),
+ TriggerTaskEvent::$NAME
+ );
+ }
+ }
+
+ /**
+ * Update dynamic display groups' dynamicCriteriaTags when a tag is edited from the tag administration.
+ *
+ * @param TagEditEvent $event
+ * @return void
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function onTagEdit(TagEditEvent $event): void
+ {
+ // Retrieve all dynamic display groups
+ $displayGroups = $this->displayGroupFactory->getByIsDynamic(1);
+
+ foreach ($displayGroups as $displayGroup) {
+ // Convert the tag string into an array for easier processing
+ $tags = explode(',', $displayGroup->dynamicCriteriaTags);
+
+ $displayGroup->setDisplayFactory($this->displayFactory);
+
+ foreach ($tags as &$tag) {
+ $tag = trim($tag);
+
+ // Split tag into name and value (e.g. "tagName|tagValue")
+ $parts = explode('|', $tag, 2);
+ $tagName = $parts[0];
+ $tagValue = $parts[1] ?? null;
+
+ // If tag name matches the old tag, update the name while keeping the value (if any)
+ if ($tagName == $event->getOldTag()) {
+ $tagName = $event->getNewTag();
+ $tag = $tagValue !== null ? $tagName . '|' . $tagValue : $tagName;
+
+ $displayGroup->dynamicCriteriaTags = implode(',', $tags);
+ $displayGroup->save();
+ }
+ }
+ }
+ }
+}
diff --git a/lib/Listener/LayoutListener.php b/lib/Listener/LayoutListener.php
new file mode 100644
index 0000000..353e33c
--- /dev/null
+++ b/lib/Listener/LayoutListener.php
@@ -0,0 +1,271 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\Layout;
+use Xibo\Entity\Region;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\LayoutOwnerChangeEvent;
+use Xibo\Event\LayoutSharingChangeEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Event\MediaFullLoadEvent;
+use Xibo\Event\PlaylistDeleteEvent;
+use Xibo\Event\RegionAddedEvent;
+use Xibo\Event\TagDeleteEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\PermissionFactory;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Layout events
+ */
+class LayoutListener
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @param LayoutFactory $layoutFactory
+ * @param StorageServiceInterface $storageService
+ * @param \Xibo\Factory\PermissionFactory $permissionFactory
+ */
+ public function __construct(
+ private readonly LayoutFactory $layoutFactory,
+ private readonly StorageServiceInterface $storageService,
+ private readonly PermissionFactory $permissionFactory
+ ) {
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher) : LayoutListener
+ {
+ $dispatcher->addListener(MediaDeleteEvent::$NAME, [$this, 'onMediaDelete']);
+ $dispatcher->addListener(UserDeleteEvent::$NAME, [$this, 'onUserDelete']);
+ $dispatcher->addListener(DisplayGroupLoadEvent::$NAME, [$this, 'onDisplayGroupLoad']);
+ $dispatcher->addListener(MediaFullLoadEvent::$NAME, [$this, 'onMediaLoad']);
+ $dispatcher->addListener(LayoutOwnerChangeEvent::$NAME, [$this, 'onOwnerChange']);
+ $dispatcher->addListener(TagDeleteEvent::$NAME, [$this, 'onTagDelete']);
+ $dispatcher->addListener(PlaylistDeleteEvent::$NAME, [$this, 'onPlaylistDelete']);
+ $dispatcher->addListener(LayoutSharingChangeEvent::$NAME, [$this, 'onLayoutSharingChange']);
+ $dispatcher->addListener(RegionAddedEvent::$NAME, [$this, 'onRegionAdded']);
+
+ return $this;
+ }
+
+ /**
+ * @param MediaDeleteEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onMediaDelete(MediaDeleteEvent $event)
+ {
+ $media = $event->getMedia();
+ $parentMedia = $event->getParentMedia();
+
+ foreach ($this->layoutFactory->getByBackgroundImageId($media->mediaId) as $layout) {
+ if ($media->mediaType == 'image' && $parentMedia != null) {
+ $this->getLogger()->debug(sprintf(
+ 'Updating layouts with the old media %d as the background image.',
+ $media->mediaId
+ ));
+ $this->getLogger()->debug(sprintf(
+ 'Found layout that needs updating. ID = %d. Setting background image id to %d',
+ $layout->layoutId,
+ $parentMedia->mediaId
+ ));
+
+ $layout->backgroundImageId = $parentMedia->mediaId;
+ } else {
+ $layout->backgroundImageId = null;
+ }
+
+ $layout->save(Layout::$saveOptionsMinimum);
+ }
+
+ // do we have any full screen Layout linked to this Media item?
+ $linkedLayout = $this->layoutFactory->getLinkedFullScreenLayout('media', $media->mediaId);
+
+ if (!empty($linkedLayout)) {
+ $linkedLayout->delete();
+ }
+ }
+
+ /**
+ * @param UserDeleteEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onUserDelete(UserDeleteEvent $event)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+
+ if ($function === 'delete') {
+ // Delete any Layouts
+ foreach ($this->layoutFactory->getByOwnerId($user->userId) as $layout) {
+ $layout->delete();
+ }
+ } else if ($function === 'reassignAll') {
+ // Reassign layouts, regions, region Playlists and Widgets.
+ foreach ($this->layoutFactory->getByOwnerId($user->userId) as $layout) {
+ $layout->setOwner($newUser->userId, true);
+ $layout->save(['notify' => false, 'saveTags' => false, 'setBuildRequired' => false]);
+ }
+ } else if ($function === 'countChildren') {
+ $layouts = $this->layoutFactory->getByOwnerId($user->userId);
+
+ $count = count($layouts);
+ $this->getLogger()->debug(
+ sprintf(
+ 'Counted Children Layouts on User ID %d, there are %d',
+ $user->userId,
+ $count
+ )
+ );
+
+ $event->setReturnValue($event->getReturnValue() + $count);
+ }
+ }
+
+ /**
+ * @param DisplayGroupLoadEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onDisplayGroupLoad(DisplayGroupLoadEvent $event)
+ {
+ $displayGroup = $event->getDisplayGroup();
+
+ $displayGroup->layouts = ($displayGroup->displayGroupId != null)
+ ? $this->layoutFactory->getByDisplayGroupId($displayGroup->displayGroupId)
+ : [];
+ }
+
+ /**
+ * @param MediaFullLoadEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onMediaLoad(MediaFullLoadEvent $event)
+ {
+ $media = $event->getMedia();
+
+ $media->layoutBackgroundImages = $this->layoutFactory->getByBackgroundImageId($media->mediaId);
+ }
+
+ /**
+ * @param LayoutOwnerChangeEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onOwnerChange(LayoutOwnerChangeEvent $event)
+ {
+ $campaignId = $event->getCampaignId();
+ $ownerId = $event->getOwnerId();
+
+ foreach ($this->layoutFactory->getByCampaignId($campaignId, true, true) as $layout) {
+ $layout->setOwner($ownerId, true);
+ $layout->save(['notify' => false]);
+ }
+ }
+
+ /**
+ * @param TagDeleteEvent $event
+ * @return void
+ */
+ public function onTagDelete(TagDeleteEvent $event)
+ {
+ $this->storageService->update(
+ 'DELETE FROM `lktaglayout` WHERE `lktaglayout`.tagId = :tagId',
+ ['tagId' => $event->getTagId()]
+ );
+ }
+
+ /**
+ * @param PlaylistDeleteEvent $event
+ * @return void
+ */
+ public function onPlaylistDelete(PlaylistDeleteEvent $event)
+ {
+ $playlist = $event->getPlaylist();
+
+ // do we have any full screen Layout linked to this playlist?
+ $layout = $this->layoutFactory->getLinkedFullScreenLayout('playlist', $playlist->playlistId);
+
+ if (!empty($layout)) {
+ $layout->delete();
+ }
+ }
+
+ /**
+ * @param \Xibo\Event\LayoutSharingChangeEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function onLayoutSharingChange(LayoutSharingChangeEvent $event): void
+ {
+ // Check to see if this Campaign has any Canvas regions
+ $layouts = $this->layoutFactory->getByCampaignId($event->getCampaignId(), false, true);
+ foreach ($layouts as $layout) {
+ $layout->load([
+ 'loadPlaylists' => false,
+ 'loadPermissions' => false,
+ 'loadCampaigns' => false,
+ 'loadActions' => false,
+ ]);
+
+ foreach ($layout->regions as $region) {
+ if ($region->type === 'canvas') {
+ $event->addCanvasRegionId($region->getId());
+ }
+ }
+ }
+ }
+
+ /**
+ * @param \Xibo\Event\RegionAddedEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function onRegionAdded(RegionAddedEvent $event): void
+ {
+ if ($event->getRegion()->type === 'canvas') {
+ // Set this layout's permissions on the canvas region
+ $entityId = $this->permissionFactory->getEntityId(Region::class);
+ foreach ($event->getLayout()->permissions as $permission) {
+ $new = clone $permission;
+ $new->entityId = $entityId;
+ $new->objectId = $event->getRegion()->getId();
+ $new->save();
+ }
+ }
+ }
+}
diff --git a/lib/Listener/ListenerConfigTrait.php b/lib/Listener/ListenerConfigTrait.php
new file mode 100644
index 0000000..e733dbc
--- /dev/null
+++ b/lib/Listener/ListenerConfigTrait.php
@@ -0,0 +1,49 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Xibo\Service\ConfigServiceInterface;
+
+trait ListenerConfigTrait
+{
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /**
+ * @param ConfigServiceInterface $config
+ * @return $this
+ */
+ public function useConfig(ConfigServiceInterface $config)
+ {
+ $this->config = $config;
+ return $this;
+ }
+
+ /**
+ * @return ConfigServiceInterface
+ */
+ protected function getConfig()
+ {
+ return $this->config;
+ }
+}
diff --git a/lib/Listener/ListenerLoggerTrait.php b/lib/Listener/ListenerLoggerTrait.php
new file mode 100644
index 0000000..69452aa
--- /dev/null
+++ b/lib/Listener/ListenerLoggerTrait.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Psr\Log\NullLogger;
+
+trait ListenerLoggerTrait
+{
+ /** @var \Psr\Log\LoggerInterface */
+ private $logger;
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return $this
+ */
+ public function useLogger($logger)
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ /**
+ * @return \Psr\Log\LoggerInterface
+ */
+ protected function getLogger()
+ {
+ if ($this->logger === null) {
+ $this->logger = new NullLogger();
+ }
+
+ return $this->logger;
+ }
+}
diff --git a/lib/Listener/MediaListener.php b/lib/Listener/MediaListener.php
new file mode 100644
index 0000000..10e312c
--- /dev/null
+++ b/lib/Listener/MediaListener.php
@@ -0,0 +1,194 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\TagDeleteEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\MediaFactory;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Media events
+ */
+class MediaListener
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+
+ /**
+ * @param MediaFactory $mediaFactory
+ * @param StorageServiceInterface $storageService
+ */
+ public function __construct(
+ MediaFactory $mediaFactory,
+ StorageServiceInterface $storageService
+ ) {
+ $this->mediaFactory = $mediaFactory;
+ $this->storageService = $storageService;
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher) : MediaListener
+ {
+ $dispatcher->addListener(UserDeleteEvent::$NAME, [$this, 'onUserDelete']);
+ $dispatcher->addListener(DisplayGroupLoadEvent::$NAME, [$this, 'onDisplayGroupLoad']);
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'media', [$this, 'onParsePermissions']);
+ $dispatcher->addListener(FolderMovingEvent::$NAME, [$this, 'onFolderMoving']);
+ $dispatcher->addListener(TagDeleteEvent::$NAME, [$this, 'onTagDelete']);
+
+ return $this;
+ }
+
+ /**
+ * @param UserDeleteEvent $event
+ * @param $eventName
+ * @param EventDispatcherInterface $dispatcher
+ * @return void
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function onUserDelete(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ // Delete any media
+ foreach ($this->mediaFactory->getByOwnerId($user->userId, 1) as $media) {
+ // If there is a parent, bring it back
+ try {
+ $parentMedia = $this->mediaFactory->getParentById($media->mediaId);
+ $parentMedia->isEdited = 0;
+ $parentMedia->parentId = null;
+ $parentMedia->save(['validate' => false]);
+ } catch (NotFoundException $e) {
+ // This is fine, no parent
+ $parentMedia = null;
+ }
+
+ // if this User owns any module files, reassign to systemUser instead of deleting.
+ if ($media->mediaType === 'module') {
+ $media->setOwner($systemUser->userId);
+ $media->save();
+ } else {
+ $dispatcher->dispatch(new MediaDeleteEvent($media, $parentMedia, true), MediaDeleteEvent::$NAME);
+ $media->delete();
+ }
+ }
+ } else if ($function === 'reassignAll') {
+ foreach ($this->mediaFactory->getByOwnerId($user->userId, 1) as $media) {
+ ($media->mediaType === 'module') ? $media->setOwner($systemUser->userId) : $media->setOwner($newUser->getOwnerId());
+ $media->save();
+ }
+ } else if ($function === 'countChildren') {
+ $media = $this->mediaFactory->getByOwnerId($user->userId, 1);
+
+ $count = count($media);
+ $this->getLogger()->debug(
+ sprintf(
+ 'Counted Children Media on User ID %d, there are %d',
+ $user->userId,
+ $count
+ )
+ );
+
+ $event->setReturnValue($event->getReturnValue() + $count);
+ }
+ }
+
+ /**
+ * @param DisplayGroupLoadEvent $event
+ * @return void
+ * @throws NotFoundException
+ */
+ public function onDisplayGroupLoad(DisplayGroupLoadEvent $event)
+ {
+ $displayGroup = $event->getDisplayGroup();
+
+ $displayGroup->media = ($displayGroup->displayGroupId != null)
+ ? $this->mediaFactory->getByDisplayGroupId($displayGroup->displayGroupId)
+ : [];
+ }
+
+ /**
+ * @param ParsePermissionEntityEvent $event
+ * @return void
+ * @throws NotFoundException
+ */
+ public function onParsePermissions(ParsePermissionEntityEvent $event)
+ {
+ $this->getLogger()->debug('onParsePermissions');
+ $event->setObject($this->mediaFactory->getById($event->getObjectId()));
+ }
+
+ /**
+ * @param FolderMovingEvent $event
+ * @return void
+ * @throws NotFoundException
+ */
+ public function onFolderMoving(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->mediaFactory->getByFolderId($folder->getId()) as $media) {
+ $media->folderId = $newFolder->getId();
+ $media->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $media->updateFolders('media');
+ }
+ }
+
+ /**
+ * @param TagDeleteEvent $event
+ * @return void
+ */
+ public function onTagDelete(TagDeleteEvent $event)
+ {
+ $this->storageService->update(
+ 'DELETE FROM `lktagmedia` WHERE `lktagmedia`.tagId = :tagId',
+ ['tagId' => $event->getTagId()]
+ );
+ }
+}
diff --git a/lib/Listener/MenuBoardProviderListener.php b/lib/Listener/MenuBoardProviderListener.php
new file mode 100644
index 0000000..3f88f02
--- /dev/null
+++ b/lib/Listener/MenuBoardProviderListener.php
@@ -0,0 +1,167 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\MenuBoardCategoryRequest;
+use Xibo\Event\MenuBoardModifiedDtRequest;
+use Xibo\Event\MenuBoardProductRequest;
+use Xibo\Factory\MenuBoardCategoryFactory;
+use Xibo\Factory\MenuBoardFactory;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Listener for dealing with a menu board provider
+ */
+class MenuBoardProviderListener
+{
+ use ListenerLoggerTrait;
+
+ private MenuBoardFactory $menuBoardFactory;
+
+ private MenuBoardCategoryFactory $menuBoardCategoryFactory;
+
+ public function __construct(MenuBoardFactory $menuBoardFactory, MenuBoardCategoryFactory $menuBoardCategoryFactory)
+ {
+ $this->menuBoardFactory = $menuBoardFactory;
+ $this->menuBoardCategoryFactory = $menuBoardCategoryFactory;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): MenuBoardProviderListener
+ {
+ $dispatcher->addListener(MenuBoardProductRequest::$NAME, [$this, 'onProductRequest']);
+ $dispatcher->addListener(MenuBoardCategoryRequest::$NAME, [$this, 'onCategoryRequest']);
+ $dispatcher->addListener(MenuBoardModifiedDtRequest::$NAME, [$this, 'onModifiedDtRequest']);
+ return $this;
+ }
+
+ public function onProductRequest(MenuBoardProductRequest $event): void
+ {
+ $this->getLogger()->debug('onProductRequest: data source is ' . $event->getDataProvider()->getDataSource());
+
+ $dataProvider = $event->getDataProvider();
+ $menuId = $dataProvider->getProperty('menuId', 0);
+ if (empty($menuId)) {
+ $this->getLogger()->debug('onProductRequest: no menuId.');
+ return;
+ }
+
+ // Sorting
+ $desc = $dataProvider->getProperty('sortDescending') == 1 ? ' DESC' : '';
+ $sort = match ($dataProvider->getProperty('sortField')) {
+ 'name' => '`name`' . $desc,
+ 'price' => '`price`' . $desc,
+ 'id' => '`menuProductId`' . $desc,
+ default => '`displayOrder`' . $desc,
+ };
+
+ // Build a filter
+ $filter = [
+ 'menuId' => $menuId,
+ ];
+
+ $categoryId = $dataProvider->getProperty('categoryId');
+ $this->getLogger()->debug('onProductRequest: $categoryId: ' . $categoryId);
+ if ($categoryId !== null && $categoryId !== '') {
+ $filter['menuCategoryId'] = intval($categoryId);
+ }
+
+ // Show Unavailable?
+ if ($dataProvider->getProperty('showUnavailable', 0) === 0) {
+ $filter['availability'] = 1;
+ }
+
+ // limits?
+ $lowerLimit = $dataProvider->getProperty('lowerLimit', 0);
+ $upperLimit = $dataProvider->getProperty('upperLimit', 0);
+ if ($lowerLimit !== 0 || $upperLimit !== 0) {
+ // Start should be the lower limit
+ // Size should be the distance between upper and lower
+ $filter['start'] = $lowerLimit;
+ $filter['length'] = $upperLimit - $lowerLimit;
+
+ $this->getLogger()->debug('onProductRequest: applied limits, start: '
+ . $filter['start'] . ', length: ' . $filter['length']);
+ }
+
+ $products = $this->menuBoardCategoryFactory->getProductData([$sort], $filter);
+
+ foreach ($products as $menuBoardProduct) {
+ $menuBoardProduct->productOptions = $menuBoardProduct->getOptions();
+ $product = $menuBoardProduct->toProduct();
+
+ // Convert the image to a library image?
+ if ($product->image !== null) {
+ // The content is the ID of the image
+ try {
+ $product->image = $dataProvider->addLibraryFile(intval($product->image));
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onProductRequest: Invalid library media reference: ' . $product->image);
+ $product->image = null;
+ }
+ }
+ $dataProvider->addItem($product);
+ }
+
+ $dataProvider->setIsHandled();
+ }
+
+ public function onCategoryRequest(MenuBoardCategoryRequest $event): void
+ {
+ $this->getLogger()->debug('onCategoryRequest: data source is ' . $event->getDataProvider()->getDataSource());
+
+ $dataProvider = $event->getDataProvider();
+ $menuId = $dataProvider->getProperty('menuId', 0);
+ if (empty($menuId)) {
+ $this->getLogger()->debug('onCategoryRequest: no menuId.');
+ return;
+ }
+ $categoryId = $dataProvider->getProperty('categoryId', 0);
+ if (empty($categoryId)) {
+ $this->getLogger()->debug('onCategoryRequest: no categoryId.');
+ return;
+ }
+
+ $category = $this->menuBoardCategoryFactory->getById($categoryId)->toProductCategory();
+ // Convert the image to a library image?
+ if ($category->image !== null) {
+ // The content is the ID of the image
+ try {
+ $category->image = $dataProvider->addLibraryFile(intval($category->image));
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->error('onCategoryRequest: Invalid library media reference: ' . $category->image);
+ $category->image = null;
+ }
+ }
+ $dataProvider->addItem($category);
+
+ $dataProvider->setIsHandled();
+ }
+
+ public function onModifiedDtRequest(MenuBoardModifiedDtRequest $event): void
+ {
+ $menu = $this->menuBoardFactory->getById($event->getDataSetId());
+ $event->setModifiedDt(Carbon::createFromTimestamp($menu->modifiedDt));
+ }
+}
diff --git a/lib/Listener/ModuleTemplateListener.php b/lib/Listener/ModuleTemplateListener.php
new file mode 100644
index 0000000..46051a5
--- /dev/null
+++ b/lib/Listener/ModuleTemplateListener.php
@@ -0,0 +1,58 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\ModuleTemplateFactory;
+
+class ModuleTemplateListener
+{
+ use ListenerLoggerTrait;
+
+ /** @var ModuleTemplateFactory */
+ private ModuleTemplateFactory $moduleTemplateFactory;
+
+ public function __construct(ModuleTemplateFactory $moduleTemplateFactory)
+ {
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): ModuleTemplateListener
+ {
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'moduleTemplate', [$this, 'onParsePermissions']);
+
+ return $this;
+ }
+
+ /**
+ * @param ParsePermissionEntityEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onParsePermissions(ParsePermissionEntityEvent $event)
+ {
+ $this->getLogger()->debug('onParsePermissions');
+ $event->setObject($this->moduleTemplateFactory->getUserTemplateById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/NotificationDataProviderListener.php b/lib/Listener/NotificationDataProviderListener.php
new file mode 100644
index 0000000..d6a2d06
--- /dev/null
+++ b/lib/Listener/NotificationDataProviderListener.php
@@ -0,0 +1,131 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Carbon\Carbon;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\NotificationCacheKeyRequestEvent;
+use Xibo\Event\NotificationDataRequestEvent;
+use Xibo\Event\NotificationModifiedDtRequestEvent;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Widget\Provider\DataProviderInterface;
+
+/**
+ * Listens to request for data from Notification.
+ */
+class NotificationDataProviderListener
+{
+
+ use ListenerLoggerTrait;
+
+ /** @var \Xibo\Service\ConfigServiceInterface */
+ private $config;
+
+ /** @var \Xibo\Factory\NotificationFactory */
+ private $notificationFactory;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ public function __construct(
+ ConfigServiceInterface $config,
+ NotificationFactory $notificationFactory,
+ User $user
+ ) {
+ $this->config = $config;
+ $this->notificationFactory = $notificationFactory;
+ $this->user = $user;
+ }
+
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): NotificationDataProviderListener
+ {
+ $dispatcher->addListener(NotificationDataRequestEvent::$NAME, [$this, 'onDataRequest']);
+ $dispatcher->addListener(NotificationModifiedDtRequestEvent::$NAME, [$this, 'onModifiedDtRequest']);
+ return $this;
+ }
+
+ public function onDataRequest(NotificationDataRequestEvent $event)
+ {
+ $this->getData($event->getDataProvider());
+ }
+
+ public function getData(DataProviderInterface $dataProvider)
+ {
+ $age = $dataProvider->getProperty('age', 0);
+
+ $filter = [
+ 'releaseDt' => ($age === 0) ? null : Carbon::now()->subMinutes($age)->unix(),
+ 'onlyReleased' => 1,
+ ];
+
+ if ($dataProvider->isPreview()) {
+ $filter['userId'] = $this->user->getId();
+ } else {
+ $filter['displayId'] = $dataProvider->getDisplayId();
+ }
+
+ $sort = ['releaseDt DESC', 'createDt DESC', 'subject'];
+
+ $notifications = $this->notificationFactory->query($sort, $filter);
+
+ foreach ($notifications as $notification) {
+ $item = [];
+ $item['subject'] = $notification->subject;
+ $item['body'] = strip_tags($notification->body);
+ $item['date'] = Carbon::createFromTimestamp($notification->releaseDt)->format('c');
+ $item['createdAt'] = Carbon::createFromTimestamp($notification->createDt)->format('c');
+
+ $dataProvider->addItem($item);
+ }
+
+ $dataProvider->setIsHandled();
+ }
+
+ public function onModifiedDtRequest(NotificationModifiedDtRequestEvent $event)
+ {
+ $this->getLogger()->debug('onModifiedDtRequest');
+
+ // Get the latest notification according to the filter provided.
+ $displayId = $event->getDisplayId();
+
+ // If we're a user, we should always refresh
+ if ($displayId === 0) {
+ $event->setModifiedDt(Carbon::maxValue());
+ return;
+ }
+
+ $notifications = $this->notificationFactory->query(['releaseDt DESC'], [
+ 'onlyReleased' => 1,
+ 'displayId' => $displayId,
+ 'length' => 1,
+ ]);
+
+ if (count($notifications) > 0) {
+ $event->setModifiedDt(Carbon::createFromTimestamp($notifications[0]->releaseDt));
+ }
+ }
+}
diff --git a/lib/Listener/OnCommandDelete.php b/lib/Listener/OnCommandDelete.php
new file mode 100644
index 0000000..286ee89
--- /dev/null
+++ b/lib/Listener/OnCommandDelete.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Xibo\Event\CommandDeleteEvent;
+use Xibo\Factory\DisplayProfileFactory;
+
+class OnCommandDelete
+{
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ public function __construct(DisplayProfileFactory $displayProfileFactory)
+ {
+ $this->displayProfileFactory = $displayProfileFactory;
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function __invoke(CommandDeleteEvent $event)
+ {
+ $command = $event->getCommand();
+
+ foreach ($this->displayProfileFactory->getByCommandId($command->commandId) as $displayProfile) {
+ $displayProfile->unassignCommand($command);
+ $displayProfile->save(['validate' => false]);
+ }
+ }
+}
diff --git a/lib/Listener/OnDisplayGroupLoad/DisplayGroupDisplayListener.php b/lib/Listener/OnDisplayGroupLoad/DisplayGroupDisplayListener.php
new file mode 100644
index 0000000..ff0a18a
--- /dev/null
+++ b/lib/Listener/OnDisplayGroupLoad/DisplayGroupDisplayListener.php
@@ -0,0 +1,47 @@
+.
+ */
+
+namespace Xibo\Listener\OnDisplayGroupLoad;
+
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Factory\DisplayFactory;
+
+class DisplayGroupDisplayListener
+{
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ public function __construct(DisplayFactory $displayFactory)
+ {
+ $this->displayFactory = $displayFactory;
+ }
+
+ public function __invoke(DisplayGroupLoadEvent $event)
+ {
+ $displayGroup = $event->getDisplayGroup();
+ $displayGroup->setDisplayFactory($this->displayFactory);
+
+ $displayGroup->displays = $this->displayFactory->getByDisplayGroupId($displayGroup->displayGroupId);
+ }
+}
diff --git a/lib/Listener/OnDisplayGroupLoad/DisplayGroupScheduleListener.php b/lib/Listener/OnDisplayGroupLoad/DisplayGroupScheduleListener.php
new file mode 100644
index 0000000..4b6eafc
--- /dev/null
+++ b/lib/Listener/OnDisplayGroupLoad/DisplayGroupScheduleListener.php
@@ -0,0 +1,48 @@
+.
+ */
+
+namespace Xibo\Listener\OnDisplayGroupLoad;
+
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Factory\ScheduleFactory;
+
+class DisplayGroupScheduleListener
+{
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ public function __construct(ScheduleFactory $scheduleFactory)
+ {
+ $this->scheduleFactory = $scheduleFactory;
+ }
+
+ public function __invoke(DisplayGroupLoadEvent $event)
+ {
+ $displayGroup = $event->getDisplayGroup();
+
+ $displayGroup->events = ($displayGroup->displayGroupId != null)
+ ? $this->scheduleFactory->getByDisplayGroupId($displayGroup->displayGroupId)
+ : [];
+ }
+}
diff --git a/lib/Listener/OnFolderMoving/DataSetListener.php b/lib/Listener/OnFolderMoving/DataSetListener.php
new file mode 100644
index 0000000..77cf558
--- /dev/null
+++ b/lib/Listener/OnFolderMoving/DataSetListener.php
@@ -0,0 +1,50 @@
+.
+ */
+namespace Xibo\Listener\OnFolderMoving;
+
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Factory\DataSetFactory;
+
+class DataSetListener
+{
+ /**
+ * @var DataSetFactory
+ */
+ private $dataSetFactory;
+
+ public function __construct(DataSetFactory $dataSetFactory)
+ {
+ $this->dataSetFactory = $dataSetFactory;
+ }
+
+ public function __invoke(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->dataSetFactory->getByFolderId($folder->getId()) as $dataSet) {
+ $dataSet->folderId = $newFolder->getId();
+ $dataSet->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $dataSet->updateFolders('dataset');
+ }
+ }
+}
diff --git a/lib/Listener/OnFolderMoving/FolderListener.php b/lib/Listener/OnFolderMoving/FolderListener.php
new file mode 100644
index 0000000..3a8dc26
--- /dev/null
+++ b/lib/Listener/OnFolderMoving/FolderListener.php
@@ -0,0 +1,55 @@
+.
+ */
+namespace Xibo\Listener\OnFolderMoving;
+
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Factory\FolderFactory;
+
+class FolderListener
+{
+ /**
+ * @var FolderFactory
+ */
+ private $folderFactory;
+
+ public function __construct(FolderFactory $folderFactory)
+ {
+ $this->folderFactory = $folderFactory;
+ }
+
+ public function __invoke(FolderMovingEvent $event)
+ {
+ $merge = $event->getIsMerge();
+
+ if ($merge) {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ // on merge we delete the original Folder and move its content to the new selected folder
+ // sub-folders are moved to their new parent as well
+ foreach (array_filter(explode(',', $folder->children)) as $childFolderId) {
+ $childFolder = $this->folderFactory->getById($childFolderId, 0);
+ $childFolder->updateFoldersAfterMove($folder->getId(), $newFolder->getId());
+ }
+ }
+ }
+}
diff --git a/lib/Listener/OnFolderMoving/MenuBoardListener.php b/lib/Listener/OnFolderMoving/MenuBoardListener.php
new file mode 100644
index 0000000..eca7dbf
--- /dev/null
+++ b/lib/Listener/OnFolderMoving/MenuBoardListener.php
@@ -0,0 +1,50 @@
+.
+ */
+namespace Xibo\Listener\OnFolderMoving;
+
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Factory\MenuBoardFactory;
+
+class MenuBoardListener
+{
+ /**
+ * @var MenuBoardFactory
+ */
+ private $menuBoardFactory;
+
+ public function __construct(MenuBoardFactory $menuBoardFactory)
+ {
+ $this->menuBoardFactory = $menuBoardFactory;
+ }
+
+ public function __invoke(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->menuBoardFactory->getbyFolderId($folder->getId()) as $menuBoard) {
+ $menuBoard->folderId = $newFolder->getId();
+ $menuBoard->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $menuBoard->updateFolders('menu_board');
+ }
+ }
+}
diff --git a/lib/Listener/OnFolderMoving/UserListener.php b/lib/Listener/OnFolderMoving/UserListener.php
new file mode 100644
index 0000000..3342a3e
--- /dev/null
+++ b/lib/Listener/OnFolderMoving/UserListener.php
@@ -0,0 +1,58 @@
+.
+ */
+namespace Xibo\Listener\OnFolderMoving;
+
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Factory\UserFactory;
+use Xibo\Storage\StorageServiceInterface;
+
+class UserListener
+{
+ /**
+ * @var UserFactory
+ */
+ private $userFactory;
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ public function __construct(UserFactory $userFactory, StorageServiceInterface $store)
+ {
+ $this->userFactory = $userFactory;
+ $this->store = $store;
+ }
+
+ public function __invoke(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->userFactory->getByHomeFolderId($folder->getId()) as $user) {
+ $this->store->update('UPDATE `user` SET homeFolderId = :newFolderId WHERE homeFolderId = :oldFolderId AND userId = :userId', [
+ 'newFolderId' => $newFolder->getId(),
+ 'oldFolderId' => $folder->getId(),
+ 'userId' => $user->getId()
+ ]);
+ }
+ }
+}
diff --git a/lib/Listener/OnGettingDependencyFileSize/FontsListener.php b/lib/Listener/OnGettingDependencyFileSize/FontsListener.php
new file mode 100644
index 0000000..6877d8f
--- /dev/null
+++ b/lib/Listener/OnGettingDependencyFileSize/FontsListener.php
@@ -0,0 +1,29 @@
+fontFactory = $fontFactory;
+ }
+
+ public function __invoke(DependencyFileSizeEvent $event)
+ {
+ $fontsSize = $this->fontFactory->getFontsSizeAndCount();
+ $event->addResult([
+ 'SumSize' => $fontsSize['SumSize'],
+ 'type' => 'font',
+ 'count' => $fontsSize['totalCount']
+ ]);
+ }
+}
diff --git a/lib/Listener/OnGettingDependencyFileSize/PlayerVersionListener.php b/lib/Listener/OnGettingDependencyFileSize/PlayerVersionListener.php
new file mode 100644
index 0000000..45f8b24
--- /dev/null
+++ b/lib/Listener/OnGettingDependencyFileSize/PlayerVersionListener.php
@@ -0,0 +1,29 @@
+playerVersionFactory = $playerVersionFactory;
+ }
+
+ public function __invoke(DependencyFileSizeEvent $event)
+ {
+ $versionSize = $this->playerVersionFactory->getSizeAndCount();
+ $event->addResult([
+ 'SumSize' => $versionSize['SumSize'],
+ 'type' => 'playersoftware',
+ 'count' => $versionSize['totalCount']
+ ]);
+ }
+}
diff --git a/lib/Listener/OnGettingDependencyFileSize/SavedReportListener.php b/lib/Listener/OnGettingDependencyFileSize/SavedReportListener.php
new file mode 100644
index 0000000..de89df3
--- /dev/null
+++ b/lib/Listener/OnGettingDependencyFileSize/SavedReportListener.php
@@ -0,0 +1,49 @@
+.
+ */
+
+namespace Xibo\Listener\OnGettingDependencyFileSize;
+
+use Xibo\Event\DependencyFileSizeEvent;
+use Xibo\Factory\SavedReportFactory;
+
+class SavedReportListener
+{
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ public function __construct(SavedReportFactory $savedReportFactory)
+ {
+ $this->savedReportFactory = $savedReportFactory;
+ }
+
+ public function __invoke(DependencyFileSizeEvent $event)
+ {
+ $versionSize = $this->savedReportFactory->getSizeAndCount();
+ $event->addResult([
+ 'SumSize' => $versionSize['SumSize'],
+ 'type' => 'savedreport',
+ 'count' => $versionSize['totalCount']
+ ]);
+ }
+}
diff --git a/lib/Listener/OnMediaDelete/MenuBoardListener.php b/lib/Listener/OnMediaDelete/MenuBoardListener.php
new file mode 100644
index 0000000..2933e2d
--- /dev/null
+++ b/lib/Listener/OnMediaDelete/MenuBoardListener.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Listener\OnMediaDelete;
+
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Factory\MenuBoardCategoryFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+class MenuBoardListener
+{
+ use ListenerLoggerTrait;
+
+ /** @var MenuBoardCategoryFactory */
+ private $menuBoardCategoryFactory;
+
+ public function __construct($menuBoardCategoryFactory)
+ {
+ $this->menuBoardCategoryFactory = $menuBoardCategoryFactory;
+ }
+
+ /**
+ * @param MediaDeleteEvent $event
+ * @throws InvalidArgumentException
+ */
+ public function __invoke(MediaDeleteEvent $event)
+ {
+ $media = $event->getMedia();
+
+ foreach ($this->menuBoardCategoryFactory->query(null, ['mediaId' => $media->mediaId]) as $category) {
+ $category->mediaId = null;
+ $category->save();
+ }
+
+ foreach ($this->menuBoardCategoryFactory->getProductData(null, ['mediaId' => $media->mediaId]) as $product) {
+ $product->mediaId = null;
+ $product->save();
+ }
+ }
+}
diff --git a/lib/Listener/OnMediaDelete/PurgeListListener.php b/lib/Listener/OnMediaDelete/PurgeListListener.php
new file mode 100644
index 0000000..8a3a447
--- /dev/null
+++ b/lib/Listener/OnMediaDelete/PurgeListListener.php
@@ -0,0 +1,62 @@
+.
+ */
+
+namespace Xibo\Listener\OnMediaDelete;
+
+use Carbon\Carbon;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+class PurgeListListener
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $configService;
+
+ public function __construct(StorageServiceInterface $store, ConfigServiceInterface $configService)
+ {
+ $this->store = $store;
+ $this->configService = $configService;
+ }
+
+ public function __invoke(MediaDeleteEvent $event)
+ {
+ // storedAs
+ if ($event->isSetToPurge()) {
+ $this->store->insert('INSERT INTO `purge_list` (mediaId, storedAs, expiryDate) VALUES (:mediaId, :storedAs, :expiryDate)', [
+ 'mediaId' => $event->getMedia()->mediaId,
+ 'storedAs' => $event->getMedia()->storedAs,
+ 'expiryDate' => Carbon::now()->addDays($this->configService->getSetting('DEFAULT_PURGE_LIST_TTL'))->format(DateFormatHelper::getSystemFormat())
+ ]);
+ }
+ }
+}
diff --git a/lib/Listener/OnMediaDelete/WidgetListener.php b/lib/Listener/OnMediaDelete/WidgetListener.php
new file mode 100644
index 0000000..fcb2a62
--- /dev/null
+++ b/lib/Listener/OnMediaDelete/WidgetListener.php
@@ -0,0 +1,97 @@
+.
+ */
+
+namespace Xibo\Listener\OnMediaDelete;
+
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class WidgetListener
+{
+ use ListenerLoggerTrait;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @var \Xibo\Factory\ModuleFactory */
+ private $moduleFactory;
+
+ /** @var StorageServiceInterface */
+ private $storageService;
+
+ public function __construct(
+ StorageServiceInterface $storageService,
+ WidgetFactory $widgetFactory,
+ ModuleFactory $moduleFactory
+ ) {
+ $this->storageService = $storageService;
+ $this->widgetFactory = $widgetFactory;
+ $this->moduleFactory = $moduleFactory;
+ }
+
+ /**
+ * @param MediaDeleteEvent $event
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function __invoke(MediaDeleteEvent $event)
+ {
+ $media = $event->getMedia();
+ $parentMedia = $event->getParentMedia();
+
+ foreach ($this->widgetFactory->getByMediaId($media->mediaId) as $widget) {
+ $widget->unassignMedia($media->mediaId);
+
+ if ($parentMedia != null) {
+ // Assign the parent media to the widget instead
+ $widget->assignMedia($parentMedia->mediaId);
+
+ // Swap any audio nodes over to this new widget media assignment.
+ $this->storageService->update('
+ UPDATE `lkwidgetaudio` SET mediaId = :mediaId WHERE widgetId = :widgetId AND mediaId = :oldMediaId
+ ', [
+ 'mediaId' => $parentMedia->mediaId,
+ 'widgetId' => $widget->widgetId,
+ 'oldMediaId' => $media->mediaId
+ ]);
+ } else {
+ // Also delete the `lkwidgetaudio`
+ foreach ($widget->audio as $audio) {
+ $widget->unassignAudioById($audio->mediaId);
+ $audio->delete();
+ }
+ }
+
+ // This action might result in us deleting a widget (unless we are a temporary file with an expiry date)
+ if ($media->mediaType != 'module'
+ && $this->moduleFactory->getByType($widget->type)->regionSpecific === 0
+ && count($widget->mediaIds) <= 0
+ ) {
+ $widget->delete();
+ } else {
+ $widget->save(['saveWidgetOptions' => false]);
+ }
+ }
+ }
+}
diff --git a/lib/Listener/OnMediaLoad/WidgetListener.php b/lib/Listener/OnMediaLoad/WidgetListener.php
new file mode 100644
index 0000000..426d0ff
--- /dev/null
+++ b/lib/Listener/OnMediaLoad/WidgetListener.php
@@ -0,0 +1,27 @@
+widgetFactory = $widgetFactory;
+ }
+
+ public function __invoke(MediaFullLoadEvent $event)
+ {
+ $media = $event->getMedia();
+
+ $media->widgets = $this->widgetFactory->getByMediaId($media->mediaId);
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsCommandListener.php b/lib/Listener/OnParsePermissions/PermissionsCommandListener.php
new file mode 100644
index 0000000..4dec5e3
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsCommandListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\CommandFactory;
+
+class PermissionsCommandListener
+{
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+
+ public function __construct(CommandFactory $commandFactory)
+ {
+ $this->commandFactory = $commandFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->commandFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsDataSetListener.php b/lib/Listener/OnParsePermissions/PermissionsDataSetListener.php
new file mode 100644
index 0000000..74918c3
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsDataSetListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\DataSetFactory;
+
+class PermissionsDataSetListener
+{
+ /**
+ * @var DataSetFactory
+ */
+ private $dataSetFactory;
+
+ public function __construct(DataSetFactory $dataSetFactory)
+ {
+ $this->dataSetFactory = $dataSetFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->dataSetFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsDayPartListener.php b/lib/Listener/OnParsePermissions/PermissionsDayPartListener.php
new file mode 100644
index 0000000..f81af7f
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsDayPartListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\DayPartFactory;
+
+class PermissionsDayPartListener
+{
+ /**
+ * @var DayPartFactory
+ */
+ private $dayPartFactory;
+
+ public function __construct(DayPartFactory $dayPartFactory)
+ {
+ $this->dayPartFactory = $dayPartFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->dayPartFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsFolderListener.php b/lib/Listener/OnParsePermissions/PermissionsFolderListener.php
new file mode 100644
index 0000000..90b55d3
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsFolderListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\FolderFactory;
+
+class PermissionsFolderListener
+{
+ /**
+ * @var FolderFactory
+ */
+ private $folderFactory;
+
+ public function __construct(FolderFactory $folderFactory)
+ {
+ $this->folderFactory = $folderFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->folderFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsMenuBoardListener.php b/lib/Listener/OnParsePermissions/PermissionsMenuBoardListener.php
new file mode 100644
index 0000000..48b5419
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsMenuBoardListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\MenuBoardFactory;
+
+class PermissionsMenuBoardListener
+{
+ /**
+ * @var MenuBoardFactory
+ */
+ private $menuBoardFactory;
+
+ public function __construct(MenuBoardFactory $menuBoardFactory)
+ {
+ $this->menuBoardFactory = $menuBoardFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->menuBoardFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsNotificationListener.php b/lib/Listener/OnParsePermissions/PermissionsNotificationListener.php
new file mode 100644
index 0000000..ace98e2
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsNotificationListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\NotificationFactory;
+
+class PermissionsNotificationListener
+{
+ /**
+ * @var NotificationFactory
+ */
+ private $notificationFactory;
+
+ public function __construct(NotificationFactory $notificationFactory)
+ {
+ $this->notificationFactory = $notificationFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->notificationFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsRegionListener.php b/lib/Listener/OnParsePermissions/PermissionsRegionListener.php
new file mode 100644
index 0000000..aacc265
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsRegionListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\RegionFactory;
+
+class PermissionsRegionListener
+{
+ /**
+ * @var RegionFactory
+ */
+ private $regionFactory;
+
+ public function __construct(RegionFactory $regionFactory)
+ {
+ $this->regionFactory = $regionFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->regionFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnParsePermissions/PermissionsWidgetListener.php b/lib/Listener/OnParsePermissions/PermissionsWidgetListener.php
new file mode 100644
index 0000000..e3c715a
--- /dev/null
+++ b/lib/Listener/OnParsePermissions/PermissionsWidgetListener.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Listener\OnParsePermissions;
+
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Factory\WidgetFactory;
+
+class PermissionsWidgetListener
+{
+ /**
+ * @var WidgetFactory
+ */
+ private $widgetFactory;
+
+ public function __construct(WidgetFactory $widgetFactory)
+ {
+ $this->widgetFactory = $widgetFactory;
+ }
+
+ public function __invoke(ParsePermissionEntityEvent $event)
+ {
+ $event->setObject($this->widgetFactory->getById($event->getObjectId()));
+ }
+}
diff --git a/lib/Listener/OnPlaylistMaxNumberChange.php b/lib/Listener/OnPlaylistMaxNumberChange.php
new file mode 100644
index 0000000..8aebb29
--- /dev/null
+++ b/lib/Listener/OnPlaylistMaxNumberChange.php
@@ -0,0 +1,46 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Xibo\Event\PlaylistMaxNumberChangedEvent;
+use Xibo\Storage\StorageServiceInterface;
+
+class OnPlaylistMaxNumberChange
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+
+ public function __construct(StorageServiceInterface $storageService)
+ {
+ $this->storageService = $storageService;
+ }
+
+ public function __invoke(PlaylistMaxNumberChangedEvent $event)
+ {
+ $this->storageService->update('UPDATE `playlist` SET maxNumberOfItems = :newLimit WHERE isDynamic = 1 AND maxNumberOfItems > :newLimit', [
+ 'newLimit' => $event->getNewLimit()
+ ]);
+ }
+}
diff --git a/lib/Listener/OnSystemUserChange.php b/lib/Listener/OnSystemUserChange.php
new file mode 100644
index 0000000..639c12e
--- /dev/null
+++ b/lib/Listener/OnSystemUserChange.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Xibo\Event\SystemUserChangedEvent;
+use Xibo\Storage\StorageServiceInterface;
+
+class OnSystemUserChange
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+
+ public function __construct(StorageServiceInterface $storageService)
+ {
+ $this->storageService = $storageService;
+ }
+
+ public function __invoke(SystemUserChangedEvent $event)
+ {
+ // Reassign Module files
+ $this->storageService->update('UPDATE `media` SET userId = :userId WHERE userId = :oldUserId AND type = \'module\'', [
+ 'userId' => $event->getNewSystemUser()->userId,
+ 'oldUserId' => $event->getOldSystemUser()->userId
+ ]);
+
+ // Reassign Display specific Display Groups
+ $this->storageService->update('UPDATE `displaygroup` SET userId = :userId WHERE userId = :oldUserId AND isDisplaySpecific = 1', [
+ 'userId' => $event->getNewSystemUser()->userId,
+ 'oldUserId' => $event->getOldSystemUser()->userId
+ ]);
+
+ // Reassign system dayparts
+ $this->storageService->update('UPDATE `daypart` SET userId = :userId WHERE userId = :oldUserId AND (isCustom = 1 OR isAlways = 1)', [
+ 'userId' => $event->getNewSystemUser()->userId,
+ 'oldUserId' => $event->getOldSystemUser()->userId
+ ]);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/ActionListener.php b/lib/Listener/OnUserDelete/ActionListener.php
new file mode 100644
index 0000000..255eb6f
--- /dev/null
+++ b/lib/Listener/OnUserDelete/ActionListener.php
@@ -0,0 +1,94 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\ActionFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class ActionListener implements OnUserDeleteInterface
+{
+ /**
+ * @var ActionFactory
+ */
+ private $actionFactory;
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ use ListenerLoggerTrait;
+
+ public function __construct(StorageServiceInterface $store, ActionFactory $actionFactory)
+ {
+ $this->store = $store;
+ $this->actionFactory = $actionFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ /* @inheritDoc */
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ // Delete any Actions
+ foreach ($this->actionFactory->getByOwnerId($user->userId) as $action) {
+ $action->delete();
+ }
+ }
+
+ /* @inheritDoc */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ // Reassign Actions
+ $this->store->update('UPDATE `action` SET ownerId = :userId WHERE ownerId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ /* @inheritDoc */
+ public function countChildren(User $user)
+ {
+ $actions = $this->actionFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Actions on User ID %d, there are %d', $user->userId, count($actions)));
+
+ return count($actions);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/CommandListener.php b/lib/Listener/OnUserDelete/CommandListener.php
new file mode 100644
index 0000000..ddf7f43
--- /dev/null
+++ b/lib/Listener/OnUserDelete/CommandListener.php
@@ -0,0 +1,91 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\CommandDeleteEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\CommandFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class CommandListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var CommandFactory
+ */
+ private $commandFactory;
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ public function __construct(StorageServiceInterface $store, CommandFactory $commandFactory)
+ {
+ $this->store = $store;
+ $this->commandFactory = $commandFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->commandFactory->getByOwnerId($user->userId) as $command) {
+ $dispatcher->dispatch(CommandDeleteEvent::$NAME, new CommandDeleteEvent($command));
+ $command->delete();
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ $this->store->update('UPDATE `command` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ public function countChildren(User $user)
+ {
+ $commands = $this->commandFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Command on User ID %d, there are %d', $user->userId, count($commands)));
+
+ return count($commands);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/DataSetListener.php b/lib/Listener/OnUserDelete/DataSetListener.php
new file mode 100644
index 0000000..ce89f56
--- /dev/null
+++ b/lib/Listener/OnUserDelete/DataSetListener.php
@@ -0,0 +1,103 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class DataSetListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+ /**
+ * @var DataSetFactory
+ */
+ private $dataSetFactory;
+
+ public function __construct(StorageServiceInterface $storageService, DataSetFactory $dataSetFactory)
+ {
+ $this->storageService = $storageService;
+ $this->dataSetFactory = $dataSetFactory;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->dataSetFactory->getByOwnerId($user->userId) as $dataSet) {
+ $dataSet->delete();
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ // Reassign datasets
+ $this->storageService->update('UPDATE `dataset` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function countChildren(User $user)
+ {
+ $dataSets = $this->dataSetFactory->getByOwnerId($user->userId);
+ $count = count($dataSets);
+ $this->getLogger()->debug(sprintf('Counted Children DataSet on User ID %d, there are %d', $user->userId, $count));
+
+ return $count;
+ }
+}
diff --git a/lib/Listener/OnUserDelete/DayPartListener.php b/lib/Listener/OnUserDelete/DayPartListener.php
new file mode 100644
index 0000000..eca24d4
--- /dev/null
+++ b/lib/Listener/OnUserDelete/DayPartListener.php
@@ -0,0 +1,124 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Service\DisplayNotifyServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+
+class DayPartListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+ /**
+ * @var DayPartFactory
+ */
+ private $dayPartFactory;
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /** @var DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ public function __construct(
+ StorageServiceInterface $storageService,
+ DayPartFactory $dayPartFactory,
+ ScheduleFactory $scheduleFactory,
+ DisplayNotifyServiceInterface $displayNotifyService
+ ) {
+ $this->storageService = $storageService;
+ $this->dayPartFactory = $dayPartFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->displayNotifyService = $displayNotifyService;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ // system dayParts cannot be deleted, if this user owns them reassign to systemUser
+ foreach ($this->dayPartFactory->getByOwnerId($user->userId) as $dayPart) {
+ if ($dayPart->isSystemDayPart()) {
+ $dayPart->setOwner($systemUser->userId);
+ $dayPart->save(['recalculateHash' => false]);
+ } else {
+ $dayPart->setScheduleFactory($this->scheduleFactory, $this->displayNotifyService)->delete();
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ // Reassign Dayparts
+ foreach ($this->dayPartFactory->getByOwnerId($user->userId) as $dayPart) {
+ ($dayPart->isSystemDayPart()) ? $dayPart->setOwner($systemUser->userId) : $dayPart->setOwner($newUser->getOwnerId());
+ $dayPart->save(['recalculateHash' => false]);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function countChildren(User $user)
+ {
+ $dayParts = $this->dayPartFactory->getByOwnerId($user->userId);
+ $count = count($dayParts);
+ $this->getLogger()->debug(sprintf('Counted Children DayParts on User ID %d, there are %d', $user->userId, $count));
+
+ return $count;
+ }
+}
diff --git a/lib/Listener/OnUserDelete/DisplayProfileListener.php b/lib/Listener/OnUserDelete/DisplayProfileListener.php
new file mode 100644
index 0000000..c95b972
--- /dev/null
+++ b/lib/Listener/OnUserDelete/DisplayProfileListener.php
@@ -0,0 +1,95 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\DisplayProfileFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class DisplayProfileListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+ /**
+ * @var DisplayProfileFactory
+ */
+ private $displayProfileFactory;
+
+ public function __construct(StorageServiceInterface $store, DisplayProfileFactory $displayProfileFactory)
+ {
+ $this->store = $store;
+ $this->displayProfileFactory = $displayProfileFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ // Delete any Display Profiles, reassign any default profiles to systemUser
+ foreach ($this->displayProfileFactory->getByOwnerId($user->userId) as $displayProfile) {
+ if ($displayProfile->isDefault === 1) {
+ $displayProfile->setOwner($systemUser->userId);
+ $displayProfile->save();
+ } else {
+ $displayProfile->delete();
+ }
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ $this->store->update('UPDATE `displayprofile` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ public function countChildren(User $user)
+ {
+ $profiles = $this->displayProfileFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Display Profiles on User ID %d, there are %d', $user->userId, count($profiles)));
+
+ return count($profiles);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/MenuBoardListener.php b/lib/Listener/OnUserDelete/MenuBoardListener.php
new file mode 100644
index 0000000..7aa0083
--- /dev/null
+++ b/lib/Listener/OnUserDelete/MenuBoardListener.php
@@ -0,0 +1,103 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\MenuBoardFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class MenuBoardListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+ /**
+ * @var MenuBoardFactory
+ */
+ private $menuBoardFactory;
+
+ public function __construct(StorageServiceInterface $storageService, MenuBoardFactory $menuBoardFactory)
+ {
+ $this->storageService = $storageService;
+ $this->menuBoardFactory = $menuBoardFactory;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->menuBoardFactory->getByOwnerId($user->userId) as $menuBoard) {
+ $menuBoard->delete();
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ // Reassign Menu Boards
+ $this->storageService->update('UPDATE `menu_board` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function countChildren(User $user)
+ {
+ $menuBoards = $this->menuBoardFactory->getByOwnerId($user->userId);
+ $count = count($menuBoards);
+ $this->getLogger()->debug(sprintf('Counted Children Menu Board on User ID %d, there are %d', $user->userId, $count));
+
+ return $count;
+ }
+}
diff --git a/lib/Listener/OnUserDelete/NotificationListener.php b/lib/Listener/OnUserDelete/NotificationListener.php
new file mode 100644
index 0000000..38459fa
--- /dev/null
+++ b/lib/Listener/OnUserDelete/NotificationListener.php
@@ -0,0 +1,85 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+
+class NotificationListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var NotificationFactory
+ */
+ private $notificationFactory;
+
+ public function __construct(NotificationFactory $notificationFactory)
+ {
+ $this->notificationFactory = $notificationFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ // Delete any Notifications
+ foreach ($this->notificationFactory->getByOwnerId($user->userId) as $notification) {
+ $notification->delete();
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ foreach ($this->notificationFactory->getByOwnerId($user->userId) as $notification) {
+ $notification->load();
+ $notification->userId = $newUser->userId;
+ $notification->save();
+ }
+ }
+
+ public function countChildren(User $user)
+ {
+ $notifications = $this->notificationFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Notifications on User ID %d, there are %d', $user->userId, count($notifications)));
+
+ return count($notifications);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/OnUserDelete.php b/lib/Listener/OnUserDelete/OnUserDelete.php
new file mode 100644
index 0000000..3522337
--- /dev/null
+++ b/lib/Listener/OnUserDelete/OnUserDelete.php
@@ -0,0 +1,63 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class OnUserDelete
+{
+ use ListenerLoggerTrait;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ public function __construct(StorageServiceInterface $store)
+ {
+ $this->store = $store;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function __invoke(UserDeleteEvent $event)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+
+ if ($function === 'delete' || $function === 'reassignAll') {
+ $this->deleteChildren($user);
+ }
+ }
+
+ // when we delete a User with or without reassign the session and oauth clients should always be removed
+ // other objects that can be owned by the user are deleted in their respective listeners.
+ private function deleteChildren($user)
+ {
+ // Delete oAuth clients
+ $this->store->update('DELETE FROM `oauth_clients` WHERE userId = :userId', ['userId' => $user->userId]);
+
+ $this->store->update('DELETE FROM `session` WHERE userId = :userId', ['userId' => $user->userId]);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/OnUserDeleteInterface.php b/lib/Listener/OnUserDelete/OnUserDeleteInterface.php
new file mode 100644
index 0000000..521fa2f
--- /dev/null
+++ b/lib/Listener/OnUserDelete/OnUserDeleteInterface.php
@@ -0,0 +1,63 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+
+interface OnUserDeleteInterface
+{
+ /**
+ * Listen to the UserDeleteEvent
+ *
+ * @param UserDeleteEvent $event
+ * @return mixed
+ */
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher);
+
+ /**
+ * Delete Objects owned by the User we want to delete
+ *
+ * @param User $user
+ * @return mixed
+ */
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser);
+
+ /**
+ * Reassign objects to a new User
+ *
+ * @param User $user
+ * @param User $newUser
+ * @return mixed
+ */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser);
+
+ /**
+ * Count Children, return count of objects owned by the User we want to delete
+ *
+ * @param User $user
+ * @return mixed
+ */
+ public function countChildren(User $user);
+}
diff --git a/lib/Listener/OnUserDelete/RegionListener.php b/lib/Listener/OnUserDelete/RegionListener.php
new file mode 100644
index 0000000..91bcbf3
--- /dev/null
+++ b/lib/Listener/OnUserDelete/RegionListener.php
@@ -0,0 +1,94 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\RegionFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+
+class RegionListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var RegionFactory
+ */
+ private $regionFactory;
+
+ public function __construct(RegionFactory $regionFactory)
+ {
+ $this->regionFactory = $regionFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->regionFactory->getbyOwnerId($user->userId) as $region) {
+ $region->delete();
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ $regions = $this->regionFactory->getbyOwnerId($user->userId);
+
+ $this->getLogger()->debug(sprintf('Counted Children Regions on User ID %d, there are %d', $user->userId, count($regions)));
+
+ foreach ($regions as $region) {
+ $region->setOwner($newUser->userId, true);
+ $region->save([
+ 'validate' => false,
+ 'audit' => false,
+ 'notify' => false
+ ]);
+ }
+
+ $this->getLogger()->debug(sprintf('Finished reassign Regions, there are %d children', $this->countChildren($user)));
+ }
+
+ public function countChildren(User $user)
+ {
+ $regions = $this->regionFactory->getbyOwnerId($user->userId);
+
+ $this->getLogger()->debug(sprintf('Counted Children Regions on User ID %d, there are %d', $user->userId, count($regions)));
+
+ return count($regions);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/ReportScheduleListener.php b/lib/Listener/OnUserDelete/ReportScheduleListener.php
new file mode 100644
index 0000000..86a99dc
--- /dev/null
+++ b/lib/Listener/OnUserDelete/ReportScheduleListener.php
@@ -0,0 +1,89 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class ReportScheduleListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ public function __construct(StorageServiceInterface $store, ReportScheduleFactory $reportScheduleFactory)
+ {
+ $this->store = $store;
+ $this->reportScheduleFactory = $reportScheduleFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->reportScheduleFactory->getByOwnerId($user->userId) as $reportSchedule) {
+ $reportSchedule->delete();
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ $this->store->update('UPDATE `reportschedule` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ public function countChildren(User $user)
+ {
+ $reportSchedules = $this->reportScheduleFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Report Schedules on User ID %d, there are %d', $user->userId, count($reportSchedules)));
+
+ return count($reportSchedules);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/ResolutionListener.php b/lib/Listener/OnUserDelete/ResolutionListener.php
new file mode 100644
index 0000000..384d385
--- /dev/null
+++ b/lib/Listener/OnUserDelete/ResolutionListener.php
@@ -0,0 +1,94 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\ResolutionFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class ResolutionListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+ /**
+ * @var ResolutionFactory
+ */
+ private $resolutionFactory;
+
+ public function __construct(StorageServiceInterface $store, ResolutionFactory $resolutionFactory)
+ {
+ $this->store = $store;
+ $this->resolutionFactory = $resolutionFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ /* @inheritDoc */
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ // Delete any Resolutions
+ foreach ($this->resolutionFactory->getByOwnerId($user->userId) as $resolution) {
+ $resolution->delete();
+ }
+ }
+
+ /* @inheritDoc */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ // Reassign Resolutions
+ $this->store->update('UPDATE `resolution` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ /* @inheritDoc */
+ public function countChildren(User $user)
+ {
+ $resolutions = $this->resolutionFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Resolution on User ID %d, there are %d', $user->userId, count($resolutions)));
+
+ return count($resolutions);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/SavedReportListener.php b/lib/Listener/OnUserDelete/SavedReportListener.php
new file mode 100644
index 0000000..7a2b827
--- /dev/null
+++ b/lib/Listener/OnUserDelete/SavedReportListener.php
@@ -0,0 +1,89 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class SavedReportListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ public function __construct(StorageServiceInterface $store, SavedReportFactory $savedReportFactory)
+ {
+ $this->store = $store;
+ $this->savedReportFactory = $savedReportFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->savedReportFactory->getByOwnerId($user->userId) as $savedReport) {
+ $savedReport->delete();
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ $this->store->update('UPDATE `saved_report` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ public function countChildren(User $user)
+ {
+ $savedReports = $this->savedReportFactory->getByOwnerId($user->userId);
+ $this->getLogger()->debug(sprintf('Counted Children Saved Report on User ID %d, there are %d', $user->userId, count($savedReports)));
+
+ return count($savedReports);
+ }
+}
diff --git a/lib/Listener/OnUserDelete/ScheduleListener.php b/lib/Listener/OnUserDelete/ScheduleListener.php
new file mode 100644
index 0000000..eade48f
--- /dev/null
+++ b/lib/Listener/OnUserDelete/ScheduleListener.php
@@ -0,0 +1,102 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\Schedule;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Storage\StorageServiceInterface;
+
+class ScheduleListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /** @var StorageServiceInterface */
+ private $storageService;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ public function __construct(StorageServiceInterface $storageService, ScheduleFactory $scheduleFactory)
+ {
+ $this->storageService = $storageService;
+ $this->scheduleFactory = $scheduleFactory;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+ /**
+ * @inheritDoc
+ */
+ public function deleteChildren($user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ // Delete any scheduled events
+ foreach ($this->scheduleFactory->getByOwnerId($user->userId) as $event) {
+ /* @var Schedule $event */
+ $event->delete();
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ // Reassign events
+ $this->storageService->update('UPDATE `schedule` SET userId = :userId WHERE userId = :oldUserId', [
+ 'userId' => $newUser->userId,
+ 'oldUserId' => $user->userId
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function countChildren(User $user)
+ {
+ $events = $this->scheduleFactory->getByOwnerId($user->userId);
+ $count = count($events);
+ $this->getLogger()->debug(sprintf('Counted Children Event on User ID %d, there are %d', $user->userId, $count));
+
+ return $count;
+ }
+}
diff --git a/lib/Listener/OnUserDelete/WidgetListener.php b/lib/Listener/OnUserDelete/WidgetListener.php
new file mode 100644
index 0000000..c72e0ee
--- /dev/null
+++ b/lib/Listener/OnUserDelete/WidgetListener.php
@@ -0,0 +1,93 @@
+.
+ */
+
+namespace Xibo\Listener\OnUserDelete;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+
+class WidgetListener implements OnUserDeleteInterface
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var WidgetFactory
+ */
+ private $widgetFactory;
+
+ public function __construct(WidgetFactory $widgetFactory)
+ {
+ $this->widgetFactory = $widgetFactory;
+ }
+
+ public function __invoke(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+ $systemUser = $event->getSystemUser();
+
+ if ($function === 'delete') {
+ $this->deleteChildren($user, $dispatcher, $systemUser);
+ } elseif ($function === 'reassignAll') {
+ $this->reassignAllTo($user, $newUser, $systemUser);
+ } elseif ($function === 'countChildren') {
+ $event->setReturnValue($event->getReturnValue() + $this->countChildren($user));
+ }
+ }
+
+ public function deleteChildren(User $user, EventDispatcherInterface $dispatcher, User $systemUser)
+ {
+ foreach ($this->widgetFactory->getByOwnerId($user->userId) as $widget) {
+ $widget->delete();
+ }
+ }
+
+ public function reassignAllTo(User $user, User $newUser, User $systemUser)
+ {
+ foreach ($this->widgetFactory->getByOwnerId($user->userId) as $widget) {
+ $widget->setOwner($newUser->userId);
+ $widget->save([
+ 'saveWidgetOptions' => false,
+ 'saveWidgetAudio' => false,
+ 'saveWidgetMedia' => false,
+ 'notify' => false,
+ 'notifyPlaylists' => false,
+ 'notifyDisplays' => false,
+ 'audit' => true,
+ 'alwaysUpdate' => true
+ ]);
+ }
+ }
+
+ public function countChildren(User $user)
+ {
+ $widgets = $this->widgetFactory->getByOwnerId($user->userId);
+
+ $this->getLogger()->debug(sprintf('Counted Children Widgets on User ID %d, there are %d', $user->userId, count($widgets)));
+
+ return count($widgets);
+ }
+}
diff --git a/lib/Listener/PlaylistListener.php b/lib/Listener/PlaylistListener.php
new file mode 100644
index 0000000..37271af
--- /dev/null
+++ b/lib/Listener/PlaylistListener.php
@@ -0,0 +1,159 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\TagDeleteEvent;
+use Xibo\Event\TriggerTaskEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Playlist events
+ */
+class PlaylistListener
+{
+ use ListenerLoggerTrait;
+
+ /**
+ * @var PlaylistFactory
+ */
+ private $playlistFactory;
+ /**
+ * @var StorageServiceInterface
+ */
+ private $storageService;
+
+ /**
+ * @param PlaylistFactory $playlistFactory
+ * @param StorageServiceInterface $storageService
+ */
+ public function __construct(
+ PlaylistFactory $playlistFactory,
+ StorageServiceInterface $storageService
+ ) {
+ $this->playlistFactory = $playlistFactory;
+ $this->storageService = $storageService;
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher) : PlaylistListener
+ {
+ $dispatcher->addListener(UserDeleteEvent::$NAME, [$this, 'onUserDelete']);
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'playlist', [$this, 'onParsePermissions']);
+ $dispatcher->addListener(FolderMovingEvent::$NAME, [$this, 'onFolderMoving']);
+ $dispatcher->addListener(TagDeleteEvent::$NAME, [$this, 'onTagDelete']);
+
+ return $this;
+ }
+
+ /**
+ * @param UserDeleteEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onUserDelete(UserDeleteEvent $event)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+
+ if ($function === 'delete') {
+ // Delete Playlists owned by this user
+ foreach ($this->playlistFactory->getByOwnerId($user->userId) as $playlist) {
+ $playlist->delete();
+ }
+ } else if ($function === 'reassignAll') {
+ // Reassign playlists and widgets
+ foreach ($this->playlistFactory->getByOwnerId($user->userId) as $playlist) {
+ $playlist->setOwner($newUser->userId);
+ $playlist->save();
+ }
+ } else if ($function === 'countChildren') {
+ $playlists = $this->playlistFactory->getByOwnerId($user->userId);
+
+ $count = count($playlists);
+ $this->getLogger()->debug(
+ sprintf(
+ 'Counted Children Playlists on User ID %d, there are %d',
+ $user->userId,
+ $count
+ )
+ );
+
+ $event->setReturnValue($event->getReturnValue() + $count);
+ }
+ }
+
+ /**
+ * @param ParsePermissionEntityEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onParsePermissions(ParsePermissionEntityEvent $event)
+ {
+ $this->getLogger()->debug('onParsePermissions');
+ $event->setObject($this->playlistFactory->getById($event->getObjectId()));
+ }
+
+ /**
+ * @param FolderMovingEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onFolderMoving(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->playlistFactory->getbyFolderId($folder->getId()) as $playlist) {
+ $playlist->folderId = $newFolder->getId();
+ $playlist->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $playlist->updateFolders('playlist');
+ }
+ }
+
+ /**
+ * @param TagDeleteEvent $event
+ * @param $eventName
+ * @param EventDispatcherInterface $dispatcher
+ * @return void
+ */
+ public function onTagDelete(TagDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $this->storageService->update(
+ 'DELETE FROM `lktagplaylist` WHERE `lktagplaylist`.tagId = :tagId',
+ ['tagId' => $event->getTagId()]
+ );
+
+ $dispatcher->dispatch(new TriggerTaskEvent('\Xibo\XTR\DynamicPlaylistSyncTask'), TriggerTaskEvent::$NAME);
+ }
+}
diff --git a/lib/Listener/SyncGroupListener.php b/lib/Listener/SyncGroupListener.php
new file mode 100644
index 0000000..a4d2624
--- /dev/null
+++ b/lib/Listener/SyncGroupListener.php
@@ -0,0 +1,133 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Factory\SyncGroupFactory;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * SyncGroup events
+ */
+class SyncGroupListener
+{
+ use ListenerLoggerTrait;
+ private SyncGroupFactory $syncGroupFactory;
+ private StorageServiceInterface $storageService;
+
+ /**
+ * @param SyncGroupFactory $syncGroupFactory
+ * @param StorageServiceInterface $storageService
+ */
+ public function __construct(
+ SyncGroupFactory $syncGroupFactory,
+ StorageServiceInterface $storageService
+ ) {
+ $this->syncGroupFactory = $syncGroupFactory;
+ $this->storageService = $storageService;
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): SyncGroupListener
+ {
+ $dispatcher->addListener(UserDeleteEvent::$NAME, [$this, 'onUserDelete']);
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'syncGroup', [$this, 'onParsePermissions']);
+ $dispatcher->addListener(FolderMovingEvent::$NAME, [$this, 'onFolderMoving']);
+
+ return $this;
+ }
+
+ /**
+ * @param UserDeleteEvent $event
+ * @param $eventName
+ * @param EventDispatcherInterface $dispatcher
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onUserDelete(UserDeleteEvent $event, $eventName, EventDispatcherInterface $dispatcher)
+ {
+ $user = $event->getUser();
+ $function = $event->getFunction();
+ $newUser = $event->getNewUser();
+
+ if ($function === 'delete') {
+ // we do not want to delete Display specific Display Groups, reassign to systemUser instead.
+ foreach ($this->syncGroupFactory->getByOwnerId($user->userId) as $syncGroup) {
+ $syncGroup->delete();
+ }
+ } else if ($function === 'reassignAll') {
+ foreach ($this->syncGroupFactory->getByOwnerId($user->userId) as $syncGroup) {
+ $syncGroup->setOwner($newUser->getOwnerId());
+ $syncGroup->save();
+ }
+ } else if ($function === 'countChildren') {
+ $syncGroups = $this->syncGroupFactory->getByOwnerId($user->userId);
+
+ $count = count($syncGroups);
+ $this->getLogger()->debug(
+ sprintf(
+ 'Counted Children Sync Groups on User ID %d, there are %d',
+ $user->userId,
+ $count
+ )
+ );
+
+ $event->setReturnValue($event->getReturnValue() + $count);
+ }
+ }
+
+ /**
+ * @param ParsePermissionEntityEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onParsePermissions(ParsePermissionEntityEvent $event)
+ {
+ $this->getLogger()->debug('onParsePermissions');
+ $event->setObject($this->syncGroupFactory->getById($event->getObjectId()));
+ }
+
+ /**
+ * @param FolderMovingEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onFolderMoving(FolderMovingEvent $event)
+ {
+ $folder = $event->getFolder();
+ $newFolder = $event->getNewFolder();
+
+ foreach ($this->syncGroupFactory->getByFolderId($folder->getId()) as $syncGroup) {
+ $syncGroup->folderId = $newFolder->getId();
+ $syncGroup->permissionsFolderId = $newFolder->getPermissionFolderIdOrThis();
+ $syncGroup->updateFolders('syncgroup');
+ }
+ }
+}
diff --git a/lib/Listener/TaskListener.php b/lib/Listener/TaskListener.php
new file mode 100644
index 0000000..5008531
--- /dev/null
+++ b/lib/Listener/TaskListener.php
@@ -0,0 +1,74 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Stash\Interfaces\PoolInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Event\TriggerTaskEvent;
+use Xibo\Factory\TaskFactory;
+use Xibo\Service\ConfigServiceInterface;
+
+/**
+ * A listener for events related to tasks
+ */
+class TaskListener
+{
+ use ListenerLoggerTrait;
+
+ public function __construct(
+ private readonly TaskFactory $taskFactory,
+ private readonly ConfigServiceInterface $configService,
+ private readonly PoolInterface $pool
+ ) {
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher) : TaskListener
+ {
+ $dispatcher->addListener(TriggerTaskEvent::$NAME, [$this, 'onTriggerTask']);
+
+ return $this;
+ }
+
+ /**
+ * @param TriggerTaskEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onTriggerTask(TriggerTaskEvent $event): void
+ {
+ if (!empty($event->getKey())) {
+ // Drop this setting from the cache
+ $this->pool->deleteItem($event->getKey());
+ }
+
+ // Mark the task to run now
+ $task = $this->taskFactory->getByClass($event->getClassName());
+ $task->runNow = 1;
+ $task->save(['validate' => false]);
+ }
+}
diff --git a/lib/Listener/WidgetListener.php b/lib/Listener/WidgetListener.php
new file mode 100644
index 0000000..00e4eaa
--- /dev/null
+++ b/lib/Listener/WidgetListener.php
@@ -0,0 +1,768 @@
+.
+ */
+
+namespace Xibo\Listener;
+
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\Widget;
+use Xibo\Event\RegionAddedEvent;
+use Xibo\Event\SubPlaylistDurationEvent;
+use Xibo\Event\SubPlaylistItemsEvent;
+use Xibo\Event\SubPlaylistValidityEvent;
+use Xibo\Event\SubPlaylistWidgetsEvent;
+use Xibo\Event\WidgetDeleteEvent;
+use Xibo\Event\WidgetEditEvent;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\SubPlaylistItem;
+
+/**
+ * Widget Listener.
+ *
+ * Sub Playlist Events
+ * -------------------
+ * Sub Playlists are a special case in that they resolve to multiple widgets
+ * This is handled by the standard widget edit/delete events and a special event to calculate the duration
+ * These events are processed by a SubPlaylistListener included with core.
+ *
+ * Region Events
+ * -------------
+ * We listen for a region being added and if its a canvas we add a "global" widget to it.
+ */
+class WidgetListener
+{
+ use ListenerLoggerTrait;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var \Xibo\Factory\ModuleFactory */
+ private $moduleFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @var StorageServiceInterface */
+ private $storageService;
+
+ /** @var \Xibo\Service\ConfigServiceInterface */
+ private $configService;
+
+ /**
+ * @param PlaylistFactory $playlistFactory
+ * @param \Xibo\Factory\ModuleFactory $moduleFactory
+ * @param StorageServiceInterface $storageService
+ * @param \Xibo\Service\ConfigServiceInterface $configService
+ */
+ public function __construct(
+ PlaylistFactory $playlistFactory,
+ ModuleFactory $moduleFactory,
+ WidgetFactory $widgetFactory,
+ StorageServiceInterface $storageService,
+ ConfigServiceInterface $configService
+ ) {
+ $this->playlistFactory = $playlistFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->storageService = $storageService;
+ $this->configService = $configService;
+ }
+
+ /**
+ * @param EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function registerWithDispatcher(EventDispatcherInterface $dispatcher) : WidgetListener
+ {
+ $dispatcher->addListener(WidgetEditEvent::$NAME, [$this, 'onWidgetEdit']);
+ $dispatcher->addListener(WidgetDeleteEvent::$NAME, [$this, 'onWidgetDelete']);
+ $dispatcher->addListener(SubPlaylistDurationEvent::$NAME, [$this, 'onDuration']);
+ $dispatcher->addListener(SubPlaylistWidgetsEvent::$NAME, [$this, 'onWidgets']);
+ $dispatcher->addListener(SubPlaylistItemsEvent::$NAME, [$this, 'onSubPlaylistItems']);
+ $dispatcher->addListener(SubPlaylistValidityEvent::$NAME, [$this, 'onSubPlaylistValid']);
+ $dispatcher->addListener(RegionAddedEvent::$NAME, [$this, 'onRegionAdded']);
+ return $this;
+ }
+
+ /**
+ * Widget Edit
+ * @param \Xibo\Event\WidgetEditEvent $event
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function onWidgetEdit(WidgetEditEvent $event)
+ {
+ $widget = $event->getWidget();
+ if ($widget->type !== 'subplaylist') {
+ return;
+ }
+
+ $this->getLogger()->debug('onWidgetEdit: processing subplaylist for widgetId ' . $widget->widgetId);
+
+ // Get the IDs we had before the edit and work out the difference between then and now.
+ $existingSubPlaylistIds = [];
+ foreach ($this->getAssignedPlaylists($widget, true) as $assignedPlaylist) {
+ if (!in_array($assignedPlaylist->playlistId, $existingSubPlaylistIds)) {
+ $existingSubPlaylistIds[] = $assignedPlaylist->playlistId;
+ }
+ }
+
+ $this->getLogger()->debug('onWidgetEdit: there are ' . count($existingSubPlaylistIds) . ' existing playlists');
+
+ // Make up a companion setting which maps the playlistIds to the options
+ $subPlaylists = $this->getAssignedPlaylists($widget);
+ $subPlaylistIds = [];
+
+ foreach ($subPlaylists as $playlist) {
+ if ($playlist->spots < 0) {
+ throw new InvalidArgumentException(
+ __('Number of spots must be empty, 0 or a positive number'),
+ 'subPlaylistIdSpots'
+ );
+ }
+
+ if ($playlist->spotLength < 0) {
+ throw new InvalidArgumentException(
+ __('Spot length must be empty, 0 or a positive number'),
+ 'subPlaylistIdSpotLength'
+ );
+ }
+
+ if (!in_array($playlist->playlistId, $subPlaylistIds)) {
+ $subPlaylistIds[] = $playlist->playlistId;
+ }
+ }
+
+ // Validation
+ if (count($subPlaylists) < 1) {
+ throw new InvalidArgumentException(__('Please select at least 1 Playlist to embed'), 'subPlaylistId');
+ }
+
+ // Work out whether we've added/removed
+ $addedEntries = array_diff($subPlaylistIds, $existingSubPlaylistIds);
+ $removedEntries = array_diff($existingSubPlaylistIds, $subPlaylistIds);
+
+ $this->logger->debug('onWidgetEdit Added ' . var_export($addedEntries, true));
+ $this->logger->debug('onWidgetEdit Removed ' . var_export($removedEntries, true));
+
+ // Remove items from closure table if necessary
+ foreach ($removedEntries as $entry) {
+ $this->logger->debug('Removing old link - existing link child is ' . $entry);
+
+ $this->storageService->update('
+ DELETE link
+ FROM `lkplaylistplaylist` p, `lkplaylistplaylist` link, `lkplaylistplaylist` c
+ WHERE p.parentId = link.parentId AND c.childId = link.childId
+ AND p.childId = :parentId AND c.parentId = :childId
+ ', [
+ 'parentId' => $widget->playlistId,
+ 'childId' => $entry
+ ]);
+ }
+
+ foreach ($addedEntries as $addedEntry) {
+ $this->logger->debug('Manage closure table for parent ' . $widget->playlistId
+ . ' and child ' . $addedEntry);
+
+ if ($this->storageService->exists('
+ SELECT parentId, childId, depth
+ FROM lkplaylistplaylist
+ WHERE childId = :childId AND parentId = :parentId
+ ', [
+ 'parentId' => $widget->playlistId,
+ 'childId' => $addedEntry
+ ])) {
+ throw new InvalidArgumentException(__('Cannot add the same SubPlaylist twice.'), 'playlistId');
+ }
+
+ $this->storageService->insert('
+ INSERT INTO `lkplaylistplaylist` (parentId, childId, depth)
+ SELECT p.parentId, c.childId, p.depth + c.depth + 1
+ FROM lkplaylistplaylist p, lkplaylistplaylist c
+ WHERE p.childId = :parentId AND c.parentId = :childId
+ ', [
+ 'parentId' => $widget->playlistId,
+ 'childId' => $addedEntry
+ ]);
+ }
+
+ // Make sure we've not created a circular reference
+ // this is a lazy last minute check as we can't really tell if there is a circular reference unless
+ // we've inserted the records already.
+ if ($this->storageService->exists('
+ SELECT depth
+ FROM `lkplaylistplaylist`
+ WHERE parentId = :parentId
+ AND childId = parentId
+ AND depth > 0
+ ', ['parentId' => $widget->playlistId])) {
+ throw new InvalidArgumentException(
+ __('This assignment creates a loop because the Playlist being assigned contains the Playlist being worked on.'),//phpcs:ignore
+ 'subPlaylistId'
+ );
+ }
+ }
+
+ /**
+ * @param \Xibo\Event\WidgetDeleteEvent $event
+ * @return void
+ */
+ public function onWidgetDelete(WidgetDeleteEvent $event)
+ {
+ $widget = $event->getWidget();
+
+ $this->getLogger()->debug('onWidgetDelete: processing widgetId ' . $widget->widgetId);
+
+ // Clear cache
+ $renderer = $this->moduleFactory->createWidgetHtmlRenderer();
+ $renderer->clearWidgetCache($widget);
+
+ // Everything else relates to sub-playlists
+ if ($widget->type !== 'subplaylist') {
+ return;
+ }
+
+ $subPlaylists = $this->getAssignedPlaylists($widget);
+
+ // tidy up the closure table records.
+ foreach ($subPlaylists as $subPlaylist) {
+ $this->storageService->update('
+ DELETE link
+ FROM `lkplaylistplaylist` p, `lkplaylistplaylist` link, `lkplaylistplaylist` c
+ WHERE p.parentId = link.parentId AND c.childId = link.childId
+ AND p.childId = :parentId AND c.parentId = :childId
+ ', [
+ 'parentId' => $widget->playlistId,
+ 'childId' => $subPlaylist->playlistId,
+ ]);
+ }
+ }
+
+ /**
+ * @param \Xibo\Event\SubPlaylistDurationEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onDuration(SubPlaylistDurationEvent $event)
+ {
+ $widget = $event->getWidget();
+ $this->getLogger()->debug('onDuration: for ' . $widget->type);
+
+ if ($widget->type !== 'subplaylist') {
+ return;
+ }
+
+ // We give our widgetId to the resolve method so that it resolves us as if we're a child.
+ // we only resolve top-level sub-playlists when we build the layout XLF
+ $duration = 0;
+ $countWidgets = 0;
+ foreach ($this->getSubPlaylistResolvedWidgets($widget, $widget->widgetId ?? 0) as $resolvedWidget) {
+ $duration += $resolvedWidget->calculatedDuration;
+ $countWidgets++;
+ }
+
+ if ($widget->getOptionValue('cyclePlaybackEnabled', 0) === 1 && $countWidgets > 0) {
+ $this->getLogger()->debug('onDuration: cycle playback is enabled and there are ' . $countWidgets
+ . ' widgets with a total of ' . $duration . ' seconds');
+
+ $duration = intval(ceil($duration / $countWidgets));
+ }
+
+ $event->appendDuration($duration);
+ }
+
+ /**
+ * @param \Xibo\Event\SubPlaylistWidgetsEvent $event
+ * @return void
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function onWidgets(SubPlaylistWidgetsEvent $event)
+ {
+ $widget = $event->getWidget();
+ if ($widget->type !== 'subplaylist') {
+ return;
+ }
+
+ $event->setWidgets($this->getSubPlaylistResolvedWidgets($widget, $event->getTempId()));
+ }
+
+ /**
+ * @param SubPlaylistItemsEvent $event
+ * @return void
+ */
+ public function onSubPlaylistItems(SubPlaylistItemsEvent $event)
+ {
+ $widget = $event->getWidget();
+ if ($widget->type !== 'subplaylist') {
+ return;
+ }
+
+ $event->setItems($this->getAssignedPlaylists($widget));
+ }
+
+ /**
+ * @param SubPlaylistValidityEvent $event
+ * @return void
+ */
+ public function onSubPlaylistValid(SubPlaylistValidityEvent $event): void
+ {
+ $playlists = $this->getAssignedPlaylists($event->getWidget());
+ if (count($playlists) <= 0) {
+ $event->setIsValid(false);
+ return;
+ } else {
+ foreach ($playlists as $playlistItem) {
+ try {
+ $this->playlistFactory->getById($playlistItem->playlistId);
+ } catch (NotFoundException $e) {
+ $this->getLogger()->error('Misconfigured sub playlist, playlist ID '
+ . $playlistItem->playlistId . ' Not found');
+ $event->setIsValid(false);
+ return;
+ }
+ }
+ }
+ $event->setIsValid(true);
+ }
+
+ /**
+ * @return \Xibo\Widget\SubPlaylistItem[]
+ */
+ private function getAssignedPlaylists(Widget $widget, bool $originalValue = false): array
+ {
+ $this->getLogger()->debug('getAssignedPlaylists: original value: ' . var_export($originalValue, true));
+
+ $playlistItems = [];
+ foreach (json_decode($widget->getOptionValue('subPlaylists', '[]', $originalValue), true) as $playlist) {
+ $item = new SubPlaylistItem();
+ $item->rowNo = intval($playlist['rowNo']);
+ $item->playlistId = $playlist['playlistId'];
+ $item->spotFill = $playlist['spotFill'] ?? null;
+ $item->spotLength = $playlist['spotLength'] !== '' ? intval($playlist['spotLength']) : null;
+ $item->spots = $playlist['spots'] !== '' ? intval($playlist['spots']) : null;
+
+ $playlistItems[] = $item;
+ }
+ return $playlistItems;
+ }
+
+ /**
+ * @param int $parentWidgetId this tracks the top level widgetId
+ * @return Widget[] $widgets
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ private function getSubPlaylistResolvedWidgets(Widget $widget, int $parentWidgetId = 0): array
+ {
+ $this->getLogger()->debug('getSubPlaylistResolvedWidgets: widgetId is ' . $widget->widgetId
+ . ', parentWidgetId is ' . $parentWidgetId);
+
+ $arrangement = $widget->getOptionValue('arrangement', 'none');
+ $remainder = $widget->getOptionValue('remainder', 'none');
+ $cyclePlayback = $widget->getOptionValue('cyclePlaybackEnabled', 0);
+ $playCount = $widget->getOptionValue('playCount', 0);
+ $isRandom = $widget->getOptionValue('cycleRandomWidget', 0);
+
+ $this->logger->debug('Resolve widgets for Sub-Playlist ' . $widget->widgetId
+ . ' with arrangement ' . $arrangement . ' and remainder ' . $remainder);
+
+ // As a first step, get all of our playlists widgets loaded into an array
+ /** @var Widget[] $resolvedWidgets */
+ $resolvedWidgets = [];
+ $widgets = [];
+ $firstList = null;
+ $firstListCount = 0;
+ $largestListKey = null;
+ $largestListCount = 0;
+ $smallestListCount = 0;
+
+ // Expand or Shrink each of our assigned lists according to the Spot options (if any)
+ // Expand all widgets from sub-playlists
+ foreach ($this->getAssignedPlaylists($widget) as $playlistItem) {
+ try {
+ $playlist = $this->playlistFactory->getById($playlistItem->playlistId)
+ ->setModuleFactory($this->moduleFactory);
+ } catch (NotFoundException $notFoundException) {
+ $this->logger->error('getSubPlaylistResolvedWidgets: widget references a playlist which no longer exists. widgetId: '//phpcs:ignore
+ . $widget->widgetId . ', playlistId: ' . $playlistItem->playlistId);
+ continue;
+ }
+ $expanded = $playlist->expandWidgets($parentWidgetId);
+ $countExpanded = count($expanded);
+
+ // Assert top level options
+ // ------------------------
+ // options such as stats/cycle playback are asserted from the top down
+ // this is not a saved change, we assess this every time
+ $playlistEnableStat = empty($playlist->enableStat)
+ ? $this->configService->getSetting('PLAYLIST_STATS_ENABLED_DEFAULT')
+ : $playlist->enableStat;
+
+ foreach ($expanded as $subPlaylistWidget) {
+ // Handle proof of play
+ // Go through widgets assigned to this Playlist, if their enableStat is set to Inherit alter that option
+ // in memory for this widget.
+ $subPlaylistWidgetEnableStat = $subPlaylistWidget->getOptionValue(
+ 'enableStat',
+ $this->configService->getSetting('WIDGET_STATS_ENABLED_DEFAULT')
+ );
+
+ if ($subPlaylistWidgetEnableStat == 'Inherit') {
+ $this->logger->debug('For widget ID ' . $subPlaylistWidget->widgetId
+ . ' enableStat was Inherit, changed to Playlist enableStat value - ' . $playlistEnableStat);
+ $subPlaylistWidget->setOptionValue('enableStat', 'attrib', $playlistEnableStat);
+ }
+
+ // Cycle Playback
+ // --------------
+ // currently we only support cycle playback on the topmost level.
+ // https://github.com/xibosignage/xibo/issues/2869
+ $subPlaylistWidget->setOptionValue('cyclePlayback', 'attrib', $cyclePlayback);
+ $subPlaylistWidget->setOptionValue('playCount', 'attrib', $playCount);
+ $subPlaylistWidget->setOptionValue('isRandom', 'attrib', $isRandom);
+ }
+
+ // Do we have a number of spots set?
+ $this->logger->debug($playlistItem->spots . ' spots for playlistId ' . $playlistItem->playlistId);
+
+ // Do we need to expand or shrink our list to make our Spot length
+ if ($playlistItem->spots !== null && $playlistItem->spots != $countExpanded) {
+ // We do need to do something!
+ $this->logger->debug('There are ' . count($expanded) . ' Widgets in the list and we want '
+ . $playlistItem->spots . ' fill is ' . $playlistItem->spotFill);
+
+ // If our spot size is 0, then we deliberately do not add to the final widgets array
+ if ($playlistItem->spots == 0) {
+ if ($firstList === null && count($expanded) > 0) {
+ // If this is the first list, and it contains some values, then set it.
+ $firstList = $expanded;
+ }
+
+ // Skip over this one (we want to ignore it as it has spots = 0)
+ continue;
+ }
+
+ // If there are 0 items in the list, we need to fill
+ if (count($expanded) <= 0) {
+ // If this is the first list, then we need to skip it completely
+ if ($firstList === null) {
+ continue;
+ } else {
+ // Not the first list, so we can swap over to fill mode and use the first list instead
+ $playlistItem->spotFill = 'fill';
+ }
+ }
+
+ // Expand the list out, using the fill options.
+ $spotFillIndex = 0;
+ while (count($expanded) < $playlistItem->spots) {
+ $spotsToFill = $playlistItem->spots - count($expanded);
+
+ if ($playlistItem->spotFill == 'repeat' || $firstList === null) {
+ // Repeat the list to fill the spots
+ $expanded = array_merge($expanded, $expanded);
+ } else if ($playlistItem->spotFill == 'fill') {
+ // Get Playlist 1 and use it to fill
+ // Filling means taking playlist 1 and putting in on the end of the current list
+ // until we're full
+ $expanded = array_merge($expanded, $firstList);
+ } else if ($playlistItem->spotFill == 'pad') {
+ // Get Playlist 1 and use it to pad
+ // padding means taking playlist 1 and interleaving it with the current list, until we're
+ // full
+ $new = [];
+ $loops = $spotsToFill / count($expanded);
+
+ for ($i = 0; $i < count($expanded); $i++) {
+ // Take one from the playlist we're operating on
+ $new[] = $expanded[$i];
+
+ // Take $loops from the filler playlist (the first one)
+ for ($j = 0; $j < $loops; $j++) {
+ $new[] = $firstList[$spotFillIndex];
+ $spotFillIndex++;
+
+ // if we've gone around too far, then start from the beginning.
+ if ($spotFillIndex >= count($firstList)) {
+ $spotFillIndex = 0;
+ }
+ }
+ }
+ $expanded = $new;
+ }
+ }
+
+ if (count($expanded) > $playlistItem->spots) {
+ // Chop the list down to size.
+ $expanded = array_slice($expanded, 0, $playlistItem->spots);
+ }
+
+ // Update our count of expanded widgets to be the spots
+ $countExpanded = $playlistItem->spots;
+ } else if ($countExpanded <= 0) {
+ // No spots required and no content in this list.
+ continue;
+ }
+
+ // first watermark
+ if ($firstList === null) {
+ $firstList = $expanded;
+ }
+
+ if ($firstListCount === 0) {
+ $firstListCount = $countExpanded;
+ }
+
+ // high watermark
+ if ($countExpanded > $largestListCount) {
+ $largestListCount = $countExpanded;
+ $largestListKey = $playlistItem->playlistId . '_' . $playlistItem->rowNo;
+ }
+
+ // low watermark
+ if ($countExpanded < $smallestListCount || $smallestListCount === 0) {
+ $smallestListCount = $countExpanded;
+ }
+
+ // Adjust the widget duration if necessary
+ if ($playlistItem->spotLength !== null && $playlistItem->spotLength > 0) {
+ foreach ($expanded as $widget) {
+ $widget->useDuration = 1;
+ $widget->duration = $playlistItem->spotLength;
+ $widget->calculatedDuration = $playlistItem->spotLength;
+ }
+ }
+
+ $widgets[$playlistItem->playlistId . '_' . $playlistItem->rowNo] = $expanded;
+ }
+
+ $this->logger->debug('Finished parsing all sub-playlists, smallest list is ' . $smallestListCount
+ . ' widgets in size, largest is ' . $largestListCount);
+
+ if ($smallestListCount == 0 && $largestListCount == 0) {
+ $this->logger->debug('No Widgets to order');
+ return [];
+ }
+
+ // Enable for debugging only - large log
+ //$thislogger->debug(json_encode($widgets));
+ $takeIndices = [];
+ $lastTakeIndices = [];
+
+ // Arrangement first
+ if ($arrangement === 'even' && $smallestListCount > 0) {
+ // Evenly distributed by round-robin
+ $arrangement = 'roundrobin';
+
+ // We need to decide how frequently we take from the respective lists.
+ // this is different for each list.
+ foreach (array_keys($widgets) as $listKey) {
+ $takeIndices[$listKey] = intval(floor(count($widgets[$listKey]) / $smallestListCount));
+ $lastTakeIndices[$listKey] = -1;
+ }
+ } else {
+ // On a standard round-robin, we take every 1 item (i.e. one from each).
+ foreach (array_keys($widgets) as $listKey) {
+ $takeIndices[$listKey] = 1;
+ $lastTakeIndices[$listKey] = -1;
+ }
+ }
+
+ $this->logger->debug('Take Indices: ' . json_encode($takeIndices));
+
+ // Round-robin or sequentially
+ if ($arrangement === 'roundrobin') {
+ // Round Robin
+ // Take 1 from each until we have run out, use the smallest list as the "key"
+ $loopCount = $largestListCount / $takeIndices[$largestListKey];
+
+ $this->logger->debug('Round-Robin: We will loop a maximum of ' . $loopCount . ' times');
+
+ for ($i = 0; $i < $loopCount; $i++) {
+ $this->logger->debug('Loop number ' . $i);
+
+ foreach (array_keys($widgets) as $listKey) {
+ // How many items should we take from this list each time we go around?
+ $takeEvery = $takeIndices[$listKey];
+ $countInList = count($widgets[$listKey]);
+
+ $this->logger->debug('Assessing playlistId ' . $listKey . ' which has '
+ . $countInList . ' widgets.');
+
+ for ($count = 1; $count <= $takeEvery; $count++) {
+ // Increment the last index we consumed from this list each time
+ $index = $lastTakeIndices[$listKey] + 1;
+
+ // Does this key actually have this many items?
+ if ($index >= $countInList) {
+ // it does not :o
+ $this->logger->debug('Index ' . $index
+ . ' is higher than the count of widgets in the list ' . $countInList);
+ // what we do depends on our remainder setting
+ // if we drop, we stop, otherwise we skip
+ if ($remainder === 'drop') {
+ // Stop everything, we've got enough
+ break 3;
+ } else if ($remainder === 'repeat') {
+ // start this list again from the beginning.
+ $index = 0;
+ } else {
+ // Just skip this key
+ continue 2;
+ }
+ }
+
+ $this->logger->debug('Selecting widget at position ' . $index
+ . ' from playlistId ' . $listKey);
+
+ // Append the key at the position
+ $resolvedWidgets[] = $widgets[$listKey][$index];
+
+ // Update our last take index for this list.
+ $lastTakeIndices[$listKey] = $index;
+ }
+ }
+ }
+ } else {
+ // None
+ // If the arrangement is none we just add all the widgets together
+ // Merge the arrays together for returning
+ foreach ($widgets as $items) {
+ if ($remainder === 'drop') {
+ $this->logger->debug('Dropping list of ' . count($items)
+ . ' widgets down to ' . $smallestListCount);
+
+ // We trim all arrays down to the smallest of them
+ $items = array_slice($items, 0, $smallestListCount);
+ } else if ($remainder === 'repeat') {
+ $this->logger->debug('Expanding list of ' . count($items) . ' widgets to ' . $largestListCount);
+
+ while (count($items) < $largestListCount) {
+ $items = array_merge($items, $items);
+ }
+
+ // Finally trim (we might have added too many if the list sizes aren't exactly divisible)
+ $items = array_slice($items, 0, $largestListCount);
+ }
+
+ $resolvedWidgets = array_merge($resolvedWidgets, $items);
+ }
+ }
+
+ // At the end of it, log out what we've calculated
+ $log = 'Resolved: ';
+ foreach ($resolvedWidgets as $resolvedWidget) {
+ $log .= $resolvedWidget->playlistId . '-' . $resolvedWidget->widgetId . ',';
+
+ // Should my from/to dates be applied to the resolved widget?
+ // only if they are more restrictive.
+ // because this is recursive, we should end up with the top most widget being "ruler" of the from/to dates
+ if ($widget->fromDt > $resolvedWidget->fromDt) {
+ $resolvedWidget->fromDt = $widget->fromDt;
+ }
+
+ if ($widget->toDt < $resolvedWidget->toDt) {
+ $resolvedWidget->toDt = $widget->toDt;
+ }
+ }
+ $this->logger->debug($log);
+
+ return $resolvedWidgets;
+ }
+
+ /**
+ * TODO: we will need a way to upgrade from early v3 to late v3
+ * (this can replace convertOldPlaylistOptions in Layout Factory)
+ * @return void
+ */
+ private function toDoUpgrade(Widget $widget)
+ {
+ $playlists = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
+ if (count($playlists) <= 0) {
+ // Try and load them the old way.
+ $this->getLogger()->debug('getAssignedPlaylists: playlists not found in subPlaylists option, loading the old way.');//@phpcs:ignore
+
+ $playlistIds = json_decode($widget->getOptionValue('subPlaylistIds', '[]'), true);
+ $subPlaylistOptions = json_decode($widget->getOptionValue('subPlaylistOptions', '[]'), true);
+ $i = 0;
+ foreach ($playlistIds as $playlistId) {
+ $i++;
+ $playlists[] = [
+ 'rowNo' => $i,
+ 'playlistId' => $playlistId,
+ 'spotFill' => $subPlaylistOptions[$playlistId]['subPlaylistIdSpotFill'] ?? null,
+ 'spotLength' => $subPlaylistOptions[$playlistId]['subPlaylistIdSpotLength'] ?? null,
+ 'spots' => $subPlaylistOptions[$playlistId]['subPlaylistIdSpots'] ?? null,
+ ];
+ }
+ } else {
+ $this->getLogger()->debug('getAssignedPlaylists: playlists found in subPlaylists option.');
+ }
+
+
+
+ // Tidy up any old options
+ if ($widget->getOptionValue('subPlaylistIds', null) !== null) {
+ $widget->setOptionValue('subPlaylistIds', 'attrib', null);
+ $widget->setOptionValue('subPlaylistOptions', 'attrib', null);
+ }
+ }
+
+ /**
+ * Handle a region being added
+ * @param RegionAddedEvent $event
+ * @return void
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ */
+ public function onRegionAdded(RegionAddedEvent $event)
+ {
+ // We are a canvas region
+ if ($event->getRegion()->type === 'canvas') {
+ $this->getLogger()->debug('onRegionAdded: canvas region found, adding global widget');
+
+ // Add the global widget
+ $module = $this->moduleFactory->getById('core-canvas');
+
+ $widget = $this->widgetFactory->create(
+ $event->getRegion()->getOwnerId(),
+ $event->getRegion()->regionPlaylist->playlistId,
+ $module->type,
+ $module->defaultDuration,
+ $module->schemaVersion
+ );
+
+ $widget->calculateDuration($module);
+
+ $event->getRegion()->regionPlaylist->assignWidget($widget, 1);
+ $event->getRegion()->regionPlaylist->save(['notify' => false, 'validate' => false]);
+ }
+ }
+}
diff --git a/lib/Middleware/Actions.php b/lib/Middleware/Actions.php
new file mode 100644
index 0000000..b54dd8d
--- /dev/null
+++ b/lib/Middleware/Actions.php
@@ -0,0 +1,150 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Slim\Routing\RouteContext;
+use Xibo\Entity\User;
+use Xibo\Entity\UserNotification;
+use Xibo\Factory\UserNotificationFactory;
+use Xibo\Helper\Environment;
+
+/**
+ * Class Actions
+ * Web Actions
+ * @package Xibo\Middleware
+ */
+class Actions implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ // Do not proceed unless we have completed an upgrade
+ if (Environment::migrationPending()) {
+ return $handler->handle($request);
+ }
+
+ $app = $this->app;
+ $container = $app->getContainer();
+
+ // Get the current route pattern
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+ $resource = $route->getPattern();
+ $routeParser = $app->getRouteCollector()->getRouteParser();
+
+ // Do we have a user set?
+ /** @var User $user */
+ $user = $container->get('user');
+
+ // Only process notifications if we are a full request
+ if (!$this->isAjax($request)) {
+ if ($user->userId != null
+ && $container->get('session')->isExpired() == 0
+ && $user->featureEnabled('drawer')
+ ) {
+ // Notifications
+ $notifications = [];
+ $extraNotifications = 0;
+
+ /** @var UserNotificationFactory $factory */
+ $factory = $container->get('userNotificationFactory');
+
+ // Is the CMS Docker stack in DEV mode? (this will be true for dev and test)
+ if (Environment::isDevMode()) {
+ $notifications[] = $factory->create('CMS IN DEV MODE');
+ $extraNotifications++;
+ } else {
+ // We're not in DEV mode and therefore install/index.php shouldn't be there.
+ if ($user->userTypeId == 1 && file_exists(PROJECT_ROOT . '/web/install/index.php')) {
+ $container->get('logger')->notice('Install.php exists and shouldn\'t');
+
+ $notifications[] = $factory->create(
+ __('There is a problem with this installation, the web/install folder should be deleted.')
+ );
+ $extraNotifications++;
+
+ // Test for web in the URL.
+ $url = $request->getUri();
+
+ if (!Environment::checkUrl($url)) {
+ $container->get('logger')->notice('Suspicious URL detected - it is very unlikely that /web/ should be in the URL. URL is ' . $url);
+
+ $notifications[] = $factory->create(__('CMS configuration warning, it is very unlikely that /web/ should be in the URL. This usually means that the DocumentRoot of the web server is wrong and may put your CMS at risk if not corrected.'));
+ $extraNotifications++;
+ }
+ }
+ }
+
+ // User notifications
+ $notifications = array_merge($notifications, $factory->getMine());
+ // If we aren't already in a notification interrupt, then check to see if we should be
+ if ($resource != '/drawer/notification/interrupt/{id}' && !$this->isAjax($request) && $container->get('session')->isExpired() != 1) {
+ foreach ($notifications as $notification) {
+ /** @var UserNotification $notification */
+ if ($notification->isInterrupt == 1 && $notification->read == 0) {
+ $container->get('flash')->addMessage('interruptedUrl', $resource);
+ return $handler->handle($request)
+ ->withHeader('Location', $routeParser->urlFor('notification.interrupt', ['id' => $notification->notificationId]))
+ ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
+ ->withHeader('Pragma',' no-cache')
+ ->withHeader('Expires',' 0');
+ }
+ }
+ }
+
+ $container->get('view')->offsetSet('notifications', $notifications);
+ $container->get('view')->offsetSet('notificationCount', $factory->countMyUnread() + $extraNotifications);
+ }
+ }
+
+ if (!$this->isAjax($request) && $user->isPasswordChangeRequired == 1 && $resource != '/user/page/password') {
+ return $handler->handle($request)
+ ->withStatus(302)
+ ->withHeader('Location', $routeParser->urlFor('user.force.change.password.page'));
+ }
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * Is the provided request from AJAX
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return bool
+ */
+ private function isAjax(Request $request)
+ {
+ return strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest';
+ }
+}
diff --git a/lib/Middleware/ApiAuthentication.php b/lib/Middleware/ApiAuthentication.php
new file mode 100644
index 0000000..94f05f0
--- /dev/null
+++ b/lib/Middleware/ApiAuthentication.php
@@ -0,0 +1,110 @@
+.
+ */
+namespace Xibo\Middleware;
+
+use League\OAuth2\Server\Grant\AuthCodeGrant;
+use League\OAuth2\Server\Grant\RefreshTokenGrant;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Xibo\OAuth\RefreshTokenRepository;
+use Xibo\Support\Exception\ConfigurationException;
+
+/**
+ * Class ApiAuthentication
+ * This middleware protects the AUTH entry point
+ * @package Xibo\Middleware
+ */
+class ApiAuthentication implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ /**
+ * ApiAuthorizationOAuth constructor.
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @param \Psr\Http\Server\RequestHandlerInterface $handler
+ * @return \Psr\Http\Message\ResponseInterface
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+ $container = $app->getContainer();
+
+ // DI in the server
+ $container->set('server', function(ContainerInterface $container) {
+ /** @var \Xibo\Service\LogServiceInterface $logger */
+ $logger = $container->get('logService');
+
+ // API Keys
+ $apiKeyPaths = $container->get('configService')->getApiKeyDetails();
+ $privateKey = $apiKeyPaths['privateKeyPath'];
+ $encryptionKey = $apiKeyPaths['encryptionKey'];
+
+ try {
+ $server = new \League\OAuth2\Server\AuthorizationServer(
+ $container->get('applicationFactory'),
+ new \Xibo\OAuth\AccessTokenRepository($logger, $container->get('pool'), $container->get('applicationFactory')),
+ $container->get('applicationScopeFactory'),
+ $privateKey,
+ $encryptionKey
+ );
+
+ // Grant Types
+ $server->enableGrantType(
+ new \League\OAuth2\Server\Grant\ClientCredentialsGrant(),
+ new \DateInterval('PT1H')
+ );
+
+ $server->enableGrantType(
+ new AuthCodeGrant(
+ new \Xibo\OAuth\AuthCodeRepository(),
+ new \Xibo\OAuth\RefreshTokenRepository($logger, $container->get('pool')),
+ new \DateInterval('PT10M')
+ ),
+ new \DateInterval('PT1H')
+ );
+
+ $server->enableGrantType(new RefreshTokenGrant(new RefreshTokenRepository($logger, $container->get('pool'))));
+
+ return $server;
+ } catch (\LogicException $exception) {
+ $logger->error($exception->getMessage());
+ throw new ConfigurationException('API configuration problem, consult your administrator');
+ }
+ });
+
+ return $handler->handle($request->withAttribute('_entryPoint', 'auth'));
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/ApiAuthorization.php b/lib/Middleware/ApiAuthorization.php
new file mode 100644
index 0000000..57dd929
--- /dev/null
+++ b/lib/Middleware/ApiAuthorization.php
@@ -0,0 +1,215 @@
+.
+ */
+namespace Xibo\Middleware;
+
+use Carbon\Carbon;
+use League\OAuth2\Server\Exception\OAuthServerException;
+use League\OAuth2\Server\ResourceServer;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Slim\Routing\RouteContext;
+use Xibo\Factory\ApplicationScopeFactory;
+use Xibo\Helper\UserLogProcessor;
+use Xibo\OAuth\AccessTokenRepository;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class ApiAuthenticationOAuth
+ * This middleware protects the API entry point
+ * @package Xibo\Middleware
+ */
+class ApiAuthorization implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ /**
+ * ApiAuthenticationOAuth constructor.
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws OAuthServerException
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ /* @var \Xibo\Entity\User $user */
+ $user = null;
+
+ /** @var \Xibo\Service\LogServiceInterface $logger */
+ $logger = $this->app->getContainer()->get('logService');
+
+ // Setup the authorization server
+ $this->app->getContainer()->set('server', function (ContainerInterface $container) use ($logger) {
+ // oAuth Resource
+ $apiKeyPaths = $container->get('configService')->getApiKeyDetails();
+
+ $accessTokenRepository = new AccessTokenRepository(
+ $logger,
+ $container->get('pool'),
+ $container->get('applicationFactory')
+ );
+ return new ResourceServer(
+ $accessTokenRepository,
+ $apiKeyPaths['publicKeyPath']
+ );
+ });
+
+ /** @var ResourceServer $server */
+ $server = $this->app->getContainer()->get('server');
+ $validatedRequest = $server->validateAuthenticatedRequest($request);
+
+ // We have a valid JWT/token
+ // get our user from it.
+ $userFactory = $this->app->getContainer()->get('userFactory');
+
+ // What type of Access Token to we have? Client Credentials or AuthCode
+ // client_credentials grants are issued with the correct oauth_user_id in the token, so we don't need to
+ // distinguish between them here! nice!
+ $userId = $validatedRequest->getAttribute('oauth_user_id');
+
+ $user = $userFactory->getById($userId);
+ $user->setChildAclDependencies($this->app->getContainer()->get('userGroupFactory'));
+ $user->load();
+
+ // Block access by retired users.
+ if ($user->retired === 1) {
+ throw new AccessDeniedException(__('Sorry this account does not exist or cannot be authenticated.'));
+ }
+
+ // We must check whether this user has access to the route they have requested.
+ // Get the current route pattern
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+ $resource = $route->getPattern();
+
+ // Allow public routes
+ if (!in_array($resource, $validatedRequest->getAttribute('publicRoutes', []))) {
+ $request = $request->withAttribute('public', false);
+
+ // Check that the Scopes granted to this token are allowed access to the route/method of this request
+ /** @var ApplicationScopeFactory $applicationScopeFactory */
+ $applicationScopeFactory = $this->app->getContainer()->get('applicationScopeFactory');
+ $scopes = $validatedRequest->getAttribute('oauth_scopes');
+
+ // If there are no scopes in the JWT, we should not authorise
+ // An older client which makes a request with no scopes should get an access token with
+ // all scopes configured for the application
+ if (!is_array($scopes) || count($scopes) <= 0) {
+ throw new AccessDeniedException();
+ }
+
+ $logger->debug('Scopes provided with request: ' . count($scopes));
+
+ // Check all scopes in the request before we deny access.
+ $grantAccess = false;
+
+ foreach ($scopes as $scope) {
+ // If I have the "all" scope granted then there isn't any need to test further
+ if ($scope === 'all') {
+ $grantAccess = true;
+ break;
+ }
+
+ $logger->debug(
+ sprintf(
+ 'Test authentication for %s %s against scope %s',
+ $resource,
+ $request->getMethod(),
+ $scope
+ )
+ );
+
+ // Check the route and request method
+ if ($applicationScopeFactory->getById($scope)->checkRoute($request->getMethod(), $resource)) {
+ $grantAccess = true;
+ break;
+ }
+ }
+
+ if (!$grantAccess) {
+ throw new AccessDeniedException(__('Access to this route is denied for this scope'));
+ }
+ } else {
+ // Public request
+ $validatedRequest = $validatedRequest->withAttribute('public', true);
+ }
+
+ $requestId = $this->app->getContainer()->get('store')->insert('
+ INSERT INTO `application_requests_history` (
+ userId,
+ applicationId,
+ url,
+ method,
+ startTime,
+ endTime,
+ duration
+ )
+ VALUES (
+ :userId,
+ :applicationId,
+ :url,
+ :method,
+ :startTime,
+ :endTime,
+ :duration
+ )
+ ', [
+ 'userId' => $user->userId,
+ 'applicationId' => $validatedRequest->getAttribute('oauth_client_id'),
+ 'url' => htmlspecialchars($request->getUri()->getPath()),
+ 'method' => $request->getMethod(),
+ 'startTime' => Carbon::now(),
+ 'endTime' => Carbon::now(),
+ 'duration' => 0
+ ], 'api_requests_history');
+
+ $this->app->getContainer()->get('store')->commitIfNecessary('api_requests_history');
+
+ $logger->setUserId($user->userId);
+ $this->app->getContainer()->set('user', $user);
+ $logger->setRequestId($requestId);
+
+ // Add this request information to the logger.
+ $logger->getLoggerInterface()->pushProcessor(new UserLogProcessor(
+ $user->userId,
+ null,
+ $requestId,
+ ));
+
+ return $handler->handle($validatedRequest->withAttribute('name', 'API'));
+ }
+}
diff --git a/lib/Middleware/ApiView.php b/lib/Middleware/ApiView.php
new file mode 100644
index 0000000..3a31313
--- /dev/null
+++ b/lib/Middleware/ApiView.php
@@ -0,0 +1,129 @@
+.
+ */
+
+
+namespace Xibo\Middleware;
+
+
+use Slim\Slim;
+use Slim\View;
+use Xibo\Helper\HttpsDetect;
+
+class ApiView extends View
+{
+ public function render($template = '', $data = NULL)
+ {
+ $app = Slim::getInstance();
+
+ // JSONP Callback?
+ $jsonPCallback = $app->request->get('callback', null);
+
+ // Don't envelope unless requested
+ if ($jsonPCallback != null || $app->request()->get('envelope', 0) == 1 || $app->getName() == 'test') {
+ // Envelope
+ $response = $this->all();
+
+ // append error bool
+ if (!$this->has('success') || !$this->get('success')) {
+ $response['success'] = false;
+ }
+
+ // append status code
+ $response['status'] = $app->response()->getStatus();
+
+ // add flash messages
+ if (isset($this->data->flash) && is_object($this->data->flash)){
+ $flash = $this->data->flash->getMessages();
+ if (count($flash)) {
+ $response['flash'] = $flash;
+ } else {
+ unset($response['flash']);
+ }
+ }
+
+ // Enveloped responses always return 200
+ $app->status(200);
+ } else {
+ // Don't envelope
+ // Set status
+ $app->status(intval($this->get('status')));
+
+ // Are we successful?
+ if (!$this->has('success') || !$this->get('success')) {
+ // Error condition
+ $response = [
+ 'error' => [
+ 'message' => $this->get('message'),
+ 'code' => intval($this->get('status')),
+ 'data' => $this->get('data')
+ ]
+ ];
+ }
+ else {
+ // Are we a grid?
+ if ($this->get('grid') == true) {
+ // Set the response to our data['data'] object
+ $grid = $this->get('data');
+ $response = $grid['data'];
+
+ // Total Number of Rows
+ $totalRows = $grid['recordsTotal'];
+
+ // Set some headers indicating our next/previous pages
+ $start = $app->sanitizerService->getInt('start', 0);
+ $size = $app->sanitizerService->getInt('length', 10);
+
+ $linkHeader = '';
+ $url = (new HttpsDetect())->getRootUrl() . $app->request()->getPath();
+
+ // Is there a next page?
+ if ($start + $size < $totalRows)
+ $linkHeader .= '<' . $url . '?start=' . ($start + $size) . '&length=' . $size . '>; rel="next", ';
+
+ // Is there a previous page?
+ if ($start > 0)
+ $linkHeader .= '<' . $url . '?start=' . ($start - $size) . '&length=' . $size . '>; rel="prev", ';
+
+ // The first page
+ $linkHeader .= '<' . $url . '?start=0&length=' . $size . '>; rel="first"';
+
+ $app->response()->header('X-Total-Count', $totalRows);
+ $app->response()->header('Link', $linkHeader);
+ } else {
+ // Set the response to our data object
+ $response = $this->get('data');
+ }
+ }
+ }
+
+ // JSON header
+ $app->response()->header('Content-Type', 'application/json');
+
+ if ($jsonPCallback !== null) {
+ $app->response()->body($jsonPCallback.'('.json_encode($response).')');
+ } else {
+ $app->response()->body(json_encode($response, JSON_PRETTY_PRINT));
+ }
+
+ $app->stop();
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/AuthenticationBase.php b/lib/Middleware/AuthenticationBase.php
new file mode 100644
index 0000000..bcc7a10
--- /dev/null
+++ b/lib/Middleware/AuthenticationBase.php
@@ -0,0 +1,116 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+
+/**
+ * Class AuthenticationBase
+ * @package Xibo\Middleware
+ */
+abstract class AuthenticationBase implements Middleware, AuthenticationInterface
+{
+ use AuthenticationTrait;
+
+ /**
+ * Uses a Hook to check every call for authorization
+ * Will redirect to the login route if the user is unauthorized
+ *
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ // This Middleware protects the Web Route, so we update the request with that name
+ $request = $request->withAttribute('_entryPoint', 'web');
+
+ // Add any authentication specific request modifications
+ $request = $this->addToRequest($request);
+
+ // Start with an empty user.
+ $user = $this->getEmptyUser();
+
+ // Get the current route pattern
+ $resource = $this->getRoutePattern($request);
+
+ // Check to see if this is a public resource (there are only a few, so we have them in an array)
+ if (!in_array($resource, $this->getPublicRoutes($request))) {
+ $request = $request->withAttribute('public', false);
+
+ // Need to check
+ if ($user->hasIdentity() && !$this->getSession()->isExpired()) {
+ // Replace our user with a fully loaded one
+ $user = $this->getUser(
+ $user->userId,
+ $request->getAttribute('ip_address'),
+ $this->getSession()->get('sessionHistoryId')
+ );
+
+ // We are authenticated, override with the populated user object
+ $this->setUserForRequest($user);
+
+ // Handle the rest of the Middleware stack and return
+ return $handler->handle($request);
+ } else {
+ // Session has expired or the user is already logged out.
+ // in either case, capture the route
+ $this->rememberRoute($request->getUri()->getPath());
+
+ $this->getLog()->debug('not in public routes, expired, should redirect to login');
+
+ // We update the last accessed date on the user here, if there was one logged in at this point
+ if ($user->hasIdentity()) {
+ $user->touch();
+ }
+
+ // Issue appropriate logout depending on the type of web request
+ return $this->redirectToLogin($request);
+ }
+ } else {
+ // This is a public route.
+ $request = $request->withAttribute('public', true);
+
+ // If we are expired and come from ping/clock, then we redirect
+ if ($this->shouldRedirectPublicRoute($resource)) {
+ $this->getLog()->debug('should redirect to login , resource is ' . $resource);
+
+ if ($user->hasIdentity()) {
+ $user->touch();
+ }
+
+ // Issue appropriate logout depending on the type of web request
+ return $this->redirectToLogin($request);
+ } else {
+ // We handle the rest of the request, unauthenticated.
+ return $handler->handle($request);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/AuthenticationInterface.php b/lib/Middleware/AuthenticationInterface.php
new file mode 100644
index 0000000..516b137
--- /dev/null
+++ b/lib/Middleware/AuthenticationInterface.php
@@ -0,0 +1,69 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\App;
+
+/**
+ * Interface AuthenticationInterface
+ * @package Xibo\Middleware
+ */
+interface AuthenticationInterface
+{
+ /**
+ * @param \Slim\App $app
+ * @return mixed
+ */
+ public function setDependencies(App $app);
+
+ /**
+ * @return $this
+ */
+ public function addRoutes();
+
+ /**
+ * @param Request $request
+ * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
+ */
+ public function redirectToLogin(Request $request);
+
+ /**
+ * @param Request $request
+ * @return array
+ */
+ public function getPublicRoutes(Request $request);
+
+ /**
+ * Should this public route be redirected to login when the session is expired?
+ * @param string $route
+ * @return bool
+ */
+ public function shouldRedirectPublicRoute($route);
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return Request
+ */
+ public function addToRequest(Request $request);
+}
\ No newline at end of file
diff --git a/lib/Middleware/AuthenticationTrait.php b/lib/Middleware/AuthenticationTrait.php
new file mode 100644
index 0000000..72d2d19
--- /dev/null
+++ b/lib/Middleware/AuthenticationTrait.php
@@ -0,0 +1,209 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\App;
+use Slim\Http\Factory\DecoratedResponseFactory;
+use Slim\Routing\RouteContext;
+use Xibo\Entity\User;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\UserLogProcessor;
+
+/**
+ * Trait AuthenticationTrait
+ * @package Xibo\Middleware
+ */
+trait AuthenticationTrait
+{
+ /* @var App $app */
+ protected $app;
+
+ /**
+ * Set dependencies
+ * @param App $app
+ * @return $this
+ */
+ public function setDependencies(App $app)
+ {
+ $this->app = $app;
+ return $this;
+ }
+
+ /**
+ * @return \Xibo\Service\ConfigServiceInterface
+ */
+ protected function getConfig()
+ {
+ return $this->app->getContainer()->get('configService');
+ }
+
+ /**
+ * @return \Xibo\Helper\Session
+ */
+ protected function getSession()
+ {
+ return $this->app->getContainer()->get('session');
+ }
+
+ /**
+ * @return \Xibo\Service\LogServiceInterface
+ */
+ protected function getLog()
+ {
+ return $this->app->getContainer()->get('logService');
+ }
+
+ /**
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ return $this->app->getContainer()->get('sanitizerService')->getSanitizer($array);
+ }
+
+ /**
+ * @return \Xibo\Factory\UserFactory
+ */
+ protected function getUserFactory()
+ {
+ return $this->app->getContainer()->get('userFactory');
+ }
+
+ /**
+ * @return \Xibo\Factory\UserGroupFactory
+ */
+ protected function getUserGroupFactory()
+ {
+ return $this->app->getContainer()->get('userGroupFactory');
+ }
+
+ /**
+ * @return \Xibo\Entity\User
+ */
+ protected function getEmptyUser()
+ {
+ $container = $this->app->getContainer();
+
+ /** @var User $user */
+ $user = $container->get('userFactory')->create();
+ $user->setChildAclDependencies($container->get('userGroupFactory'));
+
+ return $user;
+ }
+
+ /**
+ * @param int $userId
+ * @param string $ip
+ * @param int $sessionHistoryId
+ * @return \Xibo\Entity\User
+ */
+ protected function getUser($userId, $ip, $sessionHistoryId): User
+ {
+ $container = $this->app->getContainer();
+ $user = $container->get('userFactory')->getById($userId);
+
+ // Pass the page factory into the user object, so that it can check its page permissions
+ $user->setChildAclDependencies($container->get('userGroupFactory'));
+
+ // Load the user
+ $user->load(false);
+
+ // Configure the log service with the logged in user id
+ $container->get('logService')->setUserId($user->userId);
+ $container->get('logService')->setIpAddress($ip);
+ $container->get('logService')->setSessionHistoryId($sessionHistoryId);
+
+ return $user;
+ }
+
+ /**
+ * @param \Xibo\Entity\User $user
+ */
+ protected function setUserForRequest($user)
+ {
+ $container = $this->app->getContainer();
+ $container->set('user', $user);
+
+ // Add this users information to the logger
+ $this->getLog()->getLoggerInterface()->pushProcessor(new UserLogProcessor(
+ $user->userId,
+ $this->getLog()->getSessionHistoryId(),
+ null,
+ ));
+ }
+
+ /**
+ * @param Request $request
+ * @return string
+ */
+ protected function getRoutePattern($request)
+ {
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+ return $route->getPattern();
+ }
+
+ /**
+ * @return \Slim\Interfaces\RouteParserInterface
+ */
+ protected function getRouteParser()
+ {
+ return $this->app->getRouteCollector()->getRouteParser();
+ }
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return bool
+ */
+ protected function isAjax(Request $request)
+ {
+ return strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest';
+ }
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return \Psr\Http\Message\ResponseInterface
+ */
+ protected function createResponse(Request $request)
+ {
+ // Create a new response
+ $nyholmFactory = new Psr17Factory();
+ $decoratedResponseFactory = new DecoratedResponseFactory($nyholmFactory, $nyholmFactory);
+ return HttpsDetect::decorateWithStsIfNecessary(
+ $this->getConfig(),
+ $request,
+ $decoratedResponseFactory->createResponse()
+ );
+ }
+
+ /**
+ * @param string $route
+ */
+ protected function rememberRoute($route)
+ {
+ $this->app->getContainer()->get('flash')->addMessage('priorRoute', $route);
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/CASAuthentication.php b/lib/Middleware/CASAuthentication.php
new file mode 100644
index 0000000..a3b3877
--- /dev/null
+++ b/lib/Middleware/CASAuthentication.php
@@ -0,0 +1,175 @@
+.
+ */
+
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\Http\Response;
+use Slim\Http\ServerRequest;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\LogoutTrait;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class CASAuthentication
+ * @package Xibo\Middleware
+ *
+ * Provide CAS authentication to Xibo configured via settings.php.
+ *
+ * This class was originally contributed by Emmanuel Blindauer
+ */
+class CASAuthentication extends AuthenticationBase
+{
+ use LogoutTrait;
+
+ /**
+ * @return $this
+ */
+ public function addRoutes()
+ {
+ $app = $this->app;
+ $app->getContainer()->set('logoutRoute', 'cas.logout');
+
+ $app->map(['GET', 'POST'], '/cas/login', function (ServerRequest $request, Response $response) {
+ // Initiate CAS SSO
+ $this->initCasClient();
+ \phpCAS::setNoCasServerValidation();
+
+ // Login happens here
+ \phpCAS::forceAuthentication();
+
+ $username = \phpCAS::getUser();
+
+ try {
+ $user = $this->getUserFactory()->getByName($username);
+ } catch (NotFoundException $e) {
+ throw new AccessDeniedException('Unable to authenticate');
+ }
+
+ if ($user->retired === 1) {
+ throw new AccessDeniedException('Sorry this account does not exist or cannot be authenticated.');
+ }
+
+ if (isset($user) && $user->userId > 0) {
+ // Load User
+ $this->getUser(
+ $user->userId,
+ $request->getAttribute('ip_address'),
+ $this->getSession()->get('sessionHistoryId')
+ );
+
+ // Overwrite our stored user with this new object.
+ $this->setUserForRequest($user);
+
+ // Switch Session ID's
+ $this->getSession()->setIsExpired(0);
+ $this->getSession()->regenerateSessionId();
+ $this->getSession()->setUser($user->userId);
+
+ $user->touch();
+
+ // Audit Log
+ // Set the userId on the log object
+ $this->getLog()->audit('User', $user->userId, 'Login Granted via CAS', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ }
+
+ return $response->withRedirect($this->getRouteParser()->urlFor('home'));
+ })->setName('cas.login');
+
+ // Service for the logout of the user.
+ // End the CAS session and the application session
+ $app->get('/cas/logout', function (ServerRequest $request, Response $response) {
+ // The order is first: local session to destroy, second the cas session
+ // because phpCAS::logout() redirects to CAS server
+ $this->completeLogoutFlow(
+ $this->getUser(
+ $_SESSION['userid'],
+ $request->getAttribute('ip_address'),
+ $_SESSION['sessionHistoryId']
+ ),
+ $this->getSession(),
+ $this->getLog(),
+ $request
+ );
+
+ $this->initCasClient();
+ \phpCAS::logout();
+ })->setName('cas.logout');
+
+ return $this;
+ }
+
+ /**
+ * Initialise the CAS client
+ */
+ private function initCasClient()
+ {
+ $settings = $this->getConfig()->casSettings['config'];
+ \phpCAS::client(
+ CAS_VERSION_2_0,
+ $settings['server'],
+ intval($settings['port']),
+ $settings['uri'],
+ $settings['service_base_url']
+ );
+ }
+
+ /** @inheritDoc */
+ public function redirectToLogin(Request $request)
+ {
+ if ($this->isAjax($request)) {
+ return $this->createResponse($request)
+ ->withJson(ApplicationState::asRequiresLogin());
+ } else {
+ return $this->createResponse($request)
+ ->withRedirect($this->getRouteParser()->urlFor('login'));
+ }
+ }
+
+ /** @inheritDoc */
+ public function getPublicRoutes(Request $request)
+ {
+ return array_merge($request->getAttribute('publicRoutes', []), [
+ '/cas/login',
+ '/cas/logout',
+ ]);
+ }
+
+ /** @inheritDoc */
+ public function shouldRedirectPublicRoute($route)
+ {
+ return $this->getSession()->isExpired() && ($route == '/login/ping' || $route == 'clock');
+ }
+
+ /** @inheritDoc */
+ public function addToRequest(Request $request)
+ {
+ return $request->withAttribute(
+ 'excludedCsrfRoutes',
+ array_merge($request->getAttribute('excludedCsrfRoutes', []), ['/cas/login', '/cas/logout'])
+ );
+ }
+}
diff --git a/lib/Middleware/ConnectorMiddleware.php b/lib/Middleware/ConnectorMiddleware.php
new file mode 100644
index 0000000..1d26c5b
--- /dev/null
+++ b/lib/Middleware/ConnectorMiddleware.php
@@ -0,0 +1,82 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App;
+
+/**
+ * This middleware is used to register connectors.
+ */
+class ConnectorMiddleware implements MiddlewareInterface
+{
+ /* @var App $app */
+ private $app;
+
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+
+ // Set connectors
+ self::setConnectors($app);
+
+ // Next middleware
+ return $handler->handle($request);
+ }
+
+ /**
+ * Set connectors
+ * @param \Slim\App $app
+ * @return void
+ */
+ public static function setConnectors(App $app)
+ {
+ // Dynamically load any connectors?
+ $container = $app->getContainer();
+
+ /** @var \Xibo\Factory\ConnectorFactory $connectorFactory */
+ $connectorFactory = $container->get('connectorFactory');
+ foreach ($connectorFactory->query(['isEnabled' => 1, 'isVisible' => 1]) as $connector) {
+ try {
+ // Create a connector, add in platform settings and register it with the dispatcher.
+ $connectorFactory->create($connector)->registerWithDispatcher($container->get('dispatcher'));
+ } catch (\Exception $exception) {
+ // Log and ignore.
+ $container->get('logger')->error('Incorrectly configured connector. e=' . $exception->getMessage());
+ }
+ }
+ }
+}
diff --git a/lib/Middleware/Csp.php b/lib/Middleware/Csp.php
new file mode 100644
index 0000000..f4a8825
--- /dev/null
+++ b/lib/Middleware/Csp.php
@@ -0,0 +1,74 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Xibo\Helper\Random;
+
+/**
+ * CSP middleware to output CSP headers and add a CSP nonce to the view layer.
+ */
+class Csp implements Middleware
+{
+ public function __construct(private readonly ContainerInterface $container)
+ {
+ }
+
+ /**
+ * Call middleware
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ // Generate a nonce
+ $nonce = Random::generateString(8);
+
+ // Create CSP header
+ $csp = 'object-src \'none\'; script-src \'nonce-' . $nonce . '\'';
+ $csp .= ' \'unsafe-inline\' \'unsafe-eval\' \'strict-dynamic\' https: http:;';
+ $csp .= ' base-uri \'self\';';
+ $csp .= ' frame-ancestors \'self\';';
+
+ // Store it for use in the stack if needed
+ $request = $request->withAttribute('cspNonce', $nonce);
+
+ // Assign it to our view
+ $this->container->get('view')->offsetSet('cspNonce', $nonce);
+
+ // Call next middleware.
+ $response = $handler->handle($request);
+
+ // Add our header
+ return $response
+ ->withAddedHeader('X-Frame-Options', 'SAMEORIGIN')
+ ->withAddedHeader('Content-Security-Policy', $csp);
+ }
+}
diff --git a/lib/Middleware/CsrfGuard.php b/lib/Middleware/CsrfGuard.php
new file mode 100644
index 0000000..981f556
--- /dev/null
+++ b/lib/Middleware/CsrfGuard.php
@@ -0,0 +1,123 @@
+.
+ */
+
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Slim\Routing\RouteContext;
+use Xibo\Helper\Environment;
+use Xibo\Support\Exception\ExpiredException;
+
+class CsrfGuard implements Middleware
+{
+ /**
+ * CSRF token key name.
+ *
+ * @var string
+ */
+ protected $key;
+
+ /* @var App $app */
+ private $app;
+
+ /**
+ * Constructor.
+ *
+ * @param App $app
+ * @param string $key The CSRF token key name.
+ */
+ public function __construct($app, $key = 'csrfToken')
+ {
+ if (! is_string($key) || empty($key) || preg_match('/[^a-zA-Z0-9\-\_]/', $key)) {
+ throw new \OutOfBoundsException('Invalid CSRF token key "' . $key . '"');
+ }
+
+ $this->key = $key;
+ $this->app = $app;
+ }
+
+ /**
+ * Call middleware.
+ *
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws ExpiredException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $container = $this->app->getContainer();
+ /* @var \Xibo\Helper\Session $session */
+ $session = $this->app->getContainer()->get('session');
+
+ if (!$session->get($this->key)) {
+ $session->set($this->key, bin2hex(random_bytes(20)));
+ }
+
+ $token = $session->get($this->key);
+
+ // Validate the CSRF token.
+ if (in_array($request->getMethod(), ['POST', 'PUT', 'DELETE'])) {
+ // Validate the token unless we are on an excluded route
+ // Get the current route pattern
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+ $resource = $route->getPattern();
+
+ $excludedRoutes = $request->getAttribute('excludedCsrfRoutes');
+
+ if (($excludedRoutes !== null && is_array($excludedRoutes) && in_array($resource, $excludedRoutes))
+ || (Environment::isDevMode() && $resource === '/login')
+ ) {
+ $container->get('logger')->info('Route excluded from CSRF: ' . $resource);
+ } else {
+ // Checking CSRF
+ $userToken = $request->getHeaderLine('X-XSRF-TOKEN');
+
+ if ($userToken == '') {
+ $parsedBody = $request->getParsedBody();
+ foreach ($parsedBody as $param => $value) {
+ if ($param == $this->key) {
+ $userToken = $value;
+ }
+ }
+ }
+
+ if ($token !== $userToken) {
+ throw new ExpiredException(__('Sorry the form has expired. Please refresh.'));
+ }
+ }
+ }
+
+ // Assign CSRF token key and value to view.
+ $container->get('view')->offsetSet('csrfKey', $this->key);
+ $container->get('view')->offsetSet('csrfToken',$token);
+
+ // Call next middleware.
+ return $handler->handle($request);
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/CustomDisplayProfileInterface.php b/lib/Middleware/CustomDisplayProfileInterface.php
new file mode 100644
index 0000000..ec8be85
--- /dev/null
+++ b/lib/Middleware/CustomDisplayProfileInterface.php
@@ -0,0 +1,76 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Xibo\Entity\Display;
+use Xibo\Entity\DisplayProfile;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+interface CustomDisplayProfileInterface
+{
+ /**
+ * Return Display Profile type
+ * @return string
+ */
+ public static function getType():string;
+
+ /**
+ * Return Display Profile name
+ * @return string
+ */
+ public static function getName():string;
+
+ /**
+ * This function should return an array with default Display Profile config.
+ *
+ * @param ConfigServiceInterface $configService
+ * @return array
+ */
+ public static function getDefaultConfig(ConfigServiceInterface $configService) : array;
+
+ /**
+ * This function should return full name, including extension (.twig) to the custom display profile edit form
+ * the file is expected to be in the /custom folder along the custom Middleware.
+ * To match naming convention twig file should be called displayprofile-form-edit-.twig
+ * This will be done automatically from the CustomDisplayProfileMiddlewareTrait.
+ *
+ * If you have named your twig file differently, override getCustomEditTemplate function in your middleware
+ * @return string
+ */
+ public static function getCustomEditTemplate() : string;
+
+ /**
+ * This function handles any changes to the default Display Profile settings, as well as overrides per Display.
+ * Each editable setting should have handling here.
+ *
+ * @param DisplayProfile $displayProfile
+ * @param SanitizerInterface $sanitizedParams
+ * @param array|null $config
+ * @param Display|null $display
+ * @param LogServiceInterface $logService
+ * @return array
+ */
+ public static function editCustomConfigFields(DisplayProfile $displayProfile, SanitizerInterface $sanitizedParams, ?array $config, ?Display $display, LogServiceInterface $logService) : array;
+}
diff --git a/lib/Middleware/CustomDisplayProfileMiddlewareTrait.php b/lib/Middleware/CustomDisplayProfileMiddlewareTrait.php
new file mode 100644
index 0000000..75c47b8
--- /dev/null
+++ b/lib/Middleware/CustomDisplayProfileMiddlewareTrait.php
@@ -0,0 +1,143 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+trait CustomDisplayProfileMiddlewareTrait
+{
+ /**
+ * @return string
+ */
+ public static function getClass():string
+ {
+ return self::class;
+ }
+
+ public static function getEditTemplateFunctionName():string
+ {
+ return 'getCustomEditTemplate';
+ }
+
+ public static function getDefaultConfigFunctionName():string
+ {
+ return 'getDefaultConfig';
+ }
+
+ public static function getEditCustomFieldsFunctionName():string
+ {
+ return 'editCustomConfigFields';
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws InvalidArgumentException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $this->getFromContainer('logService')->debug('Loading additional Middleware for Custom Display Profile type:' . self::getType());
+
+ $store = $this->getFromContainer('store');
+ $results = $store->select('SELECT displayProfileId FROM displayprofile WHERE type = :type', ['type' => self::getType()]);
+
+ if (count($results) <= 0) {
+ $profile = $this->getFromContainer('displayProfileFactory')->createCustomProfile([
+ 'name' => self::getName(),
+ 'type' => self::getType(),
+ 'isDefault' => 1,
+ 'userId' => $this->getFromContainer('userFactory')->getSuperAdmins()[0]->userId
+ ]);
+ $profile->save();
+ }
+
+ $this->getFromContainer('displayProfileFactory')->registerCustomDisplayProfile(
+ self::getType(),
+ self::getClass(),
+ self::getEditTemplateFunctionName(),
+ self::getDefaultConfigFunctionName(),
+ self::getEditCustomFieldsFunctionName()
+ );
+ // Next middleware
+ return $handler->handle($request);
+ }
+
+ /**
+ * @return string
+ * @throws InvalidArgumentException
+ */
+ public static function getCustomEditTemplate() : string
+ {
+ return 'displayprofile-form-edit-'.self::getType().'.twig';
+ }
+
+ /** @var \Slim\App */
+ private $app;
+
+ /**
+ * @param \Slim\App $app
+ * @return $this
+ */
+ public function setApp(App $app)
+ {
+ $this->app = $app;
+ return $this;
+ }
+
+ /**
+ * @return \Slim\App
+ */
+ protected function getApp()
+ {
+ return $this->app;
+ }
+
+ /**
+ * @return \Psr\Container\ContainerInterface|null
+ */
+ protected function getContainer()
+ {
+ return $this->app->getContainer();
+ }
+
+ /***
+ * @param $key
+ * @return mixed
+ */
+ protected function getFromContainer($key)
+ {
+ return $this->getContainer()->get($key);
+ }
+
+ private static function handleChangedSettings($setting, $oldValue, $newValue, &$changedSettings)
+ {
+ if ($oldValue != $newValue) {
+ $changedSettings[$setting] = $oldValue . ' > ' . $newValue;
+ }
+ }
+}
diff --git a/lib/Middleware/CustomMiddlewareTrait.php b/lib/Middleware/CustomMiddlewareTrait.php
new file mode 100644
index 0000000..5794b01
--- /dev/null
+++ b/lib/Middleware/CustomMiddlewareTrait.php
@@ -0,0 +1,90 @@
+.
+ */
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\App;
+
+/**
+ * Trait CustomMiddlewareTrait
+ * Add this trait to all custom middleware
+ * @package Xibo\Middleware
+ */
+trait CustomMiddlewareTrait
+{
+ /** @var \Slim\App */
+ private $app;
+
+ /**
+ * @param \Slim\App $app
+ * @return $this
+ */
+ public function setApp(App $app)
+ {
+ $this->app = $app;
+ return $this;
+ }
+
+ /**
+ * @return \Slim\App
+ */
+ protected function getApp()
+ {
+ return $this->app;
+ }
+
+ /**
+ * @return \DI\Container|\Psr\Container\ContainerInterface
+ */
+ protected function getContainer()
+ {
+ return $this->app->getContainer();
+ }
+
+ /**
+ * @param $key
+ * @return mixed
+ * @throws \DI\DependencyException
+ * @throws \DI\NotFoundException
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ */
+ protected function getFromContainer($key): mixed
+ {
+ return $this->getContainer()->get($key);
+ }
+
+ /**
+ * Append public routes
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @param array $routes
+ * @return \Psr\Http\Message\ServerRequestInterface
+ */
+ protected function appendPublicRoutes(ServerRequestInterface $request, array $routes): ServerRequestInterface
+ {
+ // Set some public routes
+ return $request->withAttribute(
+ 'publicRoutes',
+ array_merge($request->getAttribute('publicRoutes', []), $routes)
+ );
+ }
+}
diff --git a/lib/Middleware/FeatureAuth.php b/lib/Middleware/FeatureAuth.php
new file mode 100644
index 0000000..d54ce4b
--- /dev/null
+++ b/lib/Middleware/FeatureAuth.php
@@ -0,0 +1,89 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class FeatureAuth
+ * This is route middleware to checks user access against a set of features
+ * @package Xibo\Middleware
+ */
+class FeatureAuth implements MiddlewareInterface
+{
+ /** @var \Psr\Container\ContainerInterface */
+ private $container;
+
+ /** @var array */
+ private $features;
+
+ /**
+ * FeatureAuth constructor.
+ * @param ContainerInterface $container
+ * @param array $features an array of one or more features which would authorize access
+ */
+ public function __construct(ContainerInterface $container, array $features)
+ {
+ $this->container = $container;
+ $this->features = $features;
+ }
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @param \Psr\Http\Server\RequestHandlerInterface $handler
+ * @return \Psr\Http\Message\ResponseInterface
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ // If no features are provided, then this must be public
+ if (count($this->features) <= 0) {
+ // We handle the rest of the request
+ return $handler->handle($request);
+ }
+
+ // Compare the features requested with the features this user has access to.
+ // if none match, throw 403
+ foreach ($this->features as $feature) {
+ if ($this->getUser()->featureEnabled($feature)) {
+ // We handle the rest of the request
+ return $handler->handle($request);
+ }
+ }
+
+ throw new AccessDeniedException(__('Feature not enabled'), __('This feature has not been enabled for your user.'));
+ }
+
+ /**
+ * @return \Xibo\Entity\User
+ */
+ private function getUser()
+ {
+ return $this->container->get('user');
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/Handlers.php b/lib/Middleware/Handlers.php
new file mode 100644
index 0000000..93ea142
--- /dev/null
+++ b/lib/Middleware/Handlers.php
@@ -0,0 +1,289 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Illuminate\Support\Str;
+use League\OAuth2\Server\Exception\OAuthServerException;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Slim\Exception\HttpNotFoundException;
+use Slim\Exception\HttpSpecializedException;
+use Slim\Http\Factory\DecoratedResponseFactory;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Helper\Environment;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ExpiredException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InstanceSuspendedException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\UpgradePendingException;
+
+/**
+ * Class Handlers
+ * @package Xibo\Middleware
+ */
+class Handlers
+{
+ /**
+ * A JSON error handler to format and output a JSON response and HTTP status code depending on the error received.
+ * @param \Psr\Container\ContainerInterface $container
+ * @return \Closure
+ */
+ public static function jsonErrorHandler($container)
+ {
+ return function (Request $request, \Throwable $exception, bool $displayErrorDetails, bool $logErrors, bool $logErrorDetails) use ($container) {
+ self::rollbackAndCloseStore($container);
+ self::writeLog($request, $logErrors, $logErrorDetails, $exception, $container);
+
+ // Generate a response (start with a 500)
+ $nyholmFactory = new Psr17Factory();
+ $decoratedResponseFactory = new DecoratedResponseFactory($nyholmFactory, $nyholmFactory);
+
+ /** @var Response $response */
+ $response = $decoratedResponseFactory->createResponse(500);
+
+ if ($exception instanceof GeneralException || $exception instanceof OAuthServerException) {
+ return $exception->generateHttpResponse($response);
+ } else if ($exception instanceof HttpSpecializedException) {
+ return $response->withJson([
+ 'success' => false,
+ 'error' => $exception->getCode(),
+ 'message' => $exception->getTitle(),
+ 'help' => $exception->getDescription()
+ ]);
+ } else {
+ // Any other exception, check to see if we hide the real message
+ return $response->withJson([
+ 'success' => false,
+ 'error' => 500,
+ 'message' => $displayErrorDetails
+ ? $exception->getMessage()
+ : __('Unexpected Error, please contact support.')
+ ]);
+ }
+ };
+ }
+
+ /**
+ * @param \Psr\Container\ContainerInterface $container
+ * @return \Closure
+ */
+ public static function webErrorHandler($container)
+ {
+ return function (Request $request, \Throwable $exception, bool $displayErrorDetails, bool $logErrors, bool $logErrorDetails) use ($container) {
+ self::rollbackAndCloseStore($container);
+ self::writeLog($request, $logErrors, $logErrorDetails, $exception, $container);
+
+ // Create a response
+ // we're outside Slim's middleware here, so we have to handle the response ourselves.
+ $nyholmFactory = new Psr17Factory();
+ $decoratedResponseFactory = new DecoratedResponseFactory($nyholmFactory, $nyholmFactory);
+ $response = $decoratedResponseFactory->createResponse();
+
+ // We need to build all the functions required in the views manually because our middleware stack will
+ // not have been built for this handler.
+ // Slim4 has made this much more difficult!
+ // Terrible in fact.
+
+ // Get the Twig view
+ /** @var \Slim\Views\Twig $twig */
+ $twig = $container->get('view');
+
+ /** @var \Xibo\Service\ConfigService $configService */
+ $configService = $container->get('configService');
+ $configService->setDependencies($container->get('store'), $container->get('rootUri'));
+ $configService->loadTheme();
+
+ // Do we need to issue STS?
+ $response = HttpsDetect::decorateWithStsIfNecessary($configService, $request, $response);
+
+ // Prepend our theme files to the view path
+ // Does this theme provide an alternative view path?
+ if ($configService->getThemeConfig('view_path') != '') {
+ $twig->getLoader()->prependPath(Str::replaceFirst('..', PROJECT_ROOT,
+ $configService->getThemeConfig('view_path')));
+ }
+
+ // We have translated error/not-found
+ Translate::InitLocale($configService);
+ // Build up our own params to pass to Twig
+ $viewParams = [
+ 'theme' => $configService,
+ 'homeUrl' => $configService->rootUri(),
+ 'aboutUrl' => $configService->rootUri() . 'about',
+ 'loginUrl' => $configService->rootUri() . 'login',
+ 'version' => Environment::$WEBSITE_VERSION_NAME
+ ];
+
+ // Handle 404's
+ if ($exception instanceof HttpNotFoundException) {
+ if ($request->isXhr()) {
+ return $response->withJson([
+ 'success' => false,
+ 'error' => 404,
+ 'message' => __('Sorry we could not find that page.')
+ ], 404);
+ } else {
+ return $twig->render($response, 'not-found.twig', $viewParams)->withStatus(404);
+ }
+ } else {
+ // Make a friendly message
+ if ($displayErrorDetails || $exception instanceof GeneralException) {
+ $message = htmlspecialchars($exception->getMessage());
+ } else {
+ $message = __('Unexpected Error, please contact support.');
+ }
+
+ // Parse out data for the exception
+ $exceptionData = [
+ 'success' => false,
+ 'error' => $exception->getCode(),
+ 'message' => $message
+ ];
+
+ // TODO: we need to update the support library to make getErrorData public
+ /*if ($exception instanceof GeneralException) {
+ array_merge($exception->getErrorData(), $exceptionData);
+ }*/
+
+ if ($request->isXhr()) {
+ // Note: these are currently served as 200's, which is expected by the FE.
+ return $response->withJson($exceptionData);
+ } else {
+ // What status code?
+ $statusCode = 500;
+ if ($exception instanceof GeneralException) {
+ $statusCode = $exception->getHttpStatusCode();
+ }
+ if ($exception instanceof HttpSpecializedException) {
+ $statusCode = $exception->getCode();
+ }
+
+ // Decide which error page we should load
+ $exceptionClass = 'error-' . strtolower(str_replace('\\', '-', get_class($exception)));
+
+ // Override the page for an Upgrade Pending Exception
+ if ($exception instanceof UpgradePendingException) {
+ $exceptionClass = 'upgrade-in-progress-page';
+ }
+
+ if (file_exists(PROJECT_ROOT . '/views/' . $exceptionClass . '.twig')) {
+ $template = $exceptionClass;
+ } else {
+ $template = 'error';
+ }
+
+ try {
+ return $twig->render($response, $template . '.twig', array_merge($viewParams, $exceptionData))
+ ->withStatus($statusCode);
+ } catch (\Exception $exception) {
+ $response->getBody()->write('Fatal error');
+ return $response->withStatus(500);
+ }
+ }
+ }
+ };
+ }
+
+ /**
+ * @param \Psr\Container\ContainerInterface $container
+ * @return \Closure
+ */
+ public static function testErrorHandler($container)
+ {
+ return function (Request $request, \Throwable $exception, bool $displayErrorDetails, bool $logErrors, bool $logErrorDetails) use ($container) {
+ self::rollbackAndCloseStore($container);
+
+ $nyholmFactory = new Psr17Factory();
+ $decoratedResponseFactory = new DecoratedResponseFactory($nyholmFactory, $nyholmFactory);
+ /** @var Response $response */
+ $response = $decoratedResponseFactory->createResponse($exception->getCode());
+
+ return $response->withJson([
+ 'success' => false,
+ 'error' => $exception->getMessage(),
+ 'httpStatus' => $exception->getCode(),
+ 'data' => []
+ ]);
+ };
+ }
+
+ /**
+ * Determine if we are a handled exception
+ * @param $e
+ * @return bool
+ */
+ private static function handledError($e): bool
+ {
+ return ($e instanceof InvalidArgumentException
+ || $e instanceof ExpiredException
+ || $e instanceof AccessDeniedException
+ || $e instanceof InstanceSuspendedException
+ || $e instanceof UpgradePendingException
+ );
+ }
+
+ /**
+ * @param \Psr\Container\ContainerInterface $container
+ */
+ private static function rollbackAndCloseStore($container)
+ {
+ // If we are in a transaction, then we should rollback.
+ if ($container->get('store')->getConnection()->inTransaction()) {
+ $container->get('store')->getConnection()->rollBack();
+ }
+ $container->get('store')->close();
+ }
+
+ /**
+ * @param Request $request
+ * @param bool $logErrors
+ * @param bool $logErrorDetails
+ * @param \Throwable $exception
+ * @param \Psr\Container\ContainerInterface $container
+ */
+ private static function writeLog($request, bool $logErrors, bool $logErrorDetails, \Throwable $exception, $container)
+ {
+ /** @var \Psr\Log\LoggerInterface $logger */
+ $logger = $container->get('logger');
+
+ // Add a processor to our log handler
+ Log::addLogProcessorToLogger($logger, $request);
+
+ // Handle logging the error.
+ if ($logErrors && !self::handledError($exception)) {
+ $logger->error($exception->getMessage());
+
+ if ($logErrorDetails) {
+ $logger->debug($exception->getTraceAsString());
+
+ $previous = $exception->getPrevious();
+ if ($previous !== null) {
+ $logger->debug(get_class($previous) . ': ' . $previous->getMessage());
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/HttpCache.php b/lib/Middleware/HttpCache.php
new file mode 100644
index 0000000..4a99ed4
--- /dev/null
+++ b/lib/Middleware/HttpCache.php
@@ -0,0 +1,120 @@
+.
+ */
+
+namespace Xibo\Middleware;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+
+/**
+ * Class HttpCache
+ * Http cache
+ * @package Xibo\Middleware
+ */
+class HttpCache implements Middleware
+{
+ /**
+ * Cache-Control type (public or private)
+ *
+ * @var string
+ */
+ protected $type;
+
+ /**
+ * Cache-Control max age in seconds
+ *
+ * @var int
+ */
+ protected $maxAge;
+
+ /**
+ * Cache-Control includes must-revalidate flag
+ *
+ * @var bool
+ */
+ protected $mustRevalidate;
+
+ public function __construct($type = 'private', $maxAge = 86400, $mustRevalidate = false)
+ {
+ $this->type = $type;
+ $this->maxAge = $maxAge;
+ $this->mustRevalidate = $mustRevalidate;
+ }
+
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $response = $handler->handle($request);
+
+ // Cache-Control header
+ if (!$response->hasHeader('Cache-Control')) {
+ if ($this->maxAge === 0) {
+ $response = $response->withHeader('Cache-Control', sprintf(
+ '%s, no-cache%s',
+ $this->type,
+ $this->mustRevalidate ? ', must-revalidate' : ''
+ ));
+ } else {
+ $response = $response->withHeader('Cache-Control', sprintf(
+ '%s, max-age=%s%s',
+ $this->type,
+ $this->maxAge,
+ $this->mustRevalidate ? ', must-revalidate' : ''
+ ));
+ }
+ }
+
+ // ETag header and conditional GET check
+ $etag = $response->getHeader('ETag');
+ $etag = reset($etag);
+
+ if ($etag) {
+ $ifNoneMatch = $request->getHeaderLine('If-None-Match');
+
+ if ($ifNoneMatch) {
+ $etagList = preg_split('@\s*,\s*@', $ifNoneMatch);
+ if (in_array($etag, $etagList) || in_array('*', $etagList)) {
+ return $response->withStatus(304);
+ }
+ }
+ }
+
+
+ // Last-Modified header and conditional GET check
+ $lastModified = $response->getHeaderLine('Last-Modified');
+
+ if ($lastModified) {
+ if (!is_integer($lastModified)) {
+ $lastModified = strtotime($lastModified);
+ }
+
+ $ifModifiedSince = $request->getHeaderLine('If-Modified-Since');
+
+ if ($ifModifiedSince && $lastModified <= strtotime($ifModifiedSince)) {
+ return $response->withStatus(304);
+ }
+ }
+
+ return $response;
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/LayoutLock.php b/lib/Middleware/LayoutLock.php
new file mode 100644
index 0000000..08df78f
--- /dev/null
+++ b/lib/Middleware/LayoutLock.php
@@ -0,0 +1,218 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Psr\Log\LoggerInterface;
+use Slim\App;
+use Slim\Routing\RouteContext;
+use Stash\Invalidation;
+use Stash\Item;
+use Stash\Pool;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * This Middleware will Lock the Layout for the specific User and entry point
+ * It is not added on the whole Application stack, instead it's added to selected groups of routes in routes.php
+ *
+ * For a User designing a Layout there will be no change in the way that User interacts with it
+ * However if the same Layout will be accessed by different User or Entry Point then this middleware will throw
+ * an Exception with suitable message.
+ */
+class LayoutLock implements Middleware
+{
+ /** @var Item */
+ private $lock;
+
+ private $layoutId;
+
+ private $userId;
+
+ private $entryPoint;
+
+ private LoggerInterface $logger;
+
+ /**
+ * @param \Slim\App $app
+ * @param int $ttl
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ */
+ public function __construct(
+ private readonly App $app,
+ private readonly int $ttl = 300
+ ) {
+ $this->logger = $this->app->getContainer()->get('logService')->getLoggerInterface();
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+
+ // what route are we in?
+ $resource = $route->getPattern();
+ $routeName = $route->getName();
+
+ // skip for test suite
+ if ($request->getAttribute('_entryPoint') === 'test' && $this->app->getContainer()->get('_entryPoint') === 'test') {
+ return $handler->handle($request);
+ }
+
+ $this->logger->debug('layoutLock: testing route ' . $routeName . ', pattern ' . $resource);
+
+ if (str_contains($resource, 'layout') !== false) {
+ // Layout route, we can get the Layout id from route argument.
+ $this->layoutId = (int)$route->getArgument('id');
+ } elseif (str_contains($resource, 'region') !== false) {
+ // Region route, we need to get the Layout Id from layoutFactory by Region Id
+ // if it's POST request or positionAll then id in route is already LayoutId we can use
+ if (str_contains($resource, 'position') !== false || $route->getMethods()[0] === 'POST') {
+ $this->layoutId = (int)$route->getArgument('id');
+ } else {
+ $regionId = (int)$route->getArgument('id');
+ $this->layoutId = $this->app->getContainer()->get('layoutFactory')->getByRegionId($regionId)->layoutId;
+ }
+ } else if (str_contains($routeName, 'playlist') !== false || $routeName === 'module.widget.add') {
+ // Playlist Route, we need to get to LayoutId, Widget add the same behaviour.
+ $playlistId = (int)$route->getArgument('id');
+ $regionId = $this->app->getContainer()->get('playlistFactory')->getById($playlistId)->regionId;
+
+ // if we are assigning media or ordering Region Playlist, then we will have regionId
+ // otherwise it's non Region specific Playlist, in which case we are not interested in locking anything.
+ if ($regionId != null) {
+ $this->layoutId = $this->app->getContainer()->get('layoutFactory')->getByRegionId($regionId)->layoutId;
+ }
+ } else if (str_contains($routeName, 'widget') !== false) {
+ // Widget route, the id route argument will be Widget Id
+ $widgetId = (int)$route->getArgument('id');
+
+ // get the Playlist Id for this Widget
+ $playlistId = $this->app->getContainer()->get('widgetFactory')->getById($widgetId)->playlistId;
+ $regionId = $this->app->getContainer()->get('playlistFactory')->getById($playlistId)->regionId;
+
+ // check if it's Region specific Playlist, otherwise we don't lock anything.
+ if ($regionId != null) {
+ $this->layoutId = $this->app->getContainer()->get('layoutFactory')->getByRegionId($regionId)->layoutId;
+ }
+ } else {
+ // this should never happen
+ throw new GeneralException(sprintf(
+ __('Layout Lock Middleware called with incorrect route %s'),
+ $route->getPattern(),
+ ));
+ }
+
+ // run only if we have layout id, that will exclude non Region specific Playlist requests.
+ if ($this->layoutId !== null) {
+ $this->userId = $this->app->getContainer()->get('user')->userId;
+ $this->entryPoint = $this->app->getContainer()->get('name');
+ $key = $this->getKey();
+ $this->lock = $this->getPool()->getItem('locks/layout/' . $key);
+
+ $objectToCache = new \stdClass();
+ $objectToCache->layoutId = $this->layoutId;
+ $objectToCache->userId = $this->userId;
+ $objectToCache->entryPoint = $this->entryPoint;
+
+ $this->logger->debug('Layout Lock middleware for LayoutId ' . $this->layoutId
+ . ' userId ' . $this->userId . ' emtrypoint ' . $this->entryPoint);
+
+ $this->lock->setInvalidationMethod(Invalidation::OLD);
+
+ // Get the lock
+ // other requests will wait here until we're done, or we've timed out
+ $locked = $this->lock->get();
+ $this->logger->debug('$locked is ' . var_export($locked, true) . ', key = ' . $key);
+
+ if ($this->lock->isMiss() || $locked === []) {
+ $this->logger->debug('Lock miss or false. Locking for ' . $this->ttl . ' seconds. $locked is '
+ . var_export($locked, true) . ', key = ' . $key);
+
+ // so lock now
+ $this->lock->expiresAfter($this->ttl);
+ $objectToCache->expires = $this->lock->getExpiration()->format(DateFormatHelper::getSystemFormat());
+ $this->lock->set($objectToCache);
+ $this->lock->save();
+ } else {
+ // We are a hit - we must be locked
+ $this->logger->debug('LOCK hit for ' . $key . ' expires '
+ . $this->lock->getExpiration()->format(DateFormatHelper::getSystemFormat()) . ', created '
+ . $this->lock->getCreation()->format(DateFormatHelper::getSystemFormat()));
+
+ if ($locked->userId == $this->userId && $locked->entryPoint == $this->entryPoint) {
+ // the same user in the same entry point is editing the same layoutId
+ $this->lock->expiresAfter($this->ttl);
+ $objectToCache->expires = $this->lock->getExpiration()->format(DateFormatHelper::getSystemFormat());
+ $this->lock->set($objectToCache);
+ $this->lock->save();
+
+ $this->logger->debug('Lock extended to '
+ . $this->lock->getExpiration()->format(DateFormatHelper::getSystemFormat()));
+ } else {
+ // different user or entry point
+ $this->logger->debug('Sorry Layout is locked by another User!');
+ throw new AccessDeniedException(sprintf(
+ __('Layout ID %d is locked by another User! Lock expires on: %s'),
+ $locked->layoutId,
+ $locked->expires
+ ));
+ }
+ }
+ }
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * @return Pool
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ */
+ private function getPool()
+ {
+ return $this->app->getContainer()->get('pool');
+ }
+
+ /**
+ * Get the lock key
+ * @return mixed
+ */
+ private function getKey()
+ {
+ return $this->layoutId;
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/ListenersMiddleware.php b/lib/Middleware/ListenersMiddleware.php
new file mode 100644
index 0000000..c250628
--- /dev/null
+++ b/lib/Middleware/ListenersMiddleware.php
@@ -0,0 +1,424 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App;
+use Xibo\Event\CommandDeleteEvent;
+use Xibo\Event\DependencyFileSizeEvent;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\FolderMovingEvent;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Event\MediaFullLoadEvent;
+use Xibo\Event\ParsePermissionEntityEvent;
+use Xibo\Event\PlaylistMaxNumberChangedEvent;
+use Xibo\Event\SystemUserChangedEvent;
+use Xibo\Event\UserDeleteEvent;
+use Xibo\Listener\CampaignListener;
+use Xibo\Listener\DataSetDataProviderListener;
+use Xibo\Listener\DisplayGroupListener;
+use Xibo\Listener\LayoutListener;
+use Xibo\Listener\MediaListener;
+use Xibo\Listener\MenuBoardProviderListener;
+use Xibo\Listener\ModuleTemplateListener;
+use Xibo\Listener\NotificationDataProviderListener;
+use Xibo\Listener\PlaylistListener;
+use Xibo\Listener\SyncGroupListener;
+use Xibo\Listener\TaskListener;
+use Xibo\Listener\WidgetListener;
+use Xibo\Xmds\Listeners\XmdsAssetsListener;
+use Xibo\Xmds\Listeners\XmdsDataConnectorListener;
+use Xibo\Xmds\Listeners\XmdsFontsListener;
+use Xibo\Xmds\Listeners\XmdsPlayerBundleListener;
+use Xibo\Xmds\Listeners\XmdsPlayerVersionListener;
+
+/**
+ * This middleware is used to register listeners against the dispatcher
+ */
+class ListenersMiddleware implements MiddlewareInterface
+{
+ /* @var App $app */
+ private $app;
+
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+
+ // Set connectors
+ self::setListeners($app);
+
+ // Next middleware
+ return $handler->handle($request);
+ }
+
+ /**
+ * Set listeners
+ * @param \Slim\App $app
+ * @return void
+ */
+ public static function setListeners(App $app)
+ {
+ $c = $app->getContainer();
+ $dispatcher = $c->get('dispatcher');
+
+ // Register listeners
+ // ------------------
+ // Listen for events that affect campaigns
+ (new CampaignListener(
+ $c->get('campaignFactory'),
+ $c->get('store')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for events that affect Layouts
+ (new LayoutListener(
+ $c->get('layoutFactory'),
+ $c->get('store'),
+ $c->get('permissionFactory'),
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for event that affect Display Groups
+ (new DisplayGroupListener(
+ $c->get('displayGroupFactory'),
+ $c->get('displayFactory'),
+ $c->get('store')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for event that affect Media
+ (new MediaListener(
+ $c->get('mediaFactory'),
+ $c->get('store')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for events that affect ModuleTemplates
+ (new ModuleTemplateListener(
+ $c->get('moduleTemplateFactory'),
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for event that affect Playlist
+ (new PlaylistListener(
+ $c->get('playlistFactory'),
+ $c->get('store')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for event that affect Sync Group
+ (new SyncGroupListener(
+ $c->get('syncGroupFactory'),
+ $c->get('store')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Listen for event that affect Task
+ (new TaskListener(
+ $c->get('taskFactory'),
+ $c->get('configService'),
+ $c->get('pool')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ // Media Delete Events
+ $dispatcher->addListener(MediaDeleteEvent::$NAME, (new \Xibo\Listener\OnMediaDelete\MenuBoardListener(
+ $c->get('menuBoardCategoryFactory')
+ )));
+
+ $dispatcher->addListener(MediaDeleteEvent::$NAME, (new \Xibo\Listener\OnMediaDelete\WidgetListener(
+ $c->get('store'),
+ $c->get('widgetFactory'),
+ $c->get('moduleFactory')
+ )));
+
+ $dispatcher->addListener(MediaDeleteEvent::$NAME, (new \Xibo\Listener\OnMediaDelete\PurgeListListener(
+ $c->get('store'),
+ $c->get('configService')
+ )));
+
+ // User Delete Events
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\ActionListener(
+ $c->get('store'),
+ $c->get('actionFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\CommandListener(
+ $c->get('store'),
+ $c->get('commandFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\DataSetListener(
+ $c->get('store'),
+ $c->get('dataSetFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\DayPartListener(
+ $c->get('store'),
+ $c->get('dayPartFactory'),
+ $c->get('scheduleFactory'),
+ $c->get('displayNotifyService')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\DisplayProfileListener(
+ $c->get('store'),
+ $c->get('displayProfileFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\MenuBoardListener(
+ $c->get('store'),
+ $c->get('menuBoardFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\NotificationListener(
+ $c->get('notificationFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\OnUserDelete(
+ $c->get('store')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\RegionListener(
+ $c->get('regionFactory')
+ ))->useLogger($c->get('logger')), -1);
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\ReportScheduleListener(
+ $c->get('store'),
+ $c->get('reportScheduleFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\ResolutionListener(
+ $c->get('store'),
+ $c->get('resolutionFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\SavedReportListener(
+ $c->get('store'),
+ $c->get('savedReportFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\ScheduleListener(
+ $c->get('store'),
+ $c->get('scheduleFactory')
+ ))->useLogger($c->get('logger')));
+
+ $dispatcher->addListener(UserDeleteEvent::$NAME, (new \Xibo\Listener\OnUserDelete\WidgetListener(
+ $c->get('widgetFactory')
+ ))->useLogger($c->get('logger')), -2);
+
+ // Display Group Load events
+ $dispatcher->addListener(DisplayGroupLoadEvent::$NAME, (new \Xibo\Listener\OnDisplayGroupLoad\DisplayGroupDisplayListener(
+ $c->get('displayFactory')
+ )));
+
+ $dispatcher->addListener(DisplayGroupLoadEvent::$NAME, (new \Xibo\Listener\OnDisplayGroupLoad\DisplayGroupScheduleListener(
+ $c->get('scheduleFactory')
+ )));
+
+ // Media full load events
+ $dispatcher->addListener(MediaFullLoadEvent::$NAME, (new \Xibo\Listener\OnMediaLoad\WidgetListener(
+ $c->get('widgetFactory')
+ )));
+
+ // Parse Permissions Event Listeners
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'command', (new \Xibo\Listener\OnParsePermissions\PermissionsCommandListener(
+ $c->get('commandFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'dataSet', (new \Xibo\Listener\OnParsePermissions\PermissionsDataSetListener(
+ $c->get('dataSetFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'dayPart', (new \Xibo\Listener\OnParsePermissions\PermissionsDayPartListener(
+ $c->get('dayPartFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'folder', (new \Xibo\Listener\OnParsePermissions\PermissionsFolderListener(
+ $c->get('folderFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'menuBoard', (new \Xibo\Listener\OnParsePermissions\PermissionsMenuBoardListener(
+ $c->get('menuBoardFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'notification', (new \Xibo\Listener\OnParsePermissions\PermissionsNotificationListener(
+ $c->get('notificationFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'region', (new \Xibo\Listener\OnParsePermissions\PermissionsRegionListener(
+ $c->get('regionFactory')
+ )));
+
+ $dispatcher->addListener(ParsePermissionEntityEvent::$NAME . 'widget', (new \Xibo\Listener\OnParsePermissions\PermissionsWidgetListener(
+ $c->get('widgetFactory')
+ )));
+
+ // On Command delete event listener
+ $dispatcher->addListener(CommandDeleteEvent::$NAME, (new \Xibo\Listener\OnCommandDelete(
+ $c->get('displayProfileFactory')
+ )));
+
+ // On System User change event listener
+ $dispatcher->addListener(SystemUserChangedEvent::$NAME, (new \Xibo\Listener\OnSystemUserChange(
+ $c->get('store')
+ )));
+
+ // On Playlist Max Number of Items limit change listener
+ $dispatcher->addListener(PlaylistMaxNumberChangedEvent::$NAME, (new \Xibo\Listener\OnPlaylistMaxNumberChange(
+ $c->get('store')
+ )));
+
+ // On Folder moving listeners
+ $dispatcher->addListener(FolderMovingEvent::$NAME, (new \Xibo\Listener\OnFolderMoving\DataSetListener(
+ $c->get('dataSetFactory')
+ )));
+
+ $dispatcher->addListener(FolderMovingEvent::$NAME, (new \Xibo\Listener\OnFolderMoving\FolderListener(
+ $c->get('folderFactory')
+ )), -1);
+
+ $dispatcher->addListener(FolderMovingEvent::$NAME, (new \Xibo\Listener\OnFolderMoving\MenuBoardListener(
+ $c->get('menuBoardFactory')
+ )));
+
+ $dispatcher->addListener(FolderMovingEvent::$NAME, (new \Xibo\Listener\OnFolderMoving\UserListener(
+ $c->get('userFactory'),
+ $c->get('store')
+ )));
+
+ // dependencies file size
+ $dispatcher->addListener(DependencyFileSizeEvent::$NAME, (new \Xibo\Listener\OnGettingDependencyFileSize\FontsListener(
+ $c->get('fontFactory')
+ )));
+
+ $dispatcher->addListener(DependencyFileSizeEvent::$NAME, (new \Xibo\Listener\OnGettingDependencyFileSize\PlayerVersionListener(
+ $c->get('playerVersionFactory')
+ )));
+
+ $dispatcher->addListener(DependencyFileSizeEvent::$NAME, (new \Xibo\Listener\OnGettingDependencyFileSize\SavedReportListener(
+ $c->get('savedReportFactory')
+ )));
+
+ // Widget related listeners for getting core data
+ (new DataSetDataProviderListener(
+ $c->get('store'),
+ $c->get('configService'),
+ $c->get('dataSetFactory'),
+ $c->get('displayFactory')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ (new NotificationDataProviderListener(
+ $c->get('configService'),
+ $c->get('notificationFactory'),
+ $c->get('user')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ (new WidgetListener(
+ $c->get('playlistFactory'),
+ $c->get('moduleFactory'),
+ $c->get('widgetFactory'),
+ $c->get('store'),
+ $c->get('configService')
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+
+ (new MenuBoardProviderListener(
+ $c->get('menuBoardFactory'),
+ $c->get('menuBoardCategoryFactory'),
+ ))
+ ->useLogger($c->get('logger'))
+ ->registerWithDispatcher($dispatcher);
+ }
+
+ /**
+ * Set XMDS specific listeners
+ * @param App $app
+ * @return void
+ */
+ public static function setXmdsListeners(App $app)
+ {
+ $c = $app->getContainer();
+ $dispatcher = $c->get('dispatcher');
+
+ $playerBundleListener = new XmdsPlayerBundleListener();
+ $playerBundleListener
+ ->useLogger($c->get('logger'))
+ ->useConfig($c->get('configService'));
+
+ $fontsListener = new XmdsFontsListener($c->get('fontFactory'));
+ $fontsListener
+ ->useLogger($c->get('logger'))
+ ->useConfig($c->get('configService'));
+
+ $playerVersionListner = new XmdsPlayerVersionListener($c->get('playerVersionFactory'));
+ $playerVersionListner->useLogger($c->get('logger'));
+
+ $assetsListener = new XmdsAssetsListener(
+ $c->get('moduleFactory'),
+ $c->get('moduleTemplateFactory')
+ );
+ $assetsListener
+ ->useLogger($c->get('logger'))
+ ->useConfig($c->get('configService'));
+
+ $dataConnectorListener = new XmdsDataConnectorListener();
+ $dataConnectorListener
+ ->useLogger($c->get('logger'))
+ ->useConfig($c->get('configService'));
+
+ $dispatcher->addListener('xmds.dependency.list', [$playerBundleListener, 'onDependencyList']);
+ $dispatcher->addListener('xmds.dependency.request', [$playerBundleListener, 'onDependencyRequest']);
+ $dispatcher->addListener('xmds.dependency.list', [$fontsListener, 'onDependencyList']);
+ $dispatcher->addListener('xmds.dependency.request', [$fontsListener, 'onDependencyRequest']);
+ $dispatcher->addListener('xmds.dependency.list', [$playerVersionListner, 'onDependencyList']);
+ $dispatcher->addListener('xmds.dependency.request', [$playerVersionListner, 'onDependencyRequest']);
+ $dispatcher->addListener('xmds.dependency.request', [$assetsListener, 'onDependencyRequest']);
+ $dispatcher->addListener('xmds.dependency.request', [$dataConnectorListener, 'onDependencyRequest']);
+ }
+}
diff --git a/lib/Middleware/Log.php b/lib/Middleware/Log.php
new file mode 100644
index 0000000..8c2462b
--- /dev/null
+++ b/lib/Middleware/Log.php
@@ -0,0 +1,77 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Psr\Log\LoggerInterface;
+use Slim\App as App;
+use Xibo\Helper\RouteLogProcessor;
+
+/**
+ * Log Middleware
+ */
+class Log implements Middleware
+{
+ private App $app;
+
+ /**
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Psr\Container\ContainerExceptionInterface
+ * @throws \Psr\Container\NotFoundExceptionInterface
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $container = $this->app->getContainer();
+
+ self::addLogProcessorToLogger($container->get('logger'), $request);
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ */
+ public static function addLogProcessorToLogger(
+ LoggerInterface $logger,
+ Request $request,
+ ): void {
+ $logger->pushProcessor(new RouteLogProcessor(
+ $request->getUri()->getPath(),
+ $request->getMethod(),
+ ));
+ }
+}
diff --git a/lib/Middleware/SAMLAuthentication.php b/lib/Middleware/SAMLAuthentication.php
new file mode 100644
index 0000000..c38241c
--- /dev/null
+++ b/lib/Middleware/SAMLAuthentication.php
@@ -0,0 +1,488 @@
+.
+ */
+
+
+namespace Xibo\Middleware;
+
+use OneLogin\Saml2\Auth;
+use OneLogin\Saml2\Error;
+use OneLogin\Saml2\Settings;
+use OneLogin\Saml2\Utils;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\LogoutTrait;
+use Xibo\Helper\Random;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class SAMLAuthentication
+ * @package Xibo\Middleware
+ *
+ * Provide SAML authentication to Xibo configured via settings.php.
+ */
+class SAMLAuthentication extends AuthenticationBase
+{
+ use LogoutTrait;
+ /**
+ * @return $this
+ */
+ public function addRoutes()
+ {
+ $app = $this->app;
+ $app->getContainer()->set('logoutRoute', 'saml.logout');
+
+ // Route providing SAML metadata
+ $app->get('/saml/metadata', function (Request $request, Response $response) {
+ $settings = new Settings($this->getConfig()->samlSettings, true);
+ $metadata = $settings->getSPMetadata();
+ $errors = $settings->validateMetadata($metadata);
+ if (empty($errors)) {
+ return $response
+ ->withHeader('Content-Type', 'text/xml')
+ ->write($metadata);
+ } else {
+ throw new ConfigurationException(
+ 'Invalid SP metadata: ' . implode(', ', $errors),
+ Error::METADATA_SP_INVALID
+ );
+ }
+ });
+
+ // SAML Login
+ $app->get('/saml/login', function (Request $request, Response $response) {
+ // Initiate SAML SSO
+ $auth = new Auth($this->getConfig()->samlSettings);
+ return $auth->login();
+ });
+
+ // SAML Logout
+ $app->get('/saml/logout', function (Request $request, Response $response) {
+ return $this->samlLogout($request, $response);
+ })->setName('saml.logout');
+
+ // SAML Assertion Consumer Endpoint
+ $app->post('/saml/acs', function (Request $request, Response $response) {
+ // Log some interesting things
+ $this->getLog()->debug('Arrived at the ACS route with own URL: ' . Utils::getSelfRoutedURLNoQuery());
+
+ // Pull out the SAML settings
+ $samlSettings = $this->getConfig()->samlSettings;
+ $auth = new Auth($samlSettings);
+ $auth->processResponse();
+
+ // Check for errors
+ $errors = $auth->getErrors();
+
+ if (!empty($errors)) {
+ $this->getLog()->error('Single Sign on Failed: ' . implode(', ', $errors)
+ . '. Last Reason: ' . $auth->getLastErrorReason());
+
+ throw new AccessDeniedException(__('Your authentication provider could not log you in.'));
+ } else {
+ // Pull out the SAML attributes
+ $samlAttrs = $auth->getAttributes();
+
+ $this->getLog()->debug('SAML attributes: ' . json_encode($samlAttrs));
+
+ // How should we look up the user?
+ $identityField = (isset($samlSettings['workflow']['field_to_identify']))
+ ? $samlSettings['workflow']['field_to_identify']
+ : 'UserName';
+
+ if ($identityField !== 'nameId' && empty($samlAttrs)) {
+ // We will need some attributes
+ throw new AccessDeniedException(__('No attributes retrieved from the IdP'));
+ }
+
+ // If appropriate convert the SAML Attributes into userData mapped against the workflow mappings.
+ $userData = [];
+ if (isset($samlSettings['workflow']) && isset($samlSettings['workflow']['mapping'])) {
+ foreach ($samlSettings['workflow']['mapping'] as $key => $value) {
+ if (!empty($value) && isset($samlAttrs[$value])) {
+ $userData[$key] = $samlAttrs[$value];
+ }
+ }
+
+ // If we can't map anything, then we better throw an error
+ if (empty($userData)) {
+ throw new AccessDeniedException(__('No attributes could be mapped'));
+ }
+ }
+
+ // If we're using the nameId as the identity, then we should populate our userData with that value
+ if ($identityField === 'nameId') {
+ $userData[$identityField] = $auth->getNameId();
+ } else {
+ // Check to ensure that our identity has been populated from attributes successfully
+ if (!isset($userData[$identityField]) || empty($userData[$identityField])) {
+ throw new AccessDeniedException(sprintf(__('%s not retrieved from the IdP and required since is the field to identify the user'), $identityField));
+ }
+ }
+
+ // Are we going to try and match our Xibo groups to our Idp groups?
+ $isMatchGroupFromIdp = ($samlSettings['workflow']['matchGroups']['enabled'] ?? false) === true
+ && ($samlSettings['workflow']['matchGroups']['attribute'] ?? null) !== null;
+
+ // Try and get the user record.
+ $user = null;
+
+ try {
+ switch ($identityField) {
+ case 'nameId':
+ $user = $this->getUserFactory()->getByName($userData[$identityField]);
+ break;
+
+ case 'UserID':
+ $user = $this->getUserFactory()->getById($userData[$identityField][0]);
+ break;
+
+ case 'UserName':
+ $user = $this->getUserFactory()->getByName($userData[$identityField][0]);
+ break;
+
+ case 'email':
+ $user = $this->getUserFactory()->getByEmail($userData[$identityField][0]);
+ break;
+
+ default:
+ throw new AccessDeniedException(__('Invalid field_to_identify value. Review settings.'));
+ }
+ } catch (NotFoundException $e) {
+ // User does not exist - this is valid as we might create them JIT.
+ }
+
+ if (!isset($user)) {
+ if (!isset($samlSettings['workflow']['jit']) || $samlSettings['workflow']['jit'] == false) {
+ throw new AccessDeniedException(__('User logged at the IdP but the account does not exist in the CMS and Just-In-Time provisioning is disabled'));
+ } else {
+ // Provision the user
+ $user = $this->getEmptyUser();
+ $user->homeFolderId = 1;
+
+ if (isset($userData["UserName"])) {
+ $user->userName = $userData["UserName"][0];
+ }
+
+ if (isset($userData["email"])) {
+ $user->email = $userData["email"][0];
+ }
+
+ if (isset($userData["usertypeid"])) {
+ $user->userTypeId = $userData["usertypeid"][0];
+ } else {
+ $user->userTypeId = 3;
+ }
+
+ // Xibo requires a password, generate a random one (it won't ever be used by SAML)
+ $password = Random::generateString(20);
+ $user->setNewPassword($password);
+
+ // Home page
+ if (isset($samlSettings['workflow']['homePage'])) {
+ try {
+ $user->homePageId = $this->getUserGroupFactory()->getHomepageByName(
+ $samlSettings['workflow']['homePage']
+ )->homepage;
+ } catch (NotFoundException $exception) {
+ $this->getLog()->info(
+ sprintf(
+ 'Provided homepage %s, does not exist,
+ setting the icondashboard.view as homepage',
+ $samlSettings['workflow']['homePage']
+ )
+ );
+ $user->homePageId = 'icondashboard.view';
+ }
+ } else {
+ $user->homePageId = 'icondashboard.view';
+ }
+
+ // Library Quota
+ if (isset($samlSettings['workflow']['libraryQuota'])) {
+ $user->libraryQuota = $samlSettings['workflow']['libraryQuota'];
+ } else {
+ $user->libraryQuota = 0;
+ }
+
+ // Match references
+ if (isset($samlSettings['workflow']['ref1']) && isset($userData['ref1'])) {
+ $user->ref1 = $userData['ref1'];
+ }
+
+ if (isset($samlSettings['workflow']['ref2']) && isset($userData['ref2'])) {
+ $user->ref2 = $userData['ref2'];
+ }
+
+ if (isset($samlSettings['workflow']['ref3']) && isset($userData['ref3'])) {
+ $user->ref3 = $userData['ref3'];
+ }
+
+ if (isset($samlSettings['workflow']['ref4']) && isset($userData['ref4'])) {
+ $user->ref4 = $userData['ref4'];
+ }
+
+ if (isset($samlSettings['workflow']['ref5']) && isset($userData['ref5'])) {
+ $user->ref5 = $userData['ref5'];
+ }
+
+ // Save the user
+ $user->save();
+
+ // Assign the initial group
+ if (isset($samlSettings['workflow']['group']) && !$isMatchGroupFromIdp) {
+ $group = $this->getUserGroupFactory()->getByName($samlSettings['workflow']['group']);
+ } else {
+ $group = $this->getUserGroupFactory()->getByName('Users');
+ }
+
+ $group->assignUser($user);
+ $group->save(['validate' => false]);
+ $this->getLog()->setIpAddress($request->getAttribute('ip_address'));
+
+ // Audit Log
+ $this->getLog()->audit('User', $user->userId, 'User created with SAML workflow', [
+ 'UserName' => $user->userName,
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ }
+ }
+
+ if (isset($user) && $user->userId > 0) {
+ // Load User
+ $user = $this->getUser(
+ $user->userId,
+ $request->getAttribute('ip_address'),
+ $this->getSession()->get('sessionHistoryId')
+ );
+
+ // Overwrite our stored user with this new object.
+ $this->setUserForRequest($user);
+
+ // Switch Session ID's
+ $this->getSession()->setIsExpired(0);
+ $this->getSession()->regenerateSessionId();
+ $this->getSession()->setUser($user->userId);
+
+ $user->touch();
+
+ // Audit Log
+ $this->getLog()->audit('User', $user->userId, 'Login Granted via SAML', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ }
+
+ // Match groups from IdP?
+ if ($isMatchGroupFromIdp) {
+ $this->getLog()->debug('group matching enabled');
+
+ // Match groups is enabled, and we have an attribute to get groups from.
+ $idpGroups = [];
+ $extractionRegEx = $samlSettings['workflow']['matchGroups']['extractionRegEx'] ?? null;
+
+ // Get groups.
+ foreach ($samlAttrs[$samlSettings['workflow']['matchGroups']['attribute']] as $groupAttr) {
+ // Regex?
+ if (!empty($extractionRegEx)) {
+ $matches = [];
+ preg_match_all($extractionRegEx, $groupAttr, $matches);
+
+ if (count($matches[1]) > 0) {
+ $groupAttr = $matches[1][0];
+ }
+ }
+
+ $this->getLog()->debug('checking for group ' . $groupAttr);
+
+ // Does this group exist?
+ try {
+ $idpGroups[$groupAttr] = $this->getUserGroupFactory()->getByName($groupAttr);
+ } catch (NotFoundException) {
+ $this->getLog()->debug('group ' . $groupAttr . ' does not exist');
+ }
+ }
+
+ // Go through the users groups
+ $usersGroups = [];
+ foreach ($user->groups as $userGroup) {
+ $usersGroups[$userGroup->group] = $userGroup;
+ }
+
+ foreach ($user->groups as $userGroup) {
+ // Does this group exist in the Idp? If not, remove.
+ if (!array_key_exists($userGroup->group, $idpGroups)) {
+ // Group exists in Xibo, does not exist in the response, so remove.
+ $userGroup->unassignUser($user);
+ $userGroup->save(['validate' => false]);
+
+ $this->getLog()->debug($userGroup->group
+ . ' not matched to any IdP groups linked, removing');
+
+ unset($usersGroups[$userGroup->group]);
+ } else {
+ // Matched, so remove from idpGroups
+ unset($idpGroups[$userGroup->group]);
+
+ $this->getLog()->debug($userGroup->group . ' already linked.');
+ }
+ }
+
+ // Go through remaining groups and assign the user to them.
+ foreach ($idpGroups as $idpGroup) {
+ $this->getLog()->debug($idpGroup->group . ' already linked.');
+
+ $idpGroup->assignUser($user);
+ $idpGroup->save(['validate' => false]);
+ }
+
+ // Does this user still not have any groups?
+ if (count($usersGroups) <= 0) {
+ $group = $this->getUserGroupFactory()->getByName($samlSettings['workflow']['group'] ?? 'Users');
+ $group->assignUser($user);
+ $group->save(['validate' => false]);
+ }
+ }
+
+ // Redirect back to the originally-requested url, if provided
+ // it is not clear why basename is used here, it seems to be something to do with a logout loop
+ $params = $request->getParams();
+ $relayState = $params['RelayState'] ?? null;
+ $redirect = empty($relayState) || basename($relayState) === 'login'
+ ? $this->getRouteParser()->urlFor('home')
+ : $relayState;
+
+ $this->getLog()->debug('redirecting to ' . $redirect);
+
+ return $response->withRedirect($redirect);
+ }
+ });
+
+ // Single Logout Service
+ $app->map(['GET', 'POST'], '/saml/sls', function (Request $request, Response $response) use ($app) {
+ // Make request to IDP
+ $auth = new Auth($app->getContainer()->get('configService')->samlSettings);
+ try {
+ $auth->processSLO(false, null, false, function () use ($request) {
+ // Audit that the IDP has completed this request.
+ $this->getLog()->setIpAddress($request->getAttribute('ip_address'));
+ $this->getLog()->setSessionHistoryId($this->getSession()->get('sessionHistoryId'));
+ $this->getLog()->audit('User', 0, 'Idp SLO completed', [
+ 'UserAgent' => $request->getHeader('User-Agent')
+ ]);
+ });
+ } catch (\Exception $e) {
+ // Ignored - get with getErrors()
+ }
+
+ $errors = $auth->getErrors();
+
+ if (empty($errors)) {
+ return $response->withRedirect($this->getRouteParser()->urlFor('home'));
+ } else {
+ throw new AccessDeniedException('SLO failed. ' . implode(', ', $errors));
+ }
+ });
+
+ return $this;
+ }
+
+ /**
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
+ * @throws \OneLogin\Saml2\Error
+ */
+ public function samlLogout(Request $request, Response $response)
+ {
+ $samlSettings = $this->getConfig()->samlSettings;
+
+ if (isset($samlSettings['workflow'])
+ && isset($samlSettings['workflow']['slo'])
+ && $samlSettings['workflow']['slo'] == true
+ ) {
+ // Complete our own logout flow
+ $this->completeLogoutFlow(
+ $this->getUser(
+ $_SESSION['userid'],
+ $request->getAttribute('ip_address'),
+ $_SESSION['sessionHistoryId']
+ ),
+ $this->getSession(),
+ $this->getLog(),
+ $request
+ );
+
+ // Initiate SAML SLO
+ $auth = new Auth($samlSettings);
+ return $response->withRedirect($auth->logout());
+ } else {
+ return $response->withRedirect($this->getRouteParser()->urlFor('logout'));
+ }
+ }
+
+ /**
+ * @param Request $request
+ * @return Response
+ * @throws \OneLogin\Saml2\Error
+ */
+ public function redirectToLogin(\Psr\Http\Message\ServerRequestInterface $request)
+ {
+ if ($this->isAjax($request)) {
+ return $this->createResponse($request)->withJson(ApplicationState::asRequiresLogin());
+ } else {
+ // Initiate SAML SSO
+ $auth = new Auth($this->getConfig()->samlSettings);
+ return $this->createResponse($request)->withRedirect($auth->login());
+ }
+ }
+
+ /** @inheritDoc */
+ public function getPublicRoutes(\Psr\Http\Message\ServerRequestInterface $request)
+ {
+ return array_merge($request->getAttribute('publicRoutes', []), [
+ '/saml/metadata',
+ '/saml/login',
+ '/saml/acs',
+ '/saml/logout',
+ '/saml/sls'
+ ]);
+ }
+
+ /** @inheritDoc */
+ public function shouldRedirectPublicRoute($route)
+ {
+ return ($this->getSession()->isExpired()
+ && ($route == '/login/ping' || $route == 'clock'))
+ || $route == '/login';
+ }
+
+ /** @inheritDoc */
+ public function addToRequest(\Psr\Http\Message\ServerRequestInterface $request)
+ {
+ return $request->withAttribute(
+ 'excludedCsrfRoutes',
+ array_merge($request->getAttribute('excludedCsrfRoutes', []), ['/saml/acs', '/saml/sls'])
+ );
+ }
+}
diff --git a/lib/Middleware/State.php b/lib/Middleware/State.php
new file mode 100644
index 0000000..c5819cd
--- /dev/null
+++ b/lib/Middleware/State.php
@@ -0,0 +1,336 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Carbon\Carbon;
+use Monolog\Logger;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Respect\Validation\Factory;
+use Slim\App;
+use Slim\Routing\RouteContext;
+use Slim\Views\Twig;
+use Xibo\Entity\User;
+use Xibo\Helper\Environment;
+use Xibo\Helper\HttpsDetect;
+use Xibo\Helper\NullSession;
+use Xibo\Helper\Session;
+use Xibo\Helper\Translate;
+use Xibo\Service\ReportService;
+use Xibo\Support\Exception\InstanceSuspendedException;
+use Xibo\Support\Exception\UpgradePendingException;
+use Xibo\Twig\TwigMessages;
+
+/**
+ * Class State
+ * @package Xibo\Middleware
+ */
+class State implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws InstanceSuspendedException
+ * @throws UpgradePendingException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+ $container = $app->getContainer();
+
+ // Set state
+ $request = State::setState($app, $request);
+
+ // Check to see if the instance has been suspended, if so call the special route
+ if ($container->get('configService')->getSetting('INSTANCE_SUSPENDED') == 'yes') {
+ throw new InstanceSuspendedException();
+ }
+
+ // Get to see if upgrade is pending, we don't want to throw this when we are on error page, causes
+ // redirect problems with error handler.
+ if (Environment::migrationPending() && $request->getUri()->getPath() != '/error') {
+ throw new UpgradePendingException();
+ }
+
+ // Next middleware
+ $response = $handler->handle($request);
+
+ // Do we need SSL/STS?
+ if (HttpsDetect::isShouldIssueSts($container->get('configService'), $request)) {
+ $response = HttpsDetect::decorateWithSts($container->get('configService'), $response);
+ } else if (!HttpsDetect::isHttps()) {
+ // We are not HTTPS, should we redirect?
+ // Get the current route pattern
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+ $resource = $route->getPattern();
+
+ // Allow non-https access to the clock page, otherwise force https
+ if ($resource !== '/clock' && $container->get('configService')->getSetting('FORCE_HTTPS', 0) == 1) {
+ $redirect = "https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
+ $response = $response->withHeader('Location', $redirect)
+ ->withStatus(302);
+ }
+ }
+
+ // Reset the ETAGs for GZIP
+ $requestEtag = $request->getHeaderLine('IF_NONE_MATCH');
+ if ($requestEtag) {
+ $response = $response->withHeader('IF_NONE_MATCH', str_replace('-gzip', '', $requestEtag));
+ }
+
+ // Handle correctly outputting cache headers for AJAX requests
+ // IE cache busting
+ if ($this->isAjax($request) && $request->getMethod() == 'GET' && $request->getAttribute('_entryPoint') == 'web') {
+ $response = $response->withHeader('Cache-control', 'no-cache')
+ ->withHeader('Cache-control', 'no-store')
+ ->withHeader('Pragma', 'no-cache')
+ ->withHeader('Expires', '0');
+ }
+
+ return $response;
+ }
+
+ /**
+ * @param App $app
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return \Psr\Http\Message\ServerRequestInterface
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public static function setState(App $app, Request $request): Request
+ {
+ $container = $app->getContainer();
+
+ // Set the config dependencies
+ $container->get('configService')->setDependencies($container->get('store'), $container->get('rootUri'));
+
+ // set the system user for XTR/XMDS
+ if ($container->get('name') == 'xtr' || $container->get('name') == 'xmds') {
+ // Configure a user
+ /** @var User $user */
+ $user = $container->get('userFactory')->getSystemUser();
+ $user->setChildAclDependencies($container->get('userGroupFactory'));
+
+ // Load the user
+ $user->load(false);
+ $container->set('user', $user);
+ }
+
+ // Register the report service
+ $container->set('reportService', function (ContainerInterface $container) {
+ $reportService = new ReportService(
+ $container,
+ $container->get('store'),
+ $container->get('timeSeriesStore'),
+ $container->get('logService'),
+ $container->get('configService'),
+ $container->get('sanitizerService'),
+ $container->get('savedReportFactory')
+ );
+ $reportService->setDispatcher($container->get('dispatcher'));
+ return $reportService;
+ });
+
+ // Set some public routes
+ $request = $request->withAttribute('publicRoutes', array_merge($request->getAttribute('publicRoutes', []), [
+ '/login',
+ '/login/forgotten',
+ '/clock',
+ '/about',
+ '/login/ping',
+ '/rss/{psk}',
+ '/sssp_config.xml',
+ '/sssp_dl.wgt',
+ '/playersoftware/{nonce}/sssp_dl.wgt',
+ '/playersoftware/{nonce}/sssp_config.xml',
+ '/tfa',
+ '/error',
+ '/notFound',
+ '/public/thumbnail/{id}',
+ ]));
+
+ // Setup the translations for gettext
+ Translate::InitLocale($container->get('configService'));
+
+ // Set Carbon locale
+ Carbon::setLocale(Translate::GetLocale(2));
+
+ // Default timezone
+ $defaultTimezone = $container->get('configService')->getSetting('defaultTimezone') ?? 'UTC';
+
+ date_default_timezone_set($defaultTimezone);
+
+ $container->set('session', function (ContainerInterface $container) use ($app) {
+ if ($container->get('name') == 'web' || $container->get('name') == 'auth') {
+ $sessionHandler = new Session($container->get('logService'));
+
+ session_set_save_handler($sessionHandler, true);
+ register_shutdown_function('session_write_close');
+
+ // Start the session
+ session_cache_limiter(false);
+ session_start();
+ return $sessionHandler;
+ } else {
+ return new NullSession();
+ }
+ });
+
+ // We use Slim Flash Messages so we must immediately start a session (boo)
+ $container->get('session')->set('init', '1');
+
+ // App Mode
+ $mode = $container->get('configService')->getSetting('SERVER_MODE');
+ $container->get('logService')->setMode($mode);
+
+ // Inject some additional changes on a per-container basis
+ $containerName = $container->get('name');
+ if ($containerName == 'web' || $containerName == 'xtr' || $containerName == 'xmds') {
+ /** @var Twig $view */
+ $view = $container->get('view');
+
+ if ($containerName == 'web') {
+ $container->set('flash', function () {
+ return new \Slim\Flash\Messages();
+ });
+ $view->addExtension(new TwigMessages(new \Slim\Flash\Messages()));
+ }
+
+ $twigEnvironment = $view->getEnvironment();
+
+ // add the urldecode filter to Twig.
+ $filter = new \Twig\TwigFilter('url_decode', 'urldecode');
+ $twigEnvironment->addFilter($filter);
+
+ // Set Twig auto reload if needed
+ // XMDS only renders widget html cache, and shouldn't need auto reload.
+ if ($containerName !== 'xmds') {
+ $twigEnvironment->enableAutoReload();
+ }
+ }
+
+ // Configure logging
+ // -----------------
+ // Standard handlers
+ if (Environment::isForceDebugging() || strtolower($mode) == 'test') {
+ error_reporting(E_ALL);
+ ini_set('display_errors', 1);
+
+ $container->get('logService')->setLevel(Logger::DEBUG);
+ } else {
+ // Log level
+ $level = \Xibo\Service\LogService::resolveLogLevel($container->get('configService')->getSetting('audit'));
+ $restingLevel = \Xibo\Service\LogService::resolveLogLevel($container->get('configService')->getSetting('RESTING_LOG_LEVEL'));
+
+ // the higher the number the less strict the logging.
+ if ($level < $restingLevel) {
+ // Do we allow the log level to be this high
+ $elevateUntil = $container->get('configService')->getSetting('ELEVATE_LOG_UNTIL');
+ if (intval($elevateUntil) < Carbon::now()->format('U')) {
+ // Elevation has expired, revert log level
+ $container->get('configService')->changeSetting('audit', $container->get('configService')->getSetting('RESTING_LOG_LEVEL'));
+ $level = $restingLevel;
+ }
+ }
+
+ $container->get('logService')->setLevel($level);
+ }
+
+
+ // Update logger containers to use the CMS default timezone
+ $container->get('logger')->setTimezone(new \DateTimeZone($defaultTimezone));
+
+ // Configure any extra log handlers
+ // we do these last so that they can provide their own log levels independent of the system settings
+ if ($container->get('configService')->logHandlers != null && is_array($container->get('configService')->logHandlers)) {
+ $container->get('logService')->debug('Configuring %d additional log handlers from Config', count($container->get('configService')->logHandlers));
+ foreach ($container->get('configService')->logHandlers as $handler) {
+ // Direct access to the LoggerInterface here, rather than via our log service
+ $container->get('logger')->pushHandler($handler);
+ }
+ }
+
+ // Configure any extra log processors
+ if ($container->get('configService')->logProcessors != null && is_array($container->get('configService')->logProcessors)) {
+ $container->get('logService')->debug('Configuring %d additional log processors from Config', count($container->get('configService')->logProcessors));
+ foreach ($container->get('configService')->logProcessors as $processor) {
+ $container->get('logger')->pushProcessor($processor);
+ }
+ }
+
+ // Add additional validation rules
+ Factory::setDefaultInstance(
+ (new Factory())
+ ->withRuleNamespace('Xibo\\Validation\\Rules')
+ ->withExceptionNamespace('Xibo\\Validation\\Exceptions')
+ );
+
+ return $request;
+ }
+
+ /**
+ * Set additional middleware
+ * @param App $app
+ */
+ public static function setMiddleWare($app)
+ {
+ // Handle additional Middleware
+ if (isset($app->getContainer()->get('configService')->middleware) && is_array($app->getContainer()->get('configService')->middleware)) {
+ foreach ($app->getContainer()->get('configService')->middleware as $object) {
+ // Decorate our middleware with the App if it has a method to do so
+ if (method_exists($object, 'setApp')) {
+ $object->setApp($app);
+ }
+
+ // Add any new routes from custom middleware
+ if (method_exists($object, 'addRoutes')) {
+ $object->addRoutes();
+ }
+
+ $app->add($object);
+ }
+ }
+ }
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return bool
+ */
+ private function isAjax(Request $request)
+ {
+ return strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest';
+ }
+}
diff --git a/lib/Middleware/Storage.php b/lib/Middleware/Storage.php
new file mode 100644
index 0000000..7511b45
--- /dev/null
+++ b/lib/Middleware/Storage.php
@@ -0,0 +1,87 @@
+.
+ */
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App;
+
+/**
+ * Class Storage
+ * @package Xibo\Middleware
+ */
+class Storage implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ /**
+ * Storage constructor.
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * Middleware process
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @param \Psr\Http\Server\RequestHandlerInterface $handler
+ * @return \Psr\Http\Message\ResponseInterface
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $container = $this->app->getContainer();
+
+ $startTime = microtime(true);
+
+ // Pass straight down to the next middleware
+ $response = $handler->handle($request);
+
+ // Are we in a transaction coming out of the stack?
+ if ($container->get('store')->getConnection()->inTransaction()) {
+ // We need to commit or rollback? Default is commit
+ if ($container->get('state')->getCommitState()) {
+ $container->get('store')->commitIfNecessary();
+ } else {
+ $container->get('logService')->debug('Storage rollback.');
+
+ $container->get('store')->getConnection()->rollBack();
+ }
+ }
+
+ // Get the stats for this connection
+ $stats = $container->get('store')->stats();
+ $stats['length'] = microtime(true) - $startTime;
+ $stats['memoryUsage'] = memory_get_usage();
+ $stats['peakMemoryUsage'] = memory_get_peak_usage();
+
+ $container->get('logService')->info('Request stats: %s.', json_encode($stats, JSON_PRETTY_PRINT));
+
+ $container->get('store')->close();
+
+ return $response;
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/SuperAdminAuth.php b/lib/Middleware/SuperAdminAuth.php
new file mode 100644
index 0000000..58308a9
--- /dev/null
+++ b/lib/Middleware/SuperAdminAuth.php
@@ -0,0 +1,77 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Xibo\Support\Exception\AccessDeniedException;
+
+/**
+ * Class SuperAdminAuth
+ * @package Xibo\Middleware
+ */
+class SuperAdminAuth implements MiddlewareInterface
+{
+ /** @var \Psr\Container\ContainerInterface */
+ private $container;
+
+ /** @var array */
+ private $features;
+
+ /**
+ * FeatureAuth constructor.
+ * @param ContainerInterface $container
+ */
+ public function __construct(ContainerInterface $container)
+ {
+ $this->container = $container;
+ }
+
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @param \Psr\Http\Server\RequestHandlerInterface $handler
+ * @return \Psr\Http\Message\ResponseInterface
+ * @throws \Xibo\Support\Exception\AccessDeniedException
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ // If no features are provided, then this must be public
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException(__('You do not have sufficient access'));
+ }
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * @return \Xibo\Entity\User
+ */
+ private function getUser()
+ {
+ return $this->container->get('user');
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/Theme.php b/lib/Middleware/Theme.php
new file mode 100644
index 0000000..c8f0b23
--- /dev/null
+++ b/lib/Middleware/Theme.php
@@ -0,0 +1,162 @@
+.
+ */
+
+
+namespace Xibo\Middleware;
+
+use Illuminate\Support\Str;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Slim\Interfaces\RouteParserInterface;
+use Slim\Routing\RouteContext;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Helper\Translate;
+
+/**
+ * Class Theme
+ * @package Xibo\Middleware
+ */
+class Theme implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Twig\Error\LoaderError
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ // Inject our Theme into the Twig View (if it exists)
+ $app = $this->app;
+ $app->getContainer()->get('configService')->loadTheme();
+
+ self::setTheme($app->getContainer(), $request, $app->getRouteCollector()->getRouteParser());
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * Set theme
+ * @param \Psr\Container\ContainerInterface $container
+ * @param Request $request
+ * @param RouteParserInterface $routeParser
+ * @throws \Twig\Error\LoaderError
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public static function setTheme(ContainerInterface $container, Request $request, RouteParserInterface $routeParser)
+ {
+ $view = $container->get('view');
+
+ // Provide the view path to Twig
+ $twig = $view->getLoader();
+ /* @var \Twig\Loader\FilesystemLoader $twig */
+
+ // Does this theme provide an alternative view path?
+ if ($container->get('configService')->getThemeConfig('view_path') != '') {
+ $twig->prependPath(
+ Str::replaceFirst(
+ '..',
+ PROJECT_ROOT,
+ $container->get('configService')->getThemeConfig('view_path')
+ )
+ );
+ }
+
+ $settings = $container->get('configService')->getSettings();
+
+ // Date format
+ $settings['DATE_FORMAT_JS'] = DateFormatHelper::convertPhpToMomentFormat($settings['DATE_FORMAT']);
+ $settings['DATE_FORMAT_JALALI_JS'] = DateFormatHelper::convertMomentToJalaliFormat($settings['DATE_FORMAT_JS']);
+ $settings['TIME_FORMAT'] = DateFormatHelper::extractTimeFormat($settings['DATE_FORMAT']);
+ $settings['TIME_FORMAT_JS'] = DateFormatHelper::convertPhpToMomentFormat($settings['TIME_FORMAT']);
+ $settings['DATE_ONLY_FORMAT'] = DateFormatHelper::extractDateOnlyFormat($settings['DATE_FORMAT']);
+ $settings['DATE_ONLY_FORMAT_JS'] = DateFormatHelper::convertPhpToMomentFormat($settings['DATE_ONLY_FORMAT']);
+ $settings['DATE_ONLY_FORMAT_JALALI_JS'] = DateFormatHelper::convertMomentToJalaliFormat(
+ $settings['DATE_ONLY_FORMAT_JS']
+ );
+ $settings['systemDateFormat'] = DateFormatHelper::convertPhpToMomentFormat(DateFormatHelper::getSystemFormat());
+ $settings['systemTimeFormat'] = DateFormatHelper::convertPhpToMomentFormat(
+ DateFormatHelper::extractTimeFormat(DateFormatHelper::getSystemFormat())
+ );
+
+ $routeContext = RouteContext::fromRequest($request);
+ $route = $routeContext->getRoute();
+
+ // Resolve the current route name
+ $routeName = ($route == null) ? 'notfound' : $route->getName();
+ $view['baseUrl'] = $routeParser->urlFor('home');
+
+ try {
+ $logoutRoute = empty($container->get('logoutRoute')) ? 'logout' : $container->get('logoutRoute');
+ $view['logoutUrl'] = $routeParser->urlFor($logoutRoute);
+ } catch (\Exception $e) {
+ $view['logoutUrl'] = $routeParser->urlFor('logout');
+ }
+
+ $view['route'] = $routeName;
+ $view['theme'] = $container->get('configService');
+ $view['settings'] = $settings;
+ $view['helpService'] = $container->get('helpService');
+ $view['translate'] = [
+ 'locale' => Translate::GetLocale(),
+ 'jsLocale' => Translate::getRequestedJsLocale(),
+ 'jsShortLocale' => Translate::getRequestedJsLocale(['short' => true])
+ ];
+ $view['translations'] ='{}';
+ $view['libraryUpload'] = [
+ 'maxSize' => ByteFormatter::toBytes(Environment::getMaxUploadSize()),
+ 'maxSizeMessage' => sprintf(
+ __('This form accepts files up to a maximum size of %s'),
+ Environment::getMaxUploadSize()
+ ),
+ 'validExt' => implode('|', $container->get('moduleFactory')->getValidExtensions()),
+ 'validImageExt' => implode('|', $container->get('moduleFactory')->getValidExtensions(['type' => 'image']))
+ ];
+ $view['version'] = Environment::$WEBSITE_VERSION_NAME;
+ $view['revision'] = Environment::getGitCommit();
+ $view['playerVersion'] = Environment::$PLAYER_SUPPORT;
+ $view['isDevMode'] = Environment::isDevMode();
+ $view['accountId'] = defined('ACCOUNT_ID') ? constant('ACCOUNT_ID') : null;
+
+ $samlSettings = $container->get('configService')->samlSettings;
+ if (isset($samlSettings['workflow'])
+ && isset($samlSettings['workflow']['slo'])
+ && $samlSettings['workflow']['slo'] == false) {
+ $view['hideLogout'] = true;
+ }
+ }
+}
diff --git a/lib/Middleware/TrailingSlashMiddleware.php b/lib/Middleware/TrailingSlashMiddleware.php
new file mode 100644
index 0000000..2de6c39
--- /dev/null
+++ b/lib/Middleware/TrailingSlashMiddleware.php
@@ -0,0 +1,70 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App;
+
+/**
+ * Trailing Slash Middleware
+ * this middleware is used for routes contained inside a directory
+ * Apache automatically adds a trailing slash to these URLs, Nginx does not.
+ * Slim treats trailing slashes differently to non-trailing slashes
+ * We need to mimic Apache for the director route.
+ * @package Xibo\Middleware
+ */
+class TrailingSlashMiddleware implements MiddlewareInterface
+{
+ /* @var App $app */
+ private $app;
+
+ /**
+ * Storage constructor.
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+ /**
+ * Middleware process
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @param \Psr\Http\Server\RequestHandlerInterface $handler
+ * @return \Psr\Http\Message\ResponseInterface
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $uri = $request->getUri();
+ $path = $uri->getPath();
+
+ if ($path === $this->app->getBasePath()) {
+ // Add a trailing slash for the route middleware to match
+ $request = $request->withUri($uri->withPath($path . '/'));
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/lib/Middleware/WebAuthentication.php b/lib/Middleware/WebAuthentication.php
new file mode 100644
index 0000000..a5b1262
--- /dev/null
+++ b/lib/Middleware/WebAuthentication.php
@@ -0,0 +1,69 @@
+.
+ */
+
+
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Xibo\Helper\ApplicationState;
+
+/**
+ * Class WebAuthentication
+ * @package Xibo\Middleware
+ */
+class WebAuthentication extends AuthenticationBase
+{
+ /** @inheritDoc */
+ public function addRoutes()
+ {
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function redirectToLogin(Request $request)
+ {
+ if ($this->isAjax($request)) {
+ return $this->createResponse($request)
+ ->withJson(ApplicationState::asRequiresLogin());
+ } else {
+ return $this->createResponse($request)->withRedirect($this->getRouteParser()->urlFor('login'));
+ }
+ }
+
+ /** @inheritDoc */
+ public function getPublicRoutes(Request $request)
+ {
+ return $request->getAttribute('publicRoutes', []);
+ }
+
+ /** @inheritDoc */
+ public function shouldRedirectPublicRoute($route)
+ {
+ return $this->getSession()->isExpired() && ($route == '/login/ping' || $route == 'clock');
+ }
+
+ /** @inheritDoc */
+ public function addToRequest(Request $request)
+ {
+ return $request;
+ }
+}
\ No newline at end of file
diff --git a/lib/Middleware/Xmr.php b/lib/Middleware/Xmr.php
new file mode 100644
index 0000000..04e9965
--- /dev/null
+++ b/lib/Middleware/Xmr.php
@@ -0,0 +1,142 @@
+.
+ */
+namespace Xibo\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Xibo\Service\DisplayNotifyService;
+use Xibo\Service\NullDisplayNotifyService;
+use Xibo\Service\PlayerActionService;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Class Xmr
+ * @package Xibo\Middleware
+ *
+ * NOTE: This must be the very last layer in the onion
+ */
+class Xmr implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ /**
+ * Xmr constructor.
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * Call
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+
+ // Start
+ self::setXmr($app);
+
+ // Pass along the request
+ $response = $handler->handle($request);
+
+ // Finish
+ // this must happen at the very end of the request
+ self::finish($app);
+
+ // Return the response to the browser
+ return $response;
+ }
+
+ /**
+ * Finish XMR
+ * @param App $app
+ */
+ public static function finish($app)
+ {
+ $container = $app->getContainer();
+
+ // Handle display notifications
+ if ($container->has('displayNotifyService')) {
+ try {
+ $container->get('displayNotifyService')->processQueue();
+ } catch (GeneralException $e) {
+ $container->get('logService')->error(
+ 'Unable to Process Queue of Display Notifications due to %s',
+ $e->getMessage()
+ );
+ }
+ }
+
+ // Handle player actions
+ if ($container->has('playerActionService')) {
+ try {
+ $container->get('playerActionService')->processQueue();
+ } catch (\Exception $e) {
+ $container->get('logService')->error(
+ 'Unable to Process Queue of Player actions due to %s',
+ $e->getMessage()
+ );
+ }
+ }
+
+ // Re-terminate any DB connections
+ $app->getContainer()->get('store')->close();
+ }
+
+ /**
+ * Set XMR
+ * @param App $app
+ * @param bool $triggerPlayerActions
+ */
+ public static function setXmr($app, $triggerPlayerActions = true)
+ {
+ // Player Action Helper
+ $app->getContainer()->set('playerActionService', function () use ($app, $triggerPlayerActions) {
+ return new PlayerActionService(
+ $app->getContainer()->get('configService'),
+ $app->getContainer()->get('logService'),
+ $triggerPlayerActions
+ );
+ });
+
+ // Register the display notify service
+ $app->getContainer()->set('displayNotifyService', function () use ($app) {
+ return new DisplayNotifyService(
+ $app->getContainer()->get('configService'),
+ $app->getContainer()->get('logService'),
+ $app->getContainer()->get('store'),
+ $app->getContainer()->get('pool'),
+ $app->getContainer()->get('playerActionService'),
+ $app->getContainer()->get('scheduleFactory')
+ );
+ });
+ }
+}
diff --git a/lib/Middleware/Xtr.php b/lib/Middleware/Xtr.php
new file mode 100644
index 0000000..637c43a
--- /dev/null
+++ b/lib/Middleware/Xtr.php
@@ -0,0 +1,88 @@
+.
+ */
+
+namespace Xibo\Middleware;
+
+use Illuminate\Support\Str;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App as App;
+use Xibo\Support\Exception\InstanceSuspendedException;
+
+/**
+ * Class Xtr
+ * Middleware for XTR.
+ * - sets the theme
+ * - sets the module theme files
+ * @package Xibo\Middleware
+ */
+class Xtr implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Twig\Error\LoaderError
+ * @throws \Xibo\Support\Exception\InstanceSuspendedException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ // Inject our Theme into the Twig View (if it exists)
+ $app = $this->app;
+ $container = $app->getContainer();
+
+ // Check to see if the instance has been suspended, if so call the special route
+ $instanceSuspended = $container->get('configService')->getSetting('INSTANCE_SUSPENDED');
+ if ($instanceSuspended == 'yes' || $instanceSuspended == 'partial') {
+ throw new InstanceSuspendedException();
+ }
+
+ $container->get('configService')->loadTheme();
+ $view = $container->get('view');
+ // Provide the view path to Twig
+ /* @var \Twig\Loader\FilesystemLoader $twig */
+ $twig = $view->getLoader();
+ $twig->setPaths([PROJECT_ROOT . '/views', PROJECT_ROOT . '/custom', PROJECT_ROOT . '/reports']);
+
+ // Does this theme provide an alternative view path?
+ if ($container->get('configService')->getThemeConfig('view_path') != '') {
+ $twig->prependPath(Str::replaceFirst(
+ '..',
+ PROJECT_ROOT,
+ $container->get('configService')->getThemeConfig('view_path'),
+ ));
+ }
+
+ // Call Next
+ return $handler->handle($request);
+ }
+}
\ No newline at end of file
diff --git a/lib/OAuth/AccessTokenEntity.php b/lib/OAuth/AccessTokenEntity.php
new file mode 100644
index 0000000..01b235b
--- /dev/null
+++ b/lib/OAuth/AccessTokenEntity.php
@@ -0,0 +1,79 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use Carbon\Carbon;
+use Lcobucci\JWT\Encoding\ChainedFormatter;
+use Lcobucci\JWT\Encoding\JoseEncoder;
+use Lcobucci\JWT\Signer\Key;
+use Lcobucci\JWT\Signer\Rsa\Sha256;
+use Lcobucci\JWT\Token;
+use Lcobucci\JWT\Token\Builder;
+use League\OAuth2\Server\CryptKey;
+use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
+use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
+
+/**
+ * Class AccessTokenEntity
+ * @package Xibo\OAuth
+ */
+class AccessTokenEntity implements AccessTokenEntityInterface
+{
+ use AccessTokenTrait, TokenEntityTrait, EntityTrait;
+
+ /**
+ * Generate a JWT from the access token
+ *
+ * @param CryptKey $privateKey
+ *
+ * @return Token
+ */
+ private function convertToJWT(CryptKey $privateKey)
+ {
+ $userId = $this->getUserIdentifier();
+ $tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
+ $signingKey = Key\InMemory::file($privateKey->getKeyPath());
+
+ return $tokenBuilder
+ ->issuedBy('info@xibosignage.com')
+ ->permittedFor($this->getClient()->getIdentifier())
+ ->identifiedBy($this->getIdentifier())
+ ->issuedAt(Carbon::now()->toDateTimeImmutable())
+ ->canOnlyBeUsedAfter(Carbon::now()->toDateTimeImmutable())
+ ->expiresAt($this->getExpiryDateTime())
+ ->relatedTo($userId)
+ ->withClaim('scopes', $this->getScopes())
+ ->getToken(new Sha256(), $signingKey)
+ ;
+ }
+
+ /**
+ * Generate a string representation from the access token
+ */
+ public function __toString()
+ {
+ return $this->convertToJWT($this->privateKey)->toString();
+ }
+}
diff --git a/lib/OAuth/AccessTokenRepository.php b/lib/OAuth/AccessTokenRepository.php
new file mode 100644
index 0000000..535c0db
--- /dev/null
+++ b/lib/OAuth/AccessTokenRepository.php
@@ -0,0 +1,173 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
+use League\OAuth2\Server\Entities\ClientEntityInterface;
+use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Factory\ApplicationFactory;
+
+/**
+ * Class AccessTokenRepository
+ * @package Xibo\Storage
+ */
+class AccessTokenRepository implements AccessTokenRepositoryInterface
+{
+ /** @var \Xibo\Service\LogServiceInterface*/
+ private $logger;
+ /**
+ * @var ApplicationFactory
+ */
+ private $applicationFactory;
+ /**
+ * @var PoolInterface
+ */
+ private $pool;
+
+ /**
+ * AccessTokenRepository constructor.
+ * @param \Xibo\Service\LogServiceInterface $logger
+ */
+ public function __construct($logger, PoolInterface $pool, ApplicationFactory $applicationFactory)
+ {
+ $this->logger = $logger;
+ $this->pool = $pool;
+ $this->applicationFactory = $applicationFactory;
+ }
+
+ /** @inheritDoc */
+ public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null)
+ {
+ $this->logger->debug('Getting new Access Token');
+
+ $accessToken = new AccessTokenEntity();
+ $accessToken->setClient($clientEntity);
+
+ foreach ($scopes as $scope) {
+ $accessToken->addScope($scope);
+ }
+
+ // client credentials, we take user from the client entity
+ // authentication code, we have userIdentifier already
+ $userIdentifier = $userIdentifier ?? $clientEntity->userId;
+
+ $accessToken->setUserIdentifier($userIdentifier);
+
+ // set user and log to audit
+ $this->logger->setUserId($userIdentifier);
+ $this->logger->audit(
+ 'Auth',
+ 0,
+ 'Access Token issued',
+ [
+ 'Application identifier ends with' => substr($clientEntity->getIdentifier(), -8),
+ 'Application Name' => $clientEntity->getName()
+ ]
+ );
+
+ return $accessToken;
+ }
+
+ /** @inheritDoc */
+ public function isAccessTokenRevoked($tokenId)
+ {
+ $cache = $this->pool->getItem('C_' . $tokenId);
+ $data = $cache->get();
+
+ // if cache is expired
+ if ($cache->isMiss() || empty($data)) {
+ return true;
+ }
+
+ $cache2 = $this->pool->getItem('C_' . $data['client'] . '/' . $data['userIdentifier']);
+ $data2 = $cache2->get();
+
+ // cache manually removed (revoke access, changed secret)
+ if ($cache2->isMiss() || empty($data2)) {
+ return true;
+ }
+
+ if ($data['client'] !== $data2['client'] || $data['userIdentifier'] !== $data2['userIdentifier']) {
+ return true;
+ }
+
+ // if it is correctly cached, double check that it is still authorized at the request time
+ // edge case being new access code requested with not yet expired code,
+ // otherwise one of the previous conditions will be met.
+ // Note: we can only do this if one grant type is selected on the client.
+ $client = $this->applicationFactory->getClientEntity($data['client']);
+ if ($client->clientCredentials === 0
+ && $client->authCode === 1
+ && !$this->applicationFactory->checkAuthorised($data['client'], $data['userIdentifier'])
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /** @inheritDoc */
+ public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity)
+ {
+ $date = clone $accessTokenEntity->getExpiryDateTime();
+ // since stash cache sets expiresAt at up to provided date
+ // with up to 15% less than the provided date
+ // add more time to normal token expire, to ensure cache does not expire before the token.
+ $date = $date->add(new \DateInterval('PT30M'));
+
+ // cache with token identifier
+ $cache = $this->pool->getItem('C_' . $accessTokenEntity->getIdentifier());
+
+ $cache->set(
+ [
+ 'userIdentifier' => $accessTokenEntity->getUserIdentifier(),
+ 'client' => $accessTokenEntity->getClient()->getIdentifier()
+ ]
+ );
+ $cache->expiresAt($date);
+ $this->pool->saveDeferred($cache);
+
+ // double cache with client identifier and user identifier
+ // this will allow us to revoke access to client or for specific client/user combination in the backend
+ $cache2 = $this->pool->getItem(
+ 'C_' . $accessTokenEntity->getClient()->getIdentifier() . '/' . $accessTokenEntity->getUserIdentifier()
+ );
+
+ $cache2->set(
+ [
+ 'userIdentifier' => $accessTokenEntity->getUserIdentifier(),
+ 'client' => $accessTokenEntity->getClient()->getIdentifier()
+ ]
+ );
+
+ $cache2->expiresAt($date);
+ $this->pool->saveDeferred($cache2);
+ }
+
+ /** @inheritDoc */
+ public function revokeAccessToken($tokenId)
+ {
+ $this->pool->getItem('C_' . $tokenId)->clear();
+ }
+}
diff --git a/lib/OAuth/AuthCodeEntity.php b/lib/OAuth/AuthCodeEntity.php
new file mode 100644
index 0000000..ead27a8
--- /dev/null
+++ b/lib/OAuth/AuthCodeEntity.php
@@ -0,0 +1,33 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
+use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
+
+class AuthCodeEntity implements AuthCodeEntityInterface
+{
+ use EntityTrait, TokenEntityTrait, AuthCodeTrait;
+}
diff --git a/lib/OAuth/AuthCodeRepository.php b/lib/OAuth/AuthCodeRepository.php
new file mode 100644
index 0000000..40050ba
--- /dev/null
+++ b/lib/OAuth/AuthCodeRepository.php
@@ -0,0 +1,61 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
+use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
+
+class AuthCodeRepository implements AuthCodeRepositoryInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity)
+ {
+ // Some logic to persist the auth code to a database
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function revokeAuthCode($codeId)
+ {
+ // Some logic to revoke the auth code in a database
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isAuthCodeRevoked($codeId)
+ {
+ return false; // The auth code has not been revoked
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNewAuthCode()
+ {
+ return new AuthCodeEntity();
+ }
+}
diff --git a/lib/OAuth/RefreshTokenEntity.php b/lib/OAuth/RefreshTokenEntity.php
new file mode 100644
index 0000000..3435eb5
--- /dev/null
+++ b/lib/OAuth/RefreshTokenEntity.php
@@ -0,0 +1,32 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
+
+class RefreshTokenEntity implements RefreshTokenEntityInterface
+{
+ use RefreshTokenTrait, EntityTrait;
+}
diff --git a/lib/OAuth/RefreshTokenRepository.php b/lib/OAuth/RefreshTokenRepository.php
new file mode 100644
index 0000000..3fef59a
--- /dev/null
+++ b/lib/OAuth/RefreshTokenRepository.php
@@ -0,0 +1,121 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
+use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
+use Stash\Interfaces\PoolInterface;
+
+class RefreshTokenRepository implements RefreshTokenRepositoryInterface
+{
+ /**
+ * @var \Xibo\Service\LogServiceInterface
+ */
+ private $logger;
+ /**
+ * @var PoolInterface
+ */
+ private $pool;
+
+ /**
+ * AccessTokenRepository constructor.
+ * @param \Xibo\Service\LogServiceInterface $logger
+ */
+ public function __construct(\Xibo\Service\LogServiceInterface $logger, PoolInterface $pool)
+ {
+ $this->logger = $logger;
+ $this->pool = $pool;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity)
+ {
+ $date = clone $refreshTokenEntity->getExpiryDateTime();
+ // since stash cache sets expiresAt at up to provided date
+ // with up to 15% less than the provided date
+ // add more time to normal refresh token expire, to ensure cache does not expire before the token.
+ $date = $date->add(new \DateInterval('P15D'));
+
+ // cache with refresh token identifier
+ $cache = $this->pool->getItem('R_' . $refreshTokenEntity->getIdentifier());
+ $cache->set(
+ [
+ 'accessToken' => $refreshTokenEntity->getAccessToken()->getIdentifier(),
+ ]
+ );
+ $cache->expiresAt($date);
+ $this->pool->saveDeferred($cache);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function revokeRefreshToken($tokenId)
+ {
+ $this->pool->getItem('R_' . $tokenId)->clear();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isRefreshTokenRevoked($tokenId)
+ {
+ // get cache by refresh token identifier
+ $cache = $this->pool->getItem('R_' . $tokenId);
+ $refreshTokenData = $cache->get();
+
+ if ($cache->isMiss() || empty($refreshTokenData)) {
+ return true;
+ }
+
+ // get access token cache by access token identifier
+ $tokenCache = $this->pool->getItem('C_' . $refreshTokenData['accessToken']);
+ $tokenCacheData = $tokenCache->get();
+
+ // if the token itself not expired yet
+ // check if it was unauthorised by the specific user
+ // we cannot always check this as it would revoke refresh token if the access token already expired.
+ if (!$tokenCache->isMiss() && !empty($tokenCacheData)) {
+ // check access token cache by client and user identifiers
+ // (see if application got changed secret/revoked access)
+ $cache2 = $this->pool->getItem('C_' . $tokenCacheData['client'] . '/' . $tokenCacheData['userIdentifier']);
+ $data2 = $cache2->get();
+
+ if ($cache2->isMiss() || empty($data2)) {
+ return true;
+ }
+ }
+
+ return false; // The refresh token has not been revoked
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNewRefreshToken()
+ {
+ return new RefreshTokenEntity();
+ }
+}
diff --git a/lib/OAuth/ScopeEntity.php b/lib/OAuth/ScopeEntity.php
new file mode 100644
index 0000000..056bd02
--- /dev/null
+++ b/lib/OAuth/ScopeEntity.php
@@ -0,0 +1,37 @@
+.
+ */
+
+namespace Xibo\OAuth;
+
+use League\OAuth2\Server\Entities\ScopeEntityInterface;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\ScopeTrait;
+
+/**
+ * Class ScopeEntity
+ * @package Xibo\OAuth
+ */
+class ScopeEntity implements ScopeEntityInterface
+{
+ use ScopeTrait;
+ use EntityTrait;
+}
diff --git a/lib/Report/ApiRequests.php b/lib/Report/ApiRequests.php
new file mode 100644
index 0000000..7ae0d38
--- /dev/null
+++ b/lib/Report/ApiRequests.php
@@ -0,0 +1,457 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\ApplicationRequestsFactory;
+use Xibo\Factory\AuditLogFactory;
+use Xibo\Factory\LogFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+class ApiRequests implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /** @var LogFactory */
+ private $logFactory;
+
+ /** @var AuditLogFactory */
+ private $auditLogFactory;
+
+ /** @var ApplicationRequestsFactory */
+ private $apiRequestsFactory;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->logFactory = $container->get('logFactory');
+ $this->auditLogFactory = $container->get('auditLogFactory');
+ $this->apiRequestsFactory = $container->get('apiRequestsFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'apirequests-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'apirequests-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm(): ReportForm
+ {
+ return new ReportForm(
+ 'apirequests-report-form',
+ 'apirequests',
+ 'Audit',
+ [
+ 'fromDate' => Carbon::now()->startOfMonth()->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ]
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams): array
+ {
+ $data = [];
+ $data['reportName'] = 'apirequests';
+
+ return [
+ 'template' => 'apirequests-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams): array
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $filterCriteria['userId'] = $sanitizedParams->getInt('userId');
+ $filterCriteria['type'] = $sanitizedParams->getString('type');
+ $filterCriteria['scheduledReport'] = true;
+
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams): string
+ {
+ return sprintf(
+ __('%s API requests %s log report for User'),
+ ucfirst($sanitizedParams->getString('filter')),
+ ucfirst($sanitizedParams->getString('type'))
+ );
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($json)
+ {
+ return $json;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ 'logType' => $json['metadata']['logType']
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // The report uses a custom range filter that automatically calculates the from/to dates
+ // depending on the date range selected.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ // This calculation will be retained as it is used for scheduled reports
+ switch ($reportFilter) {
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // fromDt will always be from start of day ie 00:00
+ $fromDt = $sanitizedParams->getDate('fromDt') ?? $now->copy()->startOfDay();
+ $toDt = $sanitizedParams->getDate('toDt') ?? $now;
+
+ break;
+ }
+
+ $type = $sanitizedParams->getString('type');
+
+ $metadata = [
+ 'periodStart' => Carbon::createFromTimestamp($fromDt->toDateTime()->format('U'))
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => Carbon::createFromTimestamp($toDt->toDateTime()->format('U'))
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'logType' => $type,
+ ];
+
+ if ($type === 'audit') {
+ $params = [
+ 'fromDt' => $fromDt->format('U'),
+ 'toDt' => $toDt->format('U'),
+ ];
+
+ $sql = 'SELECT
+ `auditlog`.`logId`,
+ `auditlog`.`logDate`,
+ `user`.`userName`,
+ `auditlog`.`message`,
+ `auditlog`.`objectAfter`,
+ `auditlog`.`entity`,
+ `auditlog`.`entityId`,
+ `auditlog`.userId,
+ `auditlog`.ipAddress,
+ `auditlog`.requestId,
+ `application_requests_history`.applicationId,
+ `application_requests_history`.url,
+ `application_requests_history`.method,
+ `application_requests_history`.startTime,
+ `oauth_clients`.name AS applicationName
+ FROM `auditlog`
+ INNER JOIN `user`
+ ON `user`.`userId` = `auditlog`.`userId`
+ INNER JOIN `application_requests_history`
+ ON `application_requests_history`.`requestId` = `auditlog`.`requestId`
+ INNER JOIN `oauth_clients`
+ ON `oauth_clients`.id = `application_requests_history`.applicationId
+ WHERE `auditlog`.logDate BETWEEN :fromDt AND :toDt
+ ';
+
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $sql .= ' AND `auditlog`.`userId` = :userId';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ if ($sanitizedParams->getInt('requestId') !== null) {
+ $sql .= ' AND `auditlog`.`requestId` = :requestId';
+ $params['requestId'] = $sanitizedParams->getInt('requestId');
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $auditRecord = $this->auditLogFactory->create()->hydrate($row);
+ $auditRecord->setUnmatchedProperty(
+ 'applicationId',
+ $row['applicationId']
+ );
+
+ $auditRecord->setUnmatchedProperty(
+ 'applicationName',
+ $row['applicationName']
+ );
+
+ $auditRecord->setUnmatchedProperty(
+ 'url',
+ $row['url']
+ );
+
+ $auditRecord->setUnmatchedProperty(
+ 'method',
+ $row['method']
+ );
+
+ // decode for grid view, leave as json for email/preview.
+ if (!$sanitizedParams->getCheckbox('scheduledReport')) {
+ $auditRecord->objectAfter = json_decode($auditRecord->objectAfter);
+ }
+
+ $auditRecord->logDate = Carbon::createFromTimestamp($auditRecord->logDate)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $rows[] = $auditRecord;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ } else if ($type === 'debug') {
+ $params = [
+ 'fromDt' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ $sql = 'SELECT
+ `log`.`logId`,
+ `log`.`logDate`,
+ `log`.`runNo`,
+ `log`.`channel`,
+ `log`.`page`,
+ `log`.`function`,
+ `log`.`type`,
+ `log`.`message`,
+ `log`.`userId`,
+ `log`.`requestId`,
+ `user`.`userName`,
+ `application_requests_history`.applicationId,
+ `application_requests_history`.url,
+ `application_requests_history`.method,
+ `application_requests_history`.startTime,
+ `oauth_clients`.name AS applicationName
+ FROM `log`
+ INNER JOIN `user`
+ ON `user`.`userId` = `log`.`userId`
+ INNER JOIN `application_requests_history`
+ ON `application_requests_history`.`requestId` = `log`.`requestId`
+ INNER JOIN `oauth_clients`
+ ON `oauth_clients`.id = `application_requests_history`.applicationId
+ WHERE `log`.logDate BETWEEN :fromDt AND :toDt
+ ';
+
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $sql .= ' AND `log`.`userId` = :userId';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ if ($sanitizedParams->getInt('requestId') !== null) {
+ $sql .= ' AND `log`.`requestId` = :requestId';
+ $params['requestId'] = $sanitizedParams->getInt('requestId');
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $logRecord = $this->logFactory->createEmpty()->hydrate($row, ['htmlStringProperties' => ['message']]);
+ $logRecord->setUnmatchedProperty(
+ 'applicationId',
+ $row['applicationId']
+ );
+
+ $logRecord->setUnmatchedProperty(
+ 'applicationName',
+ $row['applicationName']
+ );
+
+ $logRecord->setUnmatchedProperty(
+ 'url',
+ $row['url']
+ );
+
+ $logRecord->setUnmatchedProperty(
+ 'method',
+ $row['method']
+ );
+
+ $rows[] = $logRecord;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ } else {
+ $params = [
+ 'fromDt' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ $sql = 'SELECT
+ `application_requests_history`.applicationId,
+ `application_requests_history`.requestId,
+ `application_requests_history`.userId,
+ `application_requests_history`.url,
+ `application_requests_history`.method,
+ `application_requests_history`.startTime,
+ `oauth_clients`.name AS applicationName,
+ `user`.`userName`
+ FROM `application_requests_history`
+ INNER JOIN `user`
+ ON `user`.`userId` = `application_requests_history`.`userId`
+ INNER JOIN `oauth_clients`
+ ON `oauth_clients`.id = `application_requests_history`.applicationId
+ WHERE `application_requests_history`.startTime BETWEEN :fromDt AND :toDt
+ ';
+
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $sql .= ' AND `application_requests_history`.`userId` = :userId';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ $apiRequestRecord = $this->apiRequestsFactory->createEmpty()->hydrate($row);
+
+ $apiRequestRecord->setUnmatchedProperty(
+ 'userName',
+ $row['userName']
+ );
+
+ $apiRequestRecord->setUnmatchedProperty(
+ 'applicationName',
+ $row['applicationName']
+ );
+
+ $rows[] = $apiRequestRecord;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ }
+ }
+}
diff --git a/lib/Report/Bandwidth.php b/lib/Report/Bandwidth.php
new file mode 100644
index 0000000..2d18f37
--- /dev/null
+++ b/lib/Report/Bandwidth.php
@@ -0,0 +1,365 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class Bandwidth
+ * @package Xibo\Report
+ */
+class Bandwidth implements ReportInterface
+{
+ use ReportDefaultTrait;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'bandwidth-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'bandwidth-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'bandwidth-report-form',
+ 'bandwidth',
+ 'Display',
+ [
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ]
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+ $data['reportName'] = 'bandwidth';
+
+ return [
+ 'template' => 'bandwidth-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $displayId = $sanitizedParams->getInt('displayId');
+
+ $filterCriteria['displayId'] = $displayId;
+ $filterCriteria['filter'] = $filter;
+
+ // Bandwidth report does not support weekly as bandwidth has monthly records in DB
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ return sprintf(__('%s bandwidth report', ucfirst($sanitizedParams->getString('filter'))));
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return $result;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determins whether or not the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ // Bandwidth report does not support weekly as bandwidth has monthly records in DB
+ switch ($reportFilter) {
+ // Daily report if setup which has reportfilter = yesterday will be daily progression of bandwidth usage
+ // It always starts from the start of the month so we get the month usage
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $fromDt->startOfMonth();
+
+ $toDt = $now->copy()->startOfDay();
+
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('fromDt', ['default' => $sanitizedParams->getDate('bandwidthFromDt')]);
+ $fromDt->startOfMonth();
+
+ $toDt = $sanitizedParams->getDate('toDt', ['default' => $sanitizedParams->getDate('bandwidthToDt')]);
+ $toDt->addMonth();
+
+ break;
+ }
+
+ // Get an array of display id this user has access to.
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+
+ // Get some data for a bandwidth chart
+ $dbh = $this->store->getConnection();
+
+ $displayId = $sanitizedParams->getInt('displayId');
+
+ $params = [
+ 'month' => $fromDt->copy()->format('U'),
+ 'month2' => $toDt->copy()->format('U')
+ ];
+
+ $SQL = 'SELECT display.display, IFNULL(SUM(Size), 0) AS size ';
+
+ if ($displayId != 0) {
+ $SQL .= ', bandwidthtype.name AS type ';
+ }
+
+ // For user with limited access, return only data for displays this user has permissions to.
+ $joinType = ($this->getUser()->isSuperAdmin()) ? 'LEFT OUTER JOIN' : 'INNER JOIN';
+
+ $SQL .= ' FROM `bandwidth` ' .
+ $joinType . ' `display`
+ ON display.displayid = bandwidth.displayid ';
+
+
+ // Displays
+ if (count($displayIds) > 0) {
+ $SQL .= ' AND display.displayId IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ if ($displayId != 0) {
+ $SQL .= '
+ INNER JOIN bandwidthtype
+ ON bandwidthtype.bandwidthtypeid = bandwidth.type
+ ';
+ }
+
+ $SQL .= ' WHERE month > :month
+ AND month < :month2 ';
+
+ if ($displayId != 0) {
+ $SQL .= ' AND display.displayid = :displayid ';
+ $params['displayid'] = $displayId;
+ }
+
+ $SQL .= 'GROUP BY display.displayId, display.display ';
+
+ if ($displayId != 0) {
+ $SQL .= ' , bandwidthtype.name ';
+ }
+
+ $SQL .= 'ORDER BY display.display';
+
+ $sth = $dbh->prepare($SQL);
+
+ $sth->execute($params);
+
+ // Get the results
+ $results = $sth->fetchAll();
+
+ $maxSize = 0;
+ foreach ($results as $library) {
+ $maxSize = ($library['size'] > $maxSize) ? $library['size'] : $maxSize;
+ }
+
+ // Decide what our units are going to be, based on the size
+ // We need to put a fallback value in case it returns an infinite value
+ $base = !is_infinite(floor(log($maxSize) / log(1024))) ? floor(log($maxSize) / log(1024)) : 0;
+
+ $labels = [];
+ $data = [];
+ $backgroundColor = [];
+
+ $rows = [];
+
+
+ // Set up some suffixes
+ $suffixes = array('bytes', 'k', 'M', 'G', 'T');
+ foreach ($results as $row) {
+ // label depends whether we are filtered by display
+ if ($displayId != 0) {
+ $label = $row['type'];
+ $labels[] = $label;
+ } else {
+ $label = $row['display'] === null ? __('Deleted Displays') : $row['display'];
+ $labels[] = $label;
+ }
+ $backgroundColor[] = ($row['display'] === null) ? 'rgb(255,0,0)' : 'rgb(11, 98, 164)';
+ $bandwidth = round((double)$row['size'] / (pow(1024, $base)), 2);
+ $data[] = $bandwidth;
+
+ // ----
+ // Build Tabular data
+ $entry = [];
+ $entry['label'] = $label;
+ $entry['bandwidth'] = $bandwidth;
+ $entry['unit'] = (isset($suffixes[$base]) ? $suffixes[$base] : '');
+ $rows[] = $entry;
+ }
+
+ //
+ // Output Results
+ // --------------
+ $chart = [
+ 'type' => 'bar',
+ 'data' => [
+ 'labels' => $labels,
+ 'datasets' => [
+ [
+ 'label' => __('Bandwidth'),
+ 'backgroundColor' => $backgroundColor,
+ 'data' => $data
+ ]
+ ]
+ ],
+ 'options' => [
+ 'scales' => [
+ 'yAxes' => [
+ [
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => (isset($suffixes[$base]) ? $suffixes[$base] : '')
+ ]
+ ]
+ ]
+ ],
+ 'legend' => [
+ 'display' => false
+ ],
+ 'maintainAspectRatio' => true
+ ]
+ ];
+
+ $metadata = [
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ // Total records
+ $recordsTotal = count($rows);
+
+ // ----
+ // Chart Only
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ $chart
+ );
+ }
+}
diff --git a/lib/Report/CampaignProofOfPlay.php b/lib/Report/CampaignProofOfPlay.php
new file mode 100644
index 0000000..9f1b398
--- /dev/null
+++ b/lib/Report/CampaignProofOfPlay.php
@@ -0,0 +1,385 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use MongoDB\BSON\UTCDateTime;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Event\ReportDataEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class CampaignProofOfPlay
+ * @package Xibo\Report
+ */
+class CampaignProofOfPlay implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var EventDispatcher
+ */
+ private $dispatcher;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->reportScheduleFactory = $container->get('reportScheduleFactory');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->dispatcher = $container->get('dispatcher');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'campaign-proofofplay-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'campaign-proofofplay-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'campaign-proofofplay-report-form',
+ 'campaignProofOfPlay',
+ 'Connector Reports',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ],
+ __('Select a display')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+
+ $data['hiddenFields'] = '';
+ $data['reportName'] = 'campaignProofOfPlay';
+
+ return [
+ 'template' => 'campaign-proofofplay-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $filterCriteria = [
+ 'filter' => $filter,
+ 'displayId' => $sanitizedParams->getInt('displayId'),
+ 'displayIds' => $sanitizedParams->getIntArray('displayIds'),
+ ];
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $saveAs = sprintf(__('%s report for ', ucfirst($sanitizedParams->getString('filter'))));
+
+ $displayId = $sanitizedParams->getInt('displayId');
+ if (!empty($displayId)) {
+ // Get display
+ try {
+ $displayName = $this->displayFactory->getById($displayId)->display;
+ $saveAs .= '(Display: '. $displayName . ')';
+ } catch (NotFoundException $error) {
+ $saveAs .= '(DisplayId: Not Found )';
+ }
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ 'table' => $result['result'],
+ ];
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ // Get filter criteria
+ $rs = $this->reportScheduleFactory->getById($savedReport->reportScheduleId, 1)->filterCriteria;
+ $filterCriteria = json_decode($rs, true);
+
+ // Show filter criteria
+ $metadata = [];
+
+ // Get Meta data
+ $metadata['periodStart'] = $json['metadata']['periodStart'];
+ $metadata['periodEnd'] = $json['metadata']['periodEnd'];
+ $metadata['generatedOn'] = Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat());
+ $metadata['title'] = $savedReport->saveAs;
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $parentCampaignId = $sanitizedParams->getInt('parentCampaignId');
+
+ // Get campaign
+ if (!empty($parentCampaignId)) {
+ $campaign = $this->campaignFactory->getById($parentCampaignId);
+ }
+
+ // Display filter.
+ try {
+ // Get an array of display id this user has access to.
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+ } catch (GeneralException $exception) {
+ // stop the query
+ return new ReportResult();
+ }
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determines whether the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ case 'today':
+ $fromDt = $now->copy()->startOfDay();
+ $toDt = $fromDt->copy()->addDay();
+ break;
+
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'thisweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'thismonth':
+ $fromDt = $now->copy()->startOfMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'thisyear':
+ $fromDt = $now->copy()->startOfYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('statsFromDt', ['default' => Carbon::now()->subDay()]);
+ $fromDt->startOfDay();
+
+ $toDt = $sanitizedParams->getDate('statsToDt', ['default' => Carbon::now()]);
+ $toDt->endOfDay();
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ break;
+ }
+
+ $params = [
+ 'campaignId' => $parentCampaignId,
+ 'displayIds' => $displayIds,
+ 'groupBy' => $sanitizedParams->getString('groupBy')
+ ];
+
+ // when the reportfilter is wholecampaign take campaign start/end as form/to date
+ if (!empty($parentCampaignId) && $sanitizedParams->getString('reportFilter') === 'wholecampaign') {
+ $params['fromDt'] = !empty($campaign->getStartDt()) ? $campaign->getStartDt()->format('Y-m-d H:i:s') : null;
+ $params['toDt'] = !empty($campaign->getEndDt()) ? $campaign->getEndDt()->format('Y-m-d H:i:s') : null;
+
+ if (empty($campaign->getStartDt()) || empty($campaign->getEndDt())) {
+ return new ReportResult();
+ }
+ } else {
+ $params['fromDt'] = $fromDt->format('Y-m-d H:i:s');
+ $params['toDt'] = $toDt->format('Y-m-d H:i:s');
+ }
+
+ // --------
+ // ReportDataEvent
+ $event = new ReportDataEvent('campaignProofofplay');
+
+ // Set query params for audience proof of play report
+ $event->setParams($params);
+
+ // Dispatch the event - listened by Audience Report Connector
+ $this->dispatcher->dispatch($event, ReportDataEvent::$NAME);
+ $results = $event->getResults();
+
+ $result['periodStart'] = $params['fromDt'];
+ $result['periodEnd'] = $params['toDt'];
+
+ // Sanitize results??
+ $rows = [];
+ foreach ($results['json'] as $row) {
+ $entry = [];
+
+ $entry['labelDate'] = $row['labelDate'];
+ $entry['adPlays'] = $row['adPlays'];
+ $entry['adDuration'] = $row['adDuration'];
+ $entry['impressions'] = $row['impressions'];
+ $entry['spend'] = $row['spend'];
+
+ $rows[] = $entry;
+ }
+
+ // Set Meta data
+ $metadata = [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ ];
+
+ $recordsTotal = count($rows);
+
+ // ----
+ // Table Only
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ [],
+ $results['error'] ?? null
+ );
+ }
+}
diff --git a/lib/Report/DefaultReportEmailTrait.php b/lib/Report/DefaultReportEmailTrait.php
new file mode 100644
index 0000000..f1b7629
--- /dev/null
+++ b/lib/Report/DefaultReportEmailTrait.php
@@ -0,0 +1,36 @@
+.
+ */
+
+namespace Xibo\Report;
+
+/**
+ * Trait DefaultReportEmailTrait
+ * @package Xibo\Report
+ */
+trait DefaultReportEmailTrait
+{
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'default-email-template.twig';
+ }
+}
diff --git a/lib/Report/DisplayAdPlay.php b/lib/Report/DisplayAdPlay.php
new file mode 100644
index 0000000..b92a2f1
--- /dev/null
+++ b/lib/Report/DisplayAdPlay.php
@@ -0,0 +1,492 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use MongoDB\BSON\UTCDateTime;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Event\ReportDataEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class DisplayAdPlay
+ * @package Xibo\Report
+ */
+class DisplayAdPlay implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var EventDispatcher
+ */
+ private $dispatcher;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->reportScheduleFactory = $container->get('reportScheduleFactory');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->dispatcher = $container->get('dispatcher');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'display-adplays-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'display-adplays-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'display-adplays-report-form',
+ 'displayAdPlay',
+ 'Connector Reports',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ],
+ __('Select a display')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+
+ $data['hiddenFields'] = '';
+ $data['reportName'] = 'displayAdPlay';
+
+ return [
+ 'template' => 'display-adplays-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $filterCriteria = [
+ 'filter' => $filter,
+ 'displayId' => $sanitizedParams->getInt('displayId'),
+ 'displayIds' => $sanitizedParams->getIntArray('displayIds'),
+ ];
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ $filterCriteria['groupByFilter'] = 'byweek';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ $filterCriteria['groupByFilter'] = 'bymonth';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $saveAs = sprintf(__('%s report for ', ucfirst($sanitizedParams->getString('filter'))));
+
+ $displayId = $sanitizedParams->getInt('displayId');
+ if (!empty($displayId)) {
+ // Get display
+ try {
+ $displayName = $this->displayFactory->getById($displayId)->display;
+ $saveAs .= '(Display: '. $displayName . ')';
+ } catch (NotFoundException $error) {
+ $saveAs .= '(DisplayId: Not Found )';
+ }
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ 'table' => $result['result'],
+ ];
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ // Get filter criteria
+ $rs = $this->reportScheduleFactory->getById($savedReport->reportScheduleId, 1)->filterCriteria;
+ $filterCriteria = json_decode($rs, true);
+
+ // Show filter criteria
+ $metadata = [];
+
+ // Get Meta data
+ $metadata['periodStart'] = $json['metadata']['periodStart'];
+ $metadata['periodEnd'] = $json['metadata']['periodEnd'];
+ $metadata['generatedOn'] = Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat());
+ $metadata['title'] = $savedReport->saveAs;
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritDoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $layoutId = $sanitizedParams->getInt('layoutId');
+ $parentCampaignId = $sanitizedParams->getInt('parentCampaignId');
+
+ // Get campaign
+ if (!empty($parentCampaignId)) {
+ $campaign = $this->campaignFactory->getById($parentCampaignId);
+ }
+
+ // Display filter.
+ try {
+ // Get an array of display id this user has access to.
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+ } catch (GeneralException $exception) {
+ // stop the query
+ return new ReportResult();
+ }
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determines whether the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ case 'today':
+ $fromDt = $now->copy()->startOfDay();
+ $toDt = $fromDt->copy()->addDay();
+ break;
+
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'thisweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'thismonth':
+ $fromDt = $now->copy()->startOfMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'thisyear':
+ $fromDt = $now->copy()->startOfYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('fromDt', ['default' => Carbon::now()->subDay()]);
+ $fromDt->startOfDay();
+
+ $toDt = $sanitizedParams->getDate('toDt', ['default' => Carbon::now()]);
+ $toDt->endOfDay();
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ break;
+ }
+
+ $params = [
+ 'campaignId' => $parentCampaignId,
+ 'layoutId' => $layoutId,
+ 'displayIds' => $displayIds,
+ 'groupBy' => $sanitizedParams->getString('groupBy')
+ ];
+
+ // when the reportfilter is wholecampaign take campaign start/end as form/to date
+ if (!empty($parentCampaignId) && $sanitizedParams->getString('reportFilter') === 'wholecampaign') {
+ $params['fromDt'] = !empty($campaign->getStartDt()) ? $campaign->getStartDt()->format('Y-m-d H:i:s') : null;
+ $params['toDt'] = !empty($campaign->getEndDt()) ? $campaign->getEndDt()->format('Y-m-d H:i:s') : null;
+
+ if (empty($campaign->getStartDt()) || empty($campaign->getEndDt())) {
+ return new ReportResult();
+ }
+ } else {
+ $params['fromDt'] = $fromDt->format('Y-m-d H:i:s');
+ $params['toDt'] = $toDt->format('Y-m-d H:i:s');
+ }
+
+ // --------
+ // ReportDataEvent
+ $event = new ReportDataEvent('displayAdPlay');
+
+ // Set query params for report
+ $event->setParams($params);
+
+ // Dispatch the event - listened by Audience Report Connector
+ $this->dispatcher->dispatch($event, ReportDataEvent::$NAME);
+ $results = $event->getResults();
+
+ $result['periodStart'] = $params['fromDt'];
+ $result['periodEnd'] = $params['toDt'];
+
+ $rows = [];
+ $labels = [];
+ $adPlaysData = [];
+ $impressionsData = [];
+ $spendData = [];
+ $backgroundColor = [];
+
+ foreach ($results['json'] as $row) {
+ // ----
+ // Build Chart data
+ $labels[] = $row['labelDate'];
+
+ $backgroundColor[] = 'rgb(34, 207, 207, 0.7)';
+
+ $adPlays = $row['adPlays'];
+ $adPlaysData[] = ($adPlays == '') ? 0 : $adPlays;
+
+ $impressions = $row['impressions'];
+ $impressionsData[] = ($impressions == '') ? 0 : $impressions;
+
+ $spend = $row['spend'];
+ $spendData[] = ($spend == '') ? 0 : $spend;
+
+ // ----
+ // Build Tabular data
+ $entry = [];
+
+ $entry['labelDate'] = $row['labelDate'];
+ $entry['adPlays'] = $row['adPlays'];
+ $entry['adDuration'] = $row['adDuration'];
+ $entry['impressions'] = $row['impressions'];
+ $entry['spend'] = $row['spend'];
+
+ $rows[] = $entry;
+ }
+
+ // Build Chart to pass in twig file chart.js
+ $chart = [
+ 'type' => 'bar',
+ 'data' => [
+ 'labels' => $labels,
+ 'datasets' => [
+ [
+ 'label' => __('Total ad plays'),
+ 'yAxisID' => 'AdPlay',
+ 'backgroundColor' => $backgroundColor,
+ 'data' => $adPlaysData
+ ],
+ [
+ 'label' => __('Total impressions'),
+ 'yAxisID' => 'Impression',
+ 'borderColor' => 'rgba(255,159,64,255)',
+ 'type' => 'line',
+ 'fill' => false,
+ 'data' => $impressionsData
+ ],
+ [
+ 'label' => __('Total spend'),
+ 'yAxisID' => 'Spend',
+ 'borderColor' => 'rgba(255,99,132,255)',
+ 'type' => 'line',
+ 'fill' => false,
+ 'data' => $spendData
+ ]
+ ]
+ ],
+ 'options' => [
+ 'scales' => [
+ 'yAxes' => [
+ [
+ 'id' => 'AdPlay',
+ 'type' => 'linear',
+ 'position' => 'left',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Ad Play(s)')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ], [
+ 'id' => 'Impression',
+ 'type' => 'linear',
+ 'position' => 'right',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Impression(s)')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ], [
+ 'id' => 'Spend',
+ 'type' => 'linear',
+ 'position' => 'right',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Spend')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ]
+ ]
+ ]
+ ]
+ ];
+
+ // Set Meta data
+ $metadata = [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ ];
+
+ $recordsTotal = count($rows);
+
+ // ----
+ // Table Only
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ $chart,
+ $results['error'] ?? null
+ );
+ }
+}
diff --git a/lib/Report/DisplayAlerts.php b/lib/Report/DisplayAlerts.php
new file mode 100644
index 0000000..10ae1b8
--- /dev/null
+++ b/lib/Report/DisplayAlerts.php
@@ -0,0 +1,323 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayEventFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Translate;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class DisplayAlerts
+ * @package Xibo\Report
+ */
+class DisplayAlerts implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+ /** @var DisplayEventFactory */
+ private $displayEventFactory;
+
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->displayEventFactory = $container->get('displayEventFactory');
+
+ return $this;
+ }
+
+ public function getReportEmailTemplate()
+ {
+ return 'displayalerts-email-template.twig';
+ }
+
+ public function getSavedReportTemplate()
+ {
+ return 'displayalerts-report-preview';
+ }
+
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'displayalerts-report-form',
+ 'displayalerts',
+ 'Display',
+ [
+ 'fromDate' => Carbon::now()->startOfMonth()->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ]
+ );
+ }
+
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+ $data['reportName'] = 'displayalerts';
+
+ return [
+ 'template' => 'displayalerts-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $displayId = $sanitizedParams->getInt('displayId');
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+ $filterCriteria['displayId'] = $displayId;
+
+ if (empty($displayId) && count($displayGroupIds) > 0) {
+ $filterCriteria['displayGroupId'] = $displayGroupIds;
+ }
+
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ return sprintf(__('%s report for Display'), ucfirst($sanitizedParams->getString('filter')));
+ }
+
+ public function restructureSavedReportOldJson($json)
+ {
+ return $json;
+ }
+
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ );
+ }
+
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+ $onlyLoggedIn = $sanitizedParams->getCheckbox('onlyLoggedIn') == 1;
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // The report uses a custom range filter that automatically calculates the from/to dates
+ // depending on the date range selected.
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+ $currentDate = Carbon::now()->startOfDay();
+
+ // If toDt is current date then make it current datetime
+ if ($toDt->format('Y-m-d') == $currentDate->format('Y-m-d')) {
+ $toDt = Carbon::now();
+ }
+
+ $metadata = [
+ 'periodStart' => Carbon::createFromTimestamp($fromDt->toDateTime()->format('U'))
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => Carbon::createFromTimestamp($toDt->toDateTime()->format('U'))
+ ->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ $params = [
+ 'start' => $fromDt->format('U'),
+ 'end' => $toDt->format('U')
+ ];
+
+ $sql = 'SELECT
+ `displayevent`.displayId,
+ `display`.display,
+ `displayevent`.start,
+ `displayevent`.end,
+ `displayevent`.eventTypeId,
+ `displayevent`.refId,
+ `displayevent`.detail
+ FROM `displayevent`
+ INNER JOIN `display` ON `display`.displayId = `displayevent`.displayId
+ INNER JOIN `lkdisplaydg` ON `display`.displayId = `lkdisplaydg`.displayId
+ INNER JOIN `displaygroup` ON `displaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId
+ AND `displaygroup`.isDisplaySpecific = 1
+ WHERE `displayevent`.eventDate BETWEEN :start AND :end ';
+
+ $eventTypeIdFilter = $sanitizedParams->getString('eventType');
+
+ if ($eventTypeIdFilter != -1) {
+ $params['eventTypeId'] = $eventTypeIdFilter;
+
+ $sql .= 'AND `displayevent`.eventTypeId = :eventTypeId ';
+ }
+
+ if (count($displayIds) > 0) {
+ $sql .= 'AND `displayevent`.displayId IN (' . implode(',', $displayIds) . ')';
+ }
+
+ if ($onlyLoggedIn) {
+ $sql .= ' AND `display`.loggedIn = 1 ';
+ }
+
+ // Tags
+ if (!empty($sanitizedParams->getString('tags'))) {
+ $tagFilter = $sanitizedParams->getString('tags');
+
+ if (trim($tagFilter) === '--no-tag') {
+ $sql .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ )
+ ';
+ } else {
+ $operator = $sanitizedParams->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+ $logicalOperator = $sanitizedParams->getString('logicalOperator', ['default' => 'OR']);
+ $allTags = explode(',', $tagFilter);
+ $notTags = [];
+ $tags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $notTags[] = ltrim(($tag), '-');
+ } else {
+ $tags[] = $tag;
+ }
+ }
+
+ if (!empty($notTags)) {
+ $sql .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+
+ $this->displayFactory->tagFilter(
+ $notTags,
+ 'lktagdisplaygroup',
+ 'lkTagDisplayGroupId',
+ 'displayGroupId',
+ $logicalOperator,
+ $operator,
+ true,
+ $sql,
+ $params
+ );
+ }
+
+ if (!empty($tags)) {
+ $sql .= ' AND `displaygroup`.displaygroupId IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+
+ $this->displayFactory->tagFilter(
+ $tags,
+ 'lktagdisplaygroup',
+ 'lkTagDisplayGroupId',
+ 'displayGroupId',
+ $logicalOperator,
+ $operator,
+ false,
+ $sql,
+ $params
+ );
+ }
+ }
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $displayEvent = $this->displayEventFactory->createEmpty()->hydrate($row);
+ $displayEvent->setUnmatchedProperty(
+ 'eventType',
+ $displayEvent->getEventNameFromId($displayEvent->eventTypeId)
+ );
+ $displayEvent->setUnmatchedProperty(
+ 'display',
+ $row['display']
+ );
+
+ $rows[] = $displayEvent;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ }
+}
diff --git a/lib/Report/DisplayPercentage.php b/lib/Report/DisplayPercentage.php
new file mode 100644
index 0000000..ba23da2
--- /dev/null
+++ b/lib/Report/DisplayPercentage.php
@@ -0,0 +1,316 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use MongoDB\BSON\UTCDateTime;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Event\ReportDataEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class DisplayPercentage
+ * @package Xibo\Report
+ */
+class DisplayPercentage implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var EventDispatcher
+ */
+ private $dispatcher;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->reportScheduleFactory = $container->get('reportScheduleFactory');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->dispatcher = $container->get('dispatcher');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'display-percentage-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'display-percentage-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'display-percentage-report-form',
+ 'displayPercentage',
+ 'Connector Reports',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ],
+ __('Select a campaign')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+
+ $data['hiddenFields'] = json_encode([
+ 'parentCampaignId' => $sanitizedParams->getInt('parentCampaignId')
+ ]);
+ $data['reportName'] = 'displayPercentage';
+
+ return [
+ 'template' => 'display-percentage-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $hiddenFields = json_decode($sanitizedParams->getString('hiddenFields'), true);
+
+ $filterCriteria = [
+ 'filter' => $filter,
+ 'parentCampaignId' => $hiddenFields['parentCampaignId']
+ ];
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ $filterCriteria['groupByFilter'] = 'byweek';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ $filterCriteria['groupByFilter'] = 'bymonth';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $saveAs = sprintf(__('%s report for ', ucfirst($sanitizedParams->getString('filter'))));
+
+ $parentCampaignId = $sanitizedParams->getInt('parentCampaignId');
+ if (!empty($parentCampaignId)) {
+ // Get display
+ try {
+ $parentCampaignName = $this->campaignFactory->getById($parentCampaignId)->campaign;
+ $saveAs .= '(Campaign: '. $parentCampaignName . ')';
+ } catch (NotFoundException $error) {
+ $saveAs .= '(Campaign: Not Found )';
+ }
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ 'table' => $result['result'],
+ ];
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ // Get filter criteria
+ $rs = $this->reportScheduleFactory->getById($savedReport->reportScheduleId, 1)->filterCriteria;
+ $filterCriteria = json_decode($rs, true);
+
+ // Show filter criteria
+ $metadata = [];
+
+ // Get Meta data
+ $metadata['periodStart'] = $json['metadata']['periodStart'];
+ $metadata['periodEnd'] = $json['metadata']['periodEnd'];
+ $metadata['generatedOn'] = Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat());
+ $metadata['title'] = $savedReport->saveAs;
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritDoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $params = [
+ 'parentCampaignId' => $sanitizedParams->getInt('parentCampaignId')
+ ];
+
+ // --------
+ // ReportDataEvent
+ $event = new ReportDataEvent('displayPercentage');
+
+ // Set query params for report
+ $event->setParams($params);
+
+ // Dispatch the event - listened by Audience Report Connector
+ $this->dispatcher->dispatch($event, ReportDataEvent::$NAME);
+ $results = $event->getResults();
+
+ // TODO
+ $result['periodStart'] = Carbon::now()->format('Y-m-d H:i:s');
+ $result['periodEnd'] = Carbon::now()->format('Y-m-d H:i:s');
+
+ $rows = [];
+ $displayCache = [];
+
+ foreach ($results['json'] as $row) {
+ // ----
+ // Build Chart data
+
+ // ----
+ // Build Tabular data
+ $entry = [];
+
+ // --------
+ // Get Display
+ try {
+ if (!array_key_exists($row['displayId'], $displayCache)) {
+ $display = $this->displayFactory->getById($row['displayId']);
+ $displayCache[$row['displayId']] = $display->display;
+ }
+ $entry['label'] = $displayCache[$row['displayId']] ?? '';
+ } catch (\Exception $e) {
+ $entry['label'] = __('Not found');
+ }
+
+ $entry['spendData'] = $row['spendData'];
+ $entry['playtimeDuration'] = $row['playtimeDuration'];
+ $entry['backgroundColor'] = '#'.substr(md5($row['displayId']), 0, 6);
+
+ $rows[] = $entry;
+ }
+
+ // Build Chart to pass in twig file chart.js
+ $chart = [];
+
+ // Set Meta data
+ $metadata = [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ ];
+
+ $recordsTotal = count($rows);
+
+ // ----
+ // Table Only
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ $chart,
+ $results['error'] ?? null
+ );
+ }
+}
diff --git a/lib/Report/DistributionReport.php b/lib/Report/DistributionReport.php
new file mode 100644
index 0000000..ce8e393
--- /dev/null
+++ b/lib/Report/DistributionReport.php
@@ -0,0 +1,1258 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use MongoDB\BSON\UTCDateTime;
+use Psr\Container\ContainerInterface;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class DistributionReport
+ * @package Xibo\Report
+ */
+class DistributionReport implements ReportInterface
+{
+
+ use ReportDefaultTrait;
+ use SummaryDistributionCommonTrait;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ private $table = 'stat';
+
+ private $periodTable = 'period';
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->savedReportFactory = $container->get('savedReportFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->reportService = $container->get('reportService');
+ $this->sanitizer = $container->get('sanitizerService');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'distribution-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'distribution-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'distribution-report-form',
+ 'distributionReport',
+ 'Proof of Play',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ],
+ __('Select a type and an item (i.e., layout/media/tag)')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $type = $sanitizedParams->getString('type');
+
+ $formParams = $this->getReportScheduleFormTitle($sanitizedParams);
+
+ $data = [];
+ $data['formTitle'] = $formParams['title'];
+ $data['hiddenFields'] = json_encode([
+ 'type' => $type,
+ 'selectedId' => $formParams['selectedId'],
+ 'eventTag' => $eventTag ?? null
+ ]);
+ $data['reportName'] = 'distributionReport';
+
+ return [
+ 'template' => 'distribution-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+ $displayId = $sanitizedParams->getInt('displayId');
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+ $hiddenFields = json_decode($sanitizedParams->getString('hiddenFields'), true);
+
+ $type = $hiddenFields['type'];
+ $selectedId = $hiddenFields['selectedId'];
+ $eventTag = $hiddenFields['eventTag'];
+
+ // If a display is selected we ignore the display group selection
+ $filterCriteria['displayId'] = $displayId;
+ if (empty($displayId) && count($displayGroupIds) > 0) {
+ $filterCriteria['displayGroupId'] = $displayGroupIds;
+ }
+
+ $filterCriteria['type'] = $type;
+ if ($type == 'layout') {
+ $filterCriteria['layoutId'] = $selectedId;
+ } elseif ($type == 'media') {
+ $filterCriteria['mediaId'] = $selectedId;
+ } elseif ($type == 'event') {
+ $filterCriteria['eventTag'] = $eventTag;
+ }
+
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $type = $sanitizedParams->getString('type');
+ $filter = $sanitizedParams->getString('filter');
+
+ if ($type == 'layout') {
+ try {
+ $layout = $this->layoutFactory->getById($sanitizedParams->getInt('layoutId'));
+ } catch (NotFoundException $error) {
+ // Get the campaign ID
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($sanitizedParams->getInt('layoutId'));
+ $layoutId = $this->layoutFactory->getLatestLayoutIdFromLayoutHistory($campaignId);
+ $layout = $this->layoutFactory->getById($layoutId);
+ }
+
+ $saveAs = sprintf(__('%s report for Layout %s', ucfirst($filter), $layout->layout));
+ } elseif ($type == 'media') {
+ try {
+ $media = $this->mediaFactory->getById($sanitizedParams->getInt('mediaId'));
+ $saveAs = sprintf(__('%s report for Media %s', ucfirst($filter), $media->name));
+ } catch (NotFoundException $error) {
+ $saveAs = __('Media not found');
+ }
+ } elseif ($type == 'event') {
+ $saveAs = sprintf(__('%s report for Event %s', ucfirst($filter), $sanitizedParams->getString('eventTag')));
+ }
+
+ // todo: ???
+ if (!empty($filterCriteria['displayId'])) {
+ // Get display
+ try {
+ $displayName = $this->displayFactory->getById($filterCriteria['displayId'])->display;
+ $saveAs .= ' ('. __('Display') . ': '. $displayName . ')';
+ } catch (NotFoundException $error) {
+ $saveAs .= ' '.__('(DisplayId: Not Found)');
+ }
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $type = strtolower($sanitizedParams->getString('type'));
+ $layoutId = $sanitizedParams->getInt('layoutId');
+ $mediaId = $sanitizedParams->getInt('mediaId');
+ $eventTag = $sanitizedParams->getString('eventTag');
+
+ // Filter by displayId?
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+
+ // Get an array of display groups this user has access to
+ $displayGroupIds = [];
+
+ foreach ($this->displayGroupFactory->query(null, [
+ 'isDisplaySpecific' => -1,
+ 'userCheckUserId' => $this->getUser()->userId
+ ]) as $displayGroup) {
+ $displayGroupIds[] = $displayGroup->displayGroupId;
+ }
+
+ if (count($displayGroupIds) <= 0) {
+ throw new InvalidArgumentException(__('No display groups with View permissions'), 'displayGroup');
+ }
+
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determins whether the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ case 'today':
+ $fromDt = $now->copy()->startOfDay();
+ $toDt = $fromDt->copy()->addDay();
+ break;
+
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'thisweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'thismonth':
+ $fromDt = $now->copy()->startOfMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'thisyear':
+ $fromDt = $now->copy()->startOfYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('statsFromDt', ['default' => Carbon::now()->subDay()]);
+ $fromDt->startOfDay();
+
+ $toDt = $sanitizedParams->getDate('statsToDt', ['default' => Carbon::now()]);
+ $toDt->addDay()->startOfDay();
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ break;
+ }
+
+ // Use the group by filter provided
+ // NB: this differs from the Summary Report where we set the group by according to the range selected
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+
+ //
+ // Get Results!
+ // -------------
+ $timeSeriesStore = $this->getTimeSeriesStore()->getEngine();
+ if ($timeSeriesStore == 'mongodb') {
+ $result = $this->getDistributionReportMongoDb(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $displayGroupIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag
+ );
+ } else {
+ $result = $this->getDistributionReportMySql(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $displayGroupIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag
+ );
+ }
+
+ //
+ // Output Results
+ // --------------
+ $labels = [];
+ $countData = [];
+ $durationData = [];
+ $backgroundColor = [];
+ $borderColor = [];
+
+ // Sanitize results for chart and table
+ $rows = [];
+ if (count($result) > 0) {
+ foreach ($result['result'] as $row) {
+ $sanitizedRow = $this->sanitizer->getSanitizer($row);
+
+ // ----
+ // Build Chart data
+ // Chart labels in xaxis
+ $labels[] = $row['label'];
+
+ $backgroundColor[] = 'rgb(95, 186, 218, 0.6)';
+ $borderColor[] = 'rgb(240,93,41, 0.8)';
+
+ $count = ($row['NumberPlays'] == '') ? 0 : $row['NumberPlays'];
+ $countData[] = $count;
+
+ $duration = ($row['Duration'] == '') ? 0 : $row['Duration'];
+ $durationData[] = $duration;
+
+ // ----
+ // Build Tabular data
+ $entry = [];
+ $entry['label'] = $sanitizedRow->getString('label');
+ $entry['duration'] = $duration;
+ $entry['count'] = $count;
+ $rows[] = $entry;
+ }
+ }
+
+ // Build Chart to pass in twig file chart.js
+ $chart = [
+ 'type' => 'bar',
+ 'data' => [
+ 'labels' => $labels,
+ 'datasets' => [
+ [
+ 'label' => __('Total duration'),
+ 'yAxisID' => 'Duration',
+ 'backgroundColor' => $backgroundColor,
+ 'data' => $durationData
+ ],
+ [
+ 'label' => __('Total count'),
+ 'yAxisID' => 'Count',
+ 'borderColor' => $borderColor,
+ 'type' => 'line',
+ 'fill' => false,
+ 'data' => $countData
+ ]
+ ]
+ ],
+ 'options' => [
+ 'scales' => [
+ 'yAxes' => [
+ [
+ 'id' => 'Duration',
+ 'type' => 'linear',
+ 'position' => 'left',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Duration(s)')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ], [
+ 'id' => 'Count',
+ 'type' => 'linear',
+ 'position' => 'right',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Count')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ]
+ ]
+ ]
+ ]
+ ];
+
+ $metadata = [
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ // Total records
+ $recordsTotal = count($rows);
+
+ // ----
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ $chart
+ );
+ }
+
+ /**
+ * MySQL distribution report
+ * @param Carbon $fromDt The filter range from date
+ * @param Carbon $toDt The filter range to date
+ * @param string $groupByFilter Grouping, byhour, bydayofweek and bydayofmonth
+ * @param $displayIds
+ * @param $displayGroupIds
+ * @param $type
+ * @param $layoutId
+ * @param $mediaId
+ * @param $eventTag
+ * @return array
+ */
+ private function getDistributionReportMySql(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $displayGroupIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag
+ ) {
+ // Only return something if we have the necessary options selected.
+ if (($type == 'media' && $mediaId != '')
+ || ($type == 'layout' && $layoutId != '')
+ || ($type == 'event' && $eventTag != '')
+ ) {
+ // Create periods covering the from/to dates
+ // -----------------------------------------
+ try {
+ $periods = $this->getTemporaryPeriodsTable($fromDt, $toDt, $groupByFilter);
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ return [];
+ }
+
+ // Join in stats
+ // -------------
+ $select = '
+ SELECT periodsWithStats.id,
+ MIN(periodsWithStats.start) AS start,
+ MAX(periodsWithStats.end) AS end,
+ MAX(periodsWithStats.label) AS label,
+ SUM(numberOfPlays) as NumberPlays,
+ CONVERT(SUM(periodsWithStats.actualDiff), SIGNED INTEGER) as Duration
+ FROM (
+ SELECT
+ periods.id,
+ periods.label,
+ periods.start,
+ periods.end,
+ stat.count AS numberOfPlays,
+ LEAST(stat.duration, LEAST(periods.end, statEnd, :toDt)
+ - GREATEST(periods.start, statStart, :fromDt)) AS actualDiff
+ FROM `' . $periods . '` AS periods
+ LEFT OUTER JOIN (
+ SELECT
+ layout.Layout,
+ IFNULL(`media`.name, IFNULL(`widgetoption`.value, `widget`.type)) AS Media,
+ stat.mediaId,
+ stat.`start` as statStart,
+ stat.`end` as statEnd,
+ stat.duration,
+ stat.`count`
+ FROM stat
+ LEFT OUTER JOIN layout
+ ON layout.layoutID = stat.layoutID
+ LEFT OUTER JOIN `widget`
+ ON `widget`.widgetId = stat.widgetId
+ LEFT OUTER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'name\'
+ LEFT OUTER JOIN `media`
+ ON `media`.mediaId = `stat`.mediaId
+ WHERE stat.type <> \'displaydown\'
+ AND stat.start < :toDt
+ AND stat.end >= :fromDt
+ ';
+
+ $params = [
+ 'fromDt' => $fromDt->format('U'),
+ 'toDt' => $toDt->format('U')
+ ];
+
+ // Displays
+ if (count($displayIds) > 0) {
+ $select .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ // Type filter
+ if ($type == 'layout' && $layoutId != '') {
+ // Filter by Layout
+ $select .= '
+ AND `stat`.type = \'layout\'
+ AND `stat`.campaignId = (SELECT campaignId FROM layouthistory WHERE layoutId = :layoutId)
+ ';
+ $params['layoutId'] = $layoutId;
+ } elseif ($type == 'media' && $mediaId != '') {
+ // Filter by Media
+ $select .= '
+ AND `stat`.type = \'media\' AND IFNULL(`media`.mediaId, 0) <> 0
+ AND `stat`.mediaId = :mediaId ';
+ $params['mediaId'] = $mediaId;
+ } elseif ($type == 'event' && $eventTag != '') {
+ // Filter by Event
+ $select .= '
+ AND `stat`.type = \'event\'
+ AND `stat`.tag = :tag ';
+ $params['tag'] = $eventTag;
+ }
+
+ $select .= '
+ ) stat
+ ON statStart < periods.`end`
+ AND statEnd > periods.`start`
+ ';
+
+ // Periods and Stats tables are joined, we should only have periods we're interested in, but it
+ // won't hurt to restrict them
+ $select .= '
+ WHERE periods.`start` >= :fromDt
+ AND periods.`end` <= :toDt ';
+
+ // Close out our containing view and group things together
+ $select .= '
+ ) periodsWithStats
+ GROUP BY periodsWithStats.id, periodsWithStats.label
+ ORDER BY periodsWithStats.id
+ ';
+
+ return [
+ 'result' => $this->getStore()->select($select, $params),
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat())
+ ];
+ } else {
+ return [];
+ }
+ }
+
+ private function getDistributionReportMongoDb($fromDt, $toDt, $groupByFilter, $displayIds, $displayGroupIds, $type, $layoutId, $mediaId, $eventTag)
+ {
+ if ((($type == 'media') && ($mediaId != '')) ||
+ (($type == 'layout') && ($layoutId != '')) ||
+ (($type == 'event') && ($eventTag != ''))) {
+ // Get the timezone
+ $timezone = Carbon::parse()->getTimezone()->getName();
+ $filterRangeStart = new UTCDateTime($fromDt->format('U') * 1000);
+ $filterRangeEnd = new UTCDateTime($toDt->format('U') * 1000);
+
+ $diffInDays = $toDt->diffInDays($fromDt);
+ if ($groupByFilter == 'byhour') {
+ $hour = 1;
+ $input = range(0, 24 * $diffInDays - 1); // subtract 1 as we start from 0
+ $id = '$hour';
+ } elseif ($groupByFilter == 'bydayofweek') {
+ $hour = 24;
+ $input = range(0, $diffInDays - 1);
+ $id = '$isoDayOfWeek';
+ } elseif ($groupByFilter == 'bydayofmonth') {
+ $hour = 24;
+ $input = range(0, $diffInDays - 1);
+ $id = '$dayOfMonth';
+ } else {
+ $this->getLog()->error('Unknown Grouping Selected ' . $groupByFilter);
+ throw new InvalidArgumentException(__('Unknown Grouping ') . $groupByFilter, 'groupByFilter');
+ }
+
+ // Dateparts for period generation
+ $dateFromParts['month'] = $fromDt->month;
+ $dateFromParts['year'] = $fromDt->year;
+ $dateFromParts['day'] = $fromDt->day;
+ $dateFromParts['hour'] = 0;
+
+ // PERIODS QUERY
+ $cursorPeriodQuery = [
+
+ [
+ '$addFields' => [
+
+ 'period_start' => [
+ '$dateFromParts' => [
+ 'year' => $dateFromParts['year'],
+ 'month' => $dateFromParts['month'],
+ 'day' => $dateFromParts['day'],
+ 'hour' => $dateFromParts['hour'],
+ 'timezone' => $timezone,
+ ]
+ ]
+ ]
+ ],
+
+ [
+ '$project' => [
+
+ 'periods' => [
+ '$map' => [
+ 'input' => $input,
+ 'as' => 'number',
+ 'in' => [
+ 'start' => [
+ '$add' => [
+ '$period_start',
+ [
+ '$multiply' => [
+ $hour * 3600000,
+ '$$number'
+ ]
+ ]
+ ]
+ ],
+ 'end' => [
+ '$add' => [
+ [
+ '$add' => [
+ '$period_start',
+ [
+ '$multiply' => [
+ $hour * 3600000,
+ '$$number'
+ ]
+ ]
+ ]
+ ]
+ , $hour * 3600000
+ ]
+ ],
+ ]
+ ]
+ ]
+ ]
+ ],
+
+ // periods needs to be unwind to merge next
+ [
+ '$unwind' => '$periods'
+ ],
+
+ // replace the root to eliminate _id and get only periods
+ [
+ '$replaceRoot' => [
+ 'newRoot' => '$periods'
+ ]
+ ],
+
+ [
+ '$project' => [
+ 'start' => 1,
+ 'end' => 1,
+ 'id' => [
+ $id => [
+ 'date' => '$start',
+ 'timezone'=> $timezone
+ ]
+ ],
+ ]
+ ],
+
+ [
+ '$group' => [
+ '_id' => [
+ 'id' => '$id'
+ ],
+ 'start' => ['$first' => '$start'],
+ 'end' => ['$first' => '$end'],
+ 'id' => ['$first' => '$id'],
+ ]
+ ],
+
+ [
+ '$match' => [
+ 'start' => [
+ '$lt' => $filterRangeEnd
+ ],
+ 'end' => [
+ '$gt' => $filterRangeStart
+ ]
+ ]
+ ],
+
+ [
+ '$sort' => ['id' => 1]
+ ]
+
+ ];
+
+ // Periods result
+ $periods = $this->getTimeSeriesStore()->executeQuery(['collection' => $this->periodTable, 'query' => $cursorPeriodQuery]);
+
+ // We extend the stat start and stat end so that we can push required periods for them
+ if (($groupByFilter == 'bydayofweek') || ($groupByFilter == 'bydayofmonth')) {
+ $datePartStart = [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => '$start'
+ ],
+ 'month' => [
+ '$month' => '$start'
+ ],
+ 'day' => [
+ '$dayOfMonth' => '$start'
+ ],
+ ]
+ ];
+
+ $datePartEnd = [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => '$end'
+ ],
+ 'month' => [
+ '$month' => '$end'
+ ],
+ 'day' => [
+ '$dayOfMonth' => '$end'
+ ],
+ ]
+ ];
+ } else { // by hour
+ $datePartStart = [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => '$start'
+ ],
+ 'month' => [
+ '$month' => '$start'
+ ],
+ 'day' => [
+ '$dayOfMonth' => '$start'
+ ],
+ 'hour' => [
+ '$hour' => '$start'
+ ],
+ ]
+ ];
+
+ $datePartEnd = [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => '$end'
+ ],
+ 'month' => [
+ '$month' => '$end'
+ ],
+ 'day' => [
+ '$dayOfMonth' => '$end'
+ ],
+ 'hour' => [
+ '$hour' => '$end'
+ ],
+ ]
+ ];
+ }
+
+ $match = [
+ '$match' => [
+ 'start' => [
+ '$lt' => $filterRangeEnd
+ ],
+ 'end' => [
+ '$gt' => $filterRangeStart
+ ],
+ 'type' => $type,
+ ]
+ ];
+
+ if (count($displayIds) > 0) {
+ $match['$match']['displayId'] = [
+ '$in' => $displayIds
+ ];
+ }
+
+ // Type filter
+ if (($type == 'layout') && ($layoutId != '')) {
+ // Get the campaign ID
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($layoutId);
+ $match['$match']['campaignId'] = $campaignId;
+ } elseif (($type == 'media') && ($mediaId != '')) {
+ $match['$match']['mediaId'] = $mediaId;
+ } elseif (($type == 'event') && ($eventTag != '')) {
+ $match['$match']['eventName'] = $eventTag;
+ }
+
+ // STAT AGGREGATION QUERY
+ $statQuery = [
+
+ $match,
+ [
+ '$project' => [
+ 'count' => 1,
+ 'duration' => 1,
+ 'start' => [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => [
+ 'date' => '$start',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'month' => [
+ '$month' => [
+ 'date' => '$start',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'day' => [
+ '$dayOfMonth' => [
+ 'date' => '$start',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'hour' => [
+ '$hour' => [
+ 'date' => '$start',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'minute' => [
+ '$minute' => [
+ 'date' => '$start',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'second' => [
+ '$second' => [
+ 'date' => '$start',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ ]
+ ],
+ 'end' => [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => [
+ 'date' => '$end',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'month' => [
+ '$month' => [
+ 'date' => '$end',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'day' => [
+ '$dayOfMonth' => [
+ 'date' => '$end',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'hour' => [
+ '$hour' => [
+ 'date' => '$end',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'minute' => [
+ '$minute' => [
+ 'date' => '$end',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ 'second' => [
+ '$second' => [
+ 'date' => '$end',
+ 'timezone' => $timezone,
+
+ ]
+ ],
+ ]
+ ]
+
+ ]
+ ],
+
+
+ [
+ '$addFields' => [
+ 'period_start_backward' => $datePartStart,
+ 'period_end_forward' => [
+ '$add' => [
+ $datePartEnd,
+ $hour * 3600000
+ ]
+ ]
+ ]
+ ],
+
+ [
+ '$project' => [
+ 'start' => 1,
+ 'end' => 1,
+ 'count' => 1,
+ 'duration' => 1,
+ 'period_start_backward' => 1,
+ 'period_end_forward' => 1,
+ 'range' => [
+ '$range' => [
+ 0,
+ [
+ '$ceil' => [
+ '$divide' => [
+ [
+ '$subtract' => [
+ '$period_end_forward',
+ '$period_start_backward'
+ ]
+ ],
+ $hour * 3600000
+ ]
+ ]
+ ]
+ ]
+ ],
+
+ 'period_start' => [
+ '$dateFromParts' => [
+ 'year' => [
+ '$year' => '$period_start_backward'
+ ],
+ 'month' => [
+ '$month' => '$period_start_backward'
+ ],
+ 'day' => [
+ '$dayOfMonth' => '$period_start_backward'
+ ],
+ 'hour' => [
+ '$hour' => '$period_start_backward'
+ ],
+ ]
+ ]
+ ]
+ ],
+
+ [
+ '$project' => [
+ 'start' => 1,
+ 'end' => 1,
+ 'count' => 1,
+ 'duration' => 1,
+ 'periods' => [
+ '$map' => [
+ 'input' => '$range',
+ 'as' => 'number',
+ 'in' => [
+ 'start' => [
+ '$add' => [
+ '$period_start',
+ [
+ '$multiply' => [
+ $hour * 3600000,
+ '$$number'
+ ]
+ ]
+ ]
+ ],
+ 'end' => [
+ '$add' => [
+ [
+ '$add' => [
+ '$period_start',
+ [
+ '$multiply' => [
+ $hour * 3600000,
+ '$$number'
+ ]
+ ]
+ ]
+ ]
+ , $hour * 3600000
+ ]
+ ],
+ ]
+ ]
+ ]
+ ]
+ ],
+
+ [
+ '$unwind' => '$periods'
+ ],
+ [
+ '$match' => [
+ 'periods.start' => ['$lt' => $filterRangeEnd ],
+ 'periods.end' => ['$gt' => $filterRangeStart ],
+ ]
+ ],
+ [
+ '$project' => [
+ 'start' => 1,
+ 'end' => 1,
+ 'count' => 1,
+ 'duration' => 1,
+ 'period_start' => '$periods.start',
+ 'period_end' => '$periods.end',
+ 'id' => [
+ $id => [
+ 'date' => '$periods.start',
+ 'timezone'=> 'UTC'
+ ]
+ ],
+
+ 'actualStart' => [
+ '$max' => ['$start', '$periods.start', $filterRangeStart]
+ ],
+ 'actualEnd' => [
+ '$min' => ['$end', '$periods.end', $filterRangeEnd]
+ ],
+ 'actualDiff' => [
+ '$min' => [
+ '$duration',
+ [
+ '$divide' => [
+ [
+ '$subtract' => [
+ ['$min' => ['$end', '$periods.end', $filterRangeEnd]],
+ ['$max' => ['$start', '$periods.start', $filterRangeStart]]
+ ]
+ ], 1000
+ ]
+ ]
+ ]
+ ],
+
+ ]
+
+ ],
+ [
+ '$match' => [
+ '$expr' => [
+ '$lt' => ['$actualStart' , '$actualEnd' ],
+ ]
+
+ ]
+ ],
+
+ [
+ '$group' => [
+ '_id' => [
+ 'id' => '$id'
+ ],
+ 'period_start' => ['$first' => '$period_start'],
+ 'period_end' => ['$first' => '$period_end'],
+ 'NumberPlays' => ['$sum' => '$count'],
+ 'Duration' => ['$sum' => '$actualDiff'],
+ 'start' => ['$first' => '$start'],
+ 'end' => ['$first' => '$end'],
+ 'id' => ['$first' => '$id'],
+
+ ]
+ ],
+
+ [
+ '$project' => [
+ 'start' => '$start',
+ 'end' => '$end',
+ 'period_start' => 1,
+ 'period_end' => 1,
+ 'NumberPlays' => ['$toInt' => '$NumberPlays'],
+ 'Duration' => ['$toInt' => '$Duration'],
+ 'id' => 1,
+
+
+ ]
+ ],
+
+ [
+ '$sort' => ['id' => 1]
+ ]
+
+ ];
+
+ // Stats result
+ $results = $this->getTimeSeriesStore()->executeQuery(['collection' => $this->table, 'query' => $statQuery]);
+
+ // Run period loop and map the stat result for each period
+ $resultArray = [];
+ $day = [ 1 => 'Mon', 2 => 'Tues', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', 7 => 'Sun'];
+
+ foreach ($periods as $key => $period) {
+ $id = $period['id'];
+
+ if ($groupByFilter == 'byhour') {
+ $label = Carbon::createFromTimestamp($period['start']->toDateTime()->format('U'))->format('g:i A');
+ } elseif ($groupByFilter == 'bydayofweek') {
+ $label = $day[$id];
+ } elseif ($groupByFilter == 'bydayofmonth') {
+ $label = Carbon::createFromTimestamp($period['start']->toDateTime()->format('U'))->format('d');
+ }
+
+ $matched = false;
+ foreach ($results as $k => $result) {
+ if ($result['id'] == $period['id']) {
+ $NumberPlays = $result['NumberPlays'];
+ $Duration = $result['Duration'];
+
+ $matched = true;
+ break;
+ }
+ }
+
+ $resultArray[$key]['id'] = $id;
+ $resultArray[$key]['label'] = $label;
+
+ if ($matched == true) {
+ $resultArray[$key]['NumberPlays'] = $NumberPlays;
+ $resultArray[$key]['Duration'] = $Duration;
+ } else {
+ $resultArray[$key]['NumberPlays'] = 0;
+ $resultArray[$key]['Duration'] = 0;
+ }
+ }
+
+ $this->getLog()->debug('Period start: ' . $fromDt->format(DateFormatHelper::getSystemFormat()) . ' Period end: ' . $toDt->format(DateFormatHelper::getSystemFormat()));
+
+ return [
+ 'result' => $resultArray,
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat())
+ ];
+ } else {
+ return [];
+ }
+ }
+}
diff --git a/lib/Report/LibraryUsage.php b/lib/Report/LibraryUsage.php
new file mode 100644
index 0000000..bbf85ba
--- /dev/null
+++ b/lib/Report/LibraryUsage.php
@@ -0,0 +1,636 @@
+mediaFactory = $container->get('mediaFactory');
+ $this->userFactory = $container->get('userFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+ $this->reportService = $container->get('reportService');
+ $this->configService = $container->get('configService');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->dispatcher = $container->get('dispatcher');
+
+ return $this;
+ }
+
+ public function getDispatcher()
+ {
+ return $this->dispatcher;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'libraryusage-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'libraryusage-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ $data = [];
+
+ // Set up some suffixes
+ $suffixes = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB');
+
+ // Widget for the library usage pie chart
+ try {
+ if ($this->getUser()->libraryQuota != 0) {
+ $libraryLimit = $this->getUser()->libraryQuota * 1024;
+ } else {
+ $libraryLimit = $this->configService->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+ }
+
+ // Library Size in Bytes
+ $params = [];
+ $sql = 'SELECT IFNULL(SUM(FileSize), 0) AS SumSize, type FROM `media` WHERE 1 = 1 ';
+ $this->mediaFactory->viewPermissionSql(
+ 'Xibo\Entity\Media',
+ $sql,
+ $params,
+ '`media`.mediaId',
+ '`media`.userId',
+ [
+ 'userCheckUserId' => $this->getUser()->userId
+ ]
+ );
+ $sql .= ' GROUP BY type ';
+
+ $sth = $this->store->getConnection()->prepare($sql);
+ $sth->execute($params);
+
+ $results = $sth->fetchAll();
+ // add any dependencies fonts, player software etc to the results
+ $event = new \Xibo\Event\DependencyFileSizeEvent($results);
+ $this->getDispatcher()->dispatch($event, $event::$NAME);
+ $results = $event->getResults();
+
+ // Do we base the units on the maximum size or the library limit
+ $maxSize = 0;
+ if ($libraryLimit > 0) {
+ $maxSize = $libraryLimit;
+ } else {
+ // Find the maximum sized chunk of the items in the library
+ foreach ($results as $library) {
+ $maxSize = ($library['SumSize'] > $maxSize) ? $library['SumSize'] : $maxSize;
+ }
+ }
+
+ // Decide what our units are going to be, based on the size
+ $base = ($maxSize == 0) ? 0 : floor(log($maxSize) / log(1024));
+
+ $libraryUsage = [];
+ $libraryLabels = [];
+ $totalSize = 0;
+ foreach ($results as $library) {
+ $libraryUsage[] = round((double)$library['SumSize'] / (pow(1024, $base)), 2);
+ $libraryLabels[] = ucfirst($library['type']) . ' ' . $suffixes[$base];
+
+ $totalSize = $totalSize + $library['SumSize'];
+ }
+
+ // Do we need to add the library remaining?
+ if ($libraryLimit > 0) {
+ $remaining = round(($libraryLimit - $totalSize) / (pow(1024, $base)), 2);
+
+ $libraryUsage[] = $remaining;
+ $libraryLabels[] = __('Free') . ' ' . $suffixes[$base];
+ }
+
+ // What if we are empty?
+ if (count($results) == 0 && $libraryLimit <= 0) {
+ $libraryUsage[] = 0;
+ $libraryLabels[] = __('Empty');
+ }
+
+ $data['libraryLimitSet'] = ($libraryLimit > 0);
+ $data['libraryLimit'] = (round((double)$libraryLimit / (pow(1024, $base)), 2)) . ' ' . $suffixes[$base];
+ $data['librarySize'] = ByteFormatter::format($totalSize, 1);
+ $data['librarySuffix'] = $suffixes[$base];
+ $data['libraryWidgetLabels'] = json_encode($libraryLabels);
+ $data['libraryWidgetData'] = json_encode($libraryUsage);
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Error rendering the library stats page widget');
+ }
+
+ // Note: getReportForm is only run by the web UI and therefore the logged-in users permissions are checked here
+ $data['users'] = $this->userFactory->query();
+ $data['groups'] = $this->userGroupFactory->query();
+ $data['availableReports'] = $this->reportService->listReports();
+
+ return new ReportForm(
+ 'libraryusage-report-form',
+ 'libraryusage',
+ 'Library',
+ $data
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+ $data['reportName'] = 'libraryusage';
+
+ // Note: getReportScheduleFormData is only run by the web UI and therefore the logged-in users permissions
+ // are checked here
+ $data['users'] = $this->userFactory->query();
+ $data['groups'] = $this->userGroupFactory->query();
+
+ return [
+ 'template' => 'libraryusage-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+
+ $userId = $sanitizedParams->getInt('userId');
+ $filterCriteria['userId'] = $userId;
+
+ $groupId = $sanitizedParams->getInt('groupId');
+ $filterCriteria['groupId'] = $groupId;
+
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ return sprintf(__('%s library usage report', ucfirst($sanitizedParams->getString('filter'))));
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return $result;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $filter = [
+ 'userId' => $sanitizedParams->getInt('userId'),
+ 'groupId' => $sanitizedParams->getInt('groupId'),
+ 'start' => $sanitizedParams->getInt('start'),
+ 'length' => $sanitizedParams->getInt('length'),
+ ];
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determins whether or not the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ // the monthly data starts from yesterday
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+
+ break;
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt= $now;
+ $toDt = $now;
+
+ break;
+ }
+
+
+ $params = [];
+ $select = '
+ SELECT `user`.userId,
+ `user`.userName,
+ IFNULL(SUM(`media`.FileSize), 0) AS bytesUsed,
+ COUNT(`media`.mediaId) AS numFiles
+ ';
+ $body = '
+ FROM `user`
+ LEFT OUTER JOIN `media`
+ ON `media`.userID = `user`.UserID
+ WHERE 1 = 1
+ ';
+
+ // Restrict on the users we have permission to see
+ // Normal users can only see themselves
+ $permissions = '';
+ if ($this->getUser()->userTypeId == 3) {
+ $permissions .= ' AND user.userId = :currentUserId ';
+ $filterBy['currentUserId'] = $this->getUser()->userId;
+ } elseif ($this->getUser()->userTypeId == 2) {
+ // Group admins can only see users from their groups.
+ $permissions .= '
+ AND user.userId IN (
+ SELECT `otherUserLinks`.userId
+ FROM `lkusergroup`
+ INNER JOIN `group`
+ ON `group`.groupId = `lkusergroup`.groupId
+ AND `group`.isUserSpecific = 0
+ INNER JOIN `lkusergroup` `otherUserLinks`
+ ON `otherUserLinks`.groupId = `group`.groupId
+ WHERE `lkusergroup`.userId = :currentUserId
+ )
+ ';
+ $params['currentUserId'] = $this->getUser()->userId;
+ }
+
+ // Filter by userId
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $body .= ' AND user.userId = :userId ';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ // Filter by groupId
+ if ($sanitizedParams->getInt('groupId') !== null) {
+ $body .= ' AND user.userId IN (SELECT userId FROM `lkusergroup` WHERE groupId = :groupId) ';
+ $params['groupId'] = $sanitizedParams->getInt('groupId');
+ }
+
+ $body .= $permissions;
+ $body .= '
+ GROUP BY `user`.userId,
+ `user`.userName
+ ';
+
+ // Sorting?
+ $filterBy = $this->gridRenderFilter($filter);
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ $order = '';
+ if (is_array($sortOrder)) {
+ $newSortOrder = [];
+ foreach ($sortOrder as $sort) {
+ if ($sort == '`bytesUsedFormatted`') {
+ $newSortOrder[] = '`bytesUsed`';
+ continue;
+ }
+
+ if ($sort == '`bytesUsedFormatted` DESC') {
+ $newSortOrder[] = '`bytesUsed` DESC';
+ continue;
+ }
+ $newSortOrder[] = $sort;
+ }
+ $sortOrder = $newSortOrder;
+
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $limit = '';
+ // Paging
+ if ($filterBy !== null
+ && $sanitizedParams->getInt('start') !== null
+ && $sanitizedParams->getInt('length') !== null
+ ) {
+ $limit = ' LIMIT ' . $sanitizedParams->getInt('start', ['default' => 0])
+ . ', ' . $sanitizedParams->getInt('length', ['default' => 10]);
+ }
+
+ $sql = $select . $body . $order . $limit;
+ $rows = [];
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ $entry = [];
+ $sanitizedRow = $this->sanitizer->getSanitizer($row);
+
+ $entry['userId'] = $sanitizedRow->getInt('userId');
+ $entry['userName'] = $sanitizedRow->getString('userName');
+ $entry['bytesUsed'] = $sanitizedRow->getInt('bytesUsed');
+ $entry['bytesUsedFormatted'] = ByteFormatter::format($sanitizedRow->getInt('bytesUsed'), 2);
+ $entry['numFiles'] = $sanitizedRow->getInt('numFiles');
+
+ $rows[] = $entry;
+ }
+
+ // Paging
+ $recordsTotal = 0;
+ if ($limit != '' && count($rows) > 0) {
+ $results = $this->store->select('SELECT COUNT(*) AS total FROM `user` ' . $permissions, $params);
+ $recordsTotal = intval($results[0]['total']);
+ }
+
+ // Get the Library widget labels and Widget Data
+ $libraryWidgetLabels = [];
+ $libraryWidgetData = [];
+ $suffixes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
+
+ // Widget for the library usage pie chart
+ try {
+ if ($this->getUser()->libraryQuota != 0) {
+ $libraryLimit = $this->userFactory->getUser()->libraryQuota * 1024;
+ } else {
+ $libraryLimit = $this->configService->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+ }
+
+ // Library Size in Bytes
+ $params = [];
+ $sql = 'SELECT IFNULL(SUM(FileSize), 0) AS SumSize, type FROM `media` WHERE 1 = 1 ';
+ $this->mediaFactory->viewPermissionSql(
+ 'Xibo\Entity\Media',
+ $sql,
+ $params,
+ '`media`.mediaId',
+ '`media`.userId',
+ [
+ 'userCheckUserId' => $this->getUser()->userId
+ ]
+ );
+ $sql .= ' GROUP BY type ';
+
+ $sth = $this->store->getConnection()->prepare($sql);
+ $sth->execute($params);
+
+ $results = $sth->fetchAll();
+
+ // Do we base the units on the maximum size or the library limit
+ $maxSize = 0;
+ if ($libraryLimit > 0) {
+ $maxSize = $libraryLimit;
+ } else {
+ // Find the maximum sized chunk of the items in the library
+ foreach ($results as $library) {
+ $maxSize = ($library['SumSize'] > $maxSize) ? $library['SumSize'] : $maxSize;
+ }
+ }
+
+ // Decide what our units are going to be, based on the size
+ $base = ($maxSize == 0) ? 0 : floor(log($maxSize) / log(1024));
+
+ $libraryUsage = [];
+ $libraryLabels = [];
+ $totalSize = 0;
+ foreach ($results as $library) {
+ $libraryUsage[] = round((double)$library['SumSize'] / (pow(1024, $base)), 2);
+ $libraryLabels[] = ucfirst($library['type']) . ' ' . $suffixes[$base];
+
+ $totalSize = $totalSize + $library['SumSize'];
+ }
+
+ // Do we need to add the library remaining?
+ if ($libraryLimit > 0) {
+ $remaining = round(($libraryLimit - $totalSize) / (pow(1024, $base)), 2);
+
+ $libraryUsage[] = $remaining;
+ $libraryLabels[] = __('Free') . ' ' . $suffixes[$base];
+ }
+
+ // What if we are empty?
+ if (count($results) == 0 && $libraryLimit <= 0) {
+ $libraryUsage[] = 0;
+ $libraryLabels[] = __('Empty');
+ }
+
+ $libraryWidgetLabels = $libraryLabels;
+ $libraryWidgetData = $libraryUsage;
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Error rendering the library stats page widget');
+ }
+
+
+ // Build the Library Usage and User Percentage Usage chart data
+ $totalSize = 0;
+ foreach ($rows as $row) {
+ $totalSize += $row['bytesUsed'];
+ }
+
+ $userData = [];
+ $userLabels = [];
+ foreach ($rows as $row) {
+ $userData[] = ($row['bytesUsed']/$totalSize)*100;
+ $userLabels[] = $row['userName'];
+ }
+
+ $colours = [];
+ foreach ($userData as $userDatum) {
+ $colours[] = 'rgb(' . mt_rand(0, 255).','. mt_rand(0, 255).',' . mt_rand(0, 255) .')';
+ }
+
+ $libraryColours = [];
+ foreach ($libraryWidgetData as $libraryDatum) {
+ $libraryColours[] = 'rgb(' . mt_rand(0, 255).','. mt_rand(0, 255).',' . mt_rand(0, 255) .')';
+ }
+
+ $chart = [
+ // we will use User_Percentage_Usage as report name when we export/email pdf
+ 'User_Percentage_Usage' => [
+ 'type' => 'pie',
+ 'data' => [
+ 'labels' => $userLabels,
+ 'datasets' => [
+ [
+ 'backgroundColor' => $colours,
+ 'data' => $userData
+ ]
+ ]
+ ],
+ 'options' => [
+ 'maintainAspectRatio' => false
+ ]
+ ],
+ 'Library_Usage' => [
+ 'type' => 'pie',
+ 'data' => [
+ 'labels' => $libraryWidgetLabels,
+ 'datasets' => [
+ [
+ 'backgroundColor' => $libraryColours,
+ 'data' => $libraryWidgetData
+ ]
+ ]
+ ],
+ 'options' => [
+ 'maintainAspectRatio' => false
+ ]
+ ]
+ ];
+
+ // ----
+ // Both Chart and Table
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ [
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ],
+ $rows,
+ $recordsTotal,
+ $chart
+ );
+ }
+}
diff --git a/lib/Report/MobileProofOfPlay.php b/lib/Report/MobileProofOfPlay.php
new file mode 100644
index 0000000..c7a08bc
--- /dev/null
+++ b/lib/Report/MobileProofOfPlay.php
@@ -0,0 +1,418 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Event\ReportDataEvent;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class MobileProofOfPlay
+ * @package Xibo\Report
+ */
+class MobileProofOfPlay implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /**
+ * @var CampaignFactory
+ */
+ private $campaignFactory;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var EventDispatcher
+ */
+ private $dispatcher;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->reportScheduleFactory = $container->get('reportScheduleFactory');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->dispatcher = $container->get('dispatcher');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'mobile-proofofplay-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'mobile-proofofplay-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'mobile-proofofplay-report-form',
+ 'mobileProofOfPlay',
+ 'Connector Reports',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ],
+ __('Select a display')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+
+ $data['hiddenFields'] = '';
+ $data['reportName'] = 'mobileProofOfPlay';
+
+ return [
+ 'template' => 'mobile-proofofplay-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $filterCriteria = [
+ 'filter' => $filter,
+ 'displayId' => $sanitizedParams->getInt('displayId'),
+ 'displayIds' => $sanitizedParams->getIntArray('displayIds'),
+ ];
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $saveAs = sprintf(__('%s report for ', ucfirst($sanitizedParams->getString('filter'))));
+
+ $displayId = $sanitizedParams->getInt('displayId');
+ if (!empty($displayId)) {
+ // Get display
+ try {
+ $displayName = $this->displayFactory->getById($displayId)->display;
+ $saveAs .= '(Display: '. $displayName . ')';
+ } catch (NotFoundException $error) {
+ $saveAs .= '(DisplayId: Not Found )';
+ }
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ 'table' => $result['result'],
+ ];
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ // Get filter criteria
+ $rs = $this->reportScheduleFactory->getById($savedReport->reportScheduleId, 1)->filterCriteria;
+ $filterCriteria = json_decode($rs, true);
+
+ // Show filter criteria
+ $metadata = [];
+
+ // Get Meta data
+ $metadata['periodStart'] = $json['metadata']['periodStart'];
+ $metadata['periodEnd'] = $json['metadata']['periodEnd'];
+ $metadata['generatedOn'] = Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat());
+ $metadata['title'] = $savedReport->saveAs;
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $parentCampaignId = $sanitizedParams->getInt('parentCampaignId');
+ $layoutId = $sanitizedParams->getInt('layoutId');
+
+ // Get campaign
+ if (!empty($parentCampaignId)) {
+ $campaign = $this->campaignFactory->getById($parentCampaignId);
+ }
+
+ // Display filter.
+ try {
+ // Get an array of display id this user has access to.
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+ } catch (GeneralException $exception) {
+ // stop the query
+ return new ReportResult();
+ }
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determines whether the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ case 'today':
+ $fromDt = $now->copy()->startOfDay();
+ $toDt = $fromDt->copy()->addDay();
+ break;
+
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'thisweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'thismonth':
+ $fromDt = $now->copy()->startOfMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'thisyear':
+ $fromDt = $now->copy()->startOfYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('fromDt', ['default' => Carbon::now()->subDay()]);
+ $fromDt->startOfDay();
+
+ $toDt = $sanitizedParams->getDate('toDt', ['default' => Carbon::now()]);
+ $toDt->endOfDay();
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ break;
+ }
+
+ $params = [
+ 'campaignId' => $parentCampaignId,
+ 'layoutId' => $layoutId,
+ 'displayIds' => $displayIds,
+ ];
+
+ // when the reportfilter is wholecampaign take campaign start/end as form/to date
+ if (!empty($parentCampaignId) && $sanitizedParams->getString('reportFilter') === 'wholecampaign') {
+ $params['from'] = !empty($campaign->getStartDt()) ? $campaign->getStartDt()->format('Y-m-d H:i:s') : null;
+ $params['to'] = !empty($campaign->getEndDt()) ? $campaign->getEndDt()->format('Y-m-d H:i:s') : null;
+
+ if (empty($campaign->getStartDt()) || empty($campaign->getEndDt())) {
+ return new ReportResult();
+ }
+ } else {
+ $params['from'] = $fromDt->format('Y-m-d H:i:s');
+ $params['to'] = $toDt->format('Y-m-d H:i:s');
+ }
+
+ // --------
+ // ReportDataEvent
+ $event = new ReportDataEvent('mobileProofofplay');
+
+ // Set query params for report
+ $event->setParams($params);
+
+ // Dispatch the event - listened by Audience Report Connector
+ $this->dispatcher->dispatch($event, ReportDataEvent::$NAME);
+ $results = $event->getResults();
+
+ $result['periodStart'] = $params['from'];
+ $result['periodEnd'] = $params['to'];
+
+ $rows = [];
+ $displayCache = [];
+ $layoutCache = [];
+ foreach ($results['json'] as $row) {
+ $entry = [];
+
+ $entry['from'] = $row['from'];
+ $entry['to'] = $row['to'];
+
+ // --------
+ // Get Display
+ $entry['displayId'] = $row['displayId'];
+ try {
+ if (!empty($entry['displayId'])) {
+ if (!array_key_exists($row['displayId'], $displayCache)) {
+ $display = $this->displayFactory->getById($row['displayId']);
+ $displayCache[$row['displayId']] = $display->display;
+ }
+ }
+ $entry['display'] = $displayCache[$row['displayId']] ?? '';
+ } catch (\Exception $e) {
+ $entry['display'] = __('Not found');
+ }
+ // --------
+ // Get layout
+ $entry['layoutId'] = $row['layoutId'];
+ try {
+ if (!empty($entry['layoutId'])) {
+ if (!array_key_exists($row['layoutId'], $layoutCache)) {
+ $layout = $this->layoutFactory->getById($row['layoutId']);
+ $layoutCache[$row['layoutId']] = $layout->layout;
+ }
+ }
+ $entry['layout'] = $layoutCache[$row['layoutId']] ?? '';
+ } catch (\Exception $e) {
+ $entry['layout'] = __('Not found');
+ }
+
+ $entry['startLat'] = $row['startLat'];
+ $entry['startLong'] = $row['startLong'];
+ $entry['endLat'] = $row['endLat'];
+ $entry['endLong'] = $row['endLong'];
+ $entry['duration'] = $row['duration'];
+
+ $rows[] = $entry;
+ }
+
+ // Set Meta data
+ $metadata = [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ ];
+
+ $recordsTotal = count($rows);
+
+ // ----
+ // Table Only
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ [],
+ $results['error'] ?? null
+ );
+ }
+}
diff --git a/lib/Report/PeriodTrait.php b/lib/Report/PeriodTrait.php
new file mode 100644
index 0000000..7b885aa
--- /dev/null
+++ b/lib/Report/PeriodTrait.php
@@ -0,0 +1,103 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+
+/**
+ * Trait PeriodTrait
+ * @package Xibo\Report
+ */
+trait PeriodTrait
+{
+ public function generateHourPeriods($filterRangeStart, $filterRangeEnd, $start, $end, $ranges)
+ {
+
+ $periodData = []; // to generate periods table
+
+ // Generate all hours of the period
+ foreach ($ranges as $range) {
+ $startHour = $start->addHour()->format('U');
+
+ // Remove the period which crossed the end range
+ if ($startHour >= $filterRangeEnd) {
+ continue;
+ }
+
+ // Period start
+ $periodData[$range]['start'] = $startHour;
+ if ($periodData[$range]['start'] < $filterRangeStart) {
+ $periodData[$range]['start'] = $filterRangeStart;
+ }
+
+ // Period end
+ $periodData[$range]['end'] = $end->addHour()->format('U');
+ if ($periodData[$range]['end'] > $filterRangeEnd) {
+ $periodData[$range]['end'] = $filterRangeEnd;
+ }
+
+ $hourofday = Carbon::createFromTimestamp($periodData[$range]['start'])->hour;
+
+ // groupbycol = hour
+ $periodData[$range]['groupbycol'] = $hourofday;
+ }
+
+ return $periodData;
+ }
+
+ public function generateDayPeriods($filterRangeStart, $filterRangeEnd, $start, $end, $ranges, $groupByFilter = null)
+ {
+ $periodData = []; // to generate periods table
+
+ // Generate all days of the period
+ foreach ($ranges as $range) {
+ $startDay = $start->addDay()->format('U');
+
+ // Remove the period which crossed the end range
+ if ($startDay >= $filterRangeEnd) {
+ continue;
+ }
+ // Period start
+ $periodData[$range]['start'] = $startDay;
+ if ($periodData[$range]['start'] < $filterRangeStart) {
+ $periodData[$range]['start'] = $filterRangeStart;
+ }
+
+ // Period end
+ $periodData[$range]['end'] = $end->addDay()->format('U');
+ if ($periodData[$range]['end'] > $filterRangeEnd) {
+ $periodData[$range]['end'] = $filterRangeEnd;
+ }
+
+ if ($groupByFilter == 'bydayofweek') {
+ $groupbycol = Carbon::createFromTimestamp($periodData[$range]['start'])->dayOfWeekIso;
+ } else {
+ $groupbycol = Carbon::createFromTimestamp($periodData[$range]['start'])->day;
+ }
+
+ // groupbycol = dayofweek
+ $periodData[$range]['groupbycol'] = $groupbycol;
+ }
+ return $periodData;
+ }
+}
diff --git a/lib/Report/ProofOfPlay.php b/lib/Report/ProofOfPlay.php
new file mode 100644
index 0000000..f9f9cc0
--- /dev/null
+++ b/lib/Report/ProofOfPlay.php
@@ -0,0 +1,1368 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use MongoDB\BSON\UTCDateTime;
+use Psr\Container\ContainerInterface;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\TagFactory;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class ProofOfPlay
+ * @package Xibo\Report
+ */
+class ProofOfPlay implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var ReportScheduleFactory
+ */
+ private $reportScheduleFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var TagFactory
+ */
+ private $tagFactory;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ private $table = 'stat';
+
+ private $tagsType = [
+ 'dg' => 'Display group',
+ 'media' => 'Media',
+ 'layout' => 'Layout'
+ ];
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->reportScheduleFactory = $container->get('reportScheduleFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->tagFactory = $container->get('tagFactory');
+ $this->sanitizer = $container->get('sanitizerService');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'proofofplay-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'proofofplay-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'proofofplay-report-form',
+ 'proofofplayReport',
+ 'Proof of Play',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ],
+ __('Select a type and an item (i.e., layout/media/tag)')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+ $data['type'] = $sanitizedParams->getString('type');
+ $data['tagsType'] = $sanitizedParams->getString('tagsType');
+
+ $exactTags = $sanitizedParams->getCheckbox('exactTags');
+ $data['exactTags'] = $exactTags == 'true';
+
+ $tags = $sanitizedParams->getString('tags');
+ $data['tags'] = $tags;
+
+ $data['hiddenFields'] = '';
+ $data['reportName'] = 'proofofplayReport';
+
+ return [
+ 'template' => 'proofofplay-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $filterCriteria = [
+ 'filter' => $filter,
+ 'displayId' => $sanitizedParams->getInt('displayId'),
+ 'layoutId' => $sanitizedParams->getIntArray('layoutId'),
+ 'mediaId' => $sanitizedParams->getIntArray('mediaId'),
+ 'type' => $sanitizedParams->getString('type'),
+ 'sortBy' => $sanitizedParams->getString('sortBy'),
+ 'tagsType' => $sanitizedParams->getString('tagsType'),
+ 'tags' => $sanitizedParams->getString('tags'),
+ 'exactTags' => $sanitizedParams->getCheckbox('exactTags'),
+ 'logicalOperator' => $sanitizedParams->getString('logicalOperator')
+ ];
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $saveAs = sprintf(__('%s report for ', ucfirst($sanitizedParams->getString('filter'))));
+
+ switch ($sanitizedParams->getString('type')) {
+ case 'layout':
+ $saveAs .= 'Type: Layout. ';
+ break;
+
+ case 'media':
+ $saveAs .= 'Type: Media. ';
+ break;
+
+ case 'widget':
+ $saveAs .= 'Type: Widget. ';
+ break;
+
+ case 'event':
+ $saveAs .= 'Type: Event. ';
+ break;
+
+ default:
+ $saveAs .= 'Type: All. ';
+ break;
+ }
+
+ $layoutIds = $sanitizedParams->getIntArray('layoutIds');
+ if (isset($layoutIds)) {
+ if (count($layoutIds) > 0) {
+ $layouts = '';
+ foreach ($layoutIds as $id) {
+ try {
+ $layout = $this->layoutFactory->getById($id);
+ } catch (NotFoundException $error) {
+ // Get the campaign ID
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($id);
+ $layoutId = $this->layoutFactory->getLatestLayoutIdFromLayoutHistory($campaignId);
+ $layout = $this->layoutFactory->getById($layoutId);
+ }
+
+ $layouts .= $layout->layout . ', ';
+ }
+
+ $saveAs .= 'Layouts: '. $layouts;
+ }
+ }
+
+ $mediaIds = $sanitizedParams->getIntArray('mediaIds');
+ if (isset($mediaIds)) {
+ if (count($mediaIds) > 0) {
+ $medias = '';
+ foreach ($mediaIds as $id) {
+ try {
+ $media = $this->mediaFactory->getById($id);
+ $name = $media->name;
+ } catch (NotFoundException $error) {
+ $name = 'Media not found';
+ }
+
+ $medias .= $name . ', ';
+ }
+
+ $saveAs .= 'Media: ' . $medias;
+ }
+ }
+
+ $displayId = $sanitizedParams->getInt('displayId');
+ if (!empty($displayId)) {
+ // Get display
+ try {
+ $displayName = $this->displayFactory->getById($displayId)->display;
+ $saveAs .= '(Display: '. $displayName . ')';
+ } catch (NotFoundException $error) {
+ $saveAs .= '(DisplayId: Not Found )';
+ }
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result) // TODO
+ {
+ return [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ 'table' => $result['result'],
+ ];
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ // Get filter criteria
+ $rs = $this->reportScheduleFactory->getById($savedReport->reportScheduleId, 1)->filterCriteria;
+ $filterCriteria = json_decode($rs, true);
+
+ $tagsType = $filterCriteria['tagsType'];
+ $tags = $filterCriteria['tags'];
+ $exactTags = ($filterCriteria['exactTags'] == 1) ? ' (exact match)': '';
+
+ // Show filter criteria
+ $metadata = [];
+ if ($tags != null) {
+ $metadata['filterInfo'] = 'Tags from: '. $this->tagsType[$tagsType]. ', Tags: '. $tags. $exactTags;
+ }
+
+ // Get Meta data
+ $metadata['periodStart'] = $json['metadata']['periodStart'];
+ $metadata['periodEnd'] = $json['metadata']['periodEnd'];
+ $metadata['generatedOn'] = Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat());
+ $metadata['title'] = $savedReport->saveAs;
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $layoutIds = $sanitizedParams->getIntArray('layoutId', ['default' => []]);
+ $mediaIds = $sanitizedParams->getIntArray('mediaId', ['default' => []]);
+ $type = strtolower($sanitizedParams->getString('type'));
+ $tags = $sanitizedParams->getString('tags');
+ $tagsType = $sanitizedParams->getString('tagsType');
+ $exactTags = $sanitizedParams->getCheckbox('exactTags');
+ $operator = $sanitizedParams->getString('logicalOperator', ['default' => 'OR']);
+ $parentCampaignId = $sanitizedParams->getInt('parentCampaignId');
+
+ // Group the data by display, display group, or by tag
+ $groupBy = $sanitizedParams->getString('groupBy');
+
+ // Used with groupBy in case we want to filter by specific display groups only
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+
+ // Display filter.
+ try {
+ // Get an array of display id this user has access to.
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+ } catch (GeneralException $exception) {
+ // stop the query
+ return new ReportResult();
+ }
+
+ // web
+ if ($sanitizedParams->getString('sortBy') == null) {
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+ $columns = [];
+
+ if (is_array($sortOrder)) {
+ $columns = $sortOrder;
+ }
+ } else {
+ $sortBy = $sanitizedParams->getString('sortBy', ['default' => 'widgetId']);
+ if (!in_array($sortBy, [
+ 'widgetId',
+ 'type',
+ 'display',
+ 'displayId',
+ 'media',
+ 'layout',
+ 'layoutId',
+ 'tag',
+ ])) {
+ throw new InvalidArgumentException(__('Invalid Sort By'), 'sortBy');
+ }
+ $columns = [$sortBy];
+ }
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determines whether the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ case 'today':
+ $fromDt = $now->copy()->startOfDay();
+ $toDt = $fromDt->copy()->addDay();
+ break;
+
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'thisweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'thismonth':
+ $fromDt = $now->copy()->startOfMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'thisyear':
+ $fromDt = $now->copy()->startOfYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('statsFromDt', ['default' => Carbon::now()->subDay()]);
+ $fromDt->startOfDay();
+
+ $toDt = $sanitizedParams->getDate('statsToDt', ['default' => Carbon::now()]);
+ $toDt->startOfDay();
+
+ $fromDtTime = $sanitizedParams->getString('statsFromDtTime');
+ $toDtTime = $sanitizedParams->getString('statsToDtTime');
+
+ if ($fromDtTime !== null && $toDtTime !== null) {
+ $startTimeArray = explode(':', $fromDtTime);
+ $fromDt->setTime(intval($startTimeArray[0]), intval($startTimeArray[1]));
+
+ $toTimeArray = explode(':', $toDtTime);
+ $toDt->setTime(intval($toTimeArray[0]), intval($toTimeArray[1]));
+ }
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ break;
+ }
+
+ //
+ // Get Results!
+ // -------------
+ $timeSeriesStore = $this->getTimeSeriesStore()->getEngine();
+ if ($timeSeriesStore == 'mongodb') {
+ $result = $this->getProofOfPlayReportMongoDb(
+ $fromDt,
+ $toDt,
+ $displayIds,
+ $parentCampaignId,
+ $layoutIds,
+ $mediaIds,
+ $type,
+ $columns,
+ $tags,
+ $tagsType,
+ $exactTags,
+ $operator,
+ $groupBy,
+ $displayGroupIds
+ );
+ } else {
+ $result = $this->getProofOfPlayReportMySql(
+ $fromDt,
+ $toDt,
+ $displayIds,
+ $parentCampaignId,
+ $layoutIds,
+ $mediaIds,
+ $type,
+ $columns,
+ $tags,
+ $tagsType,
+ $exactTags,
+ $operator,
+ $groupBy
+ );
+ }
+
+ // Sanitize results
+ $rows = [];
+ foreach ($result['result'] as $row) {
+ $entry = [];
+ $sanitizedRow = $this->sanitizer->getSanitizer($row);
+
+ $widgetId = $sanitizedRow->getInt('widgetId');
+ $widgetName = $sanitizedRow->getString('media');
+ // If the media name is empty, and the widgetid is not, then we can assume it has been deleted.
+ $widgetName = ($widgetName == '' && $widgetId != 0) ? __('Deleted from Layout') : $widgetName;
+ $displayName = $sanitizedRow->getString('display');
+ $layoutName = $sanitizedRow->getString('layout');
+ $parentCampaignName = $sanitizedRow->getString('parentCampaign');
+
+ $entry['type'] = $sanitizedRow->getString('type');
+ $entry['displayId'] = $sanitizedRow->getInt('displayId');
+ $entry['display'] = ($displayName != '') ? $displayName : __('Not Found');
+ $entry['layoutId'] = $sanitizedRow->getInt('layoutId');
+ $entry['layout'] = ($layoutName != '') ? $layoutName : __('Not Found');
+ $entry['parentCampaignId'] = $sanitizedRow->getInt('parentCampaignId');
+ $entry['parentCampaign'] = $parentCampaignName;
+ $entry['widgetId'] = $sanitizedRow->getInt('widgetId');
+ $entry['media'] = $widgetName;
+ $entry['tag'] = $sanitizedRow->getString('tag');
+ $entry['numberPlays'] = $sanitizedRow->getInt('numberPlays');
+ $entry['duration'] = $sanitizedRow->getInt('duration');
+ $entry['minStart'] = Carbon::createFromTimestamp($row['minStart'])->format(DateFormatHelper::getSystemFormat());
+ $entry['maxEnd'] = Carbon::createFromTimestamp($row['maxEnd'])->format(DateFormatHelper::getSystemFormat());
+ $entry['mediaId'] = $sanitizedRow->getInt('mediaId');
+ $entry['displayGroup'] = $sanitizedRow->getString('displayGroup');
+ $entry['displayGroupId'] = $sanitizedRow->getInt('displayGroupId');
+ $entry['tagName'] = $sanitizedRow->getString('tagName');
+ $entry['tagId'] = $sanitizedRow->getInt('tagId');
+ $rows[] = $entry;
+ }
+
+ // Set Meta data
+ $metadata = [
+ 'periodStart' => $result['periodStart'],
+ 'periodEnd' => $result['periodEnd'],
+ ];
+
+ $recordsTotal = $result['count'];
+
+ // ----
+ // Table Only
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal
+ );
+ }
+
+ /**
+ * MySQL proof of play report
+ * @param Carbon $fromDt The filter range from date
+ * @param Carbon $toDt The filter range to date
+ * @param $displayIds array
+ * @param $parentCampaignId int
+ * @param $layoutIds array
+ * @param $mediaIds array
+ * @param $type string
+ * @param $columns array
+ * @param $tags string
+ * @param $tagsType string
+ * @param $exactTags mixed
+ * @param $groupBy string
+ * @return array[array result, date periodStart, date periodEnd, int count, int totalStats]
+ */
+ private function getProofOfPlayReportMySql(
+ $fromDt,
+ $toDt,
+ $displayIds,
+ $parentCampaignId,
+ $layoutIds,
+ $mediaIds,
+ $type,
+ $columns,
+ $tags,
+ $tagsType,
+ $exactTags,
+ $logicalOperator,
+ $groupBy
+ ) {
+ $fromDt = $fromDt->format('U');
+ $toDt = $toDt->format('U');
+
+ // Media on Layouts Ran
+ $select = '
+ SELECT stat.type,
+ stat.parentCampaignId,
+ campaign.campaign as parentCampaign,
+ IFNULL(layout.Layout,
+ (SELECT MAX(`layout`) AS layout
+ FROM `layout`
+ INNER JOIN `layouthistory`
+ ON `layout`.layoutId = `layouthistory`.layoutId
+ WHERE `layouthistory`.campaignId = `stat`.campaignId)
+ ) AS Layout,
+ IFNULL(`media`.name, IFNULL(`widgetoption`.value, `widget`.type)) AS Media,
+ SUM(stat.count) AS NumberPlays,
+ SUM(stat.duration) AS Duration,
+ MIN(start) AS MinStart,
+ MAX(end) AS MaxEnd,
+ stat.tag,
+ stat.layoutId,
+ stat.mediaId,
+ stat.widgetId
+ ';
+
+ // We get the ID and name - either by display, display group or tag
+ if ($groupBy === 'display') {
+ $select .= ', display.Display, stat.displayId ';
+ } else if ($groupBy === 'displayGroup') {
+ $select .= ', displaydg.displayGroup, displaydg.displayGroupId ';
+ } else if ($groupBy === 'tag') {
+ if ($tagsType === 'dg' || $tagsType === 'media') {
+ $select .= ', taglink.value, taglink.tagId ';
+ } else {
+ // For layouts, we need to manually select taglink.tag
+ $select .= ', taglink.tag AS value, taglink.tagId ';
+ }
+ }
+
+ $body = '
+ FROM stat
+ LEFT OUTER JOIN display
+ ON stat.DisplayID = display.DisplayID
+ LEFT OUTER JOIN layouthistory
+ ON layouthistory.LayoutID = stat.LayoutID
+ LEFT OUTER JOIN layout
+ ON layout.LayoutID = layouthistory.layoutId
+ LEFT OUTER JOIN `widget`
+ ON `widget`.widgetId = stat.widgetId
+ LEFT OUTER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'name\'
+ LEFT OUTER JOIN `media`
+ ON `media`.mediaId = `stat`.mediaId
+ LEFT OUTER JOIN `campaign`
+ ON `campaign`.campaignId = `stat`.parentCampaignId
+ ';
+
+ if ($tags != '') {
+ if ($tagsType === 'dg') {
+ $body .= 'INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayID = display.displayid
+ INNER JOIN `displaygroup`
+ ON displaygroup.displaygroupId = lkdisplaydg.displaygroupId
+ AND `displaygroup`.isDisplaySpecific = 1 ';
+ }
+ }
+
+ if ($groupBy === 'displayGroup') {
+ // Group the data by display group
+ $body .= 'INNER JOIN `lkdisplaydg` AS linkdg
+ ON linkdg.DisplayID = display.displayid
+ INNER JOIN `displaygroup` AS displaydg
+ ON displaydg.displaygroupId = linkdg.displaygroupId
+ AND `displaydg`.isDisplaySpecific = 0 ';
+ } else if ($groupBy === 'tag') {
+ $body .= $this->groupByTagType($tagsType);
+ }
+
+ $body .= ' WHERE stat.type <> \'displaydown\'
+ AND stat.end > :fromDt
+ AND stat.start < :toDt
+ ';
+
+ // Filter by display
+ if (count($displayIds) > 0) {
+ $body .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ $params = [
+ 'fromDt' => $fromDt,
+ 'toDt' => $toDt
+ ];
+
+ if ($tags != '') {
+ if (trim($tags) === '--no-tag') {
+ if ($tagsType === 'dg') {
+ $body .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ )
+ ';
+ }
+
+ // old layout and latest layout have same tags
+ // old layoutId replaced with latest layoutId in the lktaglayout table and
+ // join with layout history to get campaignId then we can show old layouts that have no tag
+ if ($tagsType === 'layout') {
+ $body .= ' AND `stat`.campaignId NOT IN (
+ SELECT
+ `layouthistory`.campaignId
+ FROM
+ (
+ SELECT `lktaglayout`.layoutId
+ FROM tag
+ INNER JOIN `lktaglayout`
+ ON `lktaglayout`.tagId = tag.tagId ) B
+ LEFT OUTER JOIN
+ `layouthistory` ON `layouthistory`.layoutId = B.layoutId
+ )
+ ';
+ }
+ if ($tagsType === 'media') {
+ $body .= ' AND `media`.mediaId NOT IN (
+ SELECT `lktagmedia`.mediaId
+ FROM tag
+ INNER JOIN `lktagmedia`
+ ON `lktagmedia`.tagId = tag.tagId
+ )
+ ';
+ }
+ } else {
+ $operator = $exactTags == 1 ? '=' : 'LIKE';
+ $lkTagTable = '';
+ $lkTagTableIdColumn = '';
+ $idColumn = '';
+ $allTags = explode(',', $tags);
+ $excludeTags = [];
+ $includeTags = [];
+
+ foreach ($allTags as $tag) {
+ if (str_starts_with($tag, '-')) {
+ $excludeTags[] = ltrim(($tag), '-');
+ } else {
+ $includeTags[] = $tag;
+ }
+ }
+
+ if ($tagsType === 'dg') {
+ $lkTagTable = 'lktagdisplaygroup';
+ $lkTagTableIdColumn = 'lkTagDisplayGroupId';
+ $idColumn = 'displayGroupId';
+ }
+
+ if ($tagsType === 'layout') {
+ $lkTagTable = 'lktaglayout';
+ $lkTagTableIdColumn = 'lkTagLayoutId';
+ $idColumn = 'layoutId';
+ }
+ if ($tagsType === 'media') {
+ $lkTagTable = 'lktagmedia';
+ $lkTagTableIdColumn = 'lkTagMediaId';
+ $idColumn = 'mediaId';
+ }
+
+ if (!empty($excludeTags)) {
+ $body .= $this->getBodyForTagsType($tagsType, true);
+ // pass to BaseFactory tagFilter, it does not matter from which factory we do that.
+ $this->layoutFactory->tagFilter(
+ $excludeTags,
+ $lkTagTable,
+ $lkTagTableIdColumn,
+ $idColumn,
+ $logicalOperator,
+ $operator,
+ true,
+ $body,
+ $params
+ );
+
+ // old layout and latest layout have same tags
+ // old layoutId replaced with latest layoutId in the lktaglayout table and
+ // join with layout history to get campaignId then we can show old layouts that have given tag
+ if ($tagsType === 'layout') {
+ $body .= ' B
+ LEFT OUTER JOIN
+ `layouthistory` ON `layouthistory`.layoutId = B.layoutId ) ';
+ }
+ }
+
+ if (!empty($includeTags)) {
+ $body .= $this->getBodyForTagsType($tagsType, false);
+ // pass to BaseFactory tagFilter, it does not matter from which factory we do that.
+ $this->layoutFactory->tagFilter(
+ $includeTags,
+ $lkTagTable,
+ $lkTagTableIdColumn,
+ $idColumn,
+ $logicalOperator,
+ $operator,
+ false,
+ $body,
+ $params
+ );
+
+ // old layout and latest layout have same tags
+ // old layoutId replaced with latest layoutId in the lktaglayout table and
+ // join with layout history to get campaignId then we can show old layouts that have given tag
+ if ($tagsType === 'layout') {
+ $body .= ' C
+ LEFT OUTER JOIN
+ `layouthistory` ON `layouthistory`.layoutId = C.layoutId ) ';
+ }
+ }
+ }
+ }
+
+ // Type filter
+ if ($type == 'layout') {
+ $body .= ' AND `stat`.type = \'layout\' ';
+ } elseif ($type == 'media') {
+ $body .= ' AND `stat`.type = \'media\' AND IFNULL(`media`.mediaId, 0) <> 0 ';
+ } elseif ($type == 'widget') {
+ $body .= ' AND `stat`.type = \'widget\' AND IFNULL(`widget`.widgetId, 0) <> 0 ';
+ } elseif ($type == 'event') {
+ $body .= ' AND `stat`.type = \'event\' ';
+ }
+
+ // Campaign Filter
+ if ($parentCampaignId != null) {
+ $body .= ' AND `stat`.parentCampaignId = :parentCampaignId ';
+ $params['parentCampaignId'] = $parentCampaignId;
+ }
+
+ // Layout Filter
+ if (count($layoutIds) != 0) {
+ $layoutSql = '';
+ $i = 0;
+ foreach ($layoutIds as $layoutId) {
+ $i++;
+ $layoutSql .= ':layoutId_' . $i . ',';
+ $params['layoutId_' . $i] = $layoutId;
+ }
+
+ $body .= ' AND `stat`.campaignId IN (SELECT campaignId from layouthistory where layoutId IN ('
+ . trim($layoutSql, ',') . ')) ';
+ }
+
+ // Media Filter
+ if (count($mediaIds) != 0) {
+ $mediaSql = '';
+ $i = 0;
+ foreach ($mediaIds as $mediaId) {
+ $i++;
+ $mediaSql .= ':mediaId_' . $i . ',';
+ $params['mediaId_' . $i] = $mediaId;
+ }
+
+ $body .= ' AND `media`.mediaId IN (' . trim($mediaSql, ',') . ')';
+ }
+
+ // We first implement default groupings
+ $body .= '
+ GROUP BY stat.type,
+ stat.tag,
+ stat.parentCampaignId,
+ stat.campaignId,
+ layout.layout,
+ IFNULL(stat.mediaId, stat.widgetId),
+ IFNULL(`media`.name, IFNULL(`widgetoption`.value, `widget`.type)),
+ stat.layoutId,
+ stat.mediaId,
+ stat.widgetId
+ ';
+
+ // Then add the optional groupings
+ if ($groupBy === 'display') {
+ $body .= ', display.Display, stat.displayId';
+ } else if ($groupBy === 'displayGroup') {
+ $body .= ', displaydg.displayGroupId, displaydg.displayGroup';
+ } else if ($groupBy === 'tag') {
+ $body .= ', value, taglink.tagId';
+ }
+
+ $order = '';
+ if ($columns != null) {
+ $order = 'ORDER BY ' . implode(',', $columns);
+ }
+
+ /*Execute sql statement*/
+ $sql = $select . $body . $order;
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $entry = [];
+
+ $entry['type'] = $row['type'];
+ $entry['displayId'] = $row['displayId'] ?? '';
+ $entry['display'] = $row['Display'] ?? '';
+ $entry['layout'] = $row['Layout'];
+ $entry['parentCampaignId'] = $row['parentCampaignId'];
+ $entry['parentCampaign'] = $row['parentCampaign'];
+ $entry['media'] = $row['Media'];
+ $entry['numberPlays'] = $row['NumberPlays'];
+ $entry['duration'] = $row['Duration'];
+ $entry['minStart'] = $row['MinStart'];
+ $entry['maxEnd'] = $row['MaxEnd'];
+ $entry['layoutId'] = $row['layoutId'];
+ $entry['widgetId'] = $row['widgetId'];
+ $entry['mediaId'] = $row['mediaId'];
+ $entry['tag'] = $row['tag'];
+ $entry['displayGroupId'] = $row['displayGroupId'] ?? '';
+ $entry['displayGroup'] = $row['displayGroup'] ?? '';
+ $entry['tagId'] = $row['tagId'] ?? '';
+ $entry['tagName'] = $row['value'] ?? '';
+ $rows[] = $entry;
+ }
+
+ return [
+ 'periodStart' => Carbon::createFromTimestamp($fromDt)->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => Carbon::createFromTimestamp($toDt)->format(DateFormatHelper::getSystemFormat()),
+ 'result' => $rows,
+ 'count' => count($rows)
+ ];
+ }
+
+ private function getBodyForTagsType($tagsType, $exclude) :string
+ {
+ if ($tagsType === 'dg') {
+ return ' AND `displaygroup`.displaygroupId ' . ($exclude ? 'NOT' : '') . ' IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+ } else if ($tagsType === 'media') {
+ return ' AND `media`.mediaId '. ($exclude ? 'NOT' : '') . ' IN (
+ SELECT `lktagmedia`.mediaId
+ FROM tag
+ INNER JOIN `lktagmedia`
+ ON `lktagmedia`.tagId = tag.tagId
+ ';
+ } else if ($tagsType === 'layout') {
+ return ' AND `stat`.campaignId ' . ($exclude ? 'NOT' : '') . ' IN (
+ SELECT
+ `layouthistory`.campaignId
+ FROM
+ (
+ SELECT `lktaglayout`.layoutId
+ FROM tag
+ INNER JOIN `lktaglayout`
+ ON `lktaglayout`.tagId = tag.tagId
+ ';
+ } else {
+ $this->getLog()->error(__('Incorrect Tag type selected'));
+ return '';
+ }
+ }
+
+ /**
+ * MongoDB proof of play report
+ * @param Carbon $filterFromDt The filter range from date
+ * @param Carbon $filterToDt The filter range to date
+ * @param $displayIds array
+ * @param $parentCampaignId int
+ * @param $layoutIds array
+ * @param $mediaIds array
+ * @param $type string
+ * @param $columns array
+ * @param $tags string
+ * @param $tagsType string
+ * @param $exactTags mixed
+ * @param $groupBy string
+ * @param $displayGroupIds array
+ * @return array[array result, date periodStart, date periodEnd, int count, int totalStats]
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getProofOfPlayReportMongoDb(
+ $filterFromDt,
+ $filterToDt,
+ $displayIds,
+ $parentCampaignId,
+ $layoutIds,
+ $mediaIds,
+ $type,
+ $columns,
+ $tags,
+ $tagsType,
+ $exactTags,
+ $operator,
+ $groupBy,
+ $displayGroupIds
+ ) {
+ $fromDt = new UTCDateTime($filterFromDt->format('U')*1000);
+ $toDt = new UTCDateTime($filterToDt->format('U')*1000);
+
+ // Filters the documents to pass only the documents that
+ // match the specified condition(s) to the next pipeline stage.
+ $match = [
+ '$match' => [
+ 'end' => ['$gt' => $fromDt],
+ 'start' => ['$lt' => $toDt]
+ ]
+ ];
+
+ // Display Filter
+ if (count($displayIds) > 0) {
+ $match['$match']['displayId'] = [ '$in' => $displayIds ];
+ }
+
+ // Type Filter
+ if ($type != null) {
+ $match['$match']['type'] = $type;
+ }
+
+ $tagsArray = [];
+
+ // Tag Filter
+ if ($tags != null) {
+ $i = 0;
+ foreach (explode(',', $tags) as $tag) {
+ $tagV = explode('|', $tag);
+
+ if (!isset($tagV[1])) {
+ $tagsArray[$i]['tag'] = $tag;
+ } elseif ($tagV[0] == '') {
+ $tagsArray[$i]['val'] = $tagV[1];
+ } else {
+ $tagsArray[$i]['tag'] = $tagV[0];
+ $tagsArray[$i]['val'] = $tagV[1];
+ }
+ $i++;
+ }
+
+ if ($exactTags != 1) {
+ $tagsArray = array_map(function ($tagValue) {
+ return array_map(function ($tag) {
+ return new \MongoDB\BSON\Regex('.*'.$tag. '.*', 'i');
+ }, $tagValue);
+ }, $tagsArray);
+ }
+
+ // When exact match is not desired
+ if (count($tagsArray) > 0) {
+ $logicalOperator = ($operator === 'AND') ? '$and' : '$or';
+ foreach ($tagsArray as $tag) {
+ $match['$match'][$logicalOperator][] = [
+ 'tagFilter.' . $tagsType => [
+ '$elemMatch' => $tag
+ ]
+ ];
+ }
+ }
+ }
+
+ // Campaign Filter
+ if ($parentCampaignId != null) {
+ $match['$match']['parentCampaignId'] = $parentCampaignId;
+ }
+
+ // Layout Filter
+ if (count($layoutIds) != 0) {
+ // Get campaignIds for selected layoutIds
+ $campaignIds = [];
+ foreach ($layoutIds as $layoutId) {
+ try {
+ $campaignIds[] = $this->layoutFactory->getCampaignIdFromLayoutHistory($layoutId);
+ } catch (NotFoundException $notFoundException) {
+ // Ignore the missing one
+ $this->getLog()->debug('Filter for Layout without Layout History Record, layoutId is ' . $layoutId);
+ }
+ }
+ $match['$match']['campaignId'] = [ '$in' => $campaignIds ];
+ }
+
+ // Media Filter
+ if (count($mediaIds) != 0) {
+ $this->getLog()->debug(json_encode($mediaIds, JSON_PRETTY_PRINT));
+ $match['$match']['mediaId'] = [ '$in' => $mediaIds ];
+ }
+
+ // For sorting
+ // The selected column has a key
+ $temp = [
+ '_id.type' => 'type',
+ '_id.display' => 'display',
+ 'layout' => 'layout',
+ 'media' => 'media',
+ 'eventName' => 'eventName',
+ 'layoutId' => 'layoutId',
+ 'parentCampaignId' => 'parentCampaignId',
+ 'parentCampaign' => 'parentCampaign',
+ 'widgetId' => 'widgetId',
+ '_id.displayId' => 'displayId',
+ 'numberPlays' => 'numberPlays',
+ 'minStart' => 'minStart',
+ 'maxEnd' => 'maxEnd',
+ 'duration' => 'duration',
+ ];
+
+ // Remove ` and DESC from the array strings
+ $cols = [];
+ foreach ($columns as $column) {
+ $str = str_replace('`', '', str_replace(' DESC', '', $column));
+ if (\strpos($column, 'DESC') !== false) {
+ $cols[$str] = -1;
+ } else {
+ $cols[$str] = 1;
+ }
+ }
+
+ // The selected column key gets stored in an array with value 1 or -1 (for DESC)
+ $array = [];
+ foreach ($cols as $k => $v) {
+ if (array_search($k, $temp)) {
+ $array[array_search($k, $temp)] = $v;
+ }
+ }
+
+ $order = ['_id.type'=> 1]; // default sorting by type
+ if ($array != null) {
+ $order = $array;
+ }
+
+ $project = [
+ '$project' => [
+ 'campaignId' => 1,
+ 'mediaId' => 1,
+ 'mediaName'=> 1,
+ 'media'=> [ '$ifNull' => [ '$mediaName', '$widgetName' ] ],
+ 'eventName' => 1,
+ 'widgetId' => 1,
+ 'widgetName' => 1,
+ 'layoutId' => 1,
+ 'layoutName' => 1,
+ 'parentCampaignId' => 1,
+ 'parentCampaign' => 1,
+ 'displayId' => 1,
+ 'displayName' => 1,
+ 'start' => 1,
+ 'end' => 1,
+ 'type' => 1,
+ 'duration' => 1,
+ 'count' => 1,
+ 'total' => ['$sum' => 1],
+ ]
+ ];
+
+ $group = [
+ '$group' => [
+ '_id' => [
+ 'type' => '$type',
+ 'parentCampaignId'=> '$parentCampaignId',
+ 'campaignId'=> [ '$ifNull' => [ '$campaignId', '$layoutId' ] ],
+ 'mediaorwidget'=> [ '$ifNull' => [ '$mediaId', '$widgetId' ] ],
+ 'displayId'=> [ '$ifNull' => [ '$displayId', null ] ],
+ 'display'=> '$displayName',
+ 'eventName'=> '$eventName',
+ // we don't need to group by media name and widget name
+
+ ],
+
+ 'media'=> [ '$first' => '$media'],
+ 'eventName'=> [ '$first' => '$eventName'],
+ 'mediaId' => ['$first' => '$mediaId'],
+ 'widgetId' => ['$first' => '$widgetId' ],
+
+ 'layout' => ['$first' => '$layoutName'],
+
+ // use the last layoutId to say that is the latest layoutId
+ 'layoutId' => ['$last' => '$layoutId'],
+
+ 'parentCampaign' => ['$first' => '$parentCampaign'],
+ 'parentCampaignId' => ['$first' => '$parentCampaignId'],
+
+ 'minStart' => ['$min' => '$start'],
+ 'maxEnd' => ['$max' => '$end'],
+ 'numberPlays' => ['$sum' => '$count'],
+ 'duration' => ['$sum' => '$duration'],
+ 'total' => ['$max' => '$total'],
+ ],
+ ];
+
+ $query = [
+ $match,
+ $project,
+ $group, [
+ '$facet' => [
+ 'totalData' => [
+ ['$sort' => $order],
+ ]
+ ]
+ ],
+
+ ];
+
+ $result = $this->getTimeSeriesStore()->executeQuery(['collection' => $this->table, 'query' => $query]);
+
+ $rows = [];
+ if (count($result) > 0) {
+ // Grid results
+ foreach ($result[0]['totalData'] as $row) {
+ $entry = [];
+
+ $entry['type'] = $row['_id']['type'];
+ $entry['displayId'] = $row['_id']['displayId'];
+ $entry['display'] = isset($row['_id']['display']) ? $row['_id']['display']: 'No display';
+ $entry['layout'] = isset($row['layout']) ? $row['layout']: 'No layout';
+ $entry['parentCampaignId'] = isset($row['parentCampaignId']) ? $row['parentCampaignId']: '';
+ $entry['parentCampaign'] = isset($row['parentCampaign']) ? $row['parentCampaign']: '';
+ $entry['media'] = isset($row['media']) ? $row['media'] : 'No media' ;
+ $entry['numberPlays'] = $row['numberPlays'];
+ $entry['duration'] = $row['duration'];
+ $entry['minStart'] = $row['minStart']->toDateTime()->format('U');
+ $entry['maxEnd'] = $row['maxEnd']->toDateTime()->format('U');
+ $entry['layoutId'] = $row['layoutId'];
+ $entry['widgetId'] = $row['widgetId'];
+ $entry['mediaId'] = $row['mediaId'];
+ $entry['tag'] = $row['eventName'];
+ $entry['displayGroupId'] = '';
+ $entry['displayGroup'] = '';
+ $entry['tagId'] = '';
+ $entry['tagName'] = '';
+
+ $rows[] = $entry;
+ }
+ }
+
+ if ($groupBy === 'tag') {
+ $rows = $this->groupByTagMongoDb($rows, $tagsType);
+ } else if ($groupBy === 'displayGroup') {
+ $rows = $this->groupByDisplayGroupMongoDb($rows, $displayGroupIds);
+ }
+
+ return [
+ 'periodStart' => $filterFromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $filterToDt->format(DateFormatHelper::getSystemFormat()),
+ 'result' => $rows,
+ 'count' => count($rows)
+ ];
+ }
+
+ /**
+ * Add grouping by tag type
+ * @param string $tagType
+ * @return string
+ */
+ private function groupByTagType(string $tagType) : string
+ {
+ return match ($tagType) {
+ 'media' => 'INNER JOIN `lktagmedia` AS taglink ON taglink.mediaId = stat.mediaId',
+ 'layout' => 'INNER JOIN `lktaglayout` ON `lktaglayout`.layoutId = stat.layoutId
+ INNER JOIN `tag` AS taglink ON taglink.tagId = `lktaglayout`.tagId',
+ 'dg' => 'INNER JOIN `lkdisplaydg` AS linkdg
+ ON linkdg.DisplayID = display.displayid
+ INNER JOIN `displaygroup` AS displaydg
+ ON displaydg.displaygroupId = linkdg.displaygroupId
+ AND `displaydg`.isDisplaySpecific = 1 INNER JOIN
+ `lktagdisplaygroup` AS taglink ON taglink.displaygroupId = displaydg.displaygroupId',
+ };
+ }
+
+ /**
+ * Group by display group in MongoDB
+ * @param array $rows
+ * @param array $filteredDisplayGroupIds
+ * @return array
+ * @throws NotFoundException
+ */
+ private function groupByDisplayGroupMongoDb(array $rows, array $filteredDisplayGroupIds) : array
+ {
+ $data = [];
+ $displayInfoArr = $this->displayGroupFactory->query();
+
+ // Get the display groups
+ foreach ($rows as $row) {
+ foreach ($displayInfoArr as $dg) {
+ // Do we have a filter?
+ if (!$filteredDisplayGroupIds || in_array($dg->displayGroupId, $filteredDisplayGroupIds)) {
+ // Create a temporary key to group by multiple columns at once
+ // and save memory instead of checking each column recursively
+ $key = $dg->displayGroupId . '_' . $row['layoutId'] . '_' . $row['mediaId'] . '_' .
+ $row['tag'] . '_' . $row['widgetId'] . '_' . $row['parentCampaignId'] . '_' . $row['type'];
+
+ if (!isset($data[$key])) {
+ // Since we already have the display group as the grouping option, we can remove the display info
+ $row['display'] = null;
+ $row['displayId'] = null;
+ $row['displayGroupId'] = $dg->displayGroupId;
+ $row['displayGroup'] = $dg->displayGroup;
+
+ $data[$key] = $row;
+ } else {
+ $data[$key]['duration'] += $row['duration'];
+ $data[$key]['numberPlays'] += $row['numberPlays'];
+ }
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Group by tag in MongoDB
+ * @param array $rows
+ * @param string $tagsType
+ * @return array
+ */
+ private function groupByTagMongoDb(array $rows, string $tagsType) : array
+ {
+ $data = [];
+ $tags = $this->filterByTagType($tagsType);
+ $type = match ($tagsType) {
+ 'media' => 'mediaId',
+ 'layout' => 'layoutId',
+ 'dg' => 'displayId',
+ };;
+
+ foreach ($rows as $row) {
+ foreach ($tags as $tag) {
+ if ($row[$type] == $tag['entityId']) {
+ // Create a temporary key to group by multiple columns at once
+ // and save memory instead of checking each column recursively
+ $key = $tag['tagId'] . '_' . $row['layoutId'] . '_' . $row['mediaId'] . '_' .
+ $row['tag'] . '_' . $row['widgetId'] . '_' . $row['parentCampaignId'] . '_' . $row['type'];
+
+ if (!isset($data[$key])) {
+ // Since we already have the tags as the grouping option, we can remove the display info
+ $row['display'] = null;
+ $row['displayId'] = null;
+ $row['tagName'] = $tag['tag'];
+ $row['tagId'] = $tag['tagId'];
+
+ $data[$key] = $row;
+ } else {
+ $data[$key]['duration'] += $row['duration'];
+ $data[$key]['numberPlays'] += $row['numberPlays'];
+ }
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param string $tagsType
+ * @return array
+ */
+ private function filterByTagType(string $tagsType): array
+ {
+ $tags = [];
+ $filter = match ($tagsType) {
+ 'media' => 'Media',
+ 'layout' => 'Layout',
+ 'dg' => 'Display',
+ };
+
+ // Get the list of tags to get the tag type (ie media tag, layout tag, or display tag)
+ $tagInfoArr = $this->tagFactory->query();
+
+ foreach ($tagInfoArr as $tag) {
+ // What type of tags are we looking for?
+ foreach ($this->tagFactory->getAllLinks(null, ['tagId' => $tag->tagId]) as $filteredTag) {
+ if ($filteredTag['type'] == $filter) {
+ $filteredTag['tagId'] = $tag->tagId;
+ $filteredTag['tag'] = $tag->tag;
+ $tags[] = $filteredTag;
+ }
+ }
+ }
+
+ return $tags;
+ }
+}
diff --git a/lib/Report/ReportDefaultTrait.php b/lib/Report/ReportDefaultTrait.php
new file mode 100644
index 0000000..adf0cd6
--- /dev/null
+++ b/lib/Report/ReportDefaultTrait.php
@@ -0,0 +1,334 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use http\Exception\RuntimeException;
+use Psr\Log\NullLogger;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\ReportResult;
+use Xibo\Helper\Translate;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Trait ReportDefaultTrait
+ * @package Xibo\Report
+ */
+trait ReportDefaultTrait
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var TimeSeriesStoreInterface
+ */
+ private $timeSeriesStore;
+
+ /**
+ * @var LogServiceInterface
+ */
+ private $logService;
+
+ /**
+ * @var Request
+ */
+ private $request;
+
+ /** @var \Xibo\Entity\User */
+ private $user;
+
+ /**
+ * Set common dependencies.
+ * @param StorageServiceInterface $store
+ * @param TimeSeriesStoreInterface $timeSeriesStore
+ * @return $this
+ */
+ public function setCommonDependencies($store, $timeSeriesStore)
+ {
+ $this->store = $store;
+ $this->timeSeriesStore = $timeSeriesStore;
+ $this->logService = new NullLogger();
+ return $this;
+ }
+
+ /**
+ * @param LogServiceInterface $logService
+ * @return $this
+ */
+ public function useLogger(LogServiceInterface $logService)
+ {
+ $this->logService = $logService;
+
+ return $this;
+ }
+
+ /**
+ * Get Store
+ * @return StorageServiceInterface
+ */
+ protected function getStore()
+ {
+ return $this->store;
+ }
+
+ /**
+ * Get TimeSeriesStore
+ * @return TimeSeriesStoreInterface
+ */
+ protected function getTimeSeriesStore()
+ {
+ return $this->timeSeriesStore;
+ }
+
+ /**
+ * Get Log
+ * @return LogServiceInterface
+ */
+ protected function getLog()
+ {
+ return $this->logService;
+ }
+
+ /**
+ * @return \Xibo\Entity\User
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Set user Id
+ * @param \Xibo\Entity\User $user
+ * @return $this
+ */
+ public function setUser($user)
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ /**
+ * Get chart script
+ * @param ReportResult $results
+ * @return string
+ */
+ public function getReportChartScript($results)
+ {
+ return null;
+ }
+
+ /**
+ * Generate saved report name
+ * @param SanitizerInterface $sanitizedParams
+ * @return string
+ */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $saveAs = sprintf(__('%s report'), ucfirst($sanitizedParams->getString('filter')));
+
+ return $saveAs. ' '. Carbon::now()->format('Y-m-d');
+ }
+
+ /**
+ * Get a temporary table representing the periods covered
+ * @param Carbon $fromDt
+ * @param Carbon $toDt
+ * @param string $groupByFilter
+ * @param string $table
+ * @param string $customLabel Custom Label
+ * @return string
+ * @throws InvalidArgumentException
+ */
+ public function getTemporaryPeriodsTable($fromDt, $toDt, $groupByFilter, $table = 'temp_periods', $customLabel = 'Y-m-d H:i:s')
+ {
+ // My from/to dt represent the entire range we're interested in.
+ // we need to generate periods according to our grouping, within that range.
+ // Clone them so as to not effect the calling object
+ $fromDt = $fromDt->copy();
+ $toDt = $toDt->copy();
+
+ // our from/to dates might not sit nicely inside our period groupings
+ // for example if we look at June, by week, the 1st of June is a Saturday, week 22.
+ // NB:
+ // FromDT/ToDt should always be at the start of the day.
+ switch ($groupByFilter) {
+ case 'byweek':
+ $fromDt->locale(Translate::GetLocale())->startOfWeek();
+ break;
+
+ case 'bymonth':
+ $fromDt->startOfMonth();
+ break;
+ }
+
+ // Temporary Periods Table
+ // -----------------------
+ // we will use a temporary table for this.
+ // Drop table if exists
+
+ $this->getStore()->getConnection()->exec('
+ DROP TABLE IF EXISTS `' . $table . '`');
+
+ $this->getStore()->getConnection()->exec('
+ CREATE TEMPORARY TABLE `' . $table . '` (
+ id INT,
+ customLabel VARCHAR(20),
+ label VARCHAR(20),
+ start INT,
+ end INT
+ );
+ ');
+
+ // Prepare an insert statement
+ $periods = $this->getStore()->getConnection()->prepare('
+ INSERT INTO `' . $table . '` (id, customLabel, label, start, end)
+ VALUES (:id, :customLabel, :label, :start, :end)
+ ');
+
+
+ // Loop until we've covered all periods needed
+ $loopDate = $fromDt->copy();
+ while ($toDt > $loopDate) {
+ // We add different periods for each type of grouping
+ if ($groupByFilter == 'byhour') {
+ $periods->execute([
+ 'id' => $loopDate->hour,
+ 'customLabel' => $loopDate->format($customLabel),
+ 'label' => $loopDate->format('g:i A'),
+ 'start' => $loopDate->format('U'),
+ 'end' => $loopDate->addHour()->format('U')
+ ]);
+ } elseif ($groupByFilter == 'byday') {
+ $periods->execute([
+ 'id' => $loopDate->year . $loopDate->month . $loopDate->day,
+ 'customLabel' => $loopDate->format($customLabel),
+ 'label' => $loopDate->format('Y-m-d'),
+ 'start' => $loopDate->format('U'),
+ 'end' => $loopDate->addDay()->format('U')
+ ]);
+ } elseif ($groupByFilter == 'byweek') {
+ $weekNo = $loopDate->locale(Translate::GetLocale())->week();
+
+ $periods->execute([
+ 'id' => $loopDate->weekOfYear . $loopDate->year,
+ 'customLabel' => $loopDate->format($customLabel),
+ 'label' => $loopDate->format('Y-m-d') . '(w' . $weekNo . ')',
+ 'start' => $loopDate->format('U'),
+ 'end' => $loopDate->addWeek()->format('U')
+ ]);
+ } elseif ($groupByFilter == 'bymonth') {
+ $periods->execute([
+ 'id' => $loopDate->year . $loopDate->month,
+ 'customLabel' => $loopDate->format($customLabel),
+ 'label' => $loopDate->format('M'),
+ 'start' => $loopDate->format('U'),
+ 'end' => $loopDate->addMonth()->format('U')
+ ]);
+ } elseif ($groupByFilter == 'bydayofweek') {
+ $periods->execute([
+ 'id' => $loopDate->dayOfWeek,
+ 'customLabel' => $loopDate->format($customLabel),
+ 'label' => $loopDate->format('D'),
+ 'start' => $loopDate->format('U'),
+ 'end' => $loopDate->addDay()->format('U')
+ ]);
+ } elseif ($groupByFilter == 'bydayofmonth') {
+ $periods->execute([
+ 'id' => $loopDate->day,
+ 'customLabel' => $loopDate->format($customLabel),
+ 'label' => $loopDate->format('d'),
+ 'start' => $loopDate->format('U'),
+ 'end' => $loopDate->addDay()->format('U')
+ ]);
+ } else {
+ $this->getLog()->error('Unknown Grouping Selected ' . $groupByFilter);
+ throw new InvalidArgumentException(__('Unknown Grouping ') . $groupByFilter, 'groupByFilter');
+ }
+ }
+
+ $this->getLog()->debug(json_encode($this->store->select('SELECT * FROM ' . $table, []), JSON_PRETTY_PRINT));
+
+ return $table;
+ }
+
+ /**
+ * Get an array of displayIds we should pass into the query,
+ * if an exception is thrown, we should stop the report and return no results.
+ * @param \Xibo\Support\Sanitizer\SanitizerInterface $params
+ * @return array displayIds
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getDisplayIdFilter(SanitizerInterface $params): array
+ {
+ $displayIds = [];
+
+ // Filters
+ $displayId = $params->getInt('displayId');
+ $displayGroupIds = $params->getIntArray('displayGroupId', ['default' => null]);
+
+ if ($displayId !== null) {
+ // Don't bother checking if we are a super admin
+ if (!$this->getUser()->isSuperAdmin()) {
+ $display = $this->displayFactory->getById($displayId);
+ if ($this->getUser()->checkViewable($display)) {
+ $displayIds[] = $displayId;
+ }
+ } else {
+ $displayIds[] = $displayId;
+ }
+ } else {
+ // If we are NOT a super admin OR we have some display group filters
+ // get an array of display id this user has access to.
+ // we cannot rely on the logged-in user because this will be run by the task runner which is a sysadmin
+ if (!$this->getUser()->isSuperAdmin() || $displayGroupIds !== null) {
+ // This will be the displayIds the user has access to, and are in the displayGroupIds provided.
+ foreach ($this->displayFactory->query(
+ null,
+ [
+ 'userCheckUserId' => $this->getUser()->userId,
+ 'displayGroupIds' => $displayGroupIds,
+ ]
+ ) as $display) {
+ $displayIds[] = $display->displayId;
+ }
+ }
+ }
+
+ // If we are a super admin without anything filtered, the object of this method is to return an empty
+ // array.
+ // If we are any other user, we must return something in the array.
+ if (!$this->getUser()->isSuperAdmin() && count($displayIds) <= 0) {
+ throw new InvalidArgumentException(__('No displays with View permissions'), 'displays');
+ }
+
+ return $displayIds;
+ }
+}
diff --git a/lib/Report/ReportInterface.php b/lib/Report/ReportInterface.php
new file mode 100644
index 0000000..6adda05
--- /dev/null
+++ b/lib/Report/ReportInterface.php
@@ -0,0 +1,127 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Psr\Container\ContainerInterface;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Interface ReportInterface
+ * @package Xibo\Report
+ */
+interface ReportInterface
+{
+ /**
+ * Set factories
+ * @param ContainerInterface $container
+ * @return $this
+ */
+ public function setFactories(ContainerInterface $container);
+
+ /**
+ * Set user Id
+ * @param \Xibo\Entity\User $user
+ * @return $this
+ */
+ public function setUser($user);
+
+ /**
+ * Get the user
+ * @return \Xibo\Entity\User
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getUser();
+
+ /**
+ * Get chart script
+ * @param ReportResult $results
+ * @return string
+ */
+ public function getReportChartScript($results);
+
+ /**
+ * Return the twig file name of the saved report email and export template
+ * @return string
+ */
+ public function getReportEmailTemplate();
+
+ /**
+ * Return the twig file name of the saved report preview template
+ * @return string
+ */
+ public function getSavedReportTemplate();
+
+ /**
+ * Return the twig file name of the report form
+ * Load the report form
+ * @return ReportForm
+ */
+ public function getReportForm();
+
+ /**
+ * Populate form title and hidden fields
+ * @param SanitizerInterface $sanitizedParams
+ * @return array
+ */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams);
+
+ /**
+ * Set Report Schedule form data
+ * @param SanitizerInterface $sanitizedParams
+ * @return array
+ */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams);
+
+ /**
+ * Generate saved report name
+ * @param SanitizerInterface $sanitizedParams
+ * @return string
+ */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams);
+
+ /**
+ * Resrtucture old saved report's json file to support schema version 2
+ * @param $json
+ * @return array
+ */
+ public function restructureSavedReportOldJson($json);
+
+ /**
+ * Return data from saved json file to build chart/table for saved report
+ * @param array $json
+ * @param object $savedReport
+ * @return ReportResult
+ */
+ public function getSavedReportResults($json, $savedReport);
+
+ /**
+ * Get results when on demand report runs and
+ * This result will get saved to a json if schedule report runs
+ * @param SanitizerInterface $sanitizedParams
+ * @return ReportResult
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function getResults(SanitizerInterface $sanitizedParams);
+}
diff --git a/lib/Report/SessionHistory.php b/lib/Report/SessionHistory.php
new file mode 100644
index 0000000..b1b6c76
--- /dev/null
+++ b/lib/Report/SessionHistory.php
@@ -0,0 +1,438 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\AuditLogFactory;
+use Xibo\Factory\LogFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\AccessDeniedException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+class SessionHistory implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /** @var LogFactory */
+ private $logFactory;
+
+ /** @var AuditLogFactory */
+ private $auditLogFactory;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->logFactory = $container->get('logFactory');
+ $this->auditLogFactory = $container->get('auditLogFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'sessionhistory-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'sessionhistory-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm(): ReportForm
+ {
+ return new ReportForm(
+ 'sessionhistory-report-form',
+ 'sessionhistory',
+ 'Audit',
+ [
+ 'fromDate' => Carbon::now()->startOfMonth()->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ]
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams): array
+ {
+ $data = [];
+ $data['reportName'] = 'sessionhistory';
+
+ return [
+ 'template' => 'sessionhistory-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams): array
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $filterCriteria['userId'] = $sanitizedParams->getInt('userId');
+ $filterCriteria['type'] = $sanitizedParams->getString('type');
+ $filterCriteria['scheduledReport'] = true;
+
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams): string
+ {
+ return sprintf(
+ __('%s Session %s log report for User'),
+ ucfirst($sanitizedParams->getString('filter')),
+ ucfirst($sanitizedParams->getString('type'))
+ );
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($json)
+ {
+ return $json;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ if (!$this->getUser()->isSuperAdmin()) {
+ throw new AccessDeniedException();
+ }
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // The report uses a custom range filter that automatically calculates the from/to dates
+ // depending on the date range selected.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ // This calculation will be retained as it is used for scheduled reports
+ switch ($reportFilter) {
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+ break;
+
+ case '':
+ default:
+ // fromDt will always be from start of day ie 00:00
+ $fromDt = $sanitizedParams->getDate('fromDt') ?? $now->copy()->startOfDay();
+ $toDt = $sanitizedParams->getDate('toDt') ?? $now;
+
+ break;
+ }
+
+ $metadata = [
+ 'periodStart' => Carbon::createFromTimestamp($fromDt->toDateTime()->format('U'))
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => Carbon::createFromTimestamp($toDt->toDateTime()->format('U'))
+ ->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ $type = $sanitizedParams->getString('type');
+
+ if ($type === 'audit') {
+ $params = [
+ 'fromDt' => $fromDt->format('U'),
+ 'toDt' => $toDt->format('U'),
+ ];
+
+ $sql = 'SELECT
+ `auditlog`.`logId`,
+ `auditlog`.`logDate`,
+ `user`.`userName`,
+ `auditlog`.`message`,
+ `auditlog`.`objectAfter`,
+ `auditlog`.`entity`,
+ `auditlog`.`entityId`,
+ `auditlog`.userId,
+ `auditlog`.ipAddress,
+ `auditlog`.sessionHistoryId,
+ `session_history`.userAgent
+ FROM `auditlog`
+ INNER JOIN `user` ON `user`.`userId` = `auditlog`.`userId`
+ INNER JOIN `session_history` ON `session_history`.`sessionId` = `auditlog`.`sessionHistoryId`
+ WHERE `auditlog`.logDate BETWEEN :fromDt AND :toDt
+ ';
+
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $sql .= ' AND `auditlog`.`userId` = :userId';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ if ($sanitizedParams->getInt('sessionHistoryId') !== null) {
+ $sql .= ' AND `auditlog`.`sessionHistoryId` = :sessionHistoryId';
+ $params['sessionHistoryId'] = $sanitizedParams->getInt('sessionHistoryId');
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $auditRecord = $this->auditLogFactory->create()->hydrate($row);
+ $auditRecord->setUnmatchedProperty(
+ 'userAgent',
+ $row['userAgent']
+ );
+
+ // decode for grid view, leave as json for email/preview.
+ if (!$sanitizedParams->getCheckbox('scheduledReport')) {
+ $auditRecord->objectAfter = json_decode($auditRecord->objectAfter);
+ }
+
+ $auditRecord->logDate = Carbon::createFromTimestamp($auditRecord->logDate)
+ ->format(DateFormatHelper::getSystemFormat());
+
+ $rows[] = $auditRecord;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ } else if ($type === 'debug') {
+ $params = [
+ 'fromDt' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ $sql = 'SELECT
+ `log`.`logId`,
+ `log`.`logDate`,
+ `log`.`runNo`,
+ `log`.`channel`,
+ `log`.`page`,
+ `log`.`function`,
+ `log`.`type`,
+ `log`.`message`,
+ `log`.`userId`,
+ `log`.`sessionHistoryId`,
+ `user`.`userName`,
+ `display`.`displayId`,
+ `display`.`display`,
+ `session_history`.ipAddress,
+ `session_history`.userAgent
+ FROM `log`
+ LEFT OUTER JOIN `display` ON `display`.`displayid` = `log`.`displayid`
+ INNER JOIN `user` ON `user`.`userId` = `log`.`userId`
+ INNER JOIN `session_history` ON `session_history`.`sessionId` = `log`.`sessionHistoryId`
+ WHERE `log`.logDate BETWEEN :fromDt AND :toDt
+ ';
+
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $sql .= ' AND `log`.`userId` = :userId';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ if ($sanitizedParams->getInt('sessionHistoryId') !== null) {
+ $sql .= ' AND `log`.`sessionHistoryId` = :sessionHistoryId';
+ $params['sessionHistoryId'] = $sanitizedParams->getInt('sessionHistoryId');
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $logRecord = $this->logFactory->createEmpty()->hydrate($row, ['htmlStringProperties' => ['message']]);
+ $logRecord->setUnmatchedProperty(
+ 'userAgent',
+ $row['userAgent']
+ );
+
+ $logRecord->setUnmatchedProperty(
+ 'ipAddress',
+ $row['ipAddress']
+ );
+
+ $logRecord->setUnmatchedProperty(
+ 'userName',
+ $row['userName']
+ );
+
+ $rows[] = $logRecord;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ } else {
+ $params = [
+ 'fromDt' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ $sql = 'SELECT
+ `session_history`.`sessionId`,
+ `session_history`.`startTime`,
+ `session_history`.`userId`,
+ `session_history`.`userAgent`,
+ `session_history`.`ipAddress`,
+ `session_history`.`lastUsedTime`,
+ `user`.`userName`,
+ `usertype`.`userType`
+ FROM `session_history`
+ LEFT OUTER JOIN `user` ON `user`.`userId` = `session_history`.`userId`
+ LEFT OUTER JOIN `usertype` ON `usertype`.`userTypeId` = `user`.`userTypeId`
+ WHERE `session_history`.startTime BETWEEN :fromDt AND :toDt
+ ';
+
+ if ($sanitizedParams->getInt('userId') !== null) {
+ $sql .= ' AND `session_history`.`userId` = :userId';
+ $params['userId'] = $sanitizedParams->getInt('userId');
+ }
+
+ if ($sanitizedParams->getInt('sessionHistoryId') !== null) {
+ $sql .= ' AND `session_history`.`sessionId` = :sessionHistoryId';
+ $params['sessionHistoryId'] = $sanitizedParams->getInt('sessionHistoryId');
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ if (is_array($sortOrder)) {
+ $sql .= ' ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ $rows = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $sessionRecord = $this->logFactory->createEmpty()->hydrate($row, ['htmlStringProperties' => ['message']]);
+ $duration = isset($row['lastUsedTime'])
+ ? date_diff(date_create($row['startTime']), date_create($row['lastUsedTime']))->format('%H:%I:%S')
+ : null;
+
+ $sessionRecord->setUnmatchedProperty(
+ 'userAgent',
+ $row['userAgent']
+ );
+
+ $sessionRecord->setUnmatchedProperty(
+ 'ipAddress',
+ $row['ipAddress']
+ );
+
+ $sessionRecord->setUnmatchedProperty(
+ 'userName',
+ $row['userName']
+ );
+
+ $sessionRecord->setUnmatchedProperty(
+ 'endTime',
+ $row['lastUsedTime']
+ );
+
+ $sessionRecord->setUnmatchedProperty(
+ 'duration',
+ $duration
+ );
+
+ $rows[] = $sessionRecord;
+ }
+
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ );
+ }
+ }
+}
diff --git a/lib/Report/SummaryDistributionCommonTrait.php b/lib/Report/SummaryDistributionCommonTrait.php
new file mode 100644
index 0000000..d9ad009
--- /dev/null
+++ b/lib/Report/SummaryDistributionCommonTrait.php
@@ -0,0 +1,145 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Common function between the Summary and Distribution reports
+ */
+trait SummaryDistributionCommonTrait
+{
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ $durationData = $result['durationData'];
+ $countData = $result['countData'];
+ $labels = $result['labels'];
+ $backgroundColor = $result['backgroundColor'];
+ $borderColor = $result['borderColor'];
+ $periodStart = $result['periodStart'];
+ $periodEnd = $result['periodEnd'];
+
+ return [
+ 'hasData' => count($durationData) > 0 && count($countData) > 0,
+ 'chart' => [
+ 'type' => 'bar',
+ 'data' => [
+ 'labels' => $labels,
+ 'datasets' => [
+ [
+ 'label' => __('Total duration'),
+ 'yAxisID' => 'Duration',
+ 'backgroundColor' => $backgroundColor,
+ 'data' => $durationData
+ ],
+ [
+ 'label' => __('Total count'),
+ 'yAxisID' => 'Count',
+ 'borderColor' => $borderColor,
+ 'type' => 'line',
+ 'fill' => false,
+ 'data' => $countData
+ ]
+ ]
+ ],
+ 'options' => [
+ 'scales' => [
+ 'yAxes' => [
+ [
+ 'id' => 'Duration',
+ 'type' => 'linear',
+ 'position' => 'left',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Duration(s)')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ], [
+ 'id' => 'Count',
+ 'type' => 'linear',
+ 'position' => 'right',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Count')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ]
+ ]
+ ]
+ ]
+ ],
+ 'periodStart' => $periodStart,
+ 'periodEnd' => $periodEnd,
+
+ ];
+ }
+
+ /**
+ * @param \Xibo\Support\Sanitizer\SanitizerInterface $sanitizedParams
+ * @return array
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function getReportScheduleFormTitle(SanitizerInterface $sanitizedParams): array
+ {
+ $type = $sanitizedParams->getString('type');
+ if ($type == 'layout') {
+ $selectedId = $sanitizedParams->getInt('layoutId');
+ $title = sprintf(
+ __('Add Report Schedule for %s - %s'),
+ $type,
+ $this->layoutFactory->getById($selectedId)->layout
+ );
+ } elseif ($type == 'media') {
+ $selectedId = $sanitizedParams->getInt('mediaId');
+ $title = sprintf(
+ __('Add Report Schedule for %s - %s'),
+ $type,
+ $this->mediaFactory->getById($selectedId)->name
+ );
+ } elseif ($type == 'event') {
+ $selectedId = 0; // we only need eventTag
+ $eventTag = $sanitizedParams->getString('eventTag');
+ $title = sprintf(
+ __('Add Report Schedule for %s - %s'),
+ $type,
+ $eventTag
+ );
+ } else {
+ throw new InvalidArgumentException(__('Unknown type ') . $type, 'type');
+ }
+
+ return [
+ 'title' => $title,
+ 'selectedId' => $selectedId
+ ];
+ }
+}
diff --git a/lib/Report/SummaryReport.php b/lib/Report/SummaryReport.php
new file mode 100644
index 0000000..980cc8c
--- /dev/null
+++ b/lib/Report/SummaryReport.php
@@ -0,0 +1,1141 @@
+.
+ */
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use MongoDB\BSON\UTCDateTime;
+use Psr\Container\ContainerInterface;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class SummaryReport
+ * @package Xibo\Report
+ */
+class SummaryReport implements ReportInterface
+{
+ use ReportDefaultTrait;
+ use SummaryDistributionCommonTrait;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /**
+ * @var LayoutFactory
+ */
+ private $layoutFactory;
+
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ private $table = 'stat';
+
+ private $periodTable = 'period';
+
+ /** @inheritDoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->reportService = $container->get('reportService');
+ $this->sanitizer = $container->get('sanitizerService');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'summary-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'summary-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'summary-report-form',
+ 'summaryReport',
+ 'Proof of Play',
+ [
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ],
+ __('Select a type and an item (i.e., layout/media/tag)')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $type = $sanitizedParams->getString('type');
+ $formParams = $this->getReportScheduleFormTitle($sanitizedParams);
+
+ $data = ['filters' => []];
+ $data['filters'][] = ['name'=> 'Daily', 'filter'=> 'daily'];
+ $data['filters'][] = ['name'=> 'Weekly', 'filter'=> 'weekly'];
+ $data['filters'][] = ['name'=> 'Monthly', 'filter'=> 'monthly'];
+ $data['filters'][] = ['name'=> 'Yearly', 'filter'=> 'yearly'];
+
+ $data['formTitle'] = $formParams['title'];
+
+ $data['hiddenFields'] = json_encode([
+ 'type' => $type,
+ 'selectedId' => $formParams['selectedId'],
+ 'eventTag' => $eventTag ?? null
+ ]);
+
+ $data['reportName'] = 'summaryReport';
+
+ return [
+ 'template' => 'summary-report-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $displayId = $sanitizedParams->getInt('displayId');
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+ $hiddenFields = json_decode($sanitizedParams->getString('hiddenFields'), true);
+
+ $type = $hiddenFields['type'];
+ $selectedId = $hiddenFields['selectedId'];
+ $eventTag = $hiddenFields['eventTag'];
+
+ // If a display is selected we ignore the display group selection
+ $filterCriteria['displayId'] = $displayId;
+ if (empty($displayId) && count($displayGroupIds) > 0) {
+ $filterCriteria['displayGroupId'] = $displayGroupIds;
+ }
+
+ $filterCriteria['type'] = $type;
+ if ($type == 'layout') {
+ $filterCriteria['layoutId'] = $selectedId;
+ } elseif ($type == 'media') {
+ $filterCriteria['mediaId'] = $selectedId;
+ } elseif ($type == 'event') {
+ $filterCriteria['eventTag'] = $eventTag;
+ }
+
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ $filterCriteria['groupByFilter'] = 'byweek';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ $filterCriteria['groupByFilter'] = 'bymonth';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ $type = $sanitizedParams->getString('type');
+ $filter = $sanitizedParams->getString('filter');
+
+ $saveAs = null;
+ if ($type == 'layout') {
+ try {
+ $layout = $this->layoutFactory->getById($sanitizedParams->getInt('layoutId'));
+ } catch (NotFoundException $error) {
+ // Get the campaign ID
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($sanitizedParams->getInt('layoutId'));
+ $layoutId = $this->layoutFactory->getLatestLayoutIdFromLayoutHistory($campaignId);
+ $layout = $this->layoutFactory->getById($layoutId);
+ }
+ $saveAs = sprintf(__('%s report for Layout %s'), ucfirst($filter), $layout->layout);
+ } elseif ($type == 'media') {
+ try {
+ $media = $this->mediaFactory->getById($sanitizedParams->getInt('mediaId'));
+ $saveAs = sprintf(__('%s report for Media'), ucfirst($filter), $media->name);
+ } catch (NotFoundException $error) {
+ $saveAs = __('Media not found');
+ }
+ } elseif ($type == 'event') {
+ $saveAs = sprintf(__('%s report for Event %s'), ucfirst($filter), $sanitizedParams->getString('eventTag'));
+ }
+
+ return $saveAs;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritDoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ $type = strtolower($sanitizedParams->getString('type'));
+ $layoutId = $sanitizedParams->getInt('layoutId');
+ $mediaId = $sanitizedParams->getInt('mediaId');
+ $eventTag = $sanitizedParams->getString('eventTag');
+
+ // Filter by displayId?
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // Our report has a range filter which determins whether or not the user has to enter their own from / to dates
+ // check the range filter first and set from/to dates accordingly.
+ $reportFilter = $sanitizedParams->getString('reportFilter');
+
+ // Use the current date as a helper
+ $now = Carbon::now();
+
+ switch ($reportFilter) {
+ case 'today':
+ $fromDt = $now->copy()->startOfDay();
+ $toDt = $fromDt->copy()->addDay();
+ $groupByFilter = 'byhour';
+ break;
+
+ case 'yesterday':
+ $fromDt = $now->copy()->startOfDay()->subDay();
+ $toDt = $now->copy()->startOfDay();
+ $groupByFilter = 'byhour';
+ break;
+
+ case 'thisweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ $groupByFilter = 'byday';
+ break;
+
+ case 'thismonth':
+ $fromDt = $now->copy()->startOfMonth();
+ $toDt = $fromDt->copy()->addMonth();
+
+ // User can pick their own group by filter when they provide a manual range
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+ break;
+
+ case 'thisyear':
+ $fromDt = $now->copy()->startOfYear();
+ $toDt = $fromDt->copy()->addYear();
+
+ // User can pick their own group by filter when they provide a manual range
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+ break;
+
+ case 'lastweek':
+ $fromDt = $now->copy()->locale(Translate::GetLocale())->startOfWeek()->subWeek();
+ $toDt = $fromDt->copy()->addWeek();
+ $groupByFilter = 'byday';
+ break;
+
+ case 'lastmonth':
+ $fromDt = $now->copy()->startOfMonth()->subMonth();
+ $toDt = $fromDt->copy()->addMonth();
+
+ // User can pick their own group by filter when they provide a manual range
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+ break;
+
+ case 'lastyear':
+ $fromDt = $now->copy()->startOfYear()->subYear();
+ $toDt = $fromDt->copy()->addYear();
+
+ // User can pick their own group by filter when they provide a manual range
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+ break;
+
+ case '':
+ default:
+ // Expect dates to be provided.
+ $fromDt = $sanitizedParams->getDate('statsFromDt', ['default' => Carbon::now()->subDay()]);
+ $fromDt->startOfDay();
+
+ $toDt = $sanitizedParams->getDate('statsToDt', ['default' => Carbon::now()]);
+ $toDt->addDay()->startOfDay();
+
+ // What if the fromdt and todt are exactly the same?
+ // in this case assume an entire day from midnight on the fromdt to midnight on the todt
+ // (i.e. add a day to the todt)
+ if ($fromDt == $toDt) {
+ $toDt->addDay();
+ }
+
+ // User can pick their own group by filter when they provide a manual range
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+
+ break;
+ }
+
+ // Get Results!
+ // -------------
+ // Validate we have necessary selections
+ if (($type === 'media' && empty($mediaId))
+ || ($type === 'layout' && empty($layoutId))
+ || ($type === 'event' && empty($eventTag))
+ ) {
+ // We have nothing to return because the filter selections don't make sense.
+ $result = [];
+ } elseif ($this->getTimeSeriesStore()->getEngine() === 'mongodb') {
+ $result = $this->getSummaryReportMongoDb(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag,
+ $reportFilter
+ );
+ } else {
+ $result = $this->getSummaryReportMySql(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag
+ );
+ }
+
+ //
+ // Output Results
+ // --------------
+ // TODO: chart definition in the backend - surely this should be frontend logic?!
+ $labels = [];
+ $countData = [];
+ $durationData = [];
+ $backgroundColor = [];
+ $borderColor = [];
+
+ // Sanitize results for chart and table
+ $rows = [];
+ if (count($result) > 0) {
+ foreach ($result['result'] as $row) {
+ $sanitizedRow = $this->sanitizer->getSanitizer($row);
+
+ // ----
+ // Build Chart data
+ $labels[] = $row['label'];
+
+ $backgroundColor[] = 'rgb(95, 186, 218, 0.6)';
+ $borderColor[] = 'rgb(240,93,41, 0.8)';
+
+ $count = $sanitizedRow->getInt('NumberPlays');
+ $countData[] = ($count == '') ? 0 : $count;
+
+ $duration = $sanitizedRow->getInt('Duration');
+ $durationData[] = ($duration == '') ? 0 : $duration;
+
+ // ----
+ // Build Tabular data
+ $entry = [];
+ $entry['label'] = $sanitizedRow->getString('label');
+ $entry['duration'] = ($duration == '') ? 0 : $duration;
+ $entry['count'] = ($count == '') ? 0 : $count;
+ $rows[] = $entry;
+ }
+ }
+
+ // Build Chart to pass in twig file chart.js
+ $chart = [
+ 'type' => 'bar',
+ 'data' => [
+ 'labels' => $labels,
+ 'datasets' => [
+ [
+ 'label' => __('Total duration'),
+ 'yAxisID' => 'Duration',
+ 'backgroundColor' => $backgroundColor,
+ 'data' => $durationData
+ ],
+ [
+ 'label' => __('Total count'),
+ 'yAxisID' => 'Count',
+ 'borderColor' => $borderColor,
+ 'type' => 'line',
+ 'fill' => false,
+ 'data' => $countData
+ ]
+ ]
+ ],
+ 'options' => [
+ 'scales' => [
+ 'yAxes' => [
+ [
+ 'id' => 'Duration',
+ 'type' => 'linear',
+ 'position' => 'left',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Duration(s)')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ], [
+ 'id' => 'Count',
+ 'type' => 'linear',
+ 'position' => 'right',
+ 'display' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => __('Count')
+ ],
+ 'ticks' => [
+ 'beginAtZero' => true
+ ]
+ ]
+ ]
+ ]
+ ]
+ ];
+
+ $metadata = [
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ // Total records
+ $recordsTotal = count($rows);
+
+ // ----
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ $recordsTotal,
+ $chart
+ );
+ }
+
+ /**
+ * MySQL summary report
+ * @param Carbon $fromDt The filter range from date
+ * @param Carbon $toDt The filter range to date
+ * @param string $groupByFilter Grouping, byhour, byday, byweek, bymonth
+ * @param $displayIds
+ * @param $type
+ * @param $layoutId
+ * @param $mediaId
+ * @param $eventTag
+ * @return array
+ */
+ private function getSummaryReportMySql(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag
+ ) {
+ // Create periods covering the from/to dates
+ // -----------------------------------------
+ try {
+ $periods = $this->getTemporaryPeriodsTable($fromDt, $toDt, $groupByFilter);
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ return [];
+ }
+
+ // Join in stats
+ // -------------
+ $select = '
+ SELECT
+ start,
+ end,
+ periodsWithStats.id,
+ MAX(periodsWithStats.label) AS label,
+ SUM(numberOfPlays) as NumberPlays,
+ CONVERT(SUM(periodsWithStats.actualDiff), SIGNED INTEGER) as Duration
+ FROM (
+ SELECT
+ periods.id,
+ periods.label,
+ periods.start,
+ periods.end,
+ stat.count AS numberOfPlays,
+ LEAST(stat.duration, LEAST(periods.end, statEnd, :toDt) - GREATEST(periods.start, statStart, :fromDt)) AS actualDiff
+ FROM `' . $periods . '` AS periods
+ LEFT OUTER JOIN (
+ SELECT
+ layout.Layout,
+ IFNULL(`media`.name, IFNULL(`widgetoption`.value, `widget`.type)) AS Media,
+ stat.mediaId,
+ stat.`start` as statStart,
+ stat.`end` as statEnd,
+ stat.duration,
+ stat.`count`
+ FROM stat
+ LEFT OUTER JOIN layout
+ ON layout.layoutID = stat.layoutID
+ LEFT OUTER JOIN `widget`
+ ON `widget`.widgetId = stat.widgetId
+ LEFT OUTER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'name\'
+ LEFT OUTER JOIN `media`
+ ON `media`.mediaId = `stat`.mediaId
+ WHERE stat.type <> \'displaydown\'
+ AND stat.start < :toDt
+ AND stat.end >= :fromDt
+ ';
+
+ $params = [
+ 'fromDt' => $fromDt->format('U'),
+ 'toDt' => $toDt->format('U')
+ ];
+
+ // Displays
+ if (count($displayIds) > 0) {
+ $select .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ // Type filter
+ if ($type == 'layout' && $layoutId != '') {
+ // Filter by Layout
+ $select .= '
+ AND `stat`.type = \'layout\'
+ AND `stat`.campaignId = (SELECT campaignId FROM layouthistory WHERE layoutId = :layoutId)
+ ';
+ $params['layoutId'] = $layoutId;
+ } elseif ($type == 'media' && $mediaId != '') {
+ // Filter by Media
+ $select .= '
+ AND `stat`.type = \'media\' AND IFNULL(`media`.mediaId, 0) <> 0
+ AND `stat`.mediaId = :mediaId ';
+ $params['mediaId'] = $mediaId;
+ } elseif ($type == 'event' && $eventTag != '') {
+ // Filter by Event
+ $select .= '
+ AND `stat`.type = \'event\'
+ AND `stat`.tag = :tag ';
+ $params['tag'] = $eventTag;
+ }
+
+ $select .= '
+ ) stat
+ ON statStart < periods.`end`
+ AND statEnd > periods.`start`
+ ';
+
+ // Periods and Stats tables are joined, we should only have periods we're interested in, but it
+ // won't hurt to restrict them
+ $select .= '
+ WHERE periods.`start` < :toDt
+ AND periods.`end` > :fromDt ';
+
+ // Close out our containing view and group things together
+ $select .= '
+ ) periodsWithStats
+ GROUP BY periodsWithStats.id, start, end
+ ORDER BY periodsWithStats.start
+ ';
+
+ return [
+ 'result' => $this->getStore()->select($select, $params),
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat())
+ ];
+ }
+
+ /**
+ * MongoDB summary report
+ * @param Carbon $fromDt The filter range from date
+ * @param Carbon $toDt The filter range to date
+ * @param string $groupByFilter Grouping, byhour, byday, byweek, bymonth
+ * @param $displayIds
+ * @param $type
+ * @param $layoutId
+ * @param $mediaId
+ * @param $eventTag
+ * @param $reportFilter
+ * @return array
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getSummaryReportMongoDb(
+ $fromDt,
+ $toDt,
+ $groupByFilter,
+ $displayIds,
+ $type,
+ $layoutId,
+ $mediaId,
+ $eventTag,
+ $reportFilter
+ ) {
+
+ $diffInDays = $toDt->diffInDays($fromDt);
+ if ($groupByFilter == 'byhour') {
+ $hour = 1;
+ $input = range(0, 23);
+ } elseif ($groupByFilter == 'byday') {
+ $hour = 24;
+ $input = range(0, $diffInDays - 1);
+ } elseif ($groupByFilter == 'byweek') {
+ $hour = 24 * 7;
+ $input = range(0, ceil($diffInDays / 7));
+ } elseif ($groupByFilter == 'bymonth') {
+ $hour = 24;
+ $input = range(0, ceil($diffInDays / 30));
+ } else {
+ $this->getLog()->error('Unknown Grouping Selected ' . $groupByFilter);
+ throw new InvalidArgumentException(__('Unknown Grouping ') . $groupByFilter, 'groupByFilter');
+ }
+
+ $filterRangeStart = new UTCDateTime($fromDt->format('U') * 1000);
+ $filterRangeEnd = new UTCDateTime($toDt->format('U') * 1000);
+
+ // Extend the range
+ if (($groupByFilter == 'byhour') || ($groupByFilter == 'byday')) {
+ $extendedPeriodStart = $filterRangeStart;
+ $extendedPeriodEnd = $filterRangeEnd;
+ } elseif ($groupByFilter == 'byweek') {
+ // Extend upto the start of the first week of the fromdt, and end of the week of the todt
+ $startOfWeek = $fromDt->copy()->locale(Translate::GetLocale())->startOfWeek();
+ $endOfWeek = $toDt->copy()->locale(Translate::GetLocale())->endOfWeek()->addSecond();
+ $extendedPeriodStart = new UTCDateTime($startOfWeek->format('U') * 1000);
+ $extendedPeriodEnd = new UTCDateTime($endOfWeek->format('U') * 1000);
+ } elseif ($groupByFilter == 'bymonth') {
+ if ($reportFilter == '') {
+ // We extend the fromDt and toDt range filter
+ // so that we can generate each month period
+ $fromDtStartOfMonth = $fromDt->copy()->startOfMonth();
+ $toDtEndOfMonth = $toDt->copy()->endOfMonth()->addSecond();
+
+ // Generate all months that lie in the extended range
+ $monthperiods = [];
+ foreach ($input as $key => $value) {
+ $monthPeriodStart = $fromDtStartOfMonth->copy()->addMonth($key);
+ $monthPeriodEnd = $fromDtStartOfMonth->copy()->addMonth($key)->addMonth();
+
+ // Remove the month period which crossed the extended end range
+ if ($monthPeriodStart >= $toDtEndOfMonth) {
+ continue;
+ }
+ $monthperiods[$key]['start'] = new UTCDateTime($monthPeriodStart->format('U') * 1000);
+ $monthperiods[$key]['end'] = new UTCDateTime($monthPeriodEnd->format('U') * 1000);
+ }
+
+ $extendedPeriodStart = new UTCDateTime($fromDtStartOfMonth->format('U') * 1000);
+ $extendedPeriodEnd = new UTCDateTime($toDtEndOfMonth->format('U') * 1000);
+ } elseif (($reportFilter == 'thisyear') || ($reportFilter == 'lastyear')) {
+ $extendedPeriodStart = $filterRangeStart;
+ $extendedPeriodEnd = $filterRangeEnd;
+
+ $start = $fromDt->copy()->subMonth()->startOfMonth();
+ $end = $fromDt->copy()->startOfMonth();
+
+ // Generate all 12 months
+ $monthperiods = [];
+ foreach ($input as $key => $value) {
+ $monthperiods[$key]['start'] = new UTCDateTime($start->addMonth()->format('U') * 1000);
+ $monthperiods[$key]['end'] = new UTCDateTime($end->addMonth()->format('U') * 1000);
+ }
+ }
+ }
+
+ $this->getLog()->debug('Period start: '
+ . $filterRangeStart->toDateTime()->format(DateFormatHelper::getSystemFormat())
+ . ' Period end: '. $filterRangeEnd->toDateTime()->format(DateFormatHelper::getSystemFormat()));
+
+ // Type filter
+ if (($type == 'layout') && ($layoutId != '')) {
+ // Get the campaign ID
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($layoutId);
+
+ $matchType = [
+ '$eq' => [ '$type', 'layout' ]
+ ];
+ $matchId = [
+ '$eq' => [ '$campaignId', $campaignId ]
+ ];
+ } elseif (($type == 'media') && ($mediaId != '')) {
+ $matchType = [
+ '$eq' => [ '$type', 'media' ]
+ ];
+ $matchId = [
+ '$eq' => [ '$mediaId', $mediaId ]
+ ];
+ } elseif (($type == 'event') && ($eventTag != '')) {
+ $matchType = [
+ '$eq' => [ '$type', 'event' ]
+ ];
+ $matchId = [
+ '$eq' => [ '$eventName', $eventTag ]
+ ];
+ } else {
+ throw new InvalidArgumentException(__('No match for event type'), 'type');
+ }
+
+ if ($groupByFilter == 'byweek') {
+ // PERIOD GENERATION
+ // Addition of 7 days from start
+ $projectMap = [
+ '$project' => [
+ 'periods' => [
+ '$map' => [
+ 'input' => $input,
+ 'as' => 'number',
+ 'in' => [
+ 'start' => [
+ '$add' => [
+ $extendedPeriodStart,
+ [
+ '$multiply' => [
+ '$$number',
+ $hour * 3600000
+ ]
+ ]
+ ]
+ ],
+ 'end' => [
+ '$add' => [
+ [
+ '$add' => [
+ $extendedPeriodStart,
+ $hour * 3600000
+ ]
+ ],
+ [
+ '$multiply' => [
+ '$$number',
+ $hour * 3600000
+ ]
+ ]
+ ]
+ ],
+ ]
+ ]
+ ]
+ ]
+ ];
+ } elseif ($groupByFilter == 'bymonth') {
+ $projectMap = [
+ '$project' => [
+ 'periods' => [
+ '$map' => [
+ 'input' => $monthperiods,
+ 'as' => 'number',
+ 'in' => [
+ 'start' => '$$number.start',
+ 'end' => '$$number.end',
+ ]
+ ]
+ ]
+ ]
+ ];
+ } else {
+ // PERIOD GENERATION
+ // Addition of 1 day/hour from start
+ $projectMap = [
+ '$project' => [
+ 'periods' => [
+ '$map' => [
+ 'input' => $input,
+ 'as' => 'number',
+ 'in' => [
+ 'start' => [
+ '$add' => [
+ $extendedPeriodStart,
+ [
+ '$multiply' => [
+ $hour * 3600000,
+ '$$number'
+ ]
+ ]
+ ]
+ ],
+ 'end' => [
+ '$add' => [
+ [
+ '$add' => [
+ $extendedPeriodStart,
+ [
+ '$multiply' => [
+ $hour * 3600000,
+ '$$number'
+ ]
+ ]
+ ]
+ ]
+ , $hour * 3600000
+ ]
+ ],
+ ]
+ ]
+ ]
+ ]
+ ];
+ }
+
+ // GROUP BY
+ $groupBy = [
+ 'period_start' => '$period_start',
+ 'period_end' => '$period_end'
+ ];
+
+ // PERIODS QUERY
+ $cursorPeriodQuery = [
+
+ $projectMap,
+
+ // periods needs to be unwind to merge next
+ [
+ '$unwind' => '$periods'
+ ],
+
+ // replace the root to eliminate _id and get only periods
+ [
+ '$replaceRoot' => [
+ 'newRoot' => '$periods'
+ ]
+ ],
+
+ [
+ '$project' => [
+ 'start' => 1,
+ 'end' => 1,
+ ]
+ ],
+
+ [
+ '$match' => [
+ 'start' => [
+ '$lt' => $extendedPeriodEnd
+ ],
+ 'end' => [
+ '$gt' => $extendedPeriodStart
+ ]
+ ]
+ ],
+
+ ];
+
+ // Periods result
+ $periods = $this->getTimeSeriesStore()->executeQuery([
+ 'collection' => $this->periodTable,
+ 'query' => $cursorPeriodQuery
+ ]);
+
+ $matchExpr = [
+
+ // match media id / layout id
+ $matchType,
+ $matchId,
+ ];
+
+ // display ids
+ if (count($displayIds) > 0) {
+ $matchExpr[] = [
+ '$in' => [ '$displayId', $displayIds ]
+ ];
+ }
+
+ // stat.start < period end AND stat.end > period start
+ // for example, when report filter 'today' is selected
+ // where start is less than last hour of the day + 1 hour (i.e., nextday of today)
+ // and end is greater than or equal first hour of the day
+ $matchExpr[] = [
+ '$lt' => [ '$start', '$statdata.periods.end' ]
+ ];
+ $matchExpr[] = [
+ '$gt' => [ '$end', '$statdata.periods.start' ]
+ ];
+
+
+ // STAT AGGREGATION QUERY
+ $statQuery = [
+
+ [
+ '$match' => [
+ 'start' => [
+ '$lt' => $filterRangeEnd
+ ],
+ 'end' => [
+ '$gt' => $filterRangeStart
+ ],
+ ]
+ ],
+
+ [
+ '$lookup' => [
+ 'from' => 'period',
+ 'let' => [
+ 'stat_start' => '$start',
+ 'stat_end' => '$end',
+ 'stat_duration' => '$duration',
+ 'stat_count' => '$count',
+ ],
+ 'pipeline' => [
+ $projectMap,
+ [
+ '$unwind' => '$periods'
+ ],
+
+ ],
+ 'as' => 'statdata'
+ ]
+ ],
+
+ [
+ '$unwind' => '$statdata'
+ ],
+
+ [
+ '$match' => [
+ '$expr' => [
+ '$and' => $matchExpr
+ ]
+ ]
+ ],
+
+ [
+ '$project' => [
+ '_id' => 1,
+ 'count' => 1,
+ 'duration' => 1,
+ 'start' => 1,
+ 'end' => 1,
+ 'period_start' => '$statdata.periods.start',
+ 'period_end' => '$statdata.periods.end',
+ 'monthNo' => [
+ '$month' => '$statdata.periods.start'
+ ],
+ 'yearDate' => [
+ '$isoWeekYear' => '$statdata.periods.start'
+ ],
+ 'weekNo' => [
+ '$isoWeek' => '$statdata.periods.start'
+ ],
+ 'actualStart' => [
+ '$max' => [ '$start', '$statdata.periods.start', $filterRangeStart ]
+ ],
+ 'actualEnd' => [
+ '$min' => [ '$end', '$statdata.periods.end', $filterRangeEnd ]
+ ],
+ 'actualDiff' => [
+ '$min' => [
+ '$duration',
+ [
+ '$divide' => [
+ [
+ '$subtract' => [
+ ['$min' => [ '$end', '$statdata.periods.end', $filterRangeEnd ]],
+ ['$max' => [ '$start', '$statdata.periods.start', $filterRangeStart ]]
+ ]
+ ], 1000
+ ]
+ ]
+ ]
+ ],
+
+ ]
+
+ ],
+
+ [
+ '$group' => [
+ '_id' => $groupBy,
+ 'period_start' => ['$first' => '$period_start'],
+ 'period_end' => ['$first' => '$period_end'],
+ 'NumberPlays' => ['$sum' => '$count'],
+ 'Duration' => ['$sum' => '$actualDiff'],
+ 'start' => ['$first' => '$start'],
+ 'end' => ['$first' => '$end'],
+ ]
+ ],
+
+ [
+ '$project' => [
+ 'start' => '$start',
+ 'end' => '$end',
+ 'period_start' => 1,
+ 'period_end' => 1,
+ 'NumberPlays' => ['$toInt' => '$NumberPlays'],
+ 'Duration' => ['$toInt' => '$Duration'],
+ ]
+ ],
+
+ ];
+
+ // Stats result
+ $results = $this->getTimeSeriesStore()->executeQuery([
+ 'collection' => $this->table,
+ 'query' => $statQuery
+ ]);
+
+ // Run period loop and map the stat result for each period
+ $resultArray = [];
+
+ foreach ($periods as $key => $period) {
+ // UTC date format
+ $period_start_u = $period['start']->toDateTime()->format('U');
+ $period_end_u = $period['end']->toDateTime()->format('U');
+
+ // CMS date
+ $period_start = Carbon::createFromTimestamp($period_start_u);
+ $period_end = Carbon::createFromTimestamp($period_end_u);
+
+ if ($groupByFilter == 'byhour') {
+ $label = $period_start->format('g:i A');
+ } elseif ($groupByFilter == 'byday') {
+ if (($reportFilter == 'thisweek') || ($reportFilter == 'lastweek')) {
+ $label = $period_start->format('D');
+ } else {
+ $label = $period_start->format('Y-m-d');
+ }
+ } elseif ($groupByFilter == 'byweek') {
+ $weekstart = $period_start->format('M d');
+ $weekend = $period_end->format('M d');
+ $weekno = $period_start->locale(Translate::GetLocale())->week();
+
+ if ($period_start_u < $fromDt->copy()->format('U')) {
+ $weekstart = $fromDt->copy()->format('M-d');
+ }
+
+ if ($period_end_u > $toDt->copy()->format('U')) {
+ $weekend = $toDt->copy()->format('M-d');
+ }
+ $label = $weekstart . ' - ' . $weekend . ' (w' . $weekno . ')';
+ } elseif ($groupByFilter == 'bymonth') {
+ $label = $period_start->format('M');
+ if ($reportFilter == '') {
+ $label .= ' ' .$period_start->format('Y');
+ }
+ } else {
+ $label = 'N/A';
+ }
+
+ $matched = false;
+ foreach ($results as $k => $result) {
+ if ($result['period_start'] == $period['start']) {
+ $NumberPlays = $result['NumberPlays'];
+ $Duration = $result['Duration'];
+ $matched = true;
+ break;
+ }
+ }
+
+ // Chart label
+ $resultArray[$key]['label'] = $label;
+ if ($matched == true) {
+ $resultArray[$key]['NumberPlays'] = $NumberPlays;
+ $resultArray[$key]['Duration'] = $Duration;
+ } else {
+ $resultArray[$key]['NumberPlays'] = 0;
+ $resultArray[$key]['Duration'] = 0;
+ }
+ }
+
+ return [
+ 'result' => $resultArray,
+ 'periodStart' => $fromDt->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => $toDt->format(DateFormatHelper::getSystemFormat())
+ ];
+ }
+}
diff --git a/lib/Report/TimeConnected.php b/lib/Report/TimeConnected.php
new file mode 100644
index 0000000..5dbbbe4
--- /dev/null
+++ b/lib/Report/TimeConnected.php
@@ -0,0 +1,394 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Translate;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class TimeConnected
+ * @package Xibo\Report
+ */
+class TimeConnected implements ReportInterface
+{
+ use ReportDefaultTrait;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var ReportServiceInterface
+ */
+ private $reportService;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->reportService = $container->get('reportService');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'timeconnected-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'timeconnected-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ $groups = [];
+ $displays = [];
+
+ foreach ($this->displayGroupFactory->query(['displayGroup'], ['isDisplaySpecific' => -1]) as $displayGroup) {
+ /* @var \Xibo\Entity\DisplayGroup $displayGroup */
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ $displays[] = $displayGroup;
+ } else {
+ $groups[] = $displayGroup;
+ }
+ }
+
+ return new ReportForm(
+ 'timeconnected-report-form',
+ 'timeconnected',
+ 'Display',
+ [
+ 'displays' => $displays,
+ 'displayGroups' => $groups,
+ 'fromDateOneDay' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ],
+ __('Select a type and an item (i.e., layout/media/tag)')
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data['hiddenFields'] = '{}';
+ $data['reportName'] = 'timeconnected';
+
+ $groups = [];
+ $displays = [];
+ foreach ($this->displayGroupFactory->query(['displayGroup'], ['isDisplaySpecific' => -1]) as $displayGroup) {
+ /* @var \Xibo\Entity\DisplayGroup $displayGroup */
+
+ if ($displayGroup->isDisplaySpecific == 1) {
+ $displays[] = $displayGroup;
+ } else {
+ $groups[] = $displayGroup;
+ }
+ }
+ $data['displays'] = $displays;
+ $data['displayGroups'] = $groups;
+
+ return [
+ 'template' => 'timeconnected-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupIds');
+ $hiddenFields = json_decode($sanitizedParams->getString('hiddenFields'), true);
+
+ $filterCriteria['displayGroupIds'] = $displayGroupIds;
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ $filterCriteria['groupByFilter'] = $groupByFilter;
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ return sprintf(__('%s report for Display', ucfirst($sanitizedParams->getString('filter'))));
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return $result;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ // Get an array of display id this user has access to.
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+
+ // From and To Date Selection
+ // --------------------------
+ // The report uses a custom range filter that automatically calculates the from/to dates
+ // depending on the date range selected.
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+
+ // Use the group by filter provided
+ // NB: this differs from the Summary Report where we set the group by according to the range selected
+ $groupByFilter = $sanitizedParams->getString('groupByFilter');
+
+ //
+ // Get Results!
+ // with keys "result", "periods", "periodStart", "periodEnd"
+ // -------------
+ $result = $this->getTimeDisconnectedMySql($fromDt, $toDt, $groupByFilter, $displayIds);
+
+ //
+ // Output Results
+ // --------------
+ if ($this->getUser()->isSuperAdmin()) {
+ $sql = 'SELECT displayId, display FROM display WHERE 1 = 1';
+ if (count($displayIds) > 0) {
+ $sql .= ' AND displayId IN (' . implode(',', $displayIds) . ')';
+ }
+ }
+
+ $timeConnected = [];
+ $displays = [];
+ $i = 0;
+ $key = 0;
+ foreach ($this->store->select($sql, []) as $row) {
+ $displayId = intval($row['displayId']);
+ $displayName = $row['display'];
+
+ // Set the display name for the displays in this row.
+ $displays[$key][$displayId] = $displayName;
+
+ // Go through each period
+ foreach ($result['periods'] as $resPeriods) {
+ //
+ $temp = $resPeriods['customLabel'];
+ if (empty($timeConnected[$temp][$displayId]['percent'])) {
+ $timeConnected[$key][$temp][$displayId]['percent'] = 100;
+ }
+ if (empty($timeConnected[$temp][$displayId]['label'])) {
+ $timeConnected[$key][$temp][$displayId]['label'] = $resPeriods['customLabel'];
+ }
+
+ foreach ($result['result'] as $res) {
+ if ($res['displayId'] == $displayId && $res['customLabel'] == $resPeriods['customLabel']) {
+ $timeConnected[$key][$temp][$displayId]['percent'] = 100 - round($res['percent'], 2);
+ $timeConnected[$key][$temp][$displayId]['label'] = $resPeriods['customLabel'];
+ } else {
+ continue;
+ }
+ }
+ }
+
+ $i++;
+ if ($i >= 3) {
+ $i = 0;
+ $key++;
+ }
+ }
+
+ // ----
+ // No grid
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ [
+ 'periodStart' => Carbon::createFromTimestamp($fromDt->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => Carbon::createFromTimestamp($toDt->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat()),
+ ],
+ [
+ 'timeConnected' => $timeConnected,
+ 'displays' => $displays
+ ]
+ );
+ }
+
+ /**
+ * MySQL distribution report
+ * @param Carbon $fromDt The filter range from date
+ * @param Carbon $toDt The filter range to date
+ * @param string $groupByFilter Grouping, byhour, bydayofweek and bydayofmonth
+ * @param array $displayIds
+ * @return array
+ */
+ private function getTimeDisconnectedMySql($fromDt, $toDt, $groupByFilter, $displayIds)
+ {
+
+ if ($groupByFilter == 'bydayofmonth') {
+ $customLabel = 'Y-m-d (D)';
+ } else {
+ $customLabel = 'Y-m-d g:i A';
+ }
+
+ // Create periods covering the from/to dates
+ // -----------------------------------------
+ try {
+ $periods = $this->getTemporaryPeriodsTable($fromDt, $toDt, $groupByFilter, 'temp_periods', $customLabel);
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ return [];
+ }
+ try {
+ $periods2 = $this->getTemporaryPeriodsTable($fromDt, $toDt, $groupByFilter, 'temp_periods2', $customLabel);
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ return [];
+ }
+
+ // Join in stats
+ // -------------
+ $query = '
+ SELECT periods.id,
+ periods.start,
+ periods.end,
+ periods.label,
+ periods.customLabel,
+ display,
+ displayId,
+ SUM(duration) AS downtime,
+ periods.end - periods.start AS periodDuration,
+ SUM(duration) / (periods.end - periods.start) * 100 AS percent
+ FROM ' . $periods2 . ' AS periods
+ INNER JOIN (
+ SELECT id,
+ label,
+ customLabel,
+ display,
+ displayId,
+ GREATEST(periods.start, down.start) AS actualStart,
+ LEAST(periods.end, down.end) AS actualEnd,
+ LEAST(periods.end, down.end) - GREATEST(periods.start, down.start) AS duration,
+ periods.end - periods.start AS periodDuration,
+ (LEAST(periods.end, down.end) - GREATEST(periods.start, down.start)) / (periods.end - periods.start) * 100 AS percent
+ FROM ' . $periods . ' AS periods
+ LEFT OUTER JOIN (
+ SELECT start,
+ IFNULL(end, UNIX_TIMESTAMP()) AS end,
+ displayevent.displayId,
+ display.display
+ FROM displayevent
+ INNER JOIN display
+ ON display.displayId = displayevent.displayId
+ WHERE `displayevent`.eventTypeId = 1
+ ';
+
+ // Displays
+ if (count($displayIds) > 0) {
+ $query .= ' AND display.displayID IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ $query .= '
+ ) down
+ ON down.start < periods.`end`
+ AND down.end > periods.`start`
+ ) joined
+ ON joined.customLabel = periods.customLabel
+ GROUP BY periods.id,
+ periods.start,
+ periods.end,
+ periods.label,
+ periods.customLabel,
+ joined.display,
+ joined.displayId
+ ORDER BY id, display
+ ';
+
+ return [
+ 'result' => $this->getStore()->select($query, []),
+ 'periods' => $this->getStore()->select('SELECT * from ' . $periods, []),
+ 'periodStart' => $fromDt->format('Y-m-d H:i:s'),
+ 'periodEnd' => $toDt->format('Y-m-d H:i:s')
+ ];
+ }
+}
diff --git a/lib/Report/TimeDisconnectedSummary.php b/lib/Report/TimeDisconnectedSummary.php
new file mode 100644
index 0000000..f4afcf9
--- /dev/null
+++ b/lib/Report/TimeDisconnectedSummary.php
@@ -0,0 +1,564 @@
+.
+ */
+
+namespace Xibo\Report;
+
+use Carbon\Carbon;
+use Psr\Container\ContainerInterface;
+use Xibo\Controller\DataTablesDotNetTrait;
+use Xibo\Entity\ReportForm;
+use Xibo\Entity\ReportResult;
+use Xibo\Entity\ReportSchedule;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Translate;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class TimeDisconnectedSummary
+ * @package Xibo\Report
+ */
+class TimeDisconnectedSummary implements ReportInterface
+{
+ use ReportDefaultTrait, DataTablesDotNetTrait;
+
+ /**
+ * @var DisplayFactory
+ */
+ private $displayFactory;
+
+ /**
+ * @var DisplayGroupFactory
+ */
+ private $displayGroupFactory;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /** @inheritdoc */
+ public function setFactories(ContainerInterface $container)
+ {
+ $this->displayFactory = $container->get('displayFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->sanitizer = $container->get('sanitizerService');
+ $this->state = $container->get('state');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getReportChartScript($results)
+ {
+ return json_encode($results->chart);
+ }
+
+ /** @inheritdoc */
+ public function getReportEmailTemplate()
+ {
+ return 'timedisconnectedsummary-email-template.twig';
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportTemplate()
+ {
+ return 'timedisconnectedsummary-report-preview';
+ }
+
+ /** @inheritdoc */
+ public function getReportForm()
+ {
+ return new ReportForm(
+ 'timedisconnectedsummary-report-form',
+ 'timedisconnectedsummary',
+ 'Display',
+ [
+ 'fromDate' => Carbon::now()->subSeconds(86400 * 35)->format(DateFormatHelper::getSystemFormat()),
+ 'toDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ]
+ );
+ }
+
+ /** @inheritdoc */
+ public function getReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $data = [];
+ $data['reportName'] = 'timedisconnectedsummary';
+
+ return [
+ 'template' => 'timedisconnectedsummary-schedule-form-add',
+ 'data' => $data
+ ];
+ }
+
+ /** @inheritdoc */
+ public function setReportScheduleFormData(SanitizerInterface $sanitizedParams)
+ {
+ $filter = $sanitizedParams->getString('filter');
+ $displayId = $sanitizedParams->getInt('displayId');
+ $displayGroupIds = $sanitizedParams->getIntArray('displayGroupId', ['default' => []]);
+
+ $filterCriteria['displayId'] = $displayId;
+ if (empty($displayId) && count($displayGroupIds) > 0) {
+ $filterCriteria['displayGroupId'] = $displayGroupIds;
+ }
+ $filterCriteria['filter'] = $filter;
+
+ $schedule = '';
+ if ($filter == 'daily') {
+ $schedule = ReportSchedule::$SCHEDULE_DAILY;
+ $filterCriteria['reportFilter'] = 'yesterday';
+ } elseif ($filter == 'weekly') {
+ $schedule = ReportSchedule::$SCHEDULE_WEEKLY;
+ $filterCriteria['reportFilter'] = 'lastweek';
+ } elseif ($filter == 'monthly') {
+ $schedule = ReportSchedule::$SCHEDULE_MONTHLY;
+ $filterCriteria['reportFilter'] = 'lastmonth';
+ } elseif ($filter == 'yearly') {
+ $schedule = ReportSchedule::$SCHEDULE_YEARLY;
+ $filterCriteria['reportFilter'] = 'lastyear';
+ }
+
+ $filterCriteria['sendEmail'] = $sanitizedParams->getCheckbox('sendEmail');
+ $filterCriteria['nonusers'] = $sanitizedParams->getString('nonusers');
+
+ // Return
+ return [
+ 'filterCriteria' => json_encode($filterCriteria),
+ 'schedule' => $schedule
+ ];
+ }
+
+ /** @inheritdoc */
+ public function generateSavedReportName(SanitizerInterface $sanitizedParams)
+ {
+ return sprintf(__('%s time disconnected summary report', ucfirst($sanitizedParams->getString('filter'))));
+ }
+
+ /** @inheritdoc */
+ public function restructureSavedReportOldJson($result)
+ {
+ return $result;
+ }
+
+ /** @inheritdoc */
+ public function getSavedReportResults($json, $savedReport)
+ {
+ $metadata = [
+ 'periodStart' => $json['metadata']['periodStart'],
+ 'periodEnd' => $json['metadata']['periodEnd'],
+ 'generatedOn' => Carbon::createFromTimestamp($savedReport->generatedOn)
+ ->format(DateFormatHelper::getSystemFormat()),
+ 'title' => $savedReport->saveAs,
+ ];
+
+ // Report result object
+ return new ReportResult(
+ $metadata,
+ $json['table'],
+ $json['recordsTotal'],
+ $json['chart']
+ );
+ }
+
+ /** @inheritdoc */
+ public function getResults(SanitizerInterface $sanitizedParams)
+ {
+ // Filter by displayId?
+ $displayIds = $this->getDisplayIdFilter($sanitizedParams);
+
+ $tags = $sanitizedParams->getString('tags');
+ $onlyLoggedIn = $sanitizedParams->getCheckbox('onlyLoggedIn') == 1;
+ $groupBy = $sanitizedParams->getString('groupBy');
+
+ //
+ // From and To Date Selection
+ // --------------------------
+ // The report uses a custom range filter that automatically calculates the from/to dates
+ // depending on the date range selected.
+ $fromDt = $sanitizedParams->getDate('fromDt');
+ $toDt = $sanitizedParams->getDate('toDt');
+ $currentDate = Carbon::now()->startOfDay();
+
+ // If toDt is current date then make it current datetime
+ if ($toDt->format('Y-m-d') == $currentDate->format('Y-m-d')) {
+ $toDt = Carbon::now();
+ }
+
+ // Get an array of display groups this user has access to
+ $displayGroupIds = [];
+
+ foreach ($this->displayGroupFactory->query(null, [
+ 'isDisplaySpecific' => -1,
+ 'userCheckUserId' => $this->getUser()->userId
+ ]) as $displayGroup) {
+ $displayGroupIds[] = $displayGroup->displayGroupId;
+ }
+
+ if (count($displayGroupIds) <= 0) {
+ throw new InvalidArgumentException(__('No display groups with View permissions'), 'displayGroup');
+ }
+
+ $params = array(
+ 'start' => $fromDt->format('U'),
+ 'end' => $toDt->format('U')
+ );
+
+ // Disconnected Displays Query
+ $select = '
+ SELECT display.display, display.displayId,
+ SUM(LEAST(IFNULL(`end`, :end), :end) - GREATEST(`start`, :start)) AS duration,
+ :end - :start as filter ';
+
+ $body = 'FROM `displayevent`
+ INNER JOIN `display`
+ ON display.displayId = `displayevent`.displayId
+ WHERE `start` <= :end
+ AND IFNULL(`end`, :end) >= :start
+ AND :end <= UNIX_TIMESTAMP(NOW())
+ AND `displayevent`.eventTypeId = 1 ';
+
+ if (count($displayIds) > 0) {
+ $body .= 'AND display.displayId IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ if ($onlyLoggedIn) {
+ $body .= ' AND `display`.loggedIn = 1 ';
+ }
+
+ $body .= '
+ GROUP BY display.display, display.displayId
+ ';
+
+ $sql = $select . $body;
+ $maxDuration = 0;
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ $maxDuration = $maxDuration + $this->sanitizer->getSanitizer($row)->getDouble('duration');
+ }
+
+ if ($maxDuration > 86400) {
+ $postUnits = __('Days');
+ $divisor = 86400;
+ } elseif ($maxDuration > 3600) {
+ $postUnits = __('Hours');
+ $divisor = 3600;
+ } else {
+ $postUnits = __('Minutes');
+ $divisor = 60;
+ }
+
+ // Tabular Data
+ $disconnectedDisplays = [];
+ foreach ($this->store->select($sql, $params) as $row) {
+ $sanitizedRow = $this->sanitizer->getSanitizer($row);
+
+ $entry = [];
+ $entry['timeDisconnected'] = round($sanitizedRow->getDouble('duration') / $divisor, 2);
+ $entry['timeConnected'] = round($sanitizedRow->getDouble('filter') / $divisor - $entry['timeDisconnected'], 2);
+ $disconnectedDisplays[$sanitizedRow->getInt(('displayId'))] = $entry;
+ }
+
+ // Displays with filters such as tags
+ $displaySelect = '
+ SELECT display.display, display.displayId ';
+
+ if ($tags != '') {
+ $displaySelect .= ', (SELECT GROUP_CONCAT(DISTINCT tag)
+ FROM tag
+ INNER JOIN lktagdisplaygroup
+ ON lktagdisplaygroup.tagId = tag.tagId
+ WHERE lktagdisplaygroup.displayGroupId = displaygroup.DisplayGroupID
+ GROUP BY lktagdisplaygroup.displayGroupId) AS tags ';
+ }
+
+ $displayBody = 'FROM `display` ';
+
+ if ($groupBy === 'displayGroup') {
+ $displaySelect .= ', displaydg.displayGroup, displaydg.displayGroupId ';
+ }
+
+ if ($tags != '') {
+ $displayBody .= 'INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayID = display.displayid
+ INNER JOIN `displaygroup`
+ ON displaygroup.displaygroupId = lkdisplaydg.displaygroupId
+ AND `displaygroup`.isDisplaySpecific = 1 ';
+ }
+
+ // Grouping Option
+ if ($groupBy === 'displayGroup') {
+ $displayBody .= 'INNER JOIN `lkdisplaydg` AS linkdg
+ ON linkdg.DisplayID = display.displayid
+ INNER JOIN `displaygroup` AS displaydg
+ ON displaydg.displaygroupId = linkdg.displaygroupId
+ AND `displaydg`.isDisplaySpecific = 0 ';
+ }
+
+ $displayBody .= 'WHERE 1 = 1 ';
+
+ if (count($displayIds) > 0) {
+ $displayBody .= 'AND display.displayId IN (' . implode(',', $displayIds) . ') ';
+ }
+
+ $tagParams = [];
+
+ if ($tags != '') {
+ if (trim($tags) === '--no-tag') {
+ $displayBody .= ' AND `displaygroup`.displaygroupId NOT IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ )
+ ';
+ } else {
+ $operator = $sanitizedParams->getCheckbox('exactTags') == 1 ? '=' : 'LIKE';
+
+ $displayBody .= ' AND `displaygroup`.displaygroupId IN (
+ SELECT `lktagdisplaygroup`.displaygroupId
+ FROM tag
+ INNER JOIN `lktagdisplaygroup`
+ ON `lktagdisplaygroup`.tagId = tag.tagId
+ ';
+ $i = 0;
+
+ foreach (explode(',', $tags) as $tag) {
+ $i++;
+
+ if ($i == 1) {
+ $displayBody .= ' WHERE `tag` ' . $operator . ' :tags' . $i;
+ } else {
+ $displayBody .= ' OR `tag` ' . $operator . ' :tags' . $i;
+ }
+
+ if ($operator === '=') {
+ $tagParams['tags' . $i] = $tag;
+ } else {
+ $tagParams['tags' . $i] = '%' . $tag . '%';
+ }
+ }
+
+ $displayBody .= ' ) ';
+ }
+ }
+
+ if ($onlyLoggedIn) {
+ $displayBody .= ' AND `display`.loggedIn = 1 ';
+ }
+
+ $displayBody .= '
+ GROUP BY display.display, display.displayId
+ ';
+
+ if ($tags != '') {
+ $displayBody .= ', displaygroup.displayGroupId ';
+ }
+
+ if ($groupBy === 'displayGroup') {
+ $displayBody .= ', displaydg.displayGroupId ';
+ }
+
+ // Sorting?
+ $sortOrder = $this->gridRenderSort($sanitizedParams);
+
+ $order = '';
+ if (is_array($sortOrder)) {
+ $order .= 'ORDER BY ' . implode(',', $sortOrder);
+ }
+
+ // Get a list of displays by filters
+ $displaySql = $displaySelect . $displayBody . $order;
+ $rows = [];
+
+ // Retrieve the disconnected/connected time from the $disconnectedDisplays array into displays
+ foreach ($this->store->select($displaySql, $tagParams) as $displayRow) {
+ $sanitizedDisplayRow = $this->sanitizer->getSanitizer($displayRow);
+ $entry = [];
+ $displayId = $sanitizedDisplayRow->getInt(('displayId'));
+ $entry['displayId'] = $displayId;
+ $entry['display'] = $sanitizedDisplayRow->getString(('display'));
+ $entry['timeDisconnected'] = $disconnectedDisplays[$displayId]['timeDisconnected'] ?? 0 ;
+ $entry['timeConnected'] = $disconnectedDisplays[$displayId]['timeConnected'] ?? round(($toDt->format('U') - $fromDt->format('U')) / $divisor, 2);
+ $entry['postUnits'] = $postUnits;
+ $entry['displayGroupId'] = $sanitizedDisplayRow->getInt(('displayGroupId'));
+ $entry['displayGroup'] = $sanitizedDisplayRow->getString(('displayGroup'));
+ $entry['avgTimeDisconnected'] = 0;
+ $entry['avgTimeConnected'] = 0;
+ $entry['availabilityPercentage'] = $this->getAvailabilityPercentage(
+ $entry['timeConnected'],
+ $entry['timeDisconnected']
+ ) . '%';
+
+ $rows[] = $entry;
+ }
+
+ //
+ // Output Results
+ // --------------
+
+ $availabilityData = [];
+ $availabilityDataConnected = [];
+ $availabilityLabels = [];
+ $postUnits = '';
+
+ if ($groupBy === 'displayGroup') {
+ $rows = $this->getByDisplayGroup($rows, $sanitizedParams->getIntArray('displayGroupId', ['default' => []]));
+ }
+
+ foreach ($rows as $row) {
+ $availabilityData[] = $row['timeDisconnected'];
+ $availabilityDataConnected[] = $row['timeConnected'];
+ $availabilityLabels[] = ($groupBy === 'displayGroup')
+ ? $row['displayGroup']
+ : $row['display'];
+ $postUnits = $row['postUnits'];
+ }
+
+ // Build Chart to pass in twig file chart.js
+ $chart = [
+ 'type' => 'bar',
+ 'data' => [
+ 'labels' => $availabilityLabels,
+ 'datasets' => [
+ [
+ 'backgroundColor' => 'rgb(11, 98, 164)',
+ 'data' => $availabilityData,
+ 'label' => __('Downtime')
+ ],
+ [
+ 'backgroundColor' => 'rgb(0, 255, 0)',
+ 'data' => $availabilityDataConnected,
+ 'label' => __('Uptime')
+ ]
+ ]
+ ],
+ 'options' => [
+
+ 'scales' => [
+ 'xAxes' => [
+ [
+ 'stacked' => true
+ ]
+ ],
+ 'yAxes' => [
+ [
+ 'stacked' => true,
+ 'scaleLabel' => [
+ 'display' => true,
+ 'labelString' => $postUnits
+ ]
+ ]
+ ]
+ ],
+ 'legend' => [
+ 'display' => false
+ ],
+ 'maintainAspectRatio' => false
+ ]
+ ];
+
+ $metadata = [
+ 'periodStart' => Carbon::createFromTimestamp($fromDt->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat()),
+ 'periodEnd' => Carbon::createFromTimestamp($toDt->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat()),
+ ];
+
+ // ----
+ // Return data to build chart/table
+ // This will get saved to a json file when schedule runs
+ return new ReportResult(
+ $metadata,
+ $rows,
+ count($rows),
+ $chart
+ );
+ }
+
+ /**
+ * Get the Availability Percentage
+ * @param float $connectedTime
+ * @param float $disconnectedTime
+ * @return float
+ */
+ private function getAvailabilityPercentage(float $connectedTime, float $disconnectedTime) : float
+ {
+ $connectedPercentage = $connectedTime/($connectedTime + $disconnectedTime ?: 1);
+
+ return abs(round($connectedPercentage * 100, 2));
+ }
+
+ /**
+ * Get the accumulated value by display groups
+ * @param array $rows
+ * @param array $displayGroupIds
+ * @return array
+ */
+ private function getByDisplayGroup(array $rows, array $displayGroupIds = []) : array
+ {
+ $data = [];
+ $displayGroups = [];
+
+ // Get the accumulated values by displayGroupId
+ foreach ($rows as $row) {
+ $displayGroupId = $row['displayGroupId'];
+
+ if (isset($displayGroups[$displayGroupId])) {
+ $displayGroups[$displayGroupId]['timeDisconnected'] += $row['timeDisconnected'];
+ $displayGroups[$displayGroupId]['timeConnected'] += $row['timeConnected'];
+ $displayGroups[$displayGroupId]['count'] += 1;
+ } else {
+ $row['count'] = 1;
+ $displayGroups[$displayGroupId] = $row;
+ }
+ }
+
+ // Get all display groups or selected display groups only
+ foreach ($displayGroups as $displayGroup) {
+ if (!$displayGroupIds || in_array($displayGroup['displayGroupId'], $displayGroupIds)) {
+ $displayGroup['timeConnected'] = round($displayGroup['timeConnected'], 2);
+ $displayGroup['timeDisconnected'] = round($displayGroup['timeDisconnected'], 2);
+ $displayGroup['availabilityPercentage'] = $this->getAvailabilityPercentage(
+ $displayGroup['timeConnected'],
+ $displayGroup['timeDisconnected']
+ ) . '%';
+
+ // Calculate the average values
+ $displayGroup['avgTimeConnected'] = round($displayGroup['timeConnected'] / $displayGroup['count'], 2);
+ $displayGroup['avgTimeDisconnected'] = round($displayGroup['timeDisconnected'] / $displayGroup['count'], 2);
+
+ $data[] = $displayGroup;
+ }
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/Service/BaseDependenciesService.php b/lib/Service/BaseDependenciesService.php
new file mode 100644
index 0000000..2e05269
--- /dev/null
+++ b/lib/Service/BaseDependenciesService.php
@@ -0,0 +1,174 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Psr\Log\NullLogger;
+use Slim\Views\Twig;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\NullSanitizer;
+use Xibo\Helper\NullView;
+use Xibo\Helper\SanitizerService;
+use Xibo\Storage\PdoStorageService;
+
+class BaseDependenciesService
+{
+ /**
+ * @var LogServiceInterface
+ */
+ private $log;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizerService;
+
+ /**
+ * @var ApplicationState
+ */
+ private $state;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $configService;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var Twig
+ */
+ private $view;
+
+ /**
+ * @var PdoStorageService
+ */
+ private $storageService;
+
+ /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */
+ private $dispatcher;
+
+ public function setLogger(LogServiceInterface $logService)
+ {
+ $this->log = $logService;
+ }
+
+ /**
+ * @return LogServiceInterface
+ */
+ public function getLogger()
+ {
+ if ($this->log === null) {
+ $this->log = new NullLogService(new NullLogger());
+ }
+
+ return $this->log;
+ }
+
+ public function setSanitizer(SanitizerService $sanitizerService)
+ {
+ $this->sanitizerService = $sanitizerService;
+ }
+
+ public function getSanitizer(): SanitizerService
+ {
+ if ($this->sanitizerService === null) {
+ $this->sanitizerService = new NullSanitizer();
+ }
+
+ return $this->sanitizerService;
+ }
+
+ public function setState(ApplicationState $applicationState)
+ {
+ $this->state = $applicationState;
+ }
+
+ public function getState(): ApplicationState
+ {
+ return $this->state;
+ }
+
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+ }
+
+ public function getUser(): User
+ {
+ return $this->user;
+ }
+
+ public function setConfig(ConfigServiceInterface $configService)
+ {
+ $this->configService = $configService;
+ }
+
+ public function getConfig() : ConfigServiceInterface
+ {
+ return $this->configService;
+ }
+
+ public function setView(Twig $view)
+ {
+ $this->view = $view;
+ }
+
+ public function getView() : Twig
+ {
+ if ($this->view === null) {
+ $this->view = new NullView();
+ }
+ return $this->view;
+ }
+
+ public function setStore(PdoStorageService $storageService)
+ {
+ $this->storageService = $storageService;
+ }
+
+ public function getStore()
+ {
+ return $this->storageService;
+ }
+
+ public function setDispatcher(EventDispatcherInterface $dispatcher): BaseDependenciesService
+ {
+ $this->dispatcher = $dispatcher;
+ return $this;
+ }
+
+ public function getDispatcher(): EventDispatcherInterface
+ {
+ if ($this->dispatcher === null) {
+ $this->getLogger()->error('getDispatcher: [base] No dispatcher found, returning an empty one');
+ $this->dispatcher = new EventDispatcher();
+ }
+ return $this->dispatcher;
+ }
+}
diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php
new file mode 100644
index 0000000..3d80b91
--- /dev/null
+++ b/lib/Service/ConfigService.php
@@ -0,0 +1,817 @@
+.
+ */
+namespace Xibo\Service;
+
+use Carbon\Carbon;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Helper\Environment;
+use Xibo\Helper\NatoAlphabet;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+
+/**
+ * Class ConfigService
+ * @package Xibo\Service
+ */
+class ConfigService implements ConfigServiceInterface
+{
+ /**
+ * @var StorageServiceInterface
+ */
+ public $store;
+
+ /**
+ * @var PoolInterface
+ */
+ public $pool;
+
+ /** @var string Setting Cache Key */
+ private $settingCacheKey = 'settings';
+
+ /** @var bool Has the settings cache been dropped this request? */
+ private $settingsCacheDropped = false;
+
+ /** @var array */
+ private $settings = null;
+
+ /**
+ * @var string
+ */
+ public $rootUri;
+
+ public $envTested = false;
+ public $envFault = false;
+ public $envWarning = false;
+
+ /**
+ * Database Config
+ * @var array
+ */
+ public static $dbConfig = [];
+
+ //
+ // Extra Settings
+ //
+ public $middleware = null;
+ public $logHandlers = null;
+ public $logProcessors = null;
+ public $authentication = null;
+ public $samlSettings = null;
+ public $casSettings = null;
+ public $cacheDrivers = null;
+ public $timeSeriesStore = null;
+ public $cacheNamespace = 'Xibo';
+ private $apiKeyPaths = null;
+ private $connectorSettings = null;
+
+ /**
+ * Theme Specific Config
+ * @var array
+ */
+ public $themeConfig = [];
+
+ /** @var bool Has a theme been loaded? */
+ private $themeLoaded = false;
+
+ /**
+ * @inheritdoc
+ */
+ public function setDependencies($store, $rootUri)
+ {
+ if ($store == null)
+ throw new \RuntimeException('ConfigService setDependencies called with null store');
+
+ if ($rootUri == null)
+ throw new \RuntimeException('ConfigService setDependencies called with null rootUri');
+
+ $this->store = $store;
+ $this->rootUri = $rootUri;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setPool($pool)
+ {
+ $this->pool = $pool;
+ }
+
+ /**
+ * Get Cache Pool
+ * @return \Stash\Interfaces\PoolInterface
+ */
+ private function getPool()
+ {
+ return $this->pool;
+ }
+
+ /**
+ * Get Store
+ * @return StorageServiceInterface
+ */
+ protected function getStore()
+ {
+ if ($this->store == null)
+ throw new \RuntimeException('Config Service called before setDependencies');
+
+ return $this->store;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDatabaseConfig()
+ {
+ return self::$dbConfig;
+ }
+
+ /**
+ * Get App Root URI
+ * @return string
+ */
+ public function rootUri()
+ {
+ if ($this->rootUri == null)
+ throw new \RuntimeException('Config Service called before setDependencies');
+
+ return $this->rootUri;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getCacheDrivers()
+ {
+ return $this->cacheDrivers;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getTimeSeriesStore()
+ {
+ return $this->timeSeriesStore;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getCacheNamespace()
+ {
+ return $this->cacheNamespace;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getConnectorSettings(string $connector): array
+ {
+ if ($this->connectorSettings !== null && array_key_exists($connector, $this->connectorSettings)) {
+ return $this->connectorSettings[$connector];
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Loads the settings from file.
+ * DO NOT CALL ANY STORE() METHODS IN HERE
+ * @param \Psr\Container\ContainerInterface $container DI container which may be used in settings.php
+ * @param string $settings Settings Path
+ * @return ConfigServiceInterface
+ */
+ public static function Load($container, string $settings)
+ {
+ $config = new ConfigService();
+
+ // Include the provided settings file.
+ require ($settings);
+
+ // Create a DB config
+ self::$dbConfig = [
+ 'host' => $dbhost,
+ 'user' => $dbuser,
+ 'password' => $dbpass,
+ 'name' => $dbname,
+ 'ssl' => $dbssl ?? null,
+ 'sslVerify' => $dbsslverify ?? null
+ ];
+
+ // Pull in other settings
+
+ // Log handlers
+ if (isset($logHandlers))
+ $config->logHandlers = $logHandlers;
+
+ // Log Processors
+ if (isset($logProcessors))
+ $config->logProcessors = $logProcessors;
+
+ // Middleware
+ if (isset($middleware))
+ $config->middleware = $middleware;
+
+ // Authentication
+ if (isset($authentication))
+ $config->authentication = $authentication;
+
+ // Saml settings
+ if (isset($samlSettings))
+ $config->samlSettings = $samlSettings;
+
+ // CAS settings
+ if (isset($casSettings))
+ $config->casSettings = $casSettings;
+
+ // Cache drivers
+ if (isset($cacheDrivers))
+ $config->cacheDrivers = $cacheDrivers;
+
+ // Time series store settings
+ if (isset($timeSeriesStore))
+ $config->timeSeriesStore = $timeSeriesStore;
+
+ if (isset($cacheNamespace))
+ $config->cacheNamespace = $cacheNamespace;
+
+ if (isset($apiKeyPaths))
+ $config->apiKeyPaths = $apiKeyPaths;
+
+ // Connector settings
+ if (isset($connectorSettings)) {
+ $config->connectorSettings = $connectorSettings;
+ }
+
+ // Set this as the global config
+ return $config;
+ }
+
+ /**
+ * Loads the theme
+ * @param string|null $themeName
+ * @throws ConfigurationException
+ */
+ public function loadTheme($themeName = null): void
+ {
+ global $config;
+
+ // What is the currently selected theme?
+ $globalTheme = ($themeName == null)
+ ? basename($this->getSetting('GLOBAL_THEME_NAME', 'default'))
+ : $themeName;
+
+ // Is this theme valid?
+ $systemTheme = (is_dir(PROJECT_ROOT . '/web/theme/' . $globalTheme)
+ && file_exists(PROJECT_ROOT . '/web/theme/' . $globalTheme . '/config.php'));
+ $customTheme = (is_dir(PROJECT_ROOT . '/web/theme/custom/' . $globalTheme)
+ && file_exists(PROJECT_ROOT . '/web/theme/custom/' . $globalTheme . '/config.php'));
+
+ if ($systemTheme) {
+ require(PROJECT_ROOT . '/web/theme/' . $globalTheme . '/config.php');
+ $themeFolder = 'theme/' . $globalTheme . '/';
+ } elseif ($customTheme) {
+ require(PROJECT_ROOT . '/web/theme/custom/' . $globalTheme . '/config.php');
+ $themeFolder = 'theme/custom/' . $globalTheme . '/';
+ } else {
+ throw new ConfigurationException(__('The theme "%s" does not exist', $globalTheme));
+ }
+
+ $this->themeLoaded = true;
+ $this->themeConfig = $config;
+ $this->themeConfig['themeCode'] = $globalTheme;
+ $this->themeConfig['themeFolder'] = $themeFolder;
+ }
+
+ /**
+ * Get Theme Specific Settings
+ * @param null $settingName
+ * @param null $default
+ * @return mixed|array|string
+ */
+ public function getThemeConfig($settingName = null, $default = null)
+ {
+ if ($settingName == null)
+ return $this->themeConfig;
+
+ if (isset($this->themeConfig[$settingName]))
+ return $this->themeConfig[$settingName];
+ else
+ return $default;
+ }
+
+ /**
+ * Get theme URI
+ * @param string $uri
+ * @param bool $local
+ * @return string
+ */
+ public function uri($uri, $local = false)
+ {
+ $rootUri = ($local) ? PROJECT_ROOT . '/web/' : $this->rootUri();
+
+ if (!$this->themeLoaded)
+ return $rootUri . 'theme/default/' . $uri;
+
+ // Serve the appropriate theme file
+ if (is_dir(PROJECT_ROOT . '/web/' . $this->themeConfig['themeFolder'] . $uri)) {
+ return $rootUri . $this->themeConfig['themeFolder'] . $uri;
+ }
+ else if (file_exists(PROJECT_ROOT . '/web/' . $this->themeConfig['themeFolder'] . $uri)) {
+ return $rootUri . $this->themeConfig['themeFolder'] . $uri;
+ }
+ else {
+ return $rootUri . 'theme/default/' . $uri;
+ }
+ }
+
+ /**
+ * Check a theme file exists
+ * @param string $uri
+ * @return string
+ */
+ public function fileExists($uri)
+ {
+ // Serve the appropriate file
+ return file_exists(PROJECT_ROOT . '/web/' . $uri);
+ }
+
+ /**
+ * Check a theme file exists
+ * @param string $uri
+ * @return string
+ */
+ public function themeFileExists($uri)
+ {
+ if (!$this->themeLoaded)
+ return file_exists(PROJECT_ROOT . '/web/theme/default/' . $uri);
+
+ // Serve the appropriate theme file
+ if (file_exists(PROJECT_ROOT . '/web/' . $this->themeConfig['themeFolder'] . $uri)) {
+ return true;
+ } else {
+ return file_exists(PROJECT_ROOT . '/web/theme/default/' . $uri);
+ }
+ }
+
+ /**
+ * @return array|mixed|null
+ */
+ private function loadSettings()
+ {
+ $item = null;
+
+ if ($this->settings === null) {
+ // We need to load in our settings
+ if ($this->getPool() !== null) {
+ // Try the cache
+ $item = $this->getPool()->getItem($this->settingCacheKey);
+
+ $data = $item->get();
+
+ if ($item->isHit()) {
+ $this->settings = $data;
+ }
+ }
+
+ // Are we still null?
+ if ($this->settings === null) {
+ // Load from the database
+ $this->settings = $this->getStore()->select('SELECT `setting`, `value`, `userSee`, `userChange` FROM `setting`', []);
+ }
+ }
+
+ // We should have our settings by now, so cache them if we can/need to
+ if ($item !== null && $item->isMiss()) {
+ // See about caching these settings - dependent on whether we're logging or not
+ $cacheExpiry = 60 * 5;
+ foreach ($this->settings as $setting) {
+ if ($setting['setting'] == 'ELEVATE_LOG_UNTIL' && intval($setting['value']) > Carbon::now()->format('U')) {
+ $cacheExpiry = intval($setting['value']);
+ break;
+ }
+ }
+
+ $item->set($this->settings);
+ $item->expiresAfter($cacheExpiry);
+ $this->getPool()->saveDeferred($item);
+ }
+
+ return $this->settings;
+ }
+
+ /** @inheritdoc */
+ public function getSettings()
+ {
+ $settings = $this->loadSettings();
+ $parsed = [];
+
+ // Go through each setting and create a key/value pair
+ foreach ($settings as $setting) {
+ $parsed[$setting['setting']] = $setting['value'];
+ }
+
+ return $parsed;
+ }
+
+ /** @inheritdoc */
+ public function getSetting($setting, $default = NULL, $full = false)
+ {
+ $settings = $this->loadSettings();
+
+ if ($full) {
+ foreach ($settings as $item) {
+ if ($item['setting'] == $setting) {
+ return $item;
+ }
+ }
+
+ return [
+ 'setting' => $setting,
+ 'value' => $default,
+ 'userSee' => 1,
+ 'userChange' => 1
+ ];
+ } else {
+ $settings = $this->getSettings();
+ return (isset($settings[$setting])) ? $settings[$setting] : $default;
+ }
+ }
+
+ /** @inheritdoc */
+ public function changeSetting($setting, $value, $userChange = 0)
+ {
+ $settings = $this->getSettings();
+
+ // Update in memory cache
+ foreach ($this->settings as $item) {
+ if ($item['setting'] == $setting) {
+ $item['value'] = $value;
+ break;
+ }
+ }
+
+ if (isset($settings[$setting])) {
+ // We've already got this setting recorded, update it for
+ // Update in database
+ $this->getStore()->update('UPDATE `setting` SET `value` = :value WHERE `setting` = :setting', [
+ 'setting' => $setting,
+ 'value' => ($value === null) ? '' : $value
+ ]);
+ } else {
+ // A new setting we've not seen before.
+ // record it in the settings table.
+ $this->getStore()->insert('
+ INSERT INTO `setting` (`value`, setting, `userChange`) VALUES (:value, :setting, :userChange);', [
+ 'setting' => $setting,
+ 'value' => ($value === null) ? '' : $value,
+ 'userChange' => $userChange
+ ]);
+ }
+
+ // Drop the cache if we've not already done so this time around
+ if (!$this->settingsCacheDropped && $this->getPool() !== null) {
+ $this->getPool()->deleteItem($this->settingCacheKey);
+ $this->settingsCacheDropped = true;
+ $this->settings = null;
+ }
+ }
+
+ /**
+ * Is the provided setting visible
+ * @param string $setting
+ * @return bool
+ */
+ public function isSettingVisible($setting)
+ {
+ return $this->getSetting($setting, null, true)['userSee'] == 1;
+ }
+
+ /**
+ * Is the provided setting editable
+ * @param string $setting
+ * @return bool
+ */
+ public function isSettingEditable($setting)
+ {
+ $item = $this->getSetting($setting, null, true);
+ return $item['userSee'] == 1 && $item['userChange'] == 1;
+ }
+
+ /**
+ * Should the host be considered a proxy exception
+ * @param $host
+ * @return bool
+ */
+ public function isProxyException($host)
+ {
+ $proxyExceptions = $this->getSetting('PROXY_EXCEPTIONS');
+
+ // If empty, cannot be an exception
+ if (empty($proxyExceptions))
+ return false;
+
+ // Simple test
+ if (stripos($host, $proxyExceptions) !== false)
+ return true;
+
+ // Host test
+ $parsedHost = parse_url($host, PHP_URL_HOST);
+
+ // Kick out extremely malformed hosts
+ if ($parsedHost === false)
+ return false;
+
+ // Go through each exception and test against the host
+ foreach (explode(',', $proxyExceptions) as $proxyException) {
+ if (stripos($parsedHost, $proxyException) !== false)
+ return true;
+ }
+
+ // If we've got here without returning, then we aren't an exception
+ return false;
+ }
+
+ /**
+ * Get Proxy Configuration
+ * @param array $httpOptions
+ * @return array
+ */
+ public function getGuzzleProxy($httpOptions = [])
+ {
+ // Proxy support
+ if ($this->getSetting('PROXY_HOST') != '') {
+
+ $proxy = $this->getSetting('PROXY_HOST') . ':' . $this->getSetting('PROXY_PORT');
+
+ if ($this->getSetting('PROXY_AUTH') != '') {
+ $scheme = explode('://', $proxy);
+
+ $proxy = $scheme[0] . '://' . $this->getSetting('PROXY_AUTH') . '@' . $scheme[1];
+ }
+
+ $httpOptions['proxy'] = [
+ 'http' => $proxy,
+ 'https' => $proxy
+ ];
+
+ if ($this->getSetting('PROXY_EXCEPTIONS') != '') {
+ $httpOptions['proxy']['no'] = explode(',', $this->getSetting('PROXY_EXCEPTIONS'));
+ }
+ }
+
+ // Global timeout
+ // All outbound HTTP should have a timeout as they tie up a PHP process while the request completes (if
+ // triggered from an incoming request)
+ // https://github.com/xibosignage/xibo/issues/2631
+ if (!array_key_exists('timeout', $httpOptions)) {
+ $httpOptions['timeout'] = 20;
+ }
+
+ if (!array_key_exists('connect_timeout', $httpOptions)) {
+ $httpOptions['connect_timeout'] = 5;
+ }
+
+ return $httpOptions;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getApiKeyDetails()
+ {
+ if ($this->apiKeyPaths == null) {
+ // We load the defaults
+ $libraryLocation = $this->getSetting('LIBRARY_LOCATION');
+
+ // We use the defaults
+ $this->apiKeyPaths = [
+ 'publicKeyPath' => $libraryLocation . 'certs/public.key',
+ 'privateKeyPath' => $libraryLocation . 'certs/private.key',
+ 'encryptionKey' => file_get_contents($libraryLocation . 'certs/encryption.key')
+ ];
+ }
+
+ return $this->apiKeyPaths;
+ }
+
+ private function testItem(&$results, $item, $result, $advice, $fault = true)
+ {
+ // 1=OK, 0=Failure, 2=Warning
+ $status = ($result) ? 1 : (($fault) ? 0 : 2);
+
+ // Set fault flag
+ if (!$result && $fault)
+ $this->envFault = true;
+
+ // Set warning flag
+ if (!$result && !$fault)
+ $this->envWarning = true;
+
+ $results[] = [
+ 'item' => $item,
+ 'status' => $status,
+ 'advice' => $advice
+ ];
+ }
+
+ /**
+ * Checks the Environment and Determines if it is suitable
+ * @return array
+ */
+ public function checkEnvironment()
+ {
+ $rows = array();
+
+ $this->testItem($rows, __('PHP Version'),
+ Environment::checkPHP(),
+ sprintf(__("PHP version %s or later required."), Environment::$VERSION_REQUIRED) . ' Detected ' . phpversion()
+ );
+
+ $this->testItem($rows, __('Cache File System Permissions'),
+ Environment::checkCacheFileSystemPermissions(),
+ __('Write permissions are required for cache/')
+ );
+
+ $this->testItem($rows, __('MySQL database (PDO MySql)'),
+ Environment::checkPDO(),
+ __('PDO support with MySQL drivers must be enabled in PHP.')
+ );
+
+ $this->testItem($rows, __('JSON Extension'),
+ Environment::checkJson(),
+ __('PHP JSON extension required to function.')
+ );
+
+ $this->testItem($rows, __('SOAP Extension'),
+ Environment::checkSoap(),
+ __('PHP SOAP extension required to function.')
+ );
+
+ $this->testItem($rows, __('GD Extension'),
+ Environment::checkGd(),
+ __('PHP GD extension required to function.')
+ );
+
+ $this->testItem($rows, __('Session'),
+ Environment::checkGd(),
+ __('PHP session support required to function.')
+ );
+
+ $this->testItem($rows, __('FileInfo'),
+ Environment::checkFileInfo(),
+ __('Requires PHP FileInfo support to function. If you are on Windows you need to enable the php_fileinfo.dll in your php.ini file.')
+ );
+
+ $this->testItem($rows, __('PCRE'),
+ Environment::checkPCRE(),
+ __('PHP PCRE support to function.')
+ );
+
+ $this->testItem($rows, __('Gettext'),
+ Environment::checkPCRE(),
+ __('PHP Gettext support to function.')
+ );
+
+ $this->testItem($rows, __('DOM Extension'),
+ Environment::checkDom(),
+ __('PHP DOM core functionality enabled.')
+ );
+
+ $this->testItem($rows, __('DOM XML Extension'),
+ Environment::checkDomXml(),
+ __('PHP DOM XML extension to function.')
+ );
+
+ $this->testItem($rows, __('Allow PHP to open external URLs'),
+ (Environment::checkCurl() || Environment::checkAllowUrlFopen()),
+ __('You must have the curl extension enabled or PHP configured with "allow_url_fopen = On" for the CMS to access external resources. We strongly recommend curl.'),
+ false
+ );
+
+ $this->testItem($rows, __('DateTimeZone'),
+ Environment::checkTimezoneIdentifiers(),
+ __('This enables us to get a list of time zones supported by the hosting server.'),
+ false
+ );
+
+ $this->testItem($rows, __('ZIP'),
+ Environment::checkZip(),
+ __('This enables import / export of layouts.')
+ );
+
+ $advice = __('Support for uploading large files is recommended.');
+ $advice .= __('We suggest setting your PHP post_max_size and upload_max_filesize to at least 128M, and also increasing your max_execution_time to at least 120 seconds.');
+
+ $this->testItem($rows, __('Large File Uploads'),
+ Environment::checkPHPUploads(),
+ $advice,
+ false
+ );
+
+ $this->testItem($rows, __('cURL'),
+ Environment::checkCurlInstalled(),
+ __('cURL is used to fetch data from the Internet or Local Network')
+ );
+
+ $this->testItem($rows, __('OpenSSL'),
+ Environment::checkOpenSsl(),
+ __('OpenSSL is used to seal and verify messages sent to XMR'),
+ false
+ );
+
+ $this->testItem($rows, __('SimpleXML'),
+ Environment::checkSimpleXml(),
+ __('SimpleXML is used to parse RSS feeds and other XML data sources')
+ );
+
+ $this->testItem($rows, __('GNUPG'),
+ Environment::checkGnu(),
+ __('checkGnu is used to verify the integrity of Player Software versions uploaded to the CMS'),
+ false
+ );
+
+ $this->envTested = true;
+
+ return $rows;
+ }
+
+ /**
+ * Is there an environment fault
+ * @return bool
+ */
+ public function environmentFault()
+ {
+ if (!$this->envTested) {
+ $this->checkEnvironment();
+ }
+
+ return $this->envFault || !Environment::checkSettingsFileSystemPermissions();
+ }
+
+ /**
+ * Is there an environment warning
+ * @return bool
+ */
+ public function environmentWarning()
+ {
+ if (!$this->envTested) {
+ $this->checkEnvironment();
+ }
+
+ return $this->envWarning;
+ }
+
+ /**
+ * Check binlog format
+ * @return bool
+ */
+ public function checkBinLogEnabled()
+ {
+ //TODO: move this into storage interface
+ $results = $this->getStore()->select('show variables like \'log_bin\'', []);
+
+ if (count($results) <= 0)
+ return false;
+
+ return ($results[0]['Value'] != 'OFF');
+ }
+
+ /**
+ * Check binlog format
+ * @return bool
+ */
+ public function checkBinLogFormat()
+ {
+ //TODO: move this into storage interface
+ $results = $this->getStore()->select('show variables like \'binlog_format\'', []);
+
+ if (count($results) <= 0)
+ return false;
+
+ return ($results[0]['Value'] != 'STATEMENT');
+ }
+
+ public function getPhoneticKey()
+ {
+ return NatoAlphabet::convertToNato($this->getSetting('SERVER_KEY'));
+ }
+}
diff --git a/lib/Service/ConfigServiceInterface.php b/lib/Service/ConfigServiceInterface.php
new file mode 100644
index 0000000..3437eaf
--- /dev/null
+++ b/lib/Service/ConfigServiceInterface.php
@@ -0,0 +1,185 @@
+.
+ */
+namespace Xibo\Service;
+
+use Stash\Interfaces\PoolInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+
+/**
+ * Interface ConfigServiceInterface
+ * @package Xibo\Service
+ */
+interface ConfigServiceInterface
+{
+ /**
+ * Set Service Dependencies
+ * @param StorageServiceInterface $store
+ * @param string $rootUri
+ */
+ public function setDependencies($store, $rootUri);
+
+ /**
+ * Get Cache Pool
+ * @param PoolInterface $pool
+ * @return mixed
+ */
+ public function setPool($pool);
+
+ /**
+ * Get Database Config
+ * @return array
+ */
+ public function getDatabaseConfig();
+
+ /**
+ * Get settings
+ * @return array|mixed|null
+ */
+ public function getSettings();
+
+ /**
+ * Gets the requested setting from the DB object given
+ * @param $setting string
+ * @param string[optional] $default
+ * @param bool[optional] $full
+ * @return string
+ */
+ public function getSetting($setting, $default = NULL, $full = false);
+
+ /**
+ * Change Setting
+ * @param string $setting
+ * @param mixed $value
+ * @param int $userChange
+ */
+ public function changeSetting($setting, $value, $userChange = 0);
+
+ /**
+ * Is the provided setting visible
+ * @param string $setting
+ * @return bool
+ */
+ public function isSettingVisible($setting);
+
+ /**
+ * Is the provided setting editable
+ * @param string $setting
+ * @return bool
+ */
+ public function isSettingEditable($setting);
+
+ /**
+ * Should the host be considered a proxy exception
+ * @param $host
+ * @return bool
+ */
+ public function isProxyException($host);
+
+ /**
+ * Get Proxy Configuration
+ * @param array $httpOptions
+ * @return array
+ */
+ public function getGuzzleProxy($httpOptions = []);
+
+ /**
+ * Get API key details from Configuration
+ * @return array
+ */
+ public function getApiKeyDetails();
+
+ /**
+ * Checks the Environment and Determines if it is suitable
+ * @return string
+ */
+ public function checkEnvironment();
+
+ /**
+ * Loads the theme
+ * @param string[Optional] $themeName
+ * @throws ConfigurationException
+ */
+ public function loadTheme($themeName = null);
+
+ /**
+ * Get Theme Specific Settings
+ * @param null $settingName
+ * @param null $default
+ * @return null
+ */
+ public function getThemeConfig($settingName = null, $default = null);
+
+ /**
+ * Get theme URI
+ * @param string $uri
+ * @param bool $local
+ * @return string
+ */
+ public function uri($uri, $local = false);
+
+ /**
+ * Check a theme file exists
+ * @param string $uri
+ * @return bool
+ */
+ public function themeFileExists($uri);
+
+ /**
+ * Check a web file exists
+ * @param string $uri
+ * @return bool
+ */
+ public function fileExists($uri);
+
+ /**
+ * Get App Root URI
+ * @return mixed
+ */
+ public function rootUri();
+
+ /**
+ * Get cache drivers
+ * @return array
+ */
+ public function getCacheDrivers();
+
+ /**
+ * Get time series store settings
+ * @return array
+ */
+ public function getTimeSeriesStore();
+
+ /**
+ * Get the cache namespace
+ * @return string
+ */
+ public function getCacheNamespace();
+
+ /**
+ * Get Connector settings from the file based settings
+ * this acts as an override for settings stored in the database
+ * @param string $connector The connector to return settings for.
+ * @return array
+ */
+ public function getConnectorSettings(string $connector): array;
+}
diff --git a/lib/Service/DisplayNotifyService.php b/lib/Service/DisplayNotifyService.php
new file mode 100644
index 0000000..fd170f8
--- /dev/null
+++ b/lib/Service/DisplayNotifyService.php
@@ -0,0 +1,978 @@
+.
+ */
+
+
+namespace Xibo\Service;
+
+use Carbon\Carbon;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Display;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\DeadlockException;
+use Xibo\XMR\CollectNowAction;
+use Xibo\XMR\DataUpdateAction;
+
+/**
+ * Class DisplayNotifyService
+ * @package Xibo\Service
+ */
+class DisplayNotifyService implements DisplayNotifyServiceInterface
+{
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var LogServiceInterface */
+ private $log;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var PlayerActionServiceInterface */
+ private $playerActionService;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var bool */
+ private $collectRequired = false;
+
+ /** @var int[] */
+ private $displayIds = [];
+
+ /** @var int[] */
+ private $displayIdsRequiringActions = [];
+
+ /** @var string[] */
+ private $keysProcessed = [];
+
+ /** @inheritdoc */
+ public function __construct($config, $log, $store, $pool, $playerActionService, $scheduleFactory)
+ {
+ $this->config = $config;
+ $this->log = $log;
+ $this->store = $store;
+ $this->pool = $pool;
+ $this->playerActionService = $playerActionService;
+ $this->scheduleFactory = $scheduleFactory;
+ }
+
+ /** @inheritdoc */
+ public function init()
+ {
+ $this->collectRequired = false;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function collectNow()
+ {
+ $this->collectRequired = true;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function collectLater()
+ {
+ $this->collectRequired = false;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function processQueue()
+ {
+ if (count($this->displayIds) <= 0) {
+ return;
+ }
+
+ $this->log->debug('Process queue of ' . count($this->displayIds) . ' display notifications');
+
+ // We want to do 3 things.
+ // 1. Drop the Cache for each displayId
+ // 2. Update the mediaInventoryStatus on each DisplayId to 3 (pending)
+ // 3. Fire a PlayerAction if appropriate - what is appropriate?!
+
+ // Unique our displayIds
+ $displayIds = array_values(array_unique($this->displayIds, SORT_NUMERIC));
+
+ // Make a list of them that we can use in the update statement
+ $qmarks = str_repeat('?,', count($displayIds) - 1) . '?';
+
+ try {
+ // This runs on the default connection which will already be committed and closed by the time we get
+ // here. This doesn't run in a transaction.
+ $this->store->updateWithDeadlockLoop(
+ 'UPDATE `display` SET mediaInventoryStatus = 3 WHERE displayId IN (' . $qmarks . ')',
+ $displayIds,
+ 'default',
+ false
+ );
+ } catch (DeadlockException $deadlockException) {
+ $this->log->error('Failed to update media inventory status: ' . $deadlockException->getMessage());
+ }
+
+ // Dump the cache
+ foreach ($displayIds as $displayId) {
+ $this->pool->deleteItem(Display::getCachePrefix() . $displayId);
+ }
+
+ // Player actions
+ $this->processPlayerActions();
+ }
+
+ /**
+ * Process Actions
+ */
+ private function processPlayerActions()
+ {
+ if (count($this->displayIdsRequiringActions) <= 0) {
+ return;
+ }
+
+ $this->log->debug('Process queue of ' . count($this->displayIdsRequiringActions) . ' display actions');
+
+ $displayIdsRequiringActions = array_values(array_unique($this->displayIdsRequiringActions, SORT_NUMERIC));
+ $qmarks = str_repeat('?,', count($displayIdsRequiringActions) - 1) . '?';
+ $displays = $this->store->select(
+ 'SELECT displayId, xmrChannel, xmrPubKey, display, client_type AS clientType, `client_code`
+ FROM `display`
+ WHERE displayId IN (' . $qmarks . ')',
+ $displayIdsRequiringActions
+ );
+
+ foreach ($displays as $row) {
+ // TOOD: this should be improved
+ $display = new Display(
+ $this->store,
+ $this->log,
+ null,
+ $this->config,
+ null,
+ null,
+ null,
+ null,
+ );
+ $display->displayId = intval($row['displayId']);
+ $display->xmrChannel = $row['xmrChannel'];
+ $display->xmrPubKey = $row['xmrPubKey'];
+ $display->display = $row['display'];
+ $display->clientType = $row['clientType'];
+ $display->clientCode = intval($row['client_code']);
+
+ try {
+ $this->playerActionService->sendAction($display, new CollectNowAction());
+ } catch (\Exception $e) {
+ $this->log->notice(
+ 'DisplayId ' .
+ $row['displayId'] .
+ ' Save would have triggered Player Action, but the action failed with message: ' . $e->getMessage()
+ );
+ }
+ }
+ }
+
+ /** @inheritdoc */
+ public function notifyByDisplayId($displayId)
+ {
+ $this->log->debug('Notify by DisplayId ' . $displayId);
+
+ // Don't process if the displayId is already in the collection (there is little point in running the
+ // extra query)
+ if (in_array($displayId, $this->displayIds)) {
+ return;
+ }
+
+ $this->displayIds[] = $displayId;
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $displayId;
+ }
+ }
+
+ /** @inheritdoc */
+ public function notifyByDisplayGroupId($displayGroupId)
+ {
+ $this->log->debug('Notify by DisplayGroupId ' . $displayGroupId);
+
+ if (in_array('displayGroup_' . $displayGroupId, $this->keysProcessed)) {
+ $this->log->debug('Already processed ' . $displayGroupId . ' skipping this time.');
+ return;
+ }
+
+ $sql = '
+ SELECT DISTINCT `lkdisplaydg`.displayId
+ FROM `lkdgdg`
+ INNER JOIN `lkdisplaydg`
+ ON `lkdisplaydg`.displayGroupID = `lkdgdg`.childId
+ WHERE `lkdgdg`.parentId = :displayGroupId
+ ';
+
+ foreach ($this->store->select($sql, ['displayGroupId' => $displayGroupId]) as $row) {
+ // Don't process if the displayId is already in the collection
+ if (in_array($row['displayId'], $this->displayIds)) {
+ continue;
+ }
+
+ $this->displayIds[] = $row['displayId'];
+
+ $this->log->debug(
+ 'DisplayGroup[' . $displayGroupId .'] change caused notify on displayId[' .
+ $row['displayId'] . ']'
+ );
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $row['displayId'];
+ }
+ }
+
+ $this->keysProcessed[] = 'displayGroup_' . $displayGroupId;
+ }
+
+ /** @inheritdoc */
+ public function notifyByCampaignId($campaignId)
+ {
+ $this->log->debug('Notify by CampaignId ' . $campaignId);
+
+ if (in_array('campaign_' . $campaignId, $this->keysProcessed)) {
+ $this->log->debug('Already processed ' . $campaignId . ' skipping this time.');
+ return;
+ }
+
+ $sql = '
+ SELECT DISTINCT display.displayId,
+ schedule.eventId,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.dayPartId
+ FROM `schedule`
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `display`
+ ON lkdisplaydg.DisplayID = display.displayID
+ INNER JOIN (
+ SELECT campaignId
+ FROM campaign
+ WHERE campaign.campaignId = :activeCampaignId
+ UNION
+ SELECT DISTINCT parent.campaignId
+ FROM `lkcampaignlayout` child
+ INNER JOIN `lkcampaignlayout` parent
+ ON parent.layoutId = child.layoutId
+ WHERE child.campaignId = :activeCampaignId
+
+ ) campaigns
+ ON campaigns.campaignId = `schedule`.campaignId
+ WHERE (
+ (`schedule`.FromDT < :toDt AND IFNULL(`schedule`.toDt, UNIX_TIMESTAMP()) > :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ UNION
+ SELECT DISTINCT display.DisplayID,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `display`
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.LayoutID = `display`.DefaultLayoutID
+ WHERE `lkcampaignlayout`.CampaignID = :activeCampaignId2
+ UNION
+ SELECT `lkdisplaydg`.displayId,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `lkdisplaydg`
+ INNER JOIN `lklayoutdisplaygroup`
+ ON `lklayoutdisplaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.layoutId = `lklayoutdisplaygroup`.layoutId
+ WHERE `lkcampaignlayout`.campaignId = :assignedCampaignId
+ UNION
+ SELECT `schedule_sync`.displayId,
+ schedule.eventId,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.dayPartId
+ FROM `schedule`
+ INNER JOIN `schedule_sync`
+ ON `schedule_sync`.eventId = `schedule`.eventId
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.layoutId = `schedule_sync`.layoutId
+ WHERE `lkcampaignlayout`.campaignId = :assignedCampaignId
+ AND (
+ (`schedule`.FromDT < :toDt AND IFNULL(`schedule`.toDt, `schedule`.fromDt) > :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ ';
+
+ $currentDate = Carbon::now();
+ $rfLookAhead = $currentDate->copy()->addSeconds($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD'));
+
+ $params = [
+ 'fromDt' => $currentDate->subHour()->format('U'),
+ 'toDt' => $rfLookAhead->format('U'),
+ 'activeCampaignId' => $campaignId,
+ 'activeCampaignId2' => $campaignId,
+ 'assignedCampaignId' => $campaignId
+ ];
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ // Don't process if the displayId is already in the collection (there is little point in running the
+ // extra query)
+ if (in_array($row['displayId'], $this->displayIds)) {
+ continue;
+ }
+
+ // Is this schedule active?
+ if ($row['eventId'] != 0) {
+ $scheduleEvents = $this->scheduleFactory
+ ->createEmpty()
+ ->hydrate($row)
+ ->getEvents($currentDate, $rfLookAhead);
+
+ if (count($scheduleEvents) <= 0) {
+ $this->log->debug(
+ 'Skipping eventId ' . $row['eventId'] .
+ ' because it doesnt have any active events in the window'
+ );
+ continue;
+ }
+ }
+
+ $this->log->debug(
+ 'Campaign[' . $campaignId .']
+ change caused notify on displayId[' . $row['displayId'] . ']'
+ );
+
+ $this->displayIds[] = $row['displayId'];
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $row['displayId'];
+ }
+ }
+
+ $this->keysProcessed[] = 'campaign_' . $campaignId;
+ }
+
+ /** @inheritdoc */
+ public function notifyByDataSetId($dataSetId)
+ {
+ $this->log->debug('notifyByDataSetId: dataSetId: ' . $dataSetId);
+
+ if (in_array('dataSet_' . $dataSetId, $this->keysProcessed)) {
+ $this->log->debug('notifyByDataSetId: already processed.');
+ return;
+ }
+
+ // Set the Sync task to runNow
+ $this->store->update('UPDATE `task` SET `runNow` = 1 WHERE `class` LIKE :taskClassLike', [
+ 'taskClassLike' => '%WidgetSyncTask%',
+ ]);
+
+ // Query the schedule for any data connectors.
+ // This is a simple test to see if there are ever any schedules for this dataSetId
+ // TODO: this could be improved.
+ $sql = '
+ SELECT DISTINCT display.displayId
+ FROM `schedule`
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON `lkdisplaydg`.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `display`
+ ON `lkdisplaydg`.DisplayID = `display`.displayID
+ WHERE `schedule`.dataSetId = :dataSetId
+ ';
+
+ foreach ($this->store->select($sql, ['dataSetId' => $dataSetId]) as $row) {
+ $this->displayIds[] = $row['displayId'];
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $row['displayId'];
+ }
+ }
+
+ $this->keysProcessed[] = 'dataSet_' . $dataSetId;
+ }
+
+ /** @inheritdoc */
+ public function notifyByPlaylistId($playlistId)
+ {
+ $this->log->debug('Notify by PlaylistId ' . $playlistId);
+
+ if (in_array('playlist_' . $playlistId, $this->keysProcessed)) {
+ $this->log->debug('Already processed ' . $playlistId . ' skipping this time.');
+ return;
+ }
+
+ $sql = '
+ SELECT DISTINCT display.displayId,
+ schedule.eventId,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.dayPartId
+ FROM `schedule`
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `display`
+ ON lkdisplaydg.DisplayID = display.displayID
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.campaignId = `schedule`.campaignId
+ INNER JOIN `region`
+ ON `lkcampaignlayout`.layoutId = region.layoutId
+ INNER JOIN `playlist`
+ ON `playlist`.regionId = `region`.regionId
+ WHERE `playlist`.playlistId = :playlistId
+ AND (
+ (schedule.FromDT < :toDt AND IFNULL(`schedule`.toDt, UNIX_TIMESTAMP()) > :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ UNION
+ SELECT DISTINCT display.DisplayID,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `display`
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.LayoutID = `display`.DefaultLayoutID
+ INNER JOIN `region`
+ ON `lkcampaignlayout`.layoutId = region.layoutId
+ INNER JOIN `playlist`
+ ON `playlist`.regionId = `region`.regionId
+ WHERE `playlist`.playlistId = :playlistId
+ UNION
+ SELECT `lkdisplaydg`.displayId,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `lkdisplaydg`
+ INNER JOIN `lklayoutdisplaygroup`
+ ON `lklayoutdisplaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.layoutId = `lklayoutdisplaygroup`.layoutId
+ INNER JOIN `region`
+ ON `lkcampaignlayout`.layoutId = region.layoutId
+ INNER JOIN `playlist`
+ ON `playlist`.regionId = `region`.regionId
+ WHERE `playlist`.playlistId = :playlistId
+ ';
+
+ $currentDate = Carbon::now();
+ $rfLookAhead = $currentDate->copy()->addSeconds($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD'));
+
+ $params = [
+ 'fromDt' => $currentDate->subHour()->format('U'),
+ 'toDt' => $rfLookAhead->format('U'),
+ 'playlistId' => $playlistId
+ ];
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ // Don't process if the displayId is already in the collection (there is little point in running the
+ // extra query)
+ if (in_array($row['displayId'], $this->displayIds)) {
+ continue;
+ }
+
+ // Is this schedule active?
+ if ($row['eventId'] != 0) {
+ $scheduleEvents = $this->scheduleFactory
+ ->createEmpty()
+ ->hydrate($row)
+ ->getEvents($currentDate, $rfLookAhead);
+
+ if (count($scheduleEvents) <= 0) {
+ $this->log->debug(
+ 'Skipping eventId ' . $row['eventId'] .
+ ' because it doesnt have any active events in the window'
+ );
+ continue;
+ }
+ }
+
+ $this->log->debug(
+ 'Playlist[' . $playlistId .'] change caused notify on displayId[' .
+ $row['displayId'] . ']'
+ );
+
+ $this->displayIds[] = $row['displayId'];
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $row['displayId'];
+ }
+ }
+
+ $this->keysProcessed[] = 'playlist_' . $playlistId;
+ }
+
+ /** @inheritdoc */
+ public function notifyByLayoutCode($code)
+ {
+ if (in_array('layoutCode_' . $code, $this->keysProcessed)) {
+ $this->log->debug('Already processed ' . $code . ' skipping this time.');
+ return;
+ }
+
+ $this->log->debug('Notify by Layout Code: ' . $code);
+
+ // Get the Display Ids we need to notify
+ $sql = '
+ SELECT DISTINCT display.displayId,
+ schedule.eventId,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.dayPartId
+ FROM `schedule`
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `display`
+ ON lkdisplaydg.DisplayID = display.displayID
+ INNER JOIN (
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN action on layout.layoutId = action.sourceId
+ WHERE action.layoutCode = :code AND layout.publishedStatusId = 1
+ UNION
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN region ON region.layoutId = layout.layoutId
+ INNER JOIN action on region.regionId = action.sourceId
+ WHERE action.layoutCode = :code AND layout.publishedStatusId = 1
+ UNION
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN region ON region.layoutId = layout.layoutId
+ INNER JOIN playlist ON playlist.regionId = region.regionId
+ INNER JOIN widget on playlist.playlistId = widget.playlistId
+ INNER JOIN action on widget.widgetId = action.sourceId
+ WHERE
+ action.layoutCode = :code AND
+ layout.publishedStatusId = 1
+ ) campaigns
+ ON campaigns.campaignId = `schedule`.campaignId
+ WHERE (
+ (`schedule`.FromDT < :toDt AND IFNULL(`schedule`.toDt, UNIX_TIMESTAMP()) > :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ UNION
+ SELECT DISTINCT display.displayId,
+ schedule.eventId,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.dayPartId
+ FROM `schedule`
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `display`
+ ON lkdisplaydg.DisplayID = display.displayID
+ WHERE schedule.actionLayoutCode = :code
+ AND (
+ (`schedule`.FromDT < :toDt AND IFNULL(`schedule`.toDt, UNIX_TIMESTAMP()) > :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ UNION
+ SELECT DISTINCT display.DisplayID,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `display`
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.LayoutID = `display`.DefaultLayoutID
+ WHERE `lkcampaignlayout`.CampaignID IN (
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN action on layout.layoutId = action.sourceId
+ WHERE action.layoutCode = :code AND layout.publishedStatusId = 1
+ UNION
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN region ON region.layoutId = layout.layoutId
+ INNER JOIN action on region.regionId = action.sourceId
+ WHERE action.layoutCode = :code AND layout.publishedStatusId = 1
+ UNION
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN region ON region.layoutId = layout.layoutId
+ INNER JOIN playlist ON playlist.regionId = region.regionId
+ INNER JOIN widget on playlist.playlistId = widget.playlistId
+ INNER JOIN action on widget.widgetId = action.sourceId
+ WHERE
+ action.layoutCode = :code AND layout.publishedStatusId = 1
+ )
+ UNION
+ SELECT `lkdisplaydg`.displayId,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `lkdisplaydg`
+ INNER JOIN `lklayoutdisplaygroup`
+ ON `lklayoutdisplaygroup`.displayGroupId = `lkdisplaydg`.displayGroupId
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.layoutId = `lklayoutdisplaygroup`.layoutId
+ WHERE `lkcampaignlayout`.campaignId IN (
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN action on layout.layoutId = action.sourceId
+ WHERE action.layoutCode = :code AND layout.publishedStatusId = 1
+ UNION
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN region ON region.layoutId = layout.layoutId
+ INNER JOIN action on region.regionId = action.sourceId
+ WHERE action.layoutCode = :code AND layout.publishedStatusId = 1
+ UNION
+ SELECT DISTINCT campaignId
+ FROM layout
+ INNER JOIN lkcampaignlayout ON lkcampaignlayout.layoutId = layout.layoutId
+ INNER JOIN region ON region.layoutId = layout.layoutId
+ INNER JOIN playlist ON playlist.regionId = region.regionId
+ INNER JOIN widget on playlist.playlistId = widget.playlistId
+ INNER JOIN action on widget.widgetId = action.sourceId
+ WHERE
+ action.layoutCode = :code AND layout.publishedStatusId = 1
+ )
+ ';
+
+ $currentDate = Carbon::now();
+ $rfLookAhead = $currentDate->copy()->addSeconds($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD'));
+
+ $params = [
+ 'fromDt' => $currentDate->subHour()->format('U'),
+ 'toDt' => $rfLookAhead->format('U'),
+ 'code' => $code
+ ];
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ // Don't process if the displayId is already in the collection (there is little point in running the
+ // extra query)
+ if (in_array($row['displayId'], $this->displayIds)) {
+ continue;
+ }
+
+ // Is this schedule active?
+ if ($row['eventId'] != 0) {
+ $scheduleEvents = $this->scheduleFactory
+ ->createEmpty()
+ ->hydrate($row)
+ ->getEvents($currentDate, $rfLookAhead);
+
+ if (count($scheduleEvents) <= 0) {
+ $this->log->debug(
+ 'Skipping eventId ' . $row['eventId'] .
+ ' because it doesnt have any active events in the window'
+ );
+ continue;
+ }
+ }
+
+ $this->log->debug(sprintf(
+ 'Saving Layout with code %s, caused notify on
+ displayId[' . $row['displayId'] . ']',
+ $code
+ ));
+
+ $this->displayIds[] = $row['displayId'];
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $row['displayId'];
+ }
+ }
+
+ $this->keysProcessed[] = 'layoutCode_' . $code;
+ }
+
+ /** @inheritdoc */
+ public function notifyByMenuBoardId($menuId)
+ {
+ $this->log->debug('Notify by MenuBoard ID ' . $menuId);
+
+ if (in_array('menuBoard_' . $menuId, $this->keysProcessed)) {
+ $this->log->debug('Already processed ' . $menuId . ' skipping this time.');
+ return;
+ }
+
+ $sql = '
+ SELECT DISTINCT display.displayId,
+ schedule.eventId,
+ schedule.fromDt,
+ schedule.toDt,
+ schedule.recurrence_type AS recurrenceType,
+ schedule.recurrence_detail AS recurrenceDetail,
+ schedule.recurrence_range AS recurrenceRange,
+ schedule.recurrenceRepeatsOn,
+ schedule.lastRecurrenceWatermark,
+ schedule.dayPartId
+ FROM `schedule`
+ INNER JOIN `lkscheduledisplaygroup`
+ ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkscheduledisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `display`
+ ON lkdisplaydg.DisplayID = display.displayID
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.campaignId = `schedule`.campaignId
+ INNER JOIN `region`
+ ON `region`.layoutId = `lkcampaignlayout`.layoutId
+ INNER JOIN `playlist`
+ ON `playlist`.regionId = `region`.regionId
+ INNER JOIN `widget`
+ ON `widget`.playlistId = `playlist`.playlistId
+ INNER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'menuId\'
+ AND `widgetoption`.value = :activeMenuId
+ WHERE (
+ (schedule.FromDT < :toDt AND IFNULL(`schedule`.toDt, `schedule`.fromDt) > :fromDt)
+ OR `schedule`.recurrence_range >= :fromDt
+ OR (
+ IFNULL(`schedule`.recurrence_range, 0) = 0 AND IFNULL(`schedule`.recurrence_type, \'\') <> \'\'
+ )
+ )
+ UNION
+ SELECT DISTINCT display.displayId,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `display`
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.LayoutID = `display`.DefaultLayoutID
+ INNER JOIN `region`
+ ON `region`.layoutId = `lkcampaignlayout`.layoutId
+ INNER JOIN `playlist`
+ ON `playlist`.regionId = `region`.regionId
+ INNER JOIN `widget`
+ ON `widget`.playlistId = `playlist`.playlistId
+ INNER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'menuId\'
+ AND `widgetoption`.value = :activeMenuId2
+ UNION
+ SELECT DISTINCT `lkdisplaydg`.displayId,
+ 0 AS eventId,
+ 0 AS fromDt,
+ 0 AS toDt,
+ NULL AS recurrenceType,
+ NULL AS recurrenceDetail,
+ NULL AS recurrenceRange,
+ NULL AS recurrenceRepeatsOn,
+ NULL AS lastRecurrenceWatermark,
+ NULL AS dayPartId
+ FROM `lklayoutdisplaygroup`
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lklayoutdisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `lkcampaignlayout`
+ ON `lkcampaignlayout`.layoutId = `lklayoutdisplaygroup`.layoutId
+ INNER JOIN `region`
+ ON `region`.layoutId = `lkcampaignlayout`.layoutId
+ INNER JOIN `playlist`
+ ON `playlist`.regionId = `region`.regionId
+ INNER JOIN `widget`
+ ON `widget`.playlistId = `playlist`.playlistId
+ INNER JOIN `widgetoption`
+ ON `widgetoption`.widgetId = `widget`.widgetId
+ AND `widgetoption`.type = \'attrib\'
+ AND `widgetoption`.option = \'menuId\'
+ AND `widgetoption`.value = :activeMenuId3
+ ';
+
+ $currentDate = Carbon::now();
+ $rfLookAhead = $currentDate->copy()->addSeconds($this->config->getSetting('REQUIRED_FILES_LOOKAHEAD'));
+
+ $params = [
+ 'fromDt' => $currentDate->subHour()->format('U'),
+ 'toDt' => $rfLookAhead->format('U'),
+ 'activeMenuId' => $menuId,
+ 'activeMenuId2' => $menuId,
+ 'activeMenuId3' => $menuId
+ ];
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ // Don't process if the displayId is already in the collection (there is little point in running the
+ // extra query)
+ if (in_array($row['displayId'], $this->displayIds)) {
+ $this->log->debug('displayId ' . $row['displayId'] . ' already in collection, skipping.');
+ continue;
+ }
+
+ // Is this schedule active?
+ if ($row['eventId'] != 0) {
+ $scheduleEvents = $this->scheduleFactory
+ ->createEmpty()
+ ->hydrate($row)
+ ->getEvents($currentDate, $rfLookAhead);
+
+ if (count($scheduleEvents) <= 0) {
+ $this->log->debug(
+ 'Skipping eventId ' . $row['eventId'] .
+ ' because it doesnt have any active events in the window'
+ );
+ continue;
+ }
+ }
+
+ $this->log->debug('MenuBoard[' . $menuId .'] change caused notify on displayId[' . $row['displayId'] . ']');
+
+ $this->displayIds[] = $row['displayId'];
+
+ if ($this->collectRequired) {
+ $this->displayIdsRequiringActions[] = $row['displayId'];
+ }
+ }
+
+ $this->keysProcessed[] = 'menuBoard_' . $menuId;
+
+ $this->log->debug('Finished notify for Menu Board ID ' . $menuId);
+ }
+
+ /** @inheritdoc */
+ public function notifyDataUpdate(Display $display, int $widgetId): void
+ {
+ if (in_array('dataUpdate_' . $display->displayId . '_' . $widgetId, $this->keysProcessed)) {
+ $this->log->debug('notifyDataUpdate: Already processed displayId: ' . $display->displayId
+ . ', widgetId: ' . $widgetId . ', skipping this time.');
+ return;
+ }
+ $this->log->debug('notifyDataUpdate: Process displayId: ' . $display->displayId . ', widgetId: ' . $widgetId);
+
+ try {
+ $this->playerActionService->sendAction($display, new DataUpdateAction($widgetId));
+ } catch (\Exception $e) {
+ $this->log->notice('notifyDataUpdate: displayId: ' . $display->displayId
+ . ', save would have triggered Player Action, but the action failed with message: ' . $e->getMessage());
+ }
+ }
+}
diff --git a/lib/Service/DisplayNotifyServiceInterface.php b/lib/Service/DisplayNotifyServiceInterface.php
new file mode 100644
index 0000000..47c46e3
--- /dev/null
+++ b/lib/Service/DisplayNotifyServiceInterface.php
@@ -0,0 +1,119 @@
+.
+ */
+
+
+namespace Xibo\Service;
+
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Display;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Interface DisplayNotifyServiceInterface
+ * @package Xibo\Service
+ */
+interface DisplayNotifyServiceInterface
+{
+ /**
+ * DisplayNotifyServiceInterface constructor.
+ * @param ConfigServiceInterface $config
+ * @param StorageServiceInterface $store
+ * @param LogServiceInterface $log
+ * @param PoolInterface $pool
+ * @param PlayerActionServiceInterface $playerActionService
+ * @param ScheduleFactory $scheduleFactory
+ */
+ public function __construct($config, $store, $log, $pool, $playerActionService, $scheduleFactory);
+
+ /**
+ * Initialise
+ * @return $this
+ */
+ public function init();
+
+ /**
+ * @return $this
+ */
+ public function collectNow();
+
+ /**
+ * @return $this
+ */
+ public function collectLater();
+
+ /**
+ * Process Queue of Display Notifications
+ * @return $this
+ */
+ public function processQueue();
+
+ /**
+ * Notify by Display Id
+ * @param $displayId
+ */
+ public function notifyByDisplayId($displayId);
+
+ /**
+ * Notify by Display Group Id
+ * @param $displayGroupId
+ */
+ public function notifyByDisplayGroupId($displayGroupId);
+
+ /**
+ * Notify by CampaignId
+ * @param $campaignId
+ */
+ public function notifyByCampaignId($campaignId);
+
+ /**
+ * Notify by DataSetId
+ * @param $dataSetId
+ */
+ public function notifyByDataSetId($dataSetId);
+
+ /**
+ * Notify by PlaylistId
+ * @param $playlistId
+ */
+ public function notifyByPlaylistId($playlistId);
+
+ /**
+ * Notify By Layout Code
+ * @param $code
+ */
+ public function notifyByLayoutCode($code);
+
+ /**
+ * Notify by Menu Board ID
+ * @param $menuId
+ */
+ public function notifyByMenuBoardId($menuId);
+
+ /**
+ * Notify that data has been updated for this display
+ * @param \Xibo\Entity\Display $display
+ * @param int $widgetId
+ * @return void
+ */
+ public function notifyDataUpdate(Display $display, int $widgetId): void;
+}
diff --git a/lib/Service/DownloadService.php b/lib/Service/DownloadService.php
new file mode 100644
index 0000000..64c8a34
--- /dev/null
+++ b/lib/Service/DownloadService.php
@@ -0,0 +1,80 @@
+filePath = $filePath;
+ $this->sendFileMode = $sendFileMode;
+ }
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return $this
+ */
+ public function useLogger(LoggerInterface $logger): DownloadService
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ public function returnFile($response, $attachmentName, $nginxRedirect)
+ {
+ // Issue some headers
+ $response = HttpCacheProvider::withEtag($response, $this->filePath);
+ $response = HttpCacheProvider::withExpires($response, '+1 week');
+ // Set some headers
+ $headers = [];
+ $headers['Content-Length'] = filesize($this->filePath);
+ $headers['Content-Type'] = 'application/octet-stream';
+ $headers['Content-Transfer-Encoding'] = 'Binary';
+ $headers['Content-disposition'] = 'attachment; filename="' . $attachmentName . '"';
+
+ // Output the file
+ if ($this->sendFileMode === 'Apache') {
+ // Send via Apache X-Sendfile header?
+ $headers['X-Sendfile'] = $this->filePath;
+ } else if ($this->sendFileMode === 'Nginx') {
+ // Send via Nginx X-Accel-Redirect?
+ $headers['X-Accel-Redirect'] = $nginxRedirect;
+ }
+
+ // Add the headers we've collected to our response
+ foreach ($headers as $header => $value) {
+ $response = $response->withHeader($header, $value);
+ }
+
+ // Should we output the file via the application stack, or directly by reading the file.
+ if ($this->sendFileMode == 'Off') {
+ // Return the file with PHP
+ $response = $response->withBody(new Stream(fopen($this->filePath, 'r')));
+
+ $this->logger->debug('Returning Stream with response body, sendfile off.');
+ } else {
+ $this->logger->debug('Using sendfile to return the file, only output headers.');
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/Service/HelpService.php b/lib/Service/HelpService.php
new file mode 100644
index 0000000..5c72028
--- /dev/null
+++ b/lib/Service/HelpService.php
@@ -0,0 +1,97 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Illuminate\Support\Str;
+use Symfony\Component\Yaml\Yaml;
+use Xibo\Entity\HelpLink;
+
+/**
+ * Class HelpService
+ * @package Xibo\Service
+ */
+class HelpService implements HelpServiceInterface
+{
+ /** @var string */
+ private string $helpBase;
+
+ private ?array $links = null;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($helpBase)
+ {
+ $this->helpBase = $helpBase;
+ }
+
+ public function getLandingPage(): string
+ {
+ return $this->helpBase;
+ }
+
+ public function getLinksForPage(string $pageName): array
+ {
+ if ($this->links === null) {
+ $this->loadLinks();
+ }
+ return $this->links[$pageName] ?? [];
+ }
+
+ private function loadLinks(): void
+ {
+ // Load links from file.
+ try {
+ if (file_exists(PROJECT_ROOT . '/custom/help-links.yaml')) {
+ $links = (array)Yaml::parseFile(PROJECT_ROOT . '/custom/help-links.yaml');
+ } else if (file_exists(PROJECT_ROOT . '/help-links.yaml')) {
+ $links = (array)Yaml::parseFile(PROJECT_ROOT . '/help-links.yaml');
+ } else {
+ $this->links = [];
+ return;
+ }
+ } catch (\Exception) {
+ return;
+ }
+
+ // Parse links.
+ $this->links = [];
+
+ foreach ($links as $pageName => $page) {
+ // New page
+ $this->links[$pageName] = [];
+
+ foreach ($page as $link) {
+ $helpLink = new HelpLink($link);
+ if (!Str::startsWith($helpLink->url, ['http://', 'https://'])) {
+ $helpLink->url = $this->helpBase . $helpLink->url;
+ }
+ if (!empty($helpLink->summary)) {
+ $helpLink->summary = \Parsedown::instance()->setSafeMode(true)->line($helpLink->summary);
+ }
+
+ $this->links[$pageName][] = $helpLink;
+ }
+ }
+ }
+}
diff --git a/lib/Service/HelpServiceInterface.php b/lib/Service/HelpServiceInterface.php
new file mode 100644
index 0000000..ab997e9
--- /dev/null
+++ b/lib/Service/HelpServiceInterface.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Xibo\Entity\HelpLink;
+
+/**
+ * Return help links for a page.
+ * @package Xibo\Service
+ */
+interface HelpServiceInterface
+{
+ /**
+ * Get the landing page
+ * @return string
+ */
+ public function getLandingPage(): string;
+
+ /**
+ * Get links for page
+ * @param string $pageName The page name to return links for
+ * @return HelpLink[]
+ */
+ public function getLinksForPage(string $pageName): array;
+}
diff --git a/lib/Service/ImageProcessingService.php b/lib/Service/ImageProcessingService.php
new file mode 100644
index 0000000..834ff59
--- /dev/null
+++ b/lib/Service/ImageProcessingService.php
@@ -0,0 +1,82 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Xibo\Service\ImageProcessingServiceInterface;
+use Intervention\Image\Exception\NotReadableException;
+use Intervention\Image\ImageManagerStatic as Img;
+
+/**
+ * Class ImageProcessingService
+ * @package Xibo\Service
+ */
+class ImageProcessingService implements ImageProcessingServiceInterface
+{
+
+ /** @var LogServiceInterface */
+ private $log;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct()
+ {
+
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setDependencies($log)
+ {
+ $this->log = $log;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function resizeImage($filePath, $width, $height)
+ {
+ try {
+ Img::configure(array('driver' => 'gd'));
+ $img = Img::make($filePath);
+ $img->resize($width, $height, function ($constraint) {
+ $constraint->aspectRatio();
+ });
+
+ // Get the updated height and width
+ $updatedHeight = $img->height();
+ $updatedWidth = $img->width();
+
+ $img->save($filePath);
+ $img->destroy();
+ } catch (NotReadableException $notReadableException) {
+ $this->log->error('Image not readable: ' . $notReadableException->getMessage());
+ }
+
+ return [
+ 'filePath' => $filePath,
+ 'height' => $updatedHeight ?? $height,
+ 'width' => $updatedWidth ?? $width
+ ];
+ }
+}
\ No newline at end of file
diff --git a/lib/Service/ImageProcessingServiceInterface.php b/lib/Service/ImageProcessingServiceInterface.php
new file mode 100644
index 0000000..3684d75
--- /dev/null
+++ b/lib/Service/ImageProcessingServiceInterface.php
@@ -0,0 +1,51 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Xibo\Service\LogServiceInterface;
+
+/**
+ * Interface ImageProcessingServiceInterface
+ * @package Xibo\Service
+ */
+interface ImageProcessingServiceInterface
+{
+ /**
+ * Image Processing constructor.
+ */
+ public function __construct();
+
+ /**
+ * Set Image Processing Dependencies
+ * @param LogServiceInterface $logger
+ */
+ public function setDependencies($logger);
+
+ /**
+ * Resize Image
+ * @param $filePath string
+ * @param $width int
+ * @param $height int
+ */
+ public function resizeImage($filePath, $width, $height);
+}
\ No newline at end of file
diff --git a/lib/Service/JwtService.php b/lib/Service/JwtService.php
new file mode 100644
index 0000000..4fe1c90
--- /dev/null
+++ b/lib/Service/JwtService.php
@@ -0,0 +1,143 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Carbon\Carbon;
+use Lcobucci\Clock\SystemClock;
+use Lcobucci\JWT\Configuration;
+use Lcobucci\JWT\Encoding\ChainedFormatter;
+use Lcobucci\JWT\Encoding\JoseEncoder;
+use Lcobucci\JWT\Signer\Key;
+use Lcobucci\JWT\Signer\Key\InMemory;
+use Lcobucci\JWT\Signer\Rsa\Sha256;
+use Lcobucci\JWT\Token;
+use Lcobucci\JWT\Token\Builder;
+use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
+use Lcobucci\JWT\Validation\Constraint\SignedWith;
+use Lcobucci\JWT\Validation\Constraint\ValidAt;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * A service to create and validate JWTs
+ */
+class JwtService implements JwtServiceInterface
+{
+ /** @var \Psr\Log\LoggerInterface */
+ private $logger;
+
+ /** @var array */
+ private $keys;
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return \Xibo\Service\JwtServiceInterface
+ */
+ public function useLogger(LoggerInterface $logger): JwtServiceInterface
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ /**
+ * @return \Psr\Log\LoggerInterface|\Psr\Log\NullLogger
+ */
+ private function getLogger(): LoggerInterface
+ {
+ if ($this->logger === null) {
+ return new NullLogger();
+ }
+ return $this->logger;
+ }
+
+ /**
+ * @param $keys
+ * @return \Xibo\Service\JwtServiceInterface
+ */
+ public function useKeys($keys): JwtServiceInterface
+ {
+ $this->keys = $keys;
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function generateJwt($issuedBy, $permittedFor, $identifiedBy, $relatedTo, $ttl): Token
+ {
+ $this->getLogger()->debug('generateJwt: Private key path is: ' . $this->getPrivateKeyPath()
+ . ', identifiedBy: ' . $identifiedBy . ', relatedTo: ' . $relatedTo);
+
+ $tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
+ $signingKey = Key\InMemory::file($this->getPrivateKeyPath());
+ return $tokenBuilder
+ ->issuedBy($issuedBy)
+ ->permittedFor($permittedFor)
+ ->identifiedBy($identifiedBy)
+ ->issuedAt(Carbon::now()->toDateTimeImmutable())
+ ->canOnlyBeUsedAfter(Carbon::now()->toDateTimeImmutable())
+ ->expiresAt(Carbon::now()->addSeconds($ttl)->toDateTimeImmutable())
+ ->relatedTo($relatedTo)
+ ->getToken(new Sha256(), $signingKey);
+ }
+
+ /** @inheritDoc */
+ public function validateJwt($jwt): ?Token
+ {
+ $this->getLogger()->debug('validateJwt: ' . $jwt);
+
+ $signingKey = Key\InMemory::file($this->getPrivateKeyPath());
+ $configuration = Configuration::forSymmetricSigner(new Sha256(), $signingKey);
+
+ $configuration->setValidationConstraints(
+ new LooseValidAt(new SystemClock(new \DateTimeZone(\date_default_timezone_get()))),
+ new SignedWith(new Sha256(), InMemory::plainText(file_get_contents($this->getPublicKeyPath())))
+ );
+
+ // Parse the token
+ $token = $configuration->parser()->parse($jwt);
+
+ $this->getLogger()->debug('validateJwt: token parsed');
+
+ // Test against constraints.
+ $constraints = $configuration->validationConstraints();
+ $configuration->validator()->assert($token, ...$constraints);
+
+ $this->getLogger()->debug('validateJwt: constraints valid');
+ return $token;
+ }
+
+ /**
+ * @return string|null
+ */
+ private function getPublicKeyPath(): ?string
+ {
+ return $this->keys['publicKeyPath'] ?? null;
+ }
+
+ /**
+ * @return string|null
+ */
+ private function getPrivateKeyPath(): ?string
+ {
+ return $this->keys['privateKeyPath'] ?? null;
+ }
+}
diff --git a/lib/Service/JwtServiceInterface.php b/lib/Service/JwtServiceInterface.php
new file mode 100644
index 0000000..520fb47
--- /dev/null
+++ b/lib/Service/JwtServiceInterface.php
@@ -0,0 +1,36 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Lcobucci\JWT\Token;
+use Psr\Log\LoggerInterface;
+
+/**
+ * A service to create and validate JWTs
+ */
+interface JwtServiceInterface
+{
+ public function useLogger(LoggerInterface $logger): JwtServiceInterface;
+ public function generateJwt($issuedBy, $permittedFor, $identifiedBy, $relatedTo, $ttl): Token;
+ public function validateJwt($jwt): ?Token;
+}
diff --git a/lib/Service/LogService.php b/lib/Service/LogService.php
new file mode 100644
index 0000000..2e2d3e8
--- /dev/null
+++ b/lib/Service/LogService.php
@@ -0,0 +1,373 @@
+.
+ */
+
+
+namespace Xibo\Service;
+
+use Carbon\Carbon;
+use Monolog\Logger;
+use Psr\Log\LoggerInterface;
+use Xibo\Helper\DatabaseLogHandler;
+use Xibo\Storage\PdoStorageService;
+
+/**
+ * Class LogService
+ * @package Xibo\Service
+ */
+class LogService implements LogServiceInterface
+{
+ /**
+ * @var \Psr\Log\LoggerInterface
+ */
+ private $log;
+
+ /**
+ * The Log Mode
+ * @var string
+ */
+ private $mode;
+
+ /**
+ * The user Id
+ * @var int
+ */
+ private $userId = 0;
+
+ /**
+ * The User IP Address
+ */
+ private $ipAddress;
+
+ /**
+ * Audit Log Statement
+ * @var \PDOStatement
+ */
+ private $_auditLogStatement;
+
+ /**
+ * The History session id.
+ */
+ private $sessionHistoryId = 0;
+
+ /**
+ * The API requestId.
+ */
+ private $requestId = 0;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($logger, $mode = 'production')
+ {
+ $this->log = $logger;
+ $this->mode = $mode;
+ }
+
+ /** @inheritDoc */
+ public function getLoggerInterface(): LoggerInterface
+ {
+ return $this->log;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setIpAddress($ip)
+ {
+ $this->ipAddress = $ip;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setUserId($userId)
+ {
+ $this->userId = $userId;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setSessionHistoryId($sessionHistoryId)
+ {
+ $this->sessionHistoryId = $sessionHistoryId;
+ }
+
+ public function setRequestId($requestId)
+ {
+ $this->requestId = $requestId;
+ }
+
+ public function getUserId(): ?int
+ {
+ return $this->userId;
+ }
+
+ public function getSessionHistoryId(): ?int
+ {
+ return $this->sessionHistoryId;
+ }
+
+ public function getRequestId(): ?int
+ {
+ return $this->requestId;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setMode($mode)
+ {
+ $this->mode = $mode;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function audit($entity, $entityId, $message, $object)
+ {
+ $this->debug(sprintf(
+ 'Audit Trail message recorded for %s with id %d. Message: %s from IP %s, session %d',
+ $entity,
+ $entityId,
+ $message,
+ $this->ipAddress,
+ $this->sessionHistoryId
+ ));
+
+ if ($this->_auditLogStatement == null) {
+ $this->prepareAuditLogStatement();
+ }
+
+ // If we aren't a string then encode
+ if (!is_string($object)) {
+ $object = json_encode($object);
+ }
+
+ $params = [
+ 'logDate' => Carbon::now()->format('U'),
+ 'userId' => $this->userId,
+ 'entity' => $entity,
+ 'message' => $message,
+ 'entityId' => $entityId,
+ 'ipAddress' => $this->ipAddress,
+ 'objectAfter' => $object,
+ 'sessionHistoryId' => $this->sessionHistoryId,
+ 'requestId' => $this->requestId
+ ];
+
+ try {
+ $this->_auditLogStatement->execute($params);
+ } catch (\PDOException $PDOException) {
+ $errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
+
+ // Catch 2006 errors (mysql gone away)
+ if ($errorCode != 2006) {
+ throw $PDOException;
+ } else {
+ $this->prepareAuditLogStatement();
+ $this->_auditLogStatement->execute($params);
+ }
+ }
+
+ // Although we use the default connection, track audit status separately.
+ PdoStorageService::incrementStat('audit', 'insert');
+ }
+
+ /**
+ * Helper function to prepare a PDO statement for inserting into the Audit Log.
+ * sets $_auditLogStatement
+ * @return void
+ */
+ private function prepareAuditLogStatement(): void
+ {
+ // Use the default connection
+ // audit log should rollback on failure.
+ $dbh = PdoStorageService::newConnection('default');
+ $this->_auditLogStatement = $dbh->prepare('
+ INSERT INTO `auditlog` (
+ `logDate`,
+ `userId`,
+ `entity`,
+ `message`,
+ `entityId`,
+ `objectAfter`,
+ `ipAddress`,
+ `sessionHistoryId`,
+ `requestId`
+ )
+ VALUES (
+ :logDate,
+ :userId,
+ :entity,
+ :message,
+ :entityId,
+ :objectAfter,
+ :ipAddress,
+ :sessionHistoryId,
+ :requestId
+ )
+ ');
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function sql($sql, $params, $logAsError = false)
+ {
+ if (strtolower($this->mode) == 'test' || $logAsError) {
+ $paramSql = '';
+ foreach ($params as $key => $param) {
+ $paramSql .= 'SET @' . $key . '=\'' . $param . '\';' . PHP_EOL;
+ }
+
+ ($logAsError)
+ ? $this->log->error($paramSql . str_replace(':', '@', $sql))
+ : $this->log->debug($paramSql . str_replace(':', '@', $sql));
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function debug($object)
+ {
+ // Get the calling class / function
+ $this->log->debug($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function notice($object)
+ {
+ $this->log->notice($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function info($object)
+ {
+ $this->log->info($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function warning($object)
+ {
+ $this->log->warning($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function error($object)
+ {
+ $this->log->error($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function critical($object)
+ {
+ $this->log->critical($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function alert($object)
+ {
+ $this->log->alert($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function emergency($object)
+ {
+ $this->log->emergency($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ private function prepare($object, $args)
+ {
+ if (is_string($object)) {
+ array_shift($args);
+
+ if (count($args) > 0)
+ $object = vsprintf($object, $args);
+ }
+
+ return $object;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function resolveLogLevel($level)
+ {
+ switch (strtolower($level)) {
+
+ case 'emergency':
+ return Logger::EMERGENCY;
+
+ case 'alert':
+ return Logger::ALERT;
+
+ case 'critical':
+ return Logger::CRITICAL;
+
+ case 'warning':
+ return Logger::WARNING;
+
+ case 'notice':
+ return Logger::NOTICE;
+
+ case 'info':
+ return Logger::INFO;
+
+ case 'debug':
+ case 'audit' :
+ return Logger::DEBUG;
+
+ case 'error':
+ default:
+ return Logger::ERROR;
+ }
+ }
+
+ /** @inheritDoc */
+ public function setLevel($level)
+ {
+ foreach ($this->log->getHandlers() as $handler) {
+ if ($handler instanceof DatabaseLogHandler) {
+ $handler->setLevel($level);
+ }
+ }
+ }
+}
diff --git a/lib/Service/LogServiceInterface.php b/lib/Service/LogServiceInterface.php
new file mode 100644
index 0000000..6ecd6ba
--- /dev/null
+++ b/lib/Service/LogServiceInterface.php
@@ -0,0 +1,162 @@
+.
+ */
+
+
+namespace Xibo\Service;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Interface LogServiceInterface
+ * @package Xibo\Service
+ */
+interface LogServiceInterface
+{
+ /**
+ * Log constructor.
+ * @param LoggerInterface $logger
+ * @param string $mode
+ */
+ public function __construct($logger, $mode = 'production');
+
+ /**
+ * Get the underlying logger interface
+ * useful for custom code and modules which may not want to know about the full Xibo LogServiceInterface
+ * @return \Psr\Log\LoggerInterface
+ */
+ public function getLoggerInterface(): LoggerInterface;
+
+ public function getUserId(): ?int;
+ public function getSessionHistoryId(): ?int;
+ public function getRequestId(): ?int;
+
+ /**
+ * Set the user Id
+ * @param int $userId
+ */
+ public function setUserId($userId);
+
+ /**
+ * Set the User IP Address
+ * @param $ip
+ * @return mixed
+ */
+ public function setIpAddress($ip);
+
+ /**
+ * Set history session id
+ * @param $sessionHistoryId
+ * @return mixed
+ */
+ public function setSessionHistoryId($sessionHistoryId);
+
+ /**
+ * Set API requestId
+ * @param $requestId
+ * @return mixed
+ */
+ public function setRequestId($requestId);
+
+ /**
+ * @param $mode
+ * @return mixed
+ */
+ public function setMode($mode);
+
+ /**
+ * Audit Log
+ * @param string $entity
+ * @param int $entityId
+ * @param string $message
+ * @param string|object|array $object
+ */
+ public function audit($entity, $entityId, $message, $object);
+
+ /**
+ * @param $sql
+ * @param $params
+ * @param bool $logAsError
+ * @return mixed
+ */
+ public function sql($sql, $params, $logAsError = false);
+
+ /**
+ * @param string
+ * @return mixed
+ */
+ public function debug($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function notice($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function info($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function warning($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function error($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function critical($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function alert($object);
+
+ /**
+ * @param ...$object
+ * @return mixed
+ */
+ public function emergency($object);
+
+ /**
+ * Resolve the log level
+ * @param string $level
+ * @return int
+ */
+ public static function resolveLogLevel($level);
+
+ /**
+ * Set the log level on all handlers
+ * @param $level
+ */
+ public function setLevel($level);
+}
diff --git a/lib/Service/MediaService.php b/lib/Service/MediaService.php
new file mode 100644
index 0000000..f0f0d8f
--- /dev/null
+++ b/lib/Service/MediaService.php
@@ -0,0 +1,386 @@
+.
+ */
+namespace Xibo\Service;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use Mimey\MimeTypes;
+use Stash\Interfaces\PoolInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Event\MediaDeleteEvent;
+use Xibo\Factory\FontFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Environment;
+use Xibo\Helper\SanitizerService;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\LibraryFullException;
+
+/**
+ * MediaService
+ */
+class MediaService implements MediaServiceInterface
+{
+ /** @var ConfigServiceInterface */
+ private $configService;
+
+ /** @var LogServiceInterface */
+ private $log;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var SanitizerService */
+ private $sanitizerService;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var User */
+ private $user;
+
+ /** @var EventDispatcherInterface */
+ private $dispatcher;
+ /**
+ * @var FontFactory
+ */
+ private $fontFactory;
+
+ /** @inheritDoc */
+ public function __construct(
+ ConfigServiceInterface $configService,
+ LogServiceInterface $logService,
+ StorageServiceInterface $store,
+ SanitizerService $sanitizerService,
+ PoolInterface $pool,
+ MediaFactory $mediaFactory,
+ FontFactory $fontFactory
+ ) {
+ $this->configService = $configService;
+ $this->log = $logService;
+ $this->store = $store;
+ $this->sanitizerService = $sanitizerService;
+ $this->pool = $pool;
+ $this->mediaFactory = $mediaFactory;
+ $this->fontFactory = $fontFactory;
+ }
+
+ /** @inheritDoc */
+ public function setUser(User $user) : MediaServiceInterface
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function getUser() : User
+ {
+ return $this->user;
+ }
+
+ public function getPool() : PoolInterface
+ {
+ return $this->pool;
+ }
+
+ /** @inheritDoc */
+ public function setDispatcher(EventDispatcherInterface $dispatcher): MediaServiceInterface
+ {
+ $this->dispatcher = $dispatcher;
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function libraryUsage(): int
+ {
+ $results = $this->store->select('SELECT IFNULL(SUM(FileSize), 0) AS SumSize FROM media', []);
+
+ return $this->sanitizerService->getSanitizer($results[0])->getInt('SumSize');
+ }
+
+ /** @inheritDoc */
+ public function initLibrary(): MediaServiceInterface
+ {
+ MediaService::ensureLibraryExists($this->configService->getSetting('LIBRARY_LOCATION'));
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function checkLibraryOrQuotaFull($isCheckUser = false): MediaServiceInterface
+ {
+ // Check that we have some space in our library
+ $librarySizeLimit = $this->configService->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+ $librarySizeLimitMB = round(($librarySizeLimit / 1024) / 1024, 2);
+
+ if ($librarySizeLimit > 0 && $this->libraryUsage() > $librarySizeLimit) {
+ throw new LibraryFullException(sprintf(__('Your library is full. Library Limit: %s MB'), $librarySizeLimitMB));
+ }
+
+ if ($isCheckUser) {
+ $this->getUser()->isQuotaFullByUser();
+ }
+
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function checkMaxUploadSize($size): MediaServiceInterface
+ {
+ if (ByteFormatter::toBytes(Environment::getMaxUploadSize()) < $size) {
+ throw new InvalidArgumentException(
+ sprintf(__('This file size exceeds your environment Max Upload Size %s'), Environment::getMaxUploadSize()),
+ 'size'
+ );
+ }
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function getDownloadInfo($url): array
+ {
+ $downloadInfo = [];
+ $guzzle = new Client($this->configService->getGuzzleProxy());
+
+ // first try to get the extension from pathinfo
+ $info = pathinfo(parse_url($url, PHP_URL_PATH));
+ $extension = $info['extension'] ?? '';
+ $size = -1;
+
+ try {
+ $head = $guzzle->head($url);
+
+ // First chance at getting the content length so that we can fail early.
+ // Will fail for downloads with redirects.
+ if ($head->hasHeader('Content-Length')) {
+ $contentLength = $head->getHeader('Content-Length');
+
+ foreach ($contentLength as $value) {
+ $size = $value;
+ }
+ }
+
+ if (empty($extension)) {
+ $contentType = $head->getHeaderLine('Content-Type');
+
+ $extension = $contentType;
+
+ if ($contentType === 'binary/octet-stream' && $head->hasHeader('x-amz-meta-filetype')) {
+ $amazonContentType = $head->getHeaderLine('x-amz-meta-filetype');
+ $extension = $amazonContentType;
+ }
+
+ // get the extension corresponding to the mime type
+ $mimeTypes = new MimeTypes();
+ $extension = $mimeTypes->getExtension($extension);
+ }
+ } catch (RequestException $e) {
+ $this->log->debug('Upload from url head request failed for URL ' . $url
+ . ' with following message ' . $e->getMessage());
+ }
+
+ $downloadInfo['size'] = $size;
+ $downloadInfo['extension'] = $extension;
+ $downloadInfo['filename'] = $info['filename'];
+
+ return $downloadInfo;
+ }
+
+ /** @inheritDoc */
+ public function updateFontsCss()
+ {
+ // delete local cms fonts.css from cache
+ $this->pool->deleteItem('localFontCss');
+
+ $this->log->debug('Regenerating player fonts.css file');
+
+ // Go through all installed fonts each time and regenerate.
+ $fontTemplate = '@font-face {
+ font-family: \'[family]\';
+ src: url(\'[url]\');
+}';
+
+ // Save a fonts.css file to the library for use as a module
+ $fonts = $this->fontFactory->query();
+
+ $css = '';
+
+ // Check the library exists
+ $libraryLocation = $this->configService->getSetting('LIBRARY_LOCATION');
+ MediaService::ensureLibraryExists($this->configService->getSetting('LIBRARY_LOCATION'));
+
+ // Build our font strings.
+ foreach ($fonts as $font) {
+ // Css for the player contains the actual stored as location of the font.
+ $css .= str_replace('[url]', $font->fileName, str_replace('[family]', $font->familyName, $fontTemplate));
+ }
+
+ // If we're a full regenerate, we want to also update the fonts.css file.
+ $existingLibraryFontsCss = '';
+ if (file_exists($libraryLocation . 'fonts/fonts.css')) {
+ $existingLibraryFontsCss = file_get_contents($libraryLocation . 'fonts/fonts.css');
+ }
+
+ $tempFontsCss = $libraryLocation . 'temp/fonts.css';
+ file_put_contents($tempFontsCss, $css);
+ // Check to see if the existing file is different from the new one
+ if ($existingLibraryFontsCss == '' || md5($existingLibraryFontsCss) !== md5($tempFontsCss)) {
+ $this->log->info('Detected change in fonts.css file, dropping the Display cache');
+ rename($tempFontsCss, $libraryLocation . 'fonts/fonts.css');
+ // Clear the display cache
+ $this->pool->deleteItem('/display');
+ } else {
+ @unlink($tempFontsCss);
+ $this->log->debug('Newly generated fonts.css is the same as the old file. Ignoring.');
+ }
+ }
+
+ /** @inheritDoc */
+ public static function ensureLibraryExists($libraryFolder)
+ {
+ // Check that this location exists - and if not create it..
+ if (!file_exists($libraryFolder)) {
+ mkdir($libraryFolder, 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/temp')) {
+ mkdir($libraryFolder . '/temp', 0777, true);
+ }
+ if (!file_exists($libraryFolder . '/cache')) {
+ mkdir($libraryFolder . '/cache', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/screenshots')) {
+ mkdir($libraryFolder . '/screenshots', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/attachment')) {
+ mkdir($libraryFolder . '/attachment', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/thumbs')) {
+ mkdir($libraryFolder . '/thumbs', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/fonts')) {
+ mkdir($libraryFolder . '/fonts', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/playersoftware')) {
+ mkdir($libraryFolder . '/playersoftware', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/playersoftware/chromeos')) {
+ mkdir($libraryFolder . '/playersoftware/chromeos', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/savedreport')) {
+ mkdir($libraryFolder . '/savedreport', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/assets')) {
+ mkdir($libraryFolder . '/assets', 0777, true);
+ }
+
+ if (!file_exists($libraryFolder . '/data_connectors')) {
+ mkdir($libraryFolder . '/data_connectors', 0777, true);
+ }
+
+ // Check that we are now writable - if not then error
+ if (!is_writable($libraryFolder)) {
+ throw new ConfigurationException(__('Library not writable'));
+ }
+ }
+
+ /** @inheritDoc */
+ public function removeTempFiles()
+ {
+ $libraryTemp = $this->configService->getSetting('LIBRARY_LOCATION') . 'temp';
+
+ if (!is_dir($libraryTemp)) {
+ return;
+ }
+
+ // Dump the files in the temp folder
+ foreach (scandir($libraryTemp) as $item) {
+ if ($item == '.' || $item == '..') {
+ continue;
+ }
+
+ // Path
+ $filePath = $libraryTemp . DIRECTORY_SEPARATOR . $item;
+
+ if (is_dir($filePath)) {
+ $this->log->debug('Skipping folder: ' . $item);
+ continue;
+ }
+
+ // Has this file been written to recently?
+ if (filemtime($filePath) > Carbon::now()->subSeconds(86400)->format('U')) {
+ $this->log->debug('Skipping active file: ' . $item);
+ continue;
+ }
+
+ $this->log->debug('Deleting temp file: ' . $item);
+
+ unlink($filePath);
+ }
+ }
+
+ /** @inheritDoc */
+ public function removeExpiredFiles()
+ {
+ // Get a list of all expired files and delete them
+ foreach ($this->mediaFactory->query(
+ null,
+ [
+ 'expires' => Carbon::now()->format('U'),
+ 'allModules' => 1,
+ 'unlinkedOnly' => 1,
+ 'length' => 100,
+ ]
+ ) as $entry) {
+ // If the media type is a module, then pretend it's a generic file
+ $this->log->info(sprintf('Removing Expired File %s', $entry->name));
+ $this->log->audit(
+ 'Media',
+ $entry->mediaId,
+ 'Removing Expired',
+ [
+ 'mediaId' => $entry->mediaId,
+ 'name' => $entry->name,
+ 'expired' => Carbon::createFromTimestamp($entry->expires)
+ ->format(DateFormatHelper::getSystemFormat())
+ ]
+ );
+ $this->dispatcher->dispatch(new MediaDeleteEvent($entry), MediaDeleteEvent::$NAME);
+ $entry->delete();
+ }
+ }
+}
diff --git a/lib/Service/MediaServiceInterface.php b/lib/Service/MediaServiceInterface.php
new file mode 100644
index 0000000..4197fa4
--- /dev/null
+++ b/lib/Service/MediaServiceInterface.php
@@ -0,0 +1,144 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Slim\Routing\RouteParser;
+use Stash\Interfaces\PoolInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\User;
+use Xibo\Factory\FontFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Helper\SanitizerService;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * MediaServiceInterface
+ * Provides common functionality for library media
+ */
+interface MediaServiceInterface
+{
+ /**
+ * MediaService constructor.
+ * @param ConfigServiceInterface $configService
+ * @param LogServiceInterface $logService
+ * @param StorageServiceInterface $store
+ * @param SanitizerService $sanitizerService
+ * @param PoolInterface $pool
+ * @param MediaFactory $mediaFactory
+ * @param FontFactory $fontFactory
+ */
+ public function __construct(
+ ConfigServiceInterface $configService,
+ LogServiceInterface $logService,
+ StorageServiceInterface $store,
+ SanitizerService $sanitizerService,
+ PoolInterface $pool,
+ MediaFactory $mediaFactory,
+ FontFactory $fontFactory
+ );
+
+ /**
+ * @param User $user
+ */
+ public function setUser(User $user): MediaServiceInterface;
+
+ /**
+ * @return User
+ */
+ public function getUser(): User;
+
+ /**
+ * @return PoolInterface
+ */
+ public function getPool() : PoolInterface;
+
+ /**
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @return MediaServiceInterface
+ */
+ public function setDispatcher(EventDispatcherInterface $dispatcher): MediaServiceInterface;
+
+ /**
+ * Library Usage
+ * @return int
+ */
+ public function libraryUsage(): int;
+
+ /**
+ * @return $this
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ */
+ public function initLibrary(): MediaServiceInterface;
+
+ /**
+ * @return $this
+ * @throws \Xibo\Support\Exception\LibraryFullException
+ */
+ public function checkLibraryOrQuotaFull($isCheckUser = false): MediaServiceInterface;
+
+ /**
+ * @param $size
+ * @return \Xibo\Service\MediaService
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function checkMaxUploadSize($size): MediaServiceInterface;
+
+ /**
+ * Get download info for a URL
+ * we're looking for the file size and the extension
+ * @param $url
+ * @return array
+ */
+ public function getDownloadInfo($url): array;
+
+ /**
+ * @return array|mixed
+ * @throws ConfigurationException
+ * @throws \Xibo\Support\Exception\DuplicateEntityException
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function updateFontsCss();
+
+ /**
+ * @param $libraryFolder
+ * @throws ConfigurationException
+ */
+ public static function ensureLibraryExists($libraryFolder);
+
+ /**
+ * Remove temporary files
+ */
+ public function removeTempFiles();
+
+ /**
+ * Removes all expired media files
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ public function removeExpiredFiles();
+}
diff --git a/lib/Service/NullLogService.php b/lib/Service/NullLogService.php
new file mode 100644
index 0000000..35418c3
--- /dev/null
+++ b/lib/Service/NullLogService.php
@@ -0,0 +1,240 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Monolog\Logger;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class NullLogService
+ * @package Xibo\Service
+ */
+class NullLogService implements LogServiceInterface
+{
+ /**
+ * @var \Psr\Log\LoggerInterface
+ */
+ private $log;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($logger, $mode = 'production')
+ {
+ $this->log = $logger;
+ }
+
+ /** @inheritDoc */
+ public function getLoggerInterface(): LoggerInterface
+ {
+ return $this->log;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setUserId($userId)
+ {
+ //
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setIpAddress($ip)
+ {
+ //
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setMode($mode)
+ {
+ //
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function audit($entity, $entityId, $message, $object)
+ {
+ //
+ }
+
+ /**
+ * @param $sql
+ * @param $params
+ * @param false $logAsError
+ * @inheritdoc
+ */
+ public function sql($sql, $params, $logAsError = false)
+ {
+ //
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function debug($object)
+ {
+ // Get the calling class / function
+ $this->log->debug($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function notice($object)
+ {
+ $this->log->notice($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function info($object)
+ {
+ $this->log->info($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function warning($object)
+ {
+ $this->log->warning($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function error($object)
+ {
+ $this->log->error($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function critical($object)
+ {
+ $this->log->critical($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function alert($object)
+ {
+ $this->log->alert($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function emergency($object)
+ {
+ $this->log->emergency($this->prepare($object, func_get_args()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ private function prepare($object, $args)
+ {
+ if (is_string($object)) {
+ array_shift($args);
+
+ if (count($args) > 0)
+ $object = vsprintf($object, $args);
+ }
+
+ return $object;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function resolveLogLevel($level)
+ {
+ switch (strtolower($level)) {
+
+ case 'emergency':
+ return Logger::EMERGENCY;
+
+ case 'alert':
+ return Logger::ALERT;
+
+ case 'critical':
+ return Logger::CRITICAL;
+
+ case 'warning':
+ return Logger::WARNING;
+
+ case 'notice':
+ return Logger::NOTICE;
+
+ case 'info':
+ return Logger::INFO;
+
+ case 'debug':
+ return Logger::DEBUG;
+
+ case 'error':
+ default:
+ return Logger::ERROR;
+ }
+ }
+
+ /** @inheritDoc */
+ public function setLevel($level)
+ {
+ //
+ }
+
+ public function getUserId(): ?int
+ {
+ return null;
+ }
+
+ public function getSessionHistoryId(): ?int
+ {
+ return null;
+ }
+
+ public function getRequestId(): ?int
+ {
+ return null;
+ }
+
+ public function setSessionHistoryId($sessionHistoryId)
+ {
+ //
+ }
+
+ public function setRequestId($requestId)
+ {
+ //
+ }
+}
diff --git a/lib/Service/PlayerActionService.php b/lib/Service/PlayerActionService.php
new file mode 100644
index 0000000..978328f
--- /dev/null
+++ b/lib/Service/PlayerActionService.php
@@ -0,0 +1,196 @@
+.
+ */
+
+
+namespace Xibo\Service;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Xibo\Entity\Display;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\XMR\PlayerAction;
+use Xibo\XMR\PlayerActionException;
+
+/**
+ * Class PlayerActionService
+ * @package Xibo\Service
+ */
+class PlayerActionService implements PlayerActionServiceInterface
+{
+ private ?string $xmrAddress;
+
+ /** @var PlayerAction[] */
+ private array $actions = [];
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct(
+ private readonly ConfigServiceInterface $config,
+ private readonly LogServiceInterface $log,
+ private readonly bool $triggerPlayerActions
+ ) {
+ $this->xmrAddress = null;
+ }
+
+ /**
+ * Get Config
+ * @return ConfigServiceInterface
+ */
+ private function getConfig(): ConfigServiceInterface
+ {
+ return $this->config;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function sendAction($displays, $action): void
+ {
+ if (!$this->triggerPlayerActions) {
+ return;
+ }
+
+ // XMR network address
+ if ($this->xmrAddress == null) {
+ $this->xmrAddress = $this->getConfig()->getSetting('XMR_ADDRESS');
+ }
+
+ if (empty($this->xmrAddress)) {
+ throw new InvalidArgumentException(__('XMR address is not set'), 'xmrAddress');
+ }
+
+ if (!is_array($displays)) {
+ $displays = [$displays];
+ }
+
+ // Send a message to all displays
+ foreach ($displays as $display) {
+ /* @var Display $display */
+ $isEncrypt = false;
+
+ if ($display->xmrChannel == '') {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('%s is not configured or ready to receive push commands over XMR. Please contact your administrator.'),//phpcs:ignore
+ $display->display
+ ),
+ 'xmrChannel'
+ );
+ }
+
+ // If we are using the old ZMQ XMR service, we also need to encrypt the message
+ if (!$display->isWebSocketXmrSupported()) {
+ // We also need a xmrPubKey
+ $isEncrypt = true;
+
+ if ($display->xmrPubKey == '') {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('%s is not configured or ready to receive push commands over XMR. Please contact your administrator.'),//phpcs:ignore
+ $display->display
+ ),
+ 'xmrPubKey'
+ );
+ }
+ }
+
+ // Do not send anything if XMR is disabled.
+ if (($isEncrypt && $this->getConfig()->getSetting('XMR_WS_ADDRESS') === 'DISABLED')
+ || (!$isEncrypt && $this->getConfig()->getSetting('XMR_PUB_ADDRESS') === 'DISABLED')
+ ) {
+ continue;
+ }
+
+ $displayAction = clone $action;
+
+ try {
+ $displayAction->setIdentity($display->xmrChannel, $isEncrypt, $display->xmrPubKey ?? null);
+ } catch (\Exception $exception) {
+ throw new InvalidArgumentException(
+ sprintf(
+ __('%s Invalid XMR registration'),
+ $display->display
+ ),
+ 'xmrPubKey'
+ );
+ }
+
+ // Add to collection
+ $this->actions[] = $displayAction;
+ }
+ }
+
+ /** @inheritDoc */
+ public function getQueue(): array
+ {
+ return $this->actions;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function processQueue(): void
+ {
+ if (count($this->actions) > 0) {
+ $this->log->debug('Player Action Service is looking to send %d actions', count($this->actions));
+ } else {
+ return;
+ }
+
+ // XMR network address
+ if ($this->xmrAddress == null) {
+ $this->xmrAddress = $this->getConfig()->getSetting('XMR_ADDRESS');
+ }
+
+ $client = new Client($this->config->getGuzzleProxy([
+ 'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'),
+ ]));
+
+ $failures = 0;
+
+ // TODO: could I send them all in one request instead?
+ foreach ($this->actions as $action) {
+ /** @var PlayerAction $action */
+ try {
+ // Send each action
+ $client->post('/', [
+ 'json' => $action->finaliseMessage(),
+ ]);
+ } catch (GuzzleException | PlayerActionException $e) {
+ $this->log->error('Player action connection failed. E = ' . $e->getMessage());
+ $failures++;
+ }
+ }
+
+ if ($failures > 0) {
+ throw new ConfigurationException(
+ sprintf(
+ __('%d of %d player actions failed'),
+ $failures,
+ count($this->actions)
+ )
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Service/PlayerActionServiceInterface.php b/lib/Service/PlayerActionServiceInterface.php
new file mode 100644
index 0000000..3b99463
--- /dev/null
+++ b/lib/Service/PlayerActionServiceInterface.php
@@ -0,0 +1,56 @@
+.
+ */
+namespace Xibo\Service;
+
+use Xibo\Entity\Display;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\XMR\PlayerAction;
+
+/**
+ * Interface PlayerActionServiceInterface
+ * @package Xibo\Service
+ */
+interface PlayerActionServiceInterface
+{
+ /**
+ * PlayerActionHelper constructor.
+ */
+ public function __construct(ConfigServiceInterface $config, LogServiceInterface $log, bool $triggerPlayerActions);
+
+ /**
+ * @param Display[]|Display $displays
+ * @param PlayerAction $action
+ * @throws GeneralException
+ */
+ public function sendAction($displays, $action): void;
+
+ /**
+ * Get the queue
+ */
+ public function getQueue(): array;
+
+ /**
+ * Process the Queue of Actions
+ * @throws GeneralException
+ */
+ public function processQueue(): void;
+}
diff --git a/lib/Service/ReportService.php b/lib/Service/ReportService.php
new file mode 100644
index 0000000..393b03b
--- /dev/null
+++ b/lib/Service/ReportService.php
@@ -0,0 +1,431 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Illuminate\Support\Str;
+use Psr\Container\ContainerInterface;
+use Slim\Http\ServerRequest as Request;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\ReportResult;
+use Xibo\Event\ConnectorReportEvent;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Helper\SanitizerService;
+use Xibo\Report\ReportInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ReportScheduleService
+ * @package Xibo\Service
+ */
+class ReportService implements ReportServiceInterface
+{
+ /**
+ * @var ContainerInterface
+ */
+ public $container;
+
+ /**
+ * @var StorageServiceInterface
+ */
+ private $store;
+
+ /**
+ * @var TimeSeriesStoreInterface
+ */
+ private $timeSeriesStore;
+
+ /**
+ * @var LogServiceInterface
+ */
+ private $log;
+
+ /**
+ * @var ConfigServiceInterface
+ */
+ private $config;
+
+ /**
+ * @var SanitizerService
+ */
+ private $sanitizer;
+
+ /**
+ * @var SavedReportFactory
+ */
+ private $savedReportFactory;
+
+ /** @var EventDispatcherInterface */
+ private $dispatcher;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($container, $store, $timeSeriesStore, $log, $config, $sanitizer, $savedReportFactory)
+ {
+ $this->container = $container;
+ $this->store = $store;
+ $this->timeSeriesStore = $timeSeriesStore;
+ $this->log = $log;
+ $this->config = $config;
+ $this->sanitizer = $sanitizer;
+ $this->savedReportFactory = $savedReportFactory;
+ }
+
+ /** @inheritDoc */
+ public function setDispatcher(EventDispatcherInterface $dispatcher): ReportServiceInterface
+ {
+ $this->dispatcher = $dispatcher;
+ return $this;
+ }
+
+ public function getDispatcher(): EventDispatcherInterface
+ {
+ if ($this->dispatcher === null) {
+ $this->dispatcher = new EventDispatcher();
+ }
+ return $this->dispatcher;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function listReports()
+ {
+ $reports = [];
+
+ $files = array_merge(glob(PROJECT_ROOT . '/reports/*.report'), glob(PROJECT_ROOT . '/custom/*.report'));
+
+ foreach ($files as $file) {
+ $config = json_decode(file_get_contents($file));
+ $config->file = Str::replaceFirst(PROJECT_ROOT, '', $file);
+
+ // Compatibility check
+ if (!isset($config->feature) || !isset($config->category)) {
+ continue;
+ }
+
+ // Check if only allowed for admin
+ if ($this->container->get('user')->userTypeId != 1) {
+ if (isset($config->adminOnly) && !empty($config->adminOnly)) {
+ continue;
+ }
+ }
+
+ // Check Permissions
+ if (!$this->container->get('user')->featureEnabled($config->feature)) {
+ continue;
+ }
+
+ $reports[$config->category][] = $config;
+ }
+
+ $this->log->debug('Reports found in total: '.count($reports));
+
+ // Get reports that are allowed by connectors
+ $event = new ConnectorReportEvent();
+ $this->getDispatcher()->dispatch($event, ConnectorReportEvent::$NAME);
+ $connectorReports = $event->getReports();
+
+ // Merge built in reports and connector reports
+ if (count($connectorReports) > 0) {
+ $reports = array_merge($reports, $connectorReports);
+ }
+
+ foreach ($reports as $k => $report) {
+ usort($report, function ($a, $b) {
+
+ if (empty($a->sort_order) || empty($b->sort_order)) {
+ return 0;
+ }
+
+ return $a->sort_order - $b->sort_order;
+ });
+
+ $reports[$k] = $report;
+ }
+
+ return $reports;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getReportByName($reportName)
+ {
+ foreach ($this->listReports() as $reports) {
+ foreach ($reports as $report) {
+ if ($report->name == $reportName) {
+ $this->log->debug('Get report by name: '.json_encode($report, JSON_PRETTY_PRINT));
+
+ return $report;
+ }
+ }
+ }
+
+ //throw error
+ throw new NotFoundException(__('Get Report By Name: No file to return'));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getReportClass($reportName)
+ {
+ foreach ($this->listReports() as $reports) {
+ foreach ($reports as $report) {
+ if ($report->name == $reportName) {
+ if ($report->class == '') {
+ throw new NotFoundException(__('Report class not found'));
+ }
+ $this->log->debug('Get report class: '.$report->class);
+
+ return $report->class;
+ }
+ }
+ }
+
+ // throw error
+ throw new NotFoundException(__('Get report class: No file to return'));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function createReportObject($className)
+ {
+ if (!\class_exists($className)) {
+ throw new NotFoundException(__('Class %s not found', $className));
+ }
+
+ /** @var ReportInterface $object */
+ $object = new $className();
+ $object
+ ->setCommonDependencies(
+ $this->store,
+ $this->timeSeriesStore
+ )
+ ->useLogger($this->log)
+ ->setFactories($this->container);
+
+ return $object;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getReportScheduleFormData($reportName, Request $request)
+ {
+ $this->log->debug('Populate form title and hidden fields');
+
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ // Populate form title and hidden fields
+ return $object->getReportScheduleFormData($this->sanitizer->getSanitizer($request->getParams()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setReportScheduleFormData($reportName, Request $request)
+ {
+ $this->log->debug('Set Report Schedule form data');
+
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ // Set Report Schedule form data
+ return $object->setReportScheduleFormData($this->sanitizer->getSanitizer($request->getParams()));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function generateSavedReportName($reportName, $filterCriteria)
+ {
+ $this->log->debug('Generate Saved Report name');
+
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ $filterCriteria = json_decode($filterCriteria, true);
+
+ return $object->generateSavedReportName($this->sanitizer->getSanitizer($filterCriteria));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getSavedReportResults($savedreportId, $reportName)
+ {
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ $savedReport = $this->savedReportFactory->getById($savedreportId);
+
+ // Open a zipfile and read the json
+ $zipFile = $this->config->getSetting('LIBRARY_LOCATION') .'savedreport/'. $savedReport->fileName;
+
+ // Do some pre-checks on the arguments we have been provided
+ if (!file_exists($zipFile)) {
+ throw new InvalidArgumentException(__('File does not exist'));
+ }
+
+ // Open the Zip file
+ $zip = new \ZipArchive();
+ if (!$zip->open($zipFile)) {
+ throw new InvalidArgumentException(__('Unable to open ZIP'));
+ }
+
+ // Get the reportscheduledetails
+ $json = json_decode($zip->getFromName('reportschedule.json'), true);
+
+ // Retrieve the saved report result array
+ $results = $object->getSavedReportResults($json, $savedReport);
+
+ $this->log->debug('Saved Report results'. json_encode($results, JSON_PRETTY_PRINT));
+
+ // Return data to build chart
+ return $results;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function convertSavedReportResults($savedreportId, $reportName)
+ {
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ $savedReport = $this->savedReportFactory->getById($savedreportId);
+
+ // Open a zipfile and read the json
+ $zipFile = $this->config->getSetting('LIBRARY_LOCATION') . $savedReport->storedAs;
+
+ // Do some pre-checks on the arguments we have been provided
+ if (!file_exists($zipFile)) {
+ throw new InvalidArgumentException(__('File does not exist'));
+ }
+
+ // Open the Zip file
+ $zip = new \ZipArchive();
+ if (!$zip->open($zipFile)) {
+ throw new InvalidArgumentException(__('Unable to open ZIP'));
+ }
+
+ // Get the old json (saved report)
+ $oldjson = json_decode($zip->getFromName('reportschedule.json'), true);
+
+ // Restructure the old json to new json
+ $json = $object->restructureSavedReportOldJson($oldjson);
+
+ // Format the JSON as schemaVersion 2
+ $fileName = tempnam($this->config->getSetting('LIBRARY_LOCATION') . '/temp/', 'reportschedule');
+ $out = fopen($fileName, 'w');
+ fwrite($out, json_encode($json));
+ fclose($out);
+
+ $zip = new \ZipArchive();
+ $result = $zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+ if ($result !== true) {
+ throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
+ }
+
+ $zip->addFile($fileName, 'reportschedule.json');
+ $zip->close();
+
+ // Remove the JSON file
+ unlink($fileName);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function runReport($reportName, $filterCriteria, $user)
+ {
+ $this->log->debug('Run the report to get results');
+
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ // Set userId
+ $object->setUser($user);
+
+ $filterCriteria = json_decode($filterCriteria, true);
+
+ // Retrieve the result array
+ return $object->getResults($this->sanitizer->getSanitizer($filterCriteria));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getReportEmailTemplate($reportName)
+ {
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ // Set Report Schedule form data
+ return $object->getReportEmailTemplate();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getSavedReportTemplate($reportName)
+ {
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ // Set Report Schedule form data
+ return $object->getSavedReportTemplate();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getReportChartScript($savedreportId, $reportName)
+ {
+ /* @var ReportResult $results */
+ $results = $this->getSavedReportResults($savedreportId, $reportName);
+
+ $className = $this->getReportClass($reportName);
+
+ $object = $this->createReportObject($className);
+
+ // Set Report Schedule form data
+ return $object->getReportChartScript($results);
+ }
+}
diff --git a/lib/Service/ReportServiceInterface.php b/lib/Service/ReportServiceInterface.php
new file mode 100644
index 0000000..e248ae9
--- /dev/null
+++ b/lib/Service/ReportServiceInterface.php
@@ -0,0 +1,166 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Slim\Http\ServerRequest as Request;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Helper\SanitizerService;
+use Xibo\Report\ReportInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Interface ReportServiceInterface
+ * @package Xibo\Service
+ */
+interface ReportServiceInterface
+{
+ /**
+ * ReportServiceInterface constructor.
+ * @param \Psr\Container\ContainerInterface $app
+ * @param StorageServiceInterface $store
+ * @param TimeSeriesStoreInterface $timeSeriesStore
+ * @param LogServiceInterface $log
+ * @param ConfigServiceInterface $config
+ * @param SanitizerService $sanitizer
+ * @param SavedReportFactory $savedReportFactory
+ */
+ public function __construct(
+ $app,
+ $store,
+ $timeSeriesStore,
+ $log,
+ $config,
+ $sanitizer,
+ $savedReportFactory
+ );
+
+ /**
+ * List all reports that are available
+ * @return array
+ */
+ public function listReports();
+
+ /**
+ * Get report by report name
+ * @param string $reportName
+ * @throws GeneralException
+ */
+ public function getReportByName($reportName);
+
+ /**
+ * Get report class by report name
+ * @param string $reportName
+ * @throws GeneralException
+ */
+ public function getReportClass($reportName);
+
+ /**
+ * Create the report object by report classname
+ * @param string $className
+ * @throws GeneralException
+ * @return ReportInterface
+ */
+ public function createReportObject($className);
+
+ /**
+ * Populate form title and hidden fields
+ * @param string $reportName
+ * @param Request $request
+ * @throws GeneralException
+ * @return array
+ */
+ public function getReportScheduleFormData($reportName, Request $request);
+
+ /**
+ * Set Report Schedule form data
+ * @param string $reportName
+ * @param Request $request
+ * @throws GeneralException
+ * @return array
+ */
+ public function setReportScheduleFormData($reportName, Request $request);
+
+ /**
+ * Generate saved report name
+ * @param string $reportName
+ * @param string $filterCriteria
+ * @throws GeneralException
+ * @return string
+ */
+ public function generateSavedReportName($reportName, $filterCriteria);
+
+ /**
+ * Get saved report results
+ * @param int $savedreportId
+ * @param string $reportName
+ * @throws GeneralException
+ * @return array
+ */
+ public function getSavedReportResults($savedreportId, $reportName);
+
+ /**
+ * Convert saved report results from old schema 1 to schema version 2
+ * @param int $savedreportId
+ * @param string $reportName
+ * @throws GeneralException
+ * @return array
+ */
+ public function convertSavedReportResults($savedreportId, $reportName);
+
+ /**
+ * Run the report
+ * @param string $reportName
+ * @param string $filterCriteria
+ * @param \Xibo\Entity\User $user
+ * @throws GeneralException
+ * @return array
+ */
+ public function runReport($reportName, $filterCriteria, $user);
+
+ /**
+ * Get report email template twig file name
+ * @param string $reportName
+ * @throws GeneralException
+ * @return string
+ */
+ public function getReportEmailTemplate($reportName);
+
+ /**
+ * Get report email template twig file name
+ * @param string $reportName
+ * @throws GeneralException
+ * @return string
+ */
+ public function getSavedReportTemplate($reportName);
+
+ /**
+ * Get chart script
+ * @param int $savedreportId
+ * @param string $reportName
+ * @throws GeneralException
+ * @return string
+ */
+ public function getReportChartScript($savedreportId, $reportName);
+}
diff --git a/lib/Service/UploadService.php b/lib/Service/UploadService.php
new file mode 100644
index 0000000..b4e88d7
--- /dev/null
+++ b/lib/Service/UploadService.php
@@ -0,0 +1,59 @@
+.
+ */
+
+namespace Xibo\Service;
+
+use Xibo\Helper\ApplicationState;
+use Xibo\Helper\UploadHandler;
+
+/**
+ * Upload Service to scaffold an upload handler
+ */
+class UploadService
+{
+ /**
+ * UploadService constructor.
+ * @param string $uploadDir
+ * @param array $settings
+ * @param LogServiceInterface $logger
+ * @param ApplicationState $state
+ */
+ public function __construct(
+ private readonly string $uploadDir,
+ private readonly array $settings,
+ private readonly LogServiceInterface $logger,
+ private readonly ApplicationState $state
+ ) {
+ }
+
+ /**
+ * Create a new upload handler
+ * @return UploadHandler
+ */
+ public function createUploadHandler(): UploadHandler
+ {
+ // Blue imp requires an extra /
+ $handler = new UploadHandler($this->uploadDir, $this->logger->getLoggerInterface(), $this->settings, false);
+
+ return $handler->setState($this->state);
+ }
+}
diff --git a/lib/Storage/MongoDbTimeSeriesStore.php b/lib/Storage/MongoDbTimeSeriesStore.php
new file mode 100644
index 0000000..3abb12e
--- /dev/null
+++ b/lib/Storage/MongoDbTimeSeriesStore.php
@@ -0,0 +1,776 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+use Carbon\Carbon;
+use MongoDB\BSON\ObjectId;
+use MongoDB\BSON\Regex;
+use MongoDB\BSON\UTCDateTime;
+use MongoDB\Client;
+use Xibo\Entity\Campaign;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class MongoDbTimeSeriesStore
+ * @package Xibo\Storage
+ */
+class MongoDbTimeSeriesStore implements TimeSeriesStoreInterface
+{
+ /** @var LogServiceInterface */
+ private $log;
+
+ /** @var array */
+ private $config;
+
+ /** @var \MongoDB\Client */
+ private $client;
+
+ private $table = 'stat';
+
+ private $periodTable = 'period';
+
+ // Keep all stats in this array after processing
+ private $stats = [];
+
+ private $mediaItems = [];
+ private $widgets = [];
+ private $layouts = [];
+ private $displayGroups = [];
+ private $layoutsNotFound = [];
+ private $mediaItemsNotFound = [];
+
+ /** @var MediaFactory */
+ protected $mediaFactory;
+
+ /** @var WidgetFactory */
+ protected $widgetFactory;
+
+ /** @var LayoutFactory */
+ protected $layoutFactory;
+
+ /** @var DisplayFactory */
+ protected $displayFactory;
+
+ /** @var DisplayGroupFactory */
+ protected $displayGroupFactory;
+
+ /** @var CampaignFactory */
+ protected $campaignFactory;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($config = null)
+ {
+ $this->config = $config;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setDependencies($log, $layoutFactory, $campaignFactory, $mediaFactory, $widgetFactory, $displayFactory, $displayGroupFactory)
+ {
+ $this->log = $log;
+ $this->layoutFactory = $layoutFactory;
+ $this->campaignFactory = $campaignFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->displayFactory = $displayFactory;
+ $this->displayGroupFactory = $displayGroupFactory;
+ return $this;
+ }
+
+ /**
+ * @param \Xibo\Storage\StorageServiceInterface $store
+ * @return $this|\Xibo\Storage\MongoDbTimeSeriesStore
+ */
+ public function setStore($store)
+ {
+ return $this;
+ }
+
+ /**
+ * Set Client in the event you want to completely replace the configuration options and roll your own client.
+ * @param \MongoDB\Client $client
+ */
+ public function setClient($client)
+ {
+ $this->client = $client;
+ }
+
+ /**
+ * Get a MongoDB client to use.
+ * @return \MongoDB\Client
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function getClient()
+ {
+ if ($this->client === null) {
+ try {
+ $uri = isset($this->config['uri']) ? $this->config['uri'] : 'mongodb://' . $this->config['host'] . ':' . $this->config['port'];
+ $this->client = new Client($uri, [
+ 'username' => $this->config['username'],
+ 'password' => $this->config['password']
+ ], (array_key_exists('driverOptions', $this->config) ? $this->config['driverOptions'] : []));
+ } catch (\MongoDB\Exception\RuntimeException $e) {
+ $this->log->error('Unable to connect to MongoDB: ' . $e->getMessage());
+ $this->log->debug($e->getTraceAsString());
+ throw new GeneralException('Connection to Time Series Database failed, please try again.');
+ }
+ }
+
+ return $this->client;
+ }
+
+ /** @inheritdoc */
+ public function addStat($statData)
+ {
+ // We need to transform string date to UTC date
+ $statData['statDate'] = new UTCDateTime($statData['statDate']->format('U') * 1000);
+
+ // In mongo collection we save fromDt/toDt as start/end
+ // and tag as eventName
+ // so we unset fromDt/toDt/tag from individual stats array
+ $statData['start'] = new UTCDateTime($statData['fromDt']->format('U') * 1000);
+ $statData['end'] = new UTCDateTime($statData['toDt']->format('U') * 1000);
+ $statData['eventName'] = $statData['tag'];
+ unset($statData['fromDt']);
+ unset($statData['toDt']);
+ unset($statData['tag']);
+
+ // Make an empty array to collect layout/media/display tags into
+ $tagFilter = [];
+
+ // Media name
+ $mediaName = null;
+ if (!empty($statData['mediaId'])) {
+ if (array_key_exists($statData['mediaId'], $this->mediaItems)) {
+ $media = $this->mediaItems[$statData['mediaId']];
+ } else {
+ try {
+ // Media exists in not found cache
+ if (in_array($statData['mediaId'], $this->mediaItemsNotFound)) {
+ return;
+ }
+
+ $media = $this->mediaFactory->getById($statData['mediaId']);
+
+ // Cache media
+ $this->mediaItems[$statData['mediaId']] = $media;
+ } catch (NotFoundException $error) {
+ // Cache Media not found, ignore and log the stat
+ if (!in_array($statData['mediaId'], $this->mediaItemsNotFound)) {
+ $this->mediaItemsNotFound[] = $statData['mediaId'];
+ $this->log->error('Media not found. Media Id: '. $statData['mediaId']);
+ }
+
+ return;
+ }
+ }
+
+ $mediaName = $media->name; //dont remove used later
+ $statData['mediaName'] = $mediaName;
+
+ $i = 0;
+ foreach ($media->tags as $tagLink) {
+ $tagFilter['media'][$i]['tag'] = $tagLink->tag;
+ if (isset($tagLink->value)) {
+ $tagFilter['media'][$i]['val'] = $tagLink->value;
+ }
+ $i++;
+ }
+ }
+
+ // Widget name
+ if (!empty($statData['widgetId'])) {
+ if (array_key_exists($statData['widgetId'], $this->widgets)) {
+ $widget = $this->widgets[$statData['widgetId']];
+ } else {
+ // We are already doing getWidgetForStat is XMDS,
+ // checking widgetId not found does not require
+ // We should always be able to get the widget
+ try {
+ $widget = $this->widgetFactory->getById($statData['widgetId']);
+
+ // Cache widget
+ $this->widgets[$statData['widgetId']] = $widget;
+ } catch (\Exception $error) {
+ // Widget not found, ignore and log the stat
+ $this->log->error('Widget not found. Widget Id: '. $statData['widgetId']);
+
+ return;
+ }
+ }
+
+ if ($widget != null) {
+ $widget->load();
+ $widgetName = isset($mediaName) ? $mediaName : $widget->getOptionValue('name', $widget->type);
+
+ // SET widgetName
+ $statData['widgetName'] = $widgetName;
+ }
+ }
+
+ // Layout data
+ $layoutName = null;
+
+ // For a type "event" we have layoutid 0 so is campaignId
+ // otherwise we should try and resolve the campaignId
+ $campaignId = 0;
+ if ($statData['type'] != 'event') {
+ if (array_key_exists($statData['layoutId'], $this->layouts)) {
+ $layout = $this->layouts[$statData['layoutId']];
+ } else {
+ try {
+ // Layout exists in not found cache
+ if (in_array($statData['layoutId'], $this->layoutsNotFound)) {
+ return;
+ }
+
+ // Get the layout campaignId - this can give us a campaignId of a layoutId which id was replaced when draft to published
+ $layout = $this->layoutFactory->getByLayoutHistory($statData['layoutId']);
+
+ $this->log->debug('Found layout : '. $statData['layoutId']);
+
+ // Cache layout
+ $this->layouts[$statData['layoutId']] = $layout;
+ } catch (NotFoundException $error) {
+ // All we can do here is log
+ // we shouldn't ever get in this situation because the campaignId we used above will have
+ // already been looked up in the layouthistory table.
+
+ // Cache layouts not found
+ if (!in_array($statData['layoutId'], $this->layoutsNotFound)) {
+ $this->layoutsNotFound[] = $statData['layoutId'];
+ $this->log->alert('Error processing statistic into MongoDB. Layout not found. Layout Id: ' . $statData['layoutId']);
+ }
+
+ return;
+ } catch (GeneralException $error) {
+ // Cache layouts not found
+ if (!in_array($statData['layoutId'], $this->layoutsNotFound)) {
+ $this->layoutsNotFound[] = $statData['layoutId'];
+ $this->log->error('Layout not found. Layout Id: '. $statData['layoutId']);
+ }
+
+ return;
+ }
+ }
+
+ $campaignId = (int) $layout->campaignId;
+ $layoutName = $layout->layout;
+
+ $i = 0;
+ foreach ($layout->tags as $tagLink) {
+ $tagFilter['layout'][$i]['tag'] = $tagLink->tag;
+ if (isset($tagLink->value)) {
+ $tagFilter['layout'][$i]['val'] = $tagLink->value;
+ }
+ $i++;
+ }
+ }
+
+ // Get layout Campaign ID
+ $statData['campaignId'] = $campaignId;
+
+ $statData['layoutName'] = $layoutName;
+
+
+ // Display
+ $display = $statData['display'];
+
+ // Display ID
+ $statData['displayId'] = $display->displayId;
+ unset($statData['display']);
+
+ // Display name
+ $statData['displayName'] = $display->display;
+
+ $i = 0;
+ foreach ($display->tags as $tagLink) {
+ $tagFilter['dg'][$i]['tag'] = $tagLink->tag;
+ if (isset($tagLink->value)) {
+ $tagFilter['dg'][$i]['val'] = $tagLink->value;
+ }
+ $i++;
+ }
+
+ // Display tags
+ if (array_key_exists($display->displayGroupId, $this->displayGroups)) {
+ $displayGroup = $this->displayGroups[$display->displayGroupId];
+ } else {
+ try {
+ $displayGroup = $this->displayGroupFactory->getById($display->displayGroupId);
+
+ // Cache displaygroup
+ $this->displayGroups[$display->displayGroupId] = $displayGroup;
+ } catch (NotFoundException $notFoundException) {
+ $this->log->error('Display group not found');
+ return;
+ }
+ }
+
+ $i = 0;
+ foreach ($displayGroup->tags as $tagLink) {
+ $tagFilter['dg'][$i]['tag'] = $tagLink->tag;
+ if (isset($tagLink->value)) {
+ $tagFilter['dg'][$i]['val'] = $tagLink->value;
+ }
+ $i++;
+ }
+
+ // TagFilter array
+ $statData['tagFilter'] = $tagFilter;
+
+ // Parent Campaign
+ if (array_key_exists('parentCampaign', $statData)) {
+ if ($statData['parentCampaign'] instanceof Campaign) {
+ $statData['parentCampaign'] = $statData['parentCampaign']->campaign;
+ } else {
+ $statData['parentCampaign'] = '';
+ }
+ }
+
+ $this->stats[] = $statData;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addStatFinalize()
+ {
+ // Insert statistics
+ if (count($this->stats) > 0) {
+ $collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
+
+ try {
+ $collection->insertMany($this->stats);
+
+ // Reset
+ $this->stats = [];
+ } catch (\MongoDB\Exception\RuntimeException $e) {
+ $this->log->error($e->getMessage());
+ throw new \MongoDB\Exception\RuntimeException($e->getMessage());
+ }
+ }
+
+ // Create a period collection if it doesnot exist
+ $collectionPeriod = $this->getClient()->selectCollection($this->config['database'], $this->periodTable);
+
+ try {
+ $cursor = $collectionPeriod->findOne(['name' => 'period']);
+
+ if ($cursor === null) {
+ $this->log->error('Period collection does not exist in Mongo DB.');
+ // Period collection created
+ $collectionPeriod->insertOne(['name' => 'period']);
+ $this->log->debug('Period collection created.');
+ }
+ } catch (\MongoDB\Exception\RuntimeException $e) {
+ $this->log->error($e->getMessage());
+ }
+ }
+
+ /** @inheritdoc */
+ public function getEarliestDate()
+ {
+ $collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
+ try {
+ // _id is the same as statDate for the purposes of sorting (stat date being the date/time of stat insert)
+ $earliestDate = $collection->find([], [
+ 'limit' => 1,
+ 'sort' => ['start' => 1]
+ ])->toArray();
+
+ if (count($earliestDate) > 0) {
+ return Carbon::instance($earliestDate[0]['start']->toDateTime());
+ }
+ } catch (\MongoDB\Exception\RuntimeException $e) {
+ $this->log->error($e->getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getStats($filterBy = [], $isBufferedQuery = false)
+ {
+ // do we consider that the fromDt and toDt will always be provided?
+ $fromDt = $filterBy['fromDt'] ?? null;
+ $toDt = $filterBy['toDt'] ?? null;
+ $statDate = $filterBy['statDate'] ?? null;
+ $statDateLessThan = $filterBy['statDateLessThan'] ?? null;
+
+ // In the case of user switches from mysql to mongo - laststatId were saved as integer
+ if (isset($filterBy['statId'])) {
+ try {
+ $statId = new ObjectID($filterBy['statId']);
+ } catch (\Exception $e) {
+ throw new InvalidArgumentException(__('Invalid statId provided'), 'statId');
+ }
+ } else {
+ $statId = null;
+ }
+
+ $type = $filterBy['type'] ?? null;
+ $displayIds = $filterBy['displayIds'] ?? [];
+ $layoutIds = $filterBy['layoutIds'] ?? [];
+ $mediaIds = $filterBy['mediaIds'] ?? [];
+ $campaignId = $filterBy['campaignId'] ?? null;
+ $parentCampaignId = $filterBy['parentCampaignId'] ?? null;
+ $mustHaveParentCampaign = $filterBy['mustHaveParentCampaign'] ?? false;
+ $eventTag = $filterBy['eventTag'] ?? null;
+
+ // Limit
+ $start = $filterBy['start'] ?? null;
+ $length = $filterBy['length'] ?? null;
+
+ // Match query
+ $match = [];
+
+ // fromDt/toDt Filter
+ if (($fromDt != null) && ($toDt != null)) {
+ $fromDt = new UTCDateTime($fromDt->format('U')*1000);
+ $match['$match']['end'] = ['$gt' => $fromDt];
+
+ $toDt = new UTCDateTime($toDt->format('U')*1000);
+ $match['$match']['start'] = ['$lte' => $toDt];
+ } elseif (($fromDt != null) && ($toDt == null)) {
+ $fromDt = new UTCDateTime($fromDt->format('U') * 1000);
+ $match['$match']['start'] = ['$gte' => $fromDt];
+ }
+
+ // statDate and statDateLessThan Filter
+ // get the next stats from the given date
+ $statDateQuery = [];
+ if ($statDate != null) {
+ $statDate = new UTCDateTime($statDate->format('U')*1000);
+ $statDateQuery['$gte'] = $statDate;
+ }
+
+ if ($statDateLessThan != null) {
+ $statDateLessThan = new UTCDateTime($statDateLessThan->format('U')*1000);
+ $statDateQuery['$lt'] = $statDateLessThan;
+ }
+
+ if (count($statDateQuery) > 0) {
+ $match['$match']['statDate'] = $statDateQuery;
+ }
+
+ if ($statId !== null) {
+ $match['$match']['_id'] = ['$gt' => new ObjectId($statId)];
+ }
+
+ // Displays Filter
+ if (count($displayIds) != 0) {
+ $match['$match']['displayId'] = ['$in' => $displayIds];
+ }
+
+ // Campaign/Layout Filter
+ // ---------------
+ // Use the Layout Factory to get all Layouts linked to the provided CampaignId
+ if ($campaignId != null) {
+ $campaignIds = [];
+ try {
+ $layouts = $this->layoutFactory->getByCampaignId($campaignId, false);
+ if (count($layouts) > 0) {
+ foreach ($layouts as $layout) {
+ $campaignIds[] = $layout->campaignId;
+ }
+
+ // Add to our match
+ $match['$match']['campaignId'] = ['$in' => $campaignIds];
+ }
+ } catch (NotFoundException $ignored) {
+ }
+ }
+
+ // Type Filter
+ if ($type != null) {
+ $match['$match']['type'] = new Regex($type, 'i');
+ }
+
+ // Event Tag Filter
+ if ($eventTag != null) {
+ $match['$match']['eventName'] = $eventTag;
+ }
+
+ // Layout Filter
+ if (count($layoutIds) != 0) {
+ // Get campaignIds for selected layoutIds
+ $campaignIds = [];
+ foreach ($layoutIds as $layoutId) {
+ try {
+ $campaignIds[] = $this->layoutFactory->getCampaignIdFromLayoutHistory($layoutId);
+ } catch (NotFoundException $notFoundException) {
+ // Ignore the missing one
+ $this->log->debug('Filter for Layout without Layout History Record, layoutId is ' . $layoutId);
+ }
+ }
+ $match['$match']['campaignId'] = ['$in' => $campaignIds];
+ }
+
+ // Media Filter
+ if (count($mediaIds) != 0) {
+ $match['$match']['mediaId'] = ['$in' => $mediaIds];
+ }
+
+ // Parent Campaign Filter
+ if ($parentCampaignId != null) {
+ $match['$match']['parentCampaignId'] = $parentCampaignId;
+ }
+
+ // Has Parent Campaign Filter
+ if ($mustHaveParentCampaign) {
+ $match['$match']['parentCampaignId'] = ['$exists' => true, '$ne' => 0];
+ }
+
+ // Select collection
+ $collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
+
+ // Paging
+ // ------
+ // Check whether or not we've requested a page, if we have then we need a count of records total for paging
+ // if we haven't then we don't bother getting a count
+ $total = 0;
+ if ($start !== null && $length !== null) {
+ // We add a group pipeline to get a total count of records
+ $group = [
+ '$group' => [
+ '_id' => null,
+ 'count' => ['$sum' => 1],
+ ]
+ ];
+
+ if (count($match) > 0) {
+ $totalQuery = [
+ $match,
+ $group,
+ ];
+ } else {
+ $totalQuery = [
+ $group,
+ ];
+ }
+
+ // Get total
+ try {
+ $totalCursor = $collection->aggregate($totalQuery, ['allowDiskUse' => true]);
+
+ $totalCount = $totalCursor->toArray();
+ $total = (count($totalCount) > 0) ? $totalCount[0]['count'] : 0;
+ } catch (\Exception $e) {
+ $this->log->error('Error: Total Count. ' . $e->getMessage());
+ throw new GeneralException(__('Sorry we encountered an error getting Proof of Play data, please consult your administrator'));
+ }
+ }
+
+ try {
+ $project = [
+ '$project' => [
+ 'id'=> '$_id',
+ 'type'=> 1,
+ 'start'=> 1,
+ 'end'=> 1,
+ 'layout'=> '$layoutName',
+ 'display'=> '$displayName',
+ 'media'=> '$mediaName',
+ 'tag'=> '$eventName',
+ 'duration'=> ['$toInt' => '$duration'],
+ 'count'=> ['$toInt' => '$count'],
+ 'displayId'=> 1,
+ 'layoutId'=> 1,
+ 'widgetId'=> 1,
+ 'mediaId'=> 1,
+ 'campaignId'=> 1,
+ 'parentCampaign'=> 1,
+ 'parentCampaignId'=> 1,
+ 'campaignStart'=> 1,
+ 'campaignEnd'=> 1,
+ 'statDate'=> 1,
+ 'engagements'=> 1,
+ 'tagFilter' => 1
+ ]
+ ];
+
+ if (count($match) > 0) {
+ $query = [
+ $match,
+ $project,
+ ];
+ } else {
+ $query = [
+ $project,
+ ];
+ }
+
+ // Paging
+ if ($start !== null && $length !== null) {
+ // Sort by id (statId) - we must sort before we do pagination as mongo stat has descending order indexing on start/end
+ $query[]['$sort'] = ['id'=> 1];
+ $query[]['$skip'] = $start;
+ $query[]['$limit'] = $length;
+ }
+
+ $cursor = $collection->aggregate($query, ['allowDiskUse' => true]);
+
+ $result = new TimeSeriesMongoDbResults($cursor);
+
+ // Total (we have worked this out above if we have paging enabled, otherwise its 0)
+ $result->totalCount = $total;
+ } catch (\Exception $e) {
+ $this->log->error('Error: Get total. '. $e->getMessage());
+ throw new GeneralException(__('Sorry we encountered an error getting Proof of Play data, please consult your administrator'));
+ }
+
+ return $result;
+ }
+
+ /** @inheritdoc */
+ public function getExportStatsCount($filterBy = [])
+ {
+ // do we consider that the fromDt and toDt will always be provided?
+ $fromDt = $filterBy['fromDt'] ?? null;
+ $toDt = $filterBy['toDt'] ?? null;
+ $displayIds = $filterBy['displayIds'] ?? [];
+
+ // Match query
+ $match = [];
+
+ // fromDt/toDt Filter
+ if (($fromDt != null) && ($toDt != null)) {
+ $fromDt = new UTCDateTime($fromDt->format('U')*1000);
+ $match['$match']['end'] = ['$gt' => $fromDt];
+
+ $toDt = new UTCDateTime($toDt->format('U')*1000);
+ $match['$match']['start'] = ['$lte' => $toDt];
+ }
+
+ // Displays Filter
+ if (count($displayIds) != 0) {
+ $match['$match']['displayId'] = ['$in' => $displayIds];
+ }
+
+ $collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
+
+ // Get total
+ try {
+ $totalQuery = [
+ $match,
+ [
+ '$group' => [
+ '_id'=> null,
+ 'count' => ['$sum' => 1],
+ ]
+ ],
+ ];
+ $totalCursor = $collection->aggregate($totalQuery, ['allowDiskUse' => true]);
+
+ $totalCount = $totalCursor->toArray();
+ $total = (count($totalCount) > 0) ? $totalCount[0]['count'] : 0;
+ } catch (\Exception $e) {
+ $this->log->error($e->getMessage());
+ throw new GeneralException(__('Sorry we encountered an error getting total number of Proof of Play data, please consult your administrator'));
+ }
+
+ return $total;
+ }
+
+ /** @inheritdoc */
+ public function deleteStats($maxage, $fromDt = null, $options = [])
+ {
+ // Filter the records we want to delete.
+ // we dont use $options['limit'] anymore.
+ // we delete all the records at once based on filter criteria (no-limit approach)
+ $filter = [
+ 'start' => ['$lte' => new UTCDateTime($maxage->format('U')*1000)],
+ ];
+
+ // Do we also limit the from date?
+ if ($fromDt !== null) {
+ $filter['end'] = ['$gt' => new UTCDateTime($fromDt->format('U')*1000)];
+ }
+
+ // Run the delete and return the number of records we deleted.
+ try {
+ $deleteResult = $this->getClient()
+ ->selectCollection($this->config['database'], $this->table)
+ ->deleteMany($filter);
+
+ return $deleteResult->getDeletedCount();
+ } catch (\MongoDB\Exception\RuntimeException $e) {
+ $this->log->error($e->getMessage());
+ throw new GeneralException('Stats cannot be deleted.');
+ }
+ }
+
+ /** @inheritdoc */
+ public function getEngine()
+ {
+ return 'mongodb';
+ }
+
+ /** @inheritdoc */
+ public function executeQuery($options = [])
+ {
+ $this->log->debug('Execute MongoDB query.');
+
+ $options = array_merge([
+ 'allowDiskUse' => true
+ ], $options);
+
+ // Aggregate command options
+ $aggregateConfig['allowDiskUse'] = $options['allowDiskUse'];
+ if (!empty($options['maxTimeMS'])) {
+ $aggregateConfig['maxTimeMS']= $options['maxTimeMS'];
+ }
+
+ $collection = $this->getClient()->selectCollection($this->config['database'], $options['collection']);
+ try {
+ $cursor = $collection->aggregate($options['query'], $aggregateConfig);
+
+ // log query
+ $this->log->debug(json_encode($options['query']));
+
+ $results = $cursor->toArray();
+ } catch (\MongoDB\Driver\Exception\RuntimeException $e) {
+ $this->log->error($e->getMessage());
+ $this->log->debug($e->getTraceAsString());
+ throw new GeneralException($e->getMessage());
+ }
+
+ return $results;
+ }
+}
diff --git a/lib/Storage/MySqlTimeSeriesStore.php b/lib/Storage/MySqlTimeSeriesStore.php
new file mode 100644
index 0000000..69c7bc8
--- /dev/null
+++ b/lib/Storage/MySqlTimeSeriesStore.php
@@ -0,0 +1,578 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+use Carbon\Carbon;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class MySqlTimeSeriesStore
+ * @package Xibo\Storage
+ */
+class MySqlTimeSeriesStore implements TimeSeriesStoreInterface
+{
+ // Keep all stats in this array after processing
+ private $stats = [];
+ private $layoutCampaignIds = [];
+ private $layoutIdsNotFound = [];
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var LogServiceInterface */
+ private $log;
+
+ /** @var LayoutFactory */
+ protected $layoutFactory;
+
+ /** @var CampaignFactory */
+ protected $campaignFactory;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($config = null)
+ {
+
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setDependencies($log, $layoutFactory, $campaignFactory, $mediaFactory, $widgetFactory, $displayFactory, $displayGroupFactory)
+ {
+ $this->log = $log;
+ $this->layoutFactory = $layoutFactory;
+ $this->campaignFactory = $campaignFactory;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function addStat($statData)
+ {
+
+ // For a type "event" we have layoutid 0 so is campaignId
+ // otherwise we should try and resolve the campaignId
+ $campaignId = 0;
+ if ($statData['type'] != 'event') {
+
+ if (array_key_exists($statData['layoutId'], $this->layoutCampaignIds)) {
+ $campaignId = $this->layoutCampaignIds[$statData['layoutId']];
+ } else {
+
+ try {
+
+ // Get the layout campaignId
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($statData['layoutId']);
+
+ // Put layout campaignId to memory
+ $this->layoutCampaignIds[$statData['layoutId']] = $campaignId;
+
+ } catch (GeneralException $error) {
+
+ if (!in_array($statData['layoutId'], $this->layoutIdsNotFound)) {
+ $this->layoutIdsNotFound[] = $statData['layoutId'];
+ $this->log->error('Layout not found. Layout Id: '. $statData['layoutId']);
+ }
+ return;
+ }
+ }
+ }
+
+
+ // Set to Unix Timestamp
+ $statData['statDate'] = $statData['statDate']->format('U');
+ $statData['fromDt'] = $statData['fromDt']->format('U');
+ $statData['toDt'] = $statData['toDt']->format('U');
+ $statData['campaignId'] = $campaignId;
+ $statData['displayId'] = $statData['display']->displayId;
+ $statData['engagements'] = json_encode($statData['engagements']);
+ unset($statData['display']);
+
+ $this->stats[] = $statData;
+
+ }
+
+ /** @inheritdoc */
+ public function addStatFinalize()
+ {
+ if (count($this->stats) > 0) {
+ $sql = '
+ INSERT INTO `stat` (
+ `type`,
+ `statDate`,
+ `start`,
+ `end`,
+ `scheduleID`,
+ `displayID`,
+ `campaignID`,
+ `layoutID`,
+ `mediaID`,
+ `tag`,
+ `widgetId`,
+ `duration`,
+ `count`,
+ `engagements`,
+ `parentCampaignId`
+ )
+ VALUES ';
+
+ $placeHolders = '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
+
+ $sql = $sql . implode(', ', array_fill(1, count($this->stats), $placeHolders));
+
+ // Flatten the array
+ $data = [];
+ foreach ($this->stats as $stat) {
+ // Be explicit about the order of the keys
+ $ordered = [
+ 'type' => $stat['type'],
+ 'statDate' => $stat['statDate'],
+ 'fromDt' => $stat['fromDt'],
+ 'toDt' => $stat['toDt'],
+ 'scheduleId' => $stat['scheduleId'],
+ 'displayId' => $stat['displayId'],
+ 'campaignId' => $stat['campaignId'],
+ 'layoutId' => $stat['layoutId'],
+ 'mediaId' => $stat['mediaId'],
+ 'tag' => $stat['tag'],
+ 'widgetId' => $stat['widgetId'],
+ 'duration' => $stat['duration'],
+ 'count' => $stat['count'],
+ 'engagements' => $stat['engagements'],
+ 'parentCampaignId' => $stat['parentCampaignId'],
+ ];
+
+ // Add each value to another array in order
+ foreach ($ordered as $field) {
+ $data[] = $field;
+ }
+ }
+
+ $this->store->update($sql, $data);
+ }
+ }
+
+ /** @inheritdoc */
+ public function getEarliestDate()
+ {
+ $result = $this->store->select('SELECT MIN(start) AS minDate FROM `stat`', []);
+ $earliestDate = $result[0]['minDate'];
+
+ return ($earliestDate === null)
+ ? null
+ : Carbon::createFromFormat('U', $result[0]['minDate']);
+ }
+
+ /** @inheritdoc */
+ public function getStats($filterBy = [], $isBufferedQuery = false)
+ {
+ $fromDt = $filterBy['fromDt'] ?? null;
+ $toDt = $filterBy['toDt'] ?? null;
+ $statDate = $filterBy['statDate'] ?? null;
+ $statDateLessThan = $filterBy['statDateLessThan'] ?? null;
+
+ // In the case of user switches from mongo to mysql - laststatId were saved as Mongo ObjectId string
+ if (isset($filterBy['statId'])) {
+ if (!is_numeric($filterBy['statId'])) {
+ throw new InvalidArgumentException(__('Invalid statId provided'), 'statId');
+ } else {
+ $statId = $filterBy['statId'];
+ }
+ } else {
+ $statId = null;
+ }
+
+ $type = $filterBy['type'] ?? null;
+ $displayIds = $filterBy['displayIds'] ?? [];
+ $layoutIds = $filterBy['layoutIds'] ?? [];
+ $mediaIds = $filterBy['mediaIds'] ?? [];
+ $campaignId = $filterBy['campaignId'] ?? null;
+ $parentCampaignId = $filterBy['parentCampaignId'] ?? null;
+ $mustHaveParentCampaign = $filterBy['mustHaveParentCampaign'] ?? false;
+ $eventTag = $filterBy['eventTag'] ?? null;
+
+ // Tag embedding
+ $embedDisplayTags = $filterBy['displayTags'] ?? false;
+ $embedLayoutTags = $filterBy['layoutTags'] ?? false;
+ $embedMediaTags = $filterBy['mediaTags'] ?? false;
+
+ // Limit
+ $start = $filterBy['start'] ?? null;
+ $length = $filterBy['length'] ?? null;
+
+ $params = [];
+ $select = 'SELECT stat.statId,
+ stat.statDate,
+ stat.type,
+ stat.displayId,
+ stat.widgetId,
+ stat.layoutId,
+ stat.mediaId,
+ stat.campaignId,
+ stat.parentCampaignId,
+ stat.start as start,
+ stat.end as end,
+ stat.tag,
+ stat.duration,
+ stat.count,
+ stat.engagements,
+ display.Display as display,
+ layout.Layout as layout,
+ campaign.campaign as parentCampaign,
+ media.Name AS media ';
+
+ if ($embedDisplayTags) {
+ $select .= ',
+ (
+ SELECT GROUP_CONCAT(DISTINCT CONCAT(tag, \'|\', IFNULL(value, \'null\')))
+ FROM tag
+ INNER JOIN lktagdisplaygroup
+ ON lktagdisplaygroup.tagId = tag.tagId
+ INNER JOIN `displaygroup`
+ ON lktagdisplaygroup.displayGroupId = displaygroup.displayGroupId
+ AND `displaygroup`.isDisplaySpecific = 1
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.displayGroupId = displaygroup.displayGroupId
+ WHERE lkdisplaydg.displayId = stat.displayId
+ GROUP BY lktagdisplaygroup.displayGroupId
+ ) AS displayTags
+ ';
+ }
+
+ if ($embedMediaTags) {
+ $select .= ',
+ (
+ SELECT GROUP_CONCAT(DISTINCT CONCAT(tag, \'|\', IFNULL(value, \'null\')))
+ FROM tag
+ INNER JOIN lktagmedia
+ ON lktagmedia.tagId = tag.tagId
+ WHERE lktagmedia.mediaId = media.mediaId
+ GROUP BY lktagmedia.mediaId
+ ) AS mediaTags
+ ';
+ }
+
+ if ($embedLayoutTags) {
+ $select .= ',
+ (
+ SELECT GROUP_CONCAT(DISTINCT CONCAT(tag, \'|\', IFNULL(value, \'null\')))
+ FROM tag
+ INNER JOIN lktaglayout
+ ON lktaglayout.tagId = tag.tagId
+ WHERE lktaglayout.layoutId = layout.layoutId
+ GROUP BY lktaglayout.layoutId
+ ) AS layoutTags
+ ';
+ }
+
+ $body = '
+ FROM stat
+ LEFT OUTER JOIN display
+ ON stat.DisplayID = display.DisplayID
+ LEFT OUTER JOIN layout
+ ON layout.LayoutID = stat.LayoutID
+ LEFT OUTER JOIN campaign
+ ON campaign.campaignID = stat.parentCampaignID
+ LEFT OUTER JOIN media
+ ON media.mediaID = stat.mediaID
+ LEFT OUTER JOIN widget
+ ON widget.widgetId = stat.widgetId
+ WHERE 1 = 1 ';
+
+ // fromDt/toDt Filter
+ if (($fromDt != null) && ($toDt != null)) {
+ $body .= ' AND stat.end > '. $fromDt->format('U') . ' AND stat.start <= '. $toDt->format('U');
+ } else if (($fromDt != null) && empty($toDt)) {
+ $body .= ' AND stat.start >= '. $fromDt->format('U');
+ }
+
+ // statDate Filter
+ // get the next stats from the given date
+ if ($statDate != null) {
+ $body .= ' AND stat.statDate >= ' . $statDate->format('U');
+ }
+
+ if ($statDateLessThan != null) {
+ $body .= ' AND stat.statDate < ' . $statDateLessThan->format('U');
+ }
+
+ if ($statId != null) {
+ $body .= ' AND stat.statId > '. $statId;
+ }
+
+ if (count($displayIds) > 0) {
+ $body .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ')';
+ }
+
+ // Type filter
+ if ($type == 'layout') {
+ $body .= ' AND `stat`.type = \'layout\' ';
+ } else if ($type == 'media') {
+ $body .= ' AND `stat`.type = \'media\' AND IFNULL(`media`.mediaId, 0) <> 0 ';
+ } else if ($type == 'widget') {
+ $body .= ' AND `stat`.type = \'widget\' AND IFNULL(`widget`.widgetId, 0) <> 0 ';
+ } else if ($type == 'event') {
+ $body .= ' AND `stat`.type = \'event\' ';
+ }
+
+ // Event Tag Filter
+ if ($eventTag) {
+ $body .= ' AND `stat`.tag = :eventTag';
+ $params['eventTag'] = $eventTag;
+ }
+
+ // Layout Filter
+ if (count($layoutIds) != 0) {
+
+ $layoutSql = '';
+ $i = 0;
+ foreach ($layoutIds as $layoutId) {
+ $i++;
+ $layoutSql .= ':layoutId_' . $i . ',';
+ $params['layoutId_' . $i] = $layoutId;
+ }
+
+ $body .= ' AND `stat`.campaignId IN (SELECT campaignId FROM `layouthistory` WHERE layoutId IN (' . trim($layoutSql, ',') . ')) ';
+ }
+
+ // Media Filter
+ if (count($mediaIds) != 0) {
+
+ $mediaSql = '';
+ $i = 0;
+ foreach ($mediaIds as $mediaId) {
+ $i++;
+ $mediaSql .= ':mediaId_' . $i . ',';
+ $params['mediaId_' . $i] = $mediaId;
+ }
+
+ $body .= ' AND `media`.mediaId IN (' . trim($mediaSql, ',') . ')';
+ }
+
+ // Parent Campaign Filter
+ if ($parentCampaignId != null) {
+ $body .= ' AND `stat`.parentCampaignId = :parentCampaignId ';
+ $params['parentCampaignId'] = $parentCampaignId;
+ }
+
+ // Has Parent Campaign Filter
+ if ($mustHaveParentCampaign) {
+ $body .= ' AND IFNULL(`stat`.parentCampaignId, 0) != 0 ';
+ }
+
+ // Campaign
+ // --------
+ // Filter on Layouts linked to a Campaign
+ if ($campaignId != null) {
+ $body .= ' AND stat.campaignId IN (
+ SELECT lkcampaignlayout.campaignId
+ FROM `lkcampaignlayout`
+ INNER JOIN `campaign`
+ ON `lkcampaignlayout`.campaignId = `campaign`.campaignId
+ AND `campaign`.isLayoutSpecific = 1
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = lkcampaignlayout.layoutId
+ WHERE lkcl.campaignId = :campaignId
+ ) ';
+ $params['campaignId'] = $campaignId;
+ }
+
+ // Sorting
+ $body .= ' ORDER BY stat.statId ';
+
+ $limit = '';
+ if ($start !== null && $length !== null) {
+ $limit = ' LIMIT ' . $start . ', ' . $length;
+ }
+
+ // Total count if paging is enabled.
+ $totalNumberOfRecords = 0;
+ if ($start !== null && $length !== null) {
+ $totalNumberOfRecords = $this->store->select('
+ SELECT COUNT(*) AS total FROM ( ' . $select . $body . ') total
+ ', $params)[0]['total'];
+ }
+
+ // Join our SQL statement together
+ $sql = $select . $body. $limit;
+
+ // Write this to our log
+ $this->log->sql($sql, $params);
+
+ // Run our query using a connection object (to save memory)
+ $connection = $this->store->getConnection();
+ $connection->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $isBufferedQuery);
+
+
+ // Prepare the statement
+ $statement = $connection->prepare($sql);
+
+ // Execute
+ $statement->execute($params);
+
+ // Create a results object and set the total number of records on it.
+ $results = new TimeSeriesMySQLResults($statement);
+ $results->totalCount = $totalNumberOfRecords;
+ return $results;
+ }
+
+ /** @inheritdoc */
+ public function getExportStatsCount($filterBy = [])
+ {
+
+ $fromDt = isset($filterBy['fromDt']) ? $filterBy['fromDt'] : null;
+ $toDt = isset($filterBy['toDt']) ? $filterBy['toDt'] : null;
+ $displayIds = isset($filterBy['displayIds']) ? $filterBy['displayIds'] : [];
+
+ $params = [];
+ $sql = ' SELECT COUNT(*) AS total FROM `stat` WHERE 1 = 1 ';
+
+ // fromDt/toDt Filter
+ if (($fromDt != null) && ($toDt != null)) {
+ $sql .= ' AND stat.end > '. $fromDt->format('U') . ' AND stat.start <= '. $toDt->format('U');
+ }
+
+ if (count($displayIds) > 0) {
+ $sql .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ')';
+ }
+
+ // Total count
+ $resTotal = $this->store->select($sql, $params);
+
+ // Total
+ return isset($resTotal[0]['total']) ? $resTotal[0]['total'] : 0;
+ }
+
+ /** @inheritdoc */
+ public function deleteStats($maxage, $fromDt = null, $options = [])
+ {
+ // Set some default options
+ $options = array_merge([
+ 'maxAttempts' => 10,
+ 'statsDeleteSleep' => 3,
+ 'limit' => 10000,
+ ], $options);
+
+ // Convert to a simple type so that we can pass by reference to bindParam.
+ $maxage = $maxage->format('U');
+
+ try {
+ $i = 0;
+ $rows = 1;
+
+ if ($fromDt !== null) {
+ // Convert to a simple type so that we can pass by reference to bindParam.
+ $fromDt = $fromDt->format('U');
+
+ // Prepare a delete statement which we will use multiple times
+ $delete = $this->store->getConnection()
+ ->prepare('DELETE FROM `stat` WHERE stat.start <= :toDt AND stat.end > :fromDt ORDER BY statId LIMIT :limit');
+
+ $delete->bindParam(':fromDt', $fromDt, \PDO::PARAM_STR);
+ $delete->bindParam(':toDt', $maxage, \PDO::PARAM_STR);
+ $delete->bindParam(':limit', $options['limit'], \PDO::PARAM_INT);
+ } else {
+ $delete = $this->store->getConnection()
+ ->prepare('DELETE FROM `stat` WHERE stat.start <= :maxage LIMIT :limit');
+ $delete->bindParam(':maxage', $maxage, \PDO::PARAM_STR);
+ $delete->bindParam(':limit', $options['limit'], \PDO::PARAM_INT);
+ }
+
+ $count = 0;
+ while ($rows > 0) {
+
+ $i++;
+
+ // Run the delete
+ $delete->execute();
+
+ // Find out how many rows we've deleted
+ $rows = $delete->rowCount();
+ $count += $rows;
+
+ // We shouldn't be in a transaction, but commit anyway just in case
+ $this->store->commitIfNecessary();
+
+ // Give SQL time to recover
+ if ($rows > 0) {
+ $this->log->debug('Stats delete effected ' . $rows . ' rows, sleeping.');
+ sleep($options['statsDeleteSleep']);
+ }
+
+ // Break if we've exceeded the maximum attempts, assuming that has been provided
+ if ($options['maxAttempts'] > -1 && $i >= $options['maxAttempts']) {
+ break;
+ }
+ }
+
+ $this->log->debug('Deleted Stats back to ' . $maxage . ' in ' . $i . ' attempts');
+
+ return $count;
+ }
+ catch (\PDOException $e) {
+ $this->log->error($e->getMessage());
+ throw new GeneralException('Stats cannot be deleted.');
+ }
+ }
+
+ /** @inheritdoc */
+ public function executeQuery($options = [])
+ {
+ $this->log->debug('Execute MySQL query.');
+
+ $query = $options['query'];
+ $params = $options['params'];
+
+ $dbh = $this->store->getConnection();
+
+ $sth = $dbh->prepare($query);
+ $sth->execute($params);
+
+ // Get the results
+ $results = $sth->fetchAll();
+
+ return $results;
+ }
+
+ /**
+ * @param StorageServiceInterface $store
+ * @return $this
+ */
+ public function setStore($store)
+ {
+ $this->store = $store;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getEngine()
+ {
+ return 'mysql';
+ }
+
+}
diff --git a/lib/Storage/PdoStorageService.php b/lib/Storage/PdoStorageService.php
new file mode 100644
index 0000000..428f5f8
--- /dev/null
+++ b/lib/Storage/PdoStorageService.php
@@ -0,0 +1,482 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+use Xibo\Service\ConfigService;
+use Xibo\Service\LogService;
+use Xibo\Support\Exception\DeadlockException;
+
+/**
+ * Class PDOConnect
+ * Manages global connection state and the creation of connections
+ * @package Xibo\Storage
+ */
+class PdoStorageService implements StorageServiceInterface
+{
+ /** @var \PDO[] An array of connections */
+ private static $conn = [];
+
+ /** @var array Statistics */
+ private static $stats = [];
+
+ /** @var string */
+ private static $version;
+
+ /**
+ * Logger
+ * @var LogService
+ */
+ private $log;
+
+ /**
+ * PDOConnect constructor.
+ * @param LogService $logger
+ */
+ public function __construct($logger = null)
+ {
+ $this->log = $logger;
+ }
+
+ /** @inheritdoc */
+ public function setConnection($name = 'default')
+ {
+ // Create a new connection
+ self::$conn[$name] = PdoStorageService::newConnection($name);
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function close($name = null)
+ {
+ if ($name !== null && isset(self::$conn[$name])) {
+ self::$conn[$name] = null;
+ unset(self::$conn[$name]);
+ } else {
+ foreach (self::$conn as &$conn) {
+ $conn = null;
+ }
+ self::$conn = [];
+ }
+ }
+
+ /**
+ * Create a DSN from the host/db name
+ * @param string $host
+ * @param string|null $name
+ * @return string
+ */
+ private static function createDsn($host, $name = null)
+ {
+ if (strstr($host, ':')) {
+ $hostParts = explode(':', $host);
+ $dsn = 'mysql:host=' . $hostParts[0] . ';port=' . $hostParts[1] . ';';
+ } else {
+ $dsn = 'mysql:host=' . $host . ';';
+ }
+
+ if ($name != null) {
+ $dsn .= 'dbname=' . $name . ';';
+ }
+
+ return $dsn;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public static function newConnection(string $name)
+ {
+ // If we already have a connection, return it.
+ if (isset(self::$conn[$name])) {
+ return self::$conn[$name];
+ }
+
+ $dsn = PdoStorageService::createDsn(ConfigService::$dbConfig['host'], ConfigService::$dbConfig['name']);
+
+ $opts = [];
+ if (!empty(ConfigService::$dbConfig['ssl']) && ConfigService::$dbConfig['ssl'] !== 'none') {
+ $opts[\PDO::MYSQL_ATTR_SSL_CA] = ConfigService::$dbConfig['ssl'];
+ $opts[\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = ConfigService::$dbConfig['sslVerify'];
+ }
+
+ // Open the connection and set the error mode
+ $conn = new \PDO(
+ $dsn,
+ ConfigService::$dbConfig['user'],
+ ConfigService::$dbConfig['password'],
+ $opts
+ );
+ $conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
+
+ $conn->query("SET NAMES 'utf8mb4'");
+
+ return $conn;
+ }
+
+ /** @inheritDoc */
+ public function connect($host, $user, $pass, $name = null, $ssl = null, $sslVerify = true)
+ {
+ if (!isset(self::$conn['default'])) {
+ $this->close('default');
+ }
+
+ $dsn = PdoStorageService::createDsn($host, $name);
+
+ $opts = [];
+ if (!empty($ssl) && $ssl !== 'none') {
+ $opts[\PDO::MYSQL_ATTR_SSL_CA] = $ssl;
+ $opts[\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = $sslVerify;
+ }
+
+ // Open the connection and set the error mode
+ self::$conn['default'] = new \PDO($dsn, $user, $pass, $opts);
+ self::$conn['default']->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
+ self::$conn['default']->query("SET NAMES 'utf8mb4'");
+
+ return self::$conn['default'];
+ }
+
+ /** @inheritdoc */
+ public function getConnection($name = 'default')
+ {
+ if (!isset(self::$conn[$name])) {
+ self::$conn[$name] = PdoStorageService::newConnection($name);
+ }
+
+ return self::$conn[$name];
+ }
+
+ /** @inheritdoc */
+ public function exists($sql, $params, $connection = 'default', $reconnect = false, $close = false)
+ {
+ if ($this->log != null) {
+ $this->log->sql($sql, $params);
+ }
+
+ try {
+ $sth = $this->getConnection($connection)->prepare($sql);
+ $sth->execute($params);
+ $exists = $sth->fetch();
+ $this->incrementStat($connection, 'exists');
+
+ if ($close) {
+ $this->close($connection);
+ }
+
+ if ($exists) {
+ return true;
+ } else {
+ return false;
+ }
+ } catch (\PDOException $PDOException) {
+ // Throw if we're not expected to reconnect.
+ if (!$reconnect) {
+ throw $PDOException;
+ }
+
+ $errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
+
+ if ($errorCode != 2006) {
+ throw $PDOException;
+ } else {
+ $this->close($connection);
+ return $this->exists($sql, $params, $connection, false, $close);
+ }
+ } catch (\ErrorException $exception) {
+ // Super odd we'd get one of these
+ // we're trying to catch "Error while sending QUERY packet."
+ if (!$reconnect) {
+ throw $exception;
+ }
+
+ // Try again
+ $this->close($connection);
+ return $this->exists($sql, $params, $connection, false, $close);
+ }
+ }
+
+ /** @inheritdoc */
+ public function insert($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false)
+ {
+ if ($this->log != null) {
+ $this->log->sql($sql, $params);
+ }
+
+ try {
+ if ($transaction && !$this->getConnection($connection)->inTransaction()) {
+ $this->getConnection($connection)->beginTransaction();
+ }
+ $sth = $this->getConnection($connection)->prepare($sql);
+
+ $sth->execute($params);
+ $id = intval($this->getConnection($connection)->lastInsertId());
+
+ $this->incrementStat($connection, 'insert');
+ if ($close) {
+ $this->close($connection);
+ }
+ return $id;
+ } catch (\PDOException $PDOException) {
+ // Throw if we're not expected to reconnect.
+ if (!$reconnect) {
+ throw $PDOException;
+ }
+
+ $errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
+
+ if ($errorCode != 2006) {
+ throw $PDOException;
+ } else {
+ $this->close($connection);
+ return $this->insert($sql, $params, $connection, false, $transaction, $close);
+ }
+ } catch (\ErrorException $exception) {
+ // Super odd we'd get one of these
+ // we're trying to catch "Error while sending QUERY packet."
+ if (!$reconnect) {
+ throw $exception;
+ }
+
+ // Try again
+ $this->close($connection);
+ return $this->insert($sql, $params, $connection, false, $transaction, $close);
+ }
+ }
+
+ /** @inheritdoc */
+ public function update($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false)
+ {
+ if ($this->log != null) {
+ $this->log->sql($sql, $params);
+ }
+
+ try {
+ if ($transaction && !$this->getConnection($connection)->inTransaction()) {
+ $this->getConnection($connection)->beginTransaction();
+ }
+
+ $sth = $this->getConnection($connection)->prepare($sql);
+
+ $sth->execute($params);
+
+ $rows = $sth->rowCount();
+
+ $this->incrementStat($connection, 'update');
+ if ($close) {
+ $this->close($connection);
+ }
+
+ return $rows;
+ } catch (\PDOException $PDOException) {
+ // Throw if we're not expected to reconnect.
+ if (!$reconnect) {
+ throw $PDOException;
+ }
+
+ $errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
+
+ if ($errorCode != 2006) {
+ throw $PDOException;
+ } else {
+ $this->close($connection);
+ return $this->update($sql, $params, $connection, false, $transaction, $close);
+ }
+ } catch (\ErrorException $exception) {
+ // Super odd we'd get one of these
+ // we're trying to catch "Error while sending QUERY packet."
+ if (!$reconnect) {
+ throw $exception;
+ }
+
+ // Try again
+ $this->close($connection);
+ return $this->update($sql, $params, $connection, false, $transaction, $close);
+ }
+ }
+
+ /** @inheritdoc */
+ public function select($sql, $params, $connection = 'default', $reconnect = false, $close = false)
+ {
+ if ($this->log != null) {
+ $this->log->sql($sql, $params);
+ }
+
+ try {
+ $sth = $this->getConnection($connection)->prepare($sql);
+
+ $sth->execute($params);
+ $records = $sth->fetchAll(\PDO::FETCH_ASSOC);
+
+ $this->incrementStat($connection, 'select');
+
+ if ($close) {
+ $this->close($connection);
+ }
+ return $records;
+ } catch (\PDOException $PDOException) {
+ $errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
+
+ // syntax error, log the sql and params in error level.
+ if ($errorCode == 1064 && $this->log != null) {
+ $this->log->sql($sql, $params, true);
+ }
+
+ // Throw if we're not expected to reconnect.
+ if (!$reconnect) {
+ throw $PDOException;
+ }
+
+ if ($errorCode != 2006) {
+ throw $PDOException;
+ } else {
+ $this->close($connection);
+ return $this->select($sql, $params, $connection, false, $close);
+ }
+ } catch (\ErrorException $exception) {
+ // Super odd we'd get one of these
+ // we're trying to catch "Error while sending QUERY packet."
+ if (!$reconnect) {
+ throw $exception;
+ }
+
+ // Try again
+ $this->close($connection);
+ return $this->select($sql, $params, $connection, false, $close);
+ }
+ }
+
+ /** @inheritdoc */
+ public function updateWithDeadlockLoop($sql, $params, $connection = 'default', $transaction = true, $close = false)
+ {
+ $maxRetries = 2;
+
+ // Should we log?
+ if ($this->log != null) {
+ $this->log->sql($sql, $params);
+ }
+
+ // Start a transaction?
+ if ($transaction && !$this->getConnection($connection)->inTransaction()) {
+ $this->getConnection($connection)->beginTransaction();
+ }
+
+ // Prepare the statement
+ $statement = $this->getConnection($connection)->prepare($sql);
+
+ // Deadlock protect this statement
+ $success = false;
+ $retries = $maxRetries;
+ do {
+ try {
+ $this->incrementStat($connection, 'update');
+ $statement->execute($params);
+ // Successful
+ $success = true;
+ } catch (\PDOException $PDOException) {
+ $errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
+
+ if ($errorCode != 1213 && $errorCode != 1205) {
+ throw $PDOException;
+ }
+ }
+
+ if ($success) {
+ break;
+ }
+
+ // Sleep a bit, give the DB time to breathe
+ $queryHash = substr($sql, 0, 15) . '... [' . md5($sql . json_encode($params)) . ']';
+ $this->log->debug('Retrying query after a short nap, try: ' . (3 - $retries)
+ . '. Query Hash: ' . $queryHash);
+
+ usleep(10000);
+ } while ($retries--);
+
+ if (!$success) {
+ throw new DeadlockException(sprintf(
+ __('Failed to write to database after %d retries. Please try again later.'),
+ $maxRetries
+ ));
+ }
+
+ if ($close) {
+ $this->close($connection);
+ }
+ }
+
+ /** @inheritdoc */
+ public function commitIfNecessary($name = 'default', $close = false)
+ {
+ if ($this->getConnection($name)->inTransaction()) {
+ $this->incrementStat($name, 'commit');
+ $this->getConnection($name)->commit();
+ }
+ }
+
+ /**
+ * Set the TimeZone for this connection
+ * @param string $timeZone e.g. -8:00
+ * @param string $connection
+ */
+ public function setTimeZone($timeZone, $connection = 'default')
+ {
+ $this->getConnection($connection)->query('SET time_zone = \'' . $timeZone . '\';');
+
+ $this->incrementStat($connection, 'utility');
+ }
+
+ /**
+ * PDO stats
+ * @return array
+ */
+ public function stats()
+ {
+ self::$stats['connections'] = count(self::$conn);
+ return self::$stats;
+ }
+
+ /** @inheritdoc */
+ public static function incrementStat($connection, $key)
+ {
+ $currentCount = (isset(self::$stats[$connection][$key])) ? self::$stats[$connection][$key] : 0;
+ self::$stats[$connection][$key] = $currentCount + 1;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getVersion()
+ {
+ if (self::$version === null) {
+ $results = $this->select('SELECT version() AS v', []);
+
+ if (count($results) <= 0) {
+ return null;
+ }
+
+ self::$version = explode('-', $results[0]['v'])[0];
+ }
+
+ return self::$version;
+ }
+}
diff --git a/lib/Storage/StorageServiceInterface.php b/lib/Storage/StorageServiceInterface.php
new file mode 100644
index 0000000..360959c
--- /dev/null
+++ b/lib/Storage/StorageServiceInterface.php
@@ -0,0 +1,172 @@
+.
+ */
+
+
+namespace Xibo\Storage;
+
+use Xibo\Service\LogService;
+use Xibo\Support\Exception\DeadlockException;
+
+/**
+ * Interface StorageInterface
+ * @package Xibo\Storage
+ */
+interface StorageServiceInterface
+{
+ /**
+ * PDOConnect constructor.
+ * @param LogService $logger
+ */
+ public function __construct($logger);
+
+ /**
+ * Set a connection
+ * @param string $name
+ * @return $this
+ */
+ public function setConnection($name = 'default');
+
+ /**
+ * Closes the stored connection
+ * @param string|null $name The name of the connection, or null for all connections
+ */
+ public function close($name = null);
+
+ /**
+ * Open a new connection using the stored details
+ * @param $name string The name of the connection, e.g. "default"
+ * @return \PDO
+ */
+ public static function newConnection(string $name);
+
+ /**
+ * Open a connection with the specified details
+ * @param string $host
+ * @param string $user
+ * @param string $pass
+ * @param string|null $name
+ * @param string|null $ssl
+ * @param boolean $sslVerify
+ * @return \PDO
+ */
+ public function connect($host, $user, $pass, $name = null, $ssl = null, $sslVerify = true);
+
+ /**
+ * Get the Raw Connection
+ * @param string $name The connection name
+ * @return \PDO
+ */
+ public function getConnection($name = 'default');
+
+ /**
+ * Check to see if the query returns records
+ * @param string $sql
+ * @param array $params
+ * @param string|null $connection Note: the transaction for non-default connections is not automatically committed
+ * @param bool $reconnect
+ * @param bool $close
+ * @return bool
+ */
+ public function exists($sql, $params, $connection = 'default', $reconnect = false, $close = false);
+
+ /**
+ * Run Insert SQL
+ * @param string $sql
+ * @param array $params
+ * @param string|null $connection Note: the transaction for non-default connections is not automatically committed
+ * @param bool $reconnect
+ * @param bool $close
+ * @return int
+ * @throws \PDOException
+ */
+ public function insert($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false);
+
+ /**
+ * Run Update SQL
+ * @param string $sql
+ * @param array $params
+ * @param string|null $connection Note: the transaction for non-default connections is not automatically committed
+ * @param bool $reconnect
+ * @param bool $transaction If we are already in a transaction, then do nothing. Otherwise, start one.
+ * @param bool $close
+ * @return int affected rows
+ * @throws \PDOException
+ */
+ public function update($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false);
+
+ /**
+ * Run Select SQL
+ * @param $sql
+ * @param $params
+ * @param string|null $connection Note: the transaction for non-default connections is not automatically committed
+ * @param bool $reconnect
+ * @param bool $close
+ * @return array
+ * @throws \PDOException
+ */
+ public function select($sql, $params, $connection = 'default', $reconnect = false, $close = false);
+
+ /**
+ * Run the SQL statement with a deadlock loop
+ * @param $sql
+ * @param $params
+ * @param string|null $connection Note: the transaction for non-default connections is not automatically committed
+ * @param bool $close
+ * @param bool $transaction If we are already in a transaction, then do nothing. Otherwise, start one.
+ * @return mixed
+ * @throws DeadlockException
+ */
+ public function updateWithDeadlockLoop($sql, $params, $connection = 'default', $transaction = true, $close = false);
+
+ /**
+ * Commit if necessary
+ * @param $name
+ * @param bool $close
+ */
+ public function commitIfNecessary($name = 'default', $close = false);
+
+ /**
+ * Set the TimeZone for this connection
+ * @param string|null $connection
+ * @param string $timeZone e.g. -8:00
+ */
+ public function setTimeZone($timeZone, $connection = 'default');
+
+ /**
+ * PDO stats
+ * @return array
+ */
+ public function stats();
+
+ /**
+ * @param $connection
+ * @param $key
+ * @return mixed
+ */
+ public static function incrementStat($connection, $key);
+
+ /**
+ * Get the Storage engine version
+ * @return string
+ */
+ public function getVersion();
+}
diff --git a/lib/Storage/TimeSeriesMongoDbResults.php b/lib/Storage/TimeSeriesMongoDbResults.php
new file mode 100644
index 0000000..624649e
--- /dev/null
+++ b/lib/Storage/TimeSeriesMongoDbResults.php
@@ -0,0 +1,134 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+use Carbon\Carbon;
+
+/**
+ * Class TimeSeriesMongoDbResults
+ * @package Xibo\Storage
+ */
+class TimeSeriesMongoDbResults implements TimeSeriesResultsInterface
+{
+ /**
+ * Statement
+ * @var \MongoDB\Driver\Cursor
+ */
+ private $object;
+
+ /**
+ * Total number of stats
+ */
+ public $totalCount;
+
+ /**
+ * Iterator
+ * @var \IteratorIterator
+ */
+ private $iterator;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($cursor = null)
+ {
+ $this->object = $cursor;
+ }
+
+ /** @inheritdoc */
+ public function getArray()
+ {
+ $this->object->setTypeMap(['root' => 'array']);
+ return $this->object->toArray();
+ }
+
+ /** @inheritDoc */
+ public function getIdFromRow($row)
+ {
+ return (string)$row['id'];
+ }
+
+ /** @inheritDoc */
+ public function getDateFromValue($value)
+ {
+ return Carbon::instance($value->toDateTime());
+ }
+
+ /** @inheritDoc */
+ public function getEngagementsFromRow($row, $decoded = true)
+ {
+ if ($decoded) {
+ return $row['engagements'] ?? [];
+ } else {
+ return isset($row['engagements']) ? json_encode($row['engagements']) : '[]';
+ }
+ }
+
+ /** @inheritDoc */
+ public function getTagFilterFromRow($row)
+ {
+ return $row['tagFilter'] ?? [
+ 'dg' => [],
+ 'layout' => [],
+ 'media' => []
+ ];
+ }
+
+ /**
+ * Gets an iterator for this result set
+ * @return \IteratorIterator
+ */
+ private function getIterator()
+ {
+ if ($this->iterator == null) {
+ $this->iterator = new \IteratorIterator($this->object);
+ $this->iterator->rewind();
+ }
+
+ return $this->iterator;
+ }
+
+ /** @inheritdoc */
+ public function getNextRow()
+ {
+
+ $this->getIterator();
+
+ if ($this->iterator->valid()) {
+
+ $document = $this->iterator->current();
+ $this->iterator->next();
+
+ return (array) $document;
+ }
+
+ return false;
+
+ }
+
+ /** @inheritdoc */
+ public function getTotalCount()
+ {
+ return $this->totalCount;
+ }
+}
\ No newline at end of file
diff --git a/lib/Storage/TimeSeriesMySQLResults.php b/lib/Storage/TimeSeriesMySQLResults.php
new file mode 100644
index 0000000..8704e64
--- /dev/null
+++ b/lib/Storage/TimeSeriesMySQLResults.php
@@ -0,0 +1,149 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+use Carbon\Carbon;
+
+/**
+ * Class TimeSeriesMySQLResults
+ * @package Xibo\Storage
+ */
+class TimeSeriesMySQLResults implements TimeSeriesResultsInterface
+{
+ /**
+ * Statement
+ * @var \PDOStatement
+ */
+ private $object;
+
+ /**
+ * Total number of stats
+ */
+ public $totalCount;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct($stmtObject = null)
+ {
+ $this->object = $stmtObject;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getArray()
+ {
+ return $this->object->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ /** @inheritDoc */
+ public function getIdFromRow($row)
+ {
+ return $row['statId'];
+ }
+
+ /** @inheritDoc */
+ public function getDateFromValue($value)
+ {
+ return Carbon::createFromTimestamp($value);
+ }
+
+ /** @inheritDoc */
+ public function getEngagementsFromRow($row, $decoded = true)
+ {
+ if ($decoded) {
+ return isset($row['engagements']) ? json_decode($row['engagements']) : [];
+ } else {
+ return $row['engagements'] ?? '[]';
+ }
+ }
+
+ /** @inheritDoc */
+ public function getTagFilterFromRow($row)
+ {
+ // Tags
+ // Mimic the structure we have in Mongo.
+ $entry['tagFilter'] = [
+ 'dg' => [],
+ 'layout' => [],
+ 'media' => []
+ ];
+
+ // Display Tags
+ if (array_key_exists('displayTags', $row) && !empty($row['displayTags'])) {
+ $tags = explode(',', $row['displayTags']);
+ foreach ($tags as $tag) {
+ $tag = explode('|', $tag);
+ $value = $tag[1] ?? null;
+ $entry['tagFilter']['dg'][] = [
+ 'tag' => $tag[0],
+ 'value' => ($value === 'null') ? null : $value
+ ];
+ }
+ }
+
+ // Layout Tags
+ if (array_key_exists('layoutTags', $row) && !empty($row['layoutTags'])) {
+ $tags = explode(',', $row['layoutTags']);
+ foreach ($tags as $tag) {
+ $tag = explode('|', $tag);
+ $value = $tag[1] ?? null;
+ $entry['tagFilter']['layout'][] = [
+ 'tag' => $tag[0],
+ 'value' => ($value === 'null') ? null : $value
+ ];
+ }
+ }
+
+ // Media Tags
+ if (array_key_exists('mediaTags', $row) && !empty($row['mediaTags'])) {
+ $tags = explode(',', $row['mediaTags']);
+ foreach ($tags as $tag) {
+ $tag = explode('|', $tag);
+ $value = $tag[1] ?? null;
+ $entry['tagFilter']['media'][] = [
+ 'tag' => $tag[0],
+ 'value' => ($value === 'null') ? null : $value
+ ];
+ }
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getNextRow()
+ {
+ return $this->object->fetch(\PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getTotalCount()
+ {
+ return $this->totalCount;
+ }
+
+}
\ No newline at end of file
diff --git a/lib/Storage/TimeSeriesResultsInterface.php b/lib/Storage/TimeSeriesResultsInterface.php
new file mode 100644
index 0000000..2f82b41
--- /dev/null
+++ b/lib/Storage/TimeSeriesResultsInterface.php
@@ -0,0 +1,80 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+/**
+ * Interface TimeSeriesResultsInterface
+ * @package Xibo\Service
+ */
+interface TimeSeriesResultsInterface
+{
+ /**
+ * Time series results constructor
+ * @param null $object
+ */
+ public function __construct($object = null);
+
+ /**
+ * Get statistics array
+ * @return array
+ */
+ public function getArray();
+
+ /**
+ * Get next row
+ * @return array|false
+ */
+ public function getNextRow();
+
+ /**
+ * Get total number of stats
+ * @return integer
+ */
+ public function getTotalCount();
+
+ /**
+ * @param $row
+ * @return string|int
+ */
+ public function getIdFromRow($row);
+
+ /**
+ * @param $row
+ * @param bool $decoded Should the engagements be decoded or strings?
+ * @return array
+ */
+ public function getEngagementsFromRow($row, $decoded = true);
+
+ /**
+ * @param $row
+ * @return array
+ */
+ public function getTagFilterFromRow($row);
+
+ /**
+ * @param string $value
+ * @return \Carbon\Carbon
+ */
+ public function getDateFromValue($value);
+
+}
\ No newline at end of file
diff --git a/lib/Storage/TimeSeriesStoreInterface.php b/lib/Storage/TimeSeriesStoreInterface.php
new file mode 100644
index 0000000..89a1ce3
--- /dev/null
+++ b/lib/Storage/TimeSeriesStoreInterface.php
@@ -0,0 +1,131 @@
+.
+ */
+
+namespace Xibo\Storage;
+
+use Carbon\Carbon;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Interface TimeSeriesStoreInterface
+ * @package Xibo\Service
+ */
+interface TimeSeriesStoreInterface
+{
+ /**
+ * Time series constructor.
+ * @param array $config
+ */
+ public function __construct($config = null);
+
+ /**
+ * Set Time series Dependencies
+ * @param LogServiceInterface $logger
+ * @param LayoutFactory $layoutFactory
+ * @param CampaignFactory $campaignFactory
+ * @param MediaFactory $mediaFactory
+ * @param WidgetFactory $widgetFactory
+ * @param DisplayFactory $displayFactory
+ * @param \Xibo\Entity\DisplayGroup $displayGroupFactory
+ */
+ public function setDependencies(
+ $logger,
+ $layoutFactory,
+ $campaignFactory,
+ $mediaFactory,
+ $widgetFactory,
+ $displayFactory,
+ $displayGroupFactory
+ );
+
+ /**
+ * @param \Xibo\Storage\StorageServiceInterface $store
+ * @return $this
+ */
+ public function setStore($store);
+
+ /**
+ * Process and add a single statdata to array
+ * @param $statData array
+ */
+ public function addStat($statData);
+
+ /**
+ * Write statistics to DB
+ */
+ public function addStatFinalize();
+
+ /**
+ * Get the earliest date
+ * @return \Carbon\Carbon|null
+ */
+ public function getEarliestDate();
+
+ /**
+ * Get statistics
+ * @param $filterBy array[mixed]|null
+ * @param $isBufferedQuery bool Option to set buffered queries in MySQL
+ * @throws GeneralException
+ * @return TimeSeriesResultsInterface
+ */
+ public function getStats($filterBy = [], $isBufferedQuery = false);
+
+ /**
+ * Get total count of export statistics
+ * @param $filterBy array[mixed]|null
+ * @throws GeneralException
+ * @return TimeSeriesResultsInterface
+ */
+ public function getExportStatsCount($filterBy = []);
+
+ /**
+ * Delete statistics
+ * @param $toDt Carbon
+ * @param $fromDt Carbon|null
+ * @param $options array
+ * @throws GeneralException
+ * @return int number of deleted stat records
+ * @throws \Exception
+ */
+ public function deleteStats($toDt, $fromDt = null, $options = []);
+
+ /**
+ * Execute query
+ * @param $options array|[]
+ * @throws GeneralException
+ * @return array
+ */
+ public function executeQuery($options = []);
+
+ /**
+ * Get the statistic store
+ * @return string
+ */
+ public function getEngine();
+
+}
\ No newline at end of file
diff --git a/lib/Twig/ByteFormatterTwigExtension.php b/lib/Twig/ByteFormatterTwigExtension.php
new file mode 100644
index 0000000..4d828c4
--- /dev/null
+++ b/lib/Twig/ByteFormatterTwigExtension.php
@@ -0,0 +1,49 @@
+.
+ */
+namespace Xibo\Twig;
+
+use Twig\Extension\AbstractExtension;
+use Xibo\Helper\ByteFormatter;
+
+/**
+ * Class ByteFormatterTwigExtension
+ * @package Xibo\Twig
+ */
+class ByteFormatterTwigExtension extends AbstractExtension
+{
+ public function getName()
+ {
+ return 'byteFormatter';
+ }
+
+ public function getFilters()
+ {
+ return array(
+ new \Twig\TwigFilter('byteFormat', array($this, 'byteFormat'))
+ );
+ }
+
+ public function byteFormat($bytes)
+ {
+ return ByteFormatter::format($bytes);
+ }
+}
\ No newline at end of file
diff --git a/lib/Twig/DateFormatTwigExtension.php b/lib/Twig/DateFormatTwigExtension.php
new file mode 100644
index 0000000..a9faeb3
--- /dev/null
+++ b/lib/Twig/DateFormatTwigExtension.php
@@ -0,0 +1,64 @@
+.
+ */
+
+namespace Xibo\Twig;
+
+use Twig\Extension\AbstractExtension;
+
+/**
+ * Class DateFormatTwigExtension
+ * @package Xibo\Twig
+ */
+class DateFormatTwigExtension extends AbstractExtension
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function getFilters()
+ {
+ return array(
+ new \Twig\TwigFilter('datehms', [$this, 'dateFormat'])
+ );
+ }
+
+ /**
+ * Formats a date
+ *
+ * @param string $date in seconds
+ *
+ * @return string formated as HH:mm:ss
+ */
+ public function dateFormat($date)
+ {
+ return gmdate('H:i:s', $date);
+ }
+
+ /**
+ * Returns the name of the extension.
+ *
+ * @return string The extension name
+ */
+ public function getName()
+ {
+ return 'datehms';
+ }
+}
diff --git a/lib/Twig/TransExtension.php b/lib/Twig/TransExtension.php
new file mode 100644
index 0000000..8c994f4
--- /dev/null
+++ b/lib/Twig/TransExtension.php
@@ -0,0 +1,60 @@
+.
+ */
+
+
+namespace Xibo\Twig;
+use Twig\Extension\AbstractExtension;
+
+class TransExtension extends AbstractExtension
+{
+ /**
+ * Returns the token parser instances to add to the existing list.
+ *
+ * @return array An array of Twig_TokenParserInterface or Twig_TokenParserBrokerInterface instances
+ */
+ public function getTokenParsers()
+ {
+ return array(new TransTokenParser());
+ }
+
+ /**
+ * Returns a list of filters to add to the existing list.
+ *
+ * @return array An array of filters
+ */
+ public function getFilters()
+ {
+ return array(
+ new \Twig\TwigFilter('trans', '__'),
+ );
+ }
+
+ /**
+ * Returns the name of the extension.
+ *
+ * @return string The extension name
+ */
+ public function getName()
+ {
+ return 'i18n';
+ }
+}
\ No newline at end of file
diff --git a/lib/Twig/TransNode.php b/lib/Twig/TransNode.php
new file mode 100644
index 0000000..f9c3202
--- /dev/null
+++ b/lib/Twig/TransNode.php
@@ -0,0 +1,181 @@
+.
+ */
+
+namespace Xibo\Twig;
+
+use Twig\Compiler;
+use Twig\Node\CheckToStringNode;
+use Twig\Node\Expression\AbstractExpression;
+use Twig\Node\Expression\ConstantExpression;
+use Twig\Node\Expression\FilterExpression;
+use Twig\Node\Expression\NameExpression;
+use Twig\Node\Expression\TempNameExpression;
+use Twig\Node\Node;
+use Twig\Node\PrintNode;
+
+/**
+ * Twig Extension for supporting gettext
+ */
+class TransNode extends Node
+{
+ public function __construct(
+ ?Node $body,
+ ?Node $plural = null,
+ ?AbstractExpression $count = null,
+ ?Node $notes = null,
+ int $lineno = 0,
+ $tag = null
+ ) {
+ $nodes = ['body' => $body];
+ if (null !== $count) {
+ $nodes['count'] = $count;
+ }
+ if (null !== $plural) {
+ $nodes['plural'] = $plural;
+ }
+ if (null !== $notes) {
+ $nodes['notes'] = $notes;
+ }
+ parent::__construct($nodes, [], $lineno, $tag);
+ }
+
+ /**
+ * Compiles the node to PHP.
+ *
+ * @param \Twig\Compiler $compiler A Twig_Compiler instance
+ */
+ public function compile(Compiler $compiler)
+ {
+ $compiler->addDebugInfo($this);
+
+ list($msg, $vars) = $this->compileString($this->getNode('body'));
+
+ if ($this->hasNode('plural')) {
+ list($msg1, $vars1) = $this->compileString($this->getNode('plural'));
+ $vars = array_merge($vars, $vars1);
+ }
+
+ $function = $this->getTransFunction($this->hasNode('plural'));
+
+ if ($this->hasNode('notes')) {
+ $message = trim($this->getNode('notes')->getAttribute('data'));
+ // line breaks are not allowed cause we want a single line comment
+ $message = str_replace(["\n", "\r"], ' ', $message);
+ $compiler->write("// notes: {$message}\n");
+ }
+
+ if ($vars) {
+ // Add a hint for xgettext so that poedit goes not treat the variable substitution as PHP format
+ // https://github.com/xibosignage/xibo/issues/1284
+ $compiler
+ ->write('/* xgettext:no-php-format */')
+ ->write('echo strtr(' . $function . '(')
+ ->subcompile($msg);
+ ;
+ if ($this->hasNode('plural')) {
+ $compiler
+ ->raw(', ')
+ ->subcompile($msg1)
+ ->raw(', abs(')
+ ->subcompile($this->hasNode('count') ? $this->getNode('count') : null)
+ ->raw(')')
+ ;
+ }
+
+ $compiler->raw('), array(');
+
+ foreach ($vars as $var) {
+ if ('count' === $var->getAttribute('name')) {
+ $compiler
+ ->string('%count%')
+ ->raw(' => abs(')
+ ->subcompile($this->hasNode('count') ? $this->getNode('count') : null)
+ ->raw('), ')
+ ;
+ } else {
+ $compiler
+ ->string('%'.$var->getAttribute('name').'%')
+ ->raw(' => ')
+ ->subcompile($var)
+ ->raw(', ')
+ ;
+ }
+ }
+ $compiler->raw("));\n");
+ } else {
+ $compiler
+ ->write('echo '.$function.'(')
+ ->subcompile($msg)
+ ;
+ if ($this->hasNode('plural')) {
+ $compiler
+ ->raw(', ')
+ ->subcompile($msg1)
+ ->raw(', abs(')
+ ->subcompile($this->hasNode('count') ? $this->getNode('count') : null)
+ ->raw(')')
+ ;
+ }
+
+ $compiler->raw(");\n");
+ }
+ }
+
+ /**
+ * @param Node $body A Twig_Node instance
+ *
+ * @return array
+ */
+ private function compileString(Node $body): array
+ {
+ if ($body instanceof NameExpression || $body instanceof ConstantExpression || $body instanceof TempNameExpression) {
+ return [$body, []];
+ }
+ $vars = [];
+ if (\count($body)) {
+ $msg = '';
+ foreach ($body as $node) {
+ if ($node instanceof PrintNode) {
+ $n = $node->getNode('expr');
+ while ($n instanceof FilterExpression) {
+ $n = $n->getNode('node');
+ }
+ while ($n instanceof CheckToStringNode) {
+ $n = $n->getNode('expr');
+ }
+ $msg .= sprintf('%%%s%%', $n->getAttribute('name'));
+ $vars[] = new NameExpression($n->getAttribute('name'), $n->getTemplateLine());
+ } else {
+ $msg .= $node->getAttribute('data');
+ }
+ }
+ } else {
+ $msg = $body->getAttribute('data');
+ }
+ return [new Node([new ConstantExpression(trim($msg), $body->getTemplateLine())]), $vars];
+ }
+
+ private function getTransFunction(bool $plural): string
+ {
+ return $plural ? 'n__' : '__';
+ }
+}
diff --git a/lib/Twig/TransTokenParser.php b/lib/Twig/TransTokenParser.php
new file mode 100644
index 0000000..755c5cc
--- /dev/null
+++ b/lib/Twig/TransTokenParser.php
@@ -0,0 +1,116 @@
+.
+ */
+
+
+namespace Xibo\Twig;
+
+
+use Twig\Node\Node;
+use Twig\Token;
+
+class TransTokenParser extends \Twig\TokenParser\AbstractTokenParser
+{
+ /**
+ * Parses a token and returns a node.
+ *
+ * @param Token $token A Twig_Token instance
+ *
+ * @return TransNode A Twig_Node instance
+ * @throws \Twig\Error\SyntaxError
+ */
+ public function parse(Token $token)
+ {
+ $lineno = $token->getLine();
+ $stream = $this->parser->getStream();
+ $count = null;
+ $plural = null;
+ $notes = null;
+
+ if (!$stream->test(Token::BLOCK_END_TYPE)) {
+ $body = $this->parser->getExpressionParser()->parseExpression();
+ } else {
+ $stream->expect(Token::BLOCK_END_TYPE);
+ $body = $this->parser->subparse([$this, 'decideForFork']);
+ $next = $stream->next()->getValue();
+ if ('plural' === $next) {
+ $count = $this->parser->getExpressionParser()->parseExpression();
+ $stream->expect(Token::BLOCK_END_TYPE);
+ $plural = $this->parser->subparse([$this, 'decideForFork']);
+ if ('notes' === $stream->next()->getValue()) {
+ $stream->expect(Token::BLOCK_END_TYPE);
+ $notes = $this->parser->subparse([$this, 'decideForEnd'], true);
+ }
+ } elseif ('notes' === $next) {
+ $stream->expect(Token::BLOCK_END_TYPE);
+ $notes = $this->parser->subparse([$this, 'decideForEnd'], true);
+ }
+ }
+
+ $stream->expect(Token::BLOCK_END_TYPE);
+ $this->checkTransString($body, $lineno);
+
+ return new TransNode($body, $plural, $count, $notes, $lineno, $this->getTag());
+ }
+
+ public function
+ decideForFork(Token $token)
+ {
+ return $token->test(array('plural', 'notes', 'endtrans'));
+ }
+
+ public function decideForEnd(Token $token)
+ {
+ return $token->test('endtrans');
+ }
+
+ /**
+ * Gets the tag name associated with this token parser.
+ *
+ * @param string The tag name
+ *
+ * @return string
+ */
+ public function getTag()
+ {
+ return 'trans';
+ }
+
+ /**
+ * @param Node $body
+ * @param $lineno
+ * @throws \Twig\Error\SyntaxError
+ */
+ protected function checkTransString(Node $body, $lineno)
+ {
+ foreach ($body as $i => $node) {
+ if (
+ $node instanceof \Twig\Node\TextNode
+ ||
+ ($node instanceof \Twig\Node\PrintNode && $node->getNode('expr') instanceof \Twig\Node\Expression\NameExpression)
+ ) {
+ continue;
+ }
+
+ throw new \Twig\Error\SyntaxError(sprintf('The text to be translated with "trans" can only contain references to simple variables'), $lineno);
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/Twig/TwigMessages.php b/lib/Twig/TwigMessages.php
new file mode 100644
index 0000000..e046b80
--- /dev/null
+++ b/lib/Twig/TwigMessages.php
@@ -0,0 +1,79 @@
+.
+ */
+
+namespace Xibo\Twig;
+
+use Slim\Flash\Messages;
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFunction;
+
+class TwigMessages extends AbstractExtension
+{
+ /**
+ * @var Messages
+ */
+ protected $flash;
+ /**
+ * Constructor.
+ *
+ * @param Messages $flash the Flash messages service provider
+ */
+ public function __construct(Messages $flash)
+ {
+ $this->flash = $flash;
+ }
+ /**
+ * Extension name.
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return 'slim-twig-flash';
+ }
+ /**
+ * Callback for twig.
+ *
+ * @return array
+ */
+ public function getFunctions()
+ {
+ return [
+ new TwigFunction('flash', [$this, 'getMessages']),
+ ];
+ }
+ /**
+ * Returns Flash messages; If key is provided then returns messages
+ * for that key.
+ *
+ * @param string $key
+ *
+ * @return array
+ */
+ public function getMessages($key = null)
+ {
+ if (null !== $key) {
+ return $this->flash->getMessage($key);
+ }
+ return $this->flash->getMessages();
+ }
+}
\ No newline at end of file
diff --git a/lib/Validation/Exceptions/LatitudeException.php b/lib/Validation/Exceptions/LatitudeException.php
new file mode 100644
index 0000000..c58d2b1
--- /dev/null
+++ b/lib/Validation/Exceptions/LatitudeException.php
@@ -0,0 +1,40 @@
+.
+ */
+
+namespace Xibo\Validation\Exceptions;
+
+use Respect\Validation\Exceptions\ValidationException;
+
+/**
+ * Latitude Exception
+ */
+class LatitudeException extends ValidationException
+{
+ protected $defaultTemplates = [
+ self::MODE_DEFAULT => [
+ self::STANDARD => 'Latitude is not valid',
+ ],
+ self::MODE_NEGATIVE => [
+ self::STANDARD => 'Negative of Latitude is not valid.',
+ ],
+ ];
+}
diff --git a/lib/Validation/Exceptions/LongitudeException.php b/lib/Validation/Exceptions/LongitudeException.php
new file mode 100644
index 0000000..d2082a8
--- /dev/null
+++ b/lib/Validation/Exceptions/LongitudeException.php
@@ -0,0 +1,40 @@
+.
+ */
+
+namespace Xibo\Validation\Exceptions;
+
+use Respect\Validation\Exceptions\ValidationException;
+
+/**
+ * Latitude Exception
+ */
+class LongitudeException extends ValidationException
+{
+ protected $defaultTemplates = [
+ self::MODE_DEFAULT => [
+ self::STANDARD => 'Longitude is not valid',
+ ],
+ self::MODE_NEGATIVE => [
+ self::STANDARD => 'Negative of Longitude is not valid.',
+ ],
+ ];
+}
diff --git a/lib/Validation/Rules/Latitude.php b/lib/Validation/Rules/Latitude.php
new file mode 100644
index 0000000..5cd9c7f
--- /dev/null
+++ b/lib/Validation/Rules/Latitude.php
@@ -0,0 +1,44 @@
+.
+ */
+
+namespace Xibo\Validation\Rules;
+
+use Respect\Validation\Rules\AbstractRule;
+
+/**
+ * Class Latitude
+ * @package Xibo\Validation\Rules
+ */
+class Latitude extends AbstractRule
+{
+ /** @inheritdoc */
+ public function validate($input): bool
+ {
+ if (!is_numeric($input)) {
+ return false;
+ }
+
+ $latitude = doubleval($input);
+
+ return ($latitude >= -90 && $latitude <= 90);
+ }
+}
diff --git a/lib/Validation/Rules/Longitude.php b/lib/Validation/Rules/Longitude.php
new file mode 100644
index 0000000..5a93f54
--- /dev/null
+++ b/lib/Validation/Rules/Longitude.php
@@ -0,0 +1,49 @@
+.
+ */
+
+
+namespace Xibo\Validation\Rules;
+
+
+use Respect\Validation\Rules\AbstractRule;
+
+/**
+ * Class Longitude
+ * @package Xibo\Validation\Rules
+ */
+class Longitude extends AbstractRule
+{
+ /**
+ * @param $input
+ * @return bool
+ */
+ public function validate($input): bool
+ {
+ if (!is_numeric($input)) {
+ return false;
+ }
+
+ $longitude = doubleval($input);
+
+ return ($longitude >= -180 && $longitude <= 180);
+ }
+}
diff --git a/lib/Widget/AudioProvider.php b/lib/Widget/AudioProvider.php
new file mode 100644
index 0000000..37731f5
--- /dev/null
+++ b/lib/Widget/AudioProvider.php
@@ -0,0 +1,68 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Handles setting the correct audio duration.
+ */
+class AudioProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ // If we have not been provided a specific duration, we should use the duration stored in the library
+ try {
+ if ($durationProvider->getWidget()->useDuration === 0) {
+ $durationProvider->setDuration($durationProvider->getWidget()->getDurationForMedia());
+ }
+ } catch (NotFoundException) {
+ $this->getLog()->error('fetchDuration: video/audio without primaryMediaId. widgetId: '
+ . $durationProvider->getWidget()->getId());
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ // No special cache key requirements.
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+}
diff --git a/lib/Widget/Compatibility/CalendarWidgetCompatibility.php b/lib/Widget/Compatibility/CalendarWidgetCompatibility.php
new file mode 100644
index 0000000..5792730
--- /dev/null
+++ b/lib/Widget/Compatibility/CalendarWidgetCompatibility.php
@@ -0,0 +1,76 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert a v3 calendar or calendaradvanced widget to its v4 counterpart.
+ */
+class CalendarWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritDoc */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ // Track if we've been upgraded.
+ $upgraded = false;
+
+ // Did we originally come from an agenda (the old calendar widget)
+ if ($widget->getOriginalValue('type') === 'calendar') {
+ $newTemplateId = 'event_custom_html';
+
+ // New options names.
+ $widget->changeOption('template', 'text');
+ } else {
+ // We are a calendaradvanced
+ // Calendar type is either 1=schedule, 2=daily, 3=weekly or 4=monthly.
+ $newTemplateId = match ($widget->getOptionValue('calendarType', 1)) {
+ 2 => 'daily',
+ 3 => 'weekly',
+ 4 => 'monthly',
+ default => 'schedule',
+ };
+
+ // Apply the theme
+ $newTemplateId .= '_' . $widget->getOptionValue('templateTheme', 'light');
+ }
+
+ if (!empty($newTemplateId)) {
+ $widget->setOptionValue('templateId', 'attrib', $newTemplateId);
+ $upgraded = true;
+ }
+
+ return $upgraded;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/ClockWidgetCompatibility.php b/lib/Widget/Compatibility/ClockWidgetCompatibility.php
new file mode 100644
index 0000000..cec5f01
--- /dev/null
+++ b/lib/Widget/Compatibility/ClockWidgetCompatibility.php
@@ -0,0 +1,66 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class ClockWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ // The old clock widget had a `clockTypeId` option which determines the template
+ // we must make a choice here.
+ $widget->type = match ($widget->getOptionValue('clockTypeId', 1)) {
+ 2 => 'clock-digital',
+ 3 => 'clock-flip',
+ default => 'clock-analogue',
+ };
+
+ // in v3 this option used to ba called theme, now it is themeId
+ if ($widget->type === 'clock-analogue') {
+ $widget->setOptionValue('themeId', 'attrib', $widget->getOptionValue('theme', 1));
+ }
+
+ // We don't need the old options anymore
+ $widget->removeOption('clockTypeId');
+ $widget->removeOption('theme');
+
+ return true;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/CountDownWidgetCompatibility.php b/lib/Widget/Compatibility/CountDownWidgetCompatibility.php
new file mode 100644
index 0000000..8369b70
--- /dev/null
+++ b/lib/Widget/Compatibility/CountDownWidgetCompatibility.php
@@ -0,0 +1,71 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class CountDownWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $countdownType = $widget->getOptionValue('countdownType', 1);
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+
+ // Old countdown had countdownType.
+ if ($overrideTemplate == 1) {
+ $widget->type = 'countdown-custom';
+ } else {
+ $widget->type = match ($countdownType) {
+ 2 => 'countdown-clock',
+ 3 => 'countdown-table',
+ 4 => 'countdown-days',
+ default => 'countdown-text',
+ };
+ }
+
+ // If overriden, we need to tranlate the legacy options to the new values
+ if ($overrideTemplate == 1) {
+ $widget->changeOption('widgetOriginalWidth', 'widgetDesignWidth');
+ $widget->changeOption('widgetOriginalHeight', 'widgetDesignHeight');
+ $widget->removeOption('templateId');
+ }
+
+ return true;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/CurrenciesWidgetCompatibility.php b/lib/Widget/Compatibility/CurrenciesWidgetCompatibility.php
new file mode 100644
index 0000000..6eba473
--- /dev/null
+++ b/lib/Widget/Compatibility/CurrenciesWidgetCompatibility.php
@@ -0,0 +1,66 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class CurrenciesWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $upgraded = false;
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+
+ // If the widget has override template
+ if ($overrideTemplate == 1) {
+ $widget->setOptionValue('templateId', 'attrib', 'currencies_custom_html');
+ $upgraded = true;
+
+ // We need to tranlate the legacy options to the new values
+ $widget->changeOption('widgetOriginalWidth', 'widgetDesignWidth');
+ $widget->changeOption('widgetOriginalHeight', 'widgetDesignHeight');
+ }
+
+ // We need to change duration per page to duration per item
+ $widget->changeOption('durationIsPerPage', 'durationIsPerItem');
+
+ return $upgraded;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
+
diff --git a/lib/Widget/Compatibility/DatasetWidgetCompatibility.php b/lib/Widget/Compatibility/DatasetWidgetCompatibility.php
new file mode 100644
index 0000000..989446f
--- /dev/null
+++ b/lib/Widget/Compatibility/DatasetWidgetCompatibility.php
@@ -0,0 +1,78 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class DatasetWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ // Did we originally come from a dataset ticker?
+ if ($widget->getOriginalValue('type') === 'datasetticker') {
+ $newTemplateId = 'dataset_custom_html';
+ $widget->changeOption('css', 'styleSheet');
+ } else {
+ if ($widget->getOptionValue('overrideTemplate', 0) == 0) {
+ $newTemplateId = match ($widget->getOptionValue('templateId', '')) {
+ 'light-green' => 'dataset_table_2',
+ 'simple-round' => 'dataset_table_3',
+ 'transparent-blue' => 'dataset_table_4',
+ 'orange-grey-striped' => 'dataset_table_5',
+ 'split-rows' => 'dataset_table_6',
+ 'dark-round' => 'dataset_table_7',
+ 'pill-colored' => 'dataset_table_8',
+ default => 'dataset_table_1',
+ };
+ } else {
+ $newTemplateId = 'dataset_table_custom_html';
+ }
+
+ // We have changed the format of columns to be an array in v4.
+ $columns = $widget->getOptionValue('columns', '');
+ if (!empty($columns)) {
+ $widget->setOptionValue('columns', 'attrib', '[' . $columns . ']');
+ }
+ }
+
+ $widget->setOptionValue('templateId', 'attrib', $newTemplateId);
+
+ return true;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/NotificationViewCompatibility.php b/lib/Widget/Compatibility/NotificationViewCompatibility.php
new file mode 100644
index 0000000..3ab10a7
--- /dev/null
+++ b/lib/Widget/Compatibility/NotificationViewCompatibility.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+class NotificationViewCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $upgraded = false;
+
+ if ($fromSchema <= 1) {
+ // Add a templateId.
+ $widget->setOptionValue('templateId', 'attrib', 'message_custom_html');
+ $upgraded = true;
+ }
+
+ return $upgraded;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/RssWidgetCompatibility.php b/lib/Widget/Compatibility/RssWidgetCompatibility.php
new file mode 100644
index 0000000..91fad72
--- /dev/null
+++ b/lib/Widget/Compatibility/RssWidgetCompatibility.php
@@ -0,0 +1,157 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert RSS old kebab-case properties to camelCase
+ */
+class RssWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ // Decode URL (always make sure we save URLs decoded)
+ $widget->setOptionValue('uri', 'attrib', urldecode($widget->getOptionValue('uri', '')));
+
+ // Swap to new template names.
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+
+ if ($overrideTemplate) {
+ $newTemplateId = 'article_custom_html';
+ } else {
+ $newTemplateId = match ($widget->getOptionValue('templateId', '')) {
+ 'media-rss-image-only' => 'article_image_only',
+ 'media-rss-with-left-hand-text' => 'article_with_left_hand_text',
+ 'media-rss-with-title' => 'article_with_title',
+ 'prominent-title-with-desc-and-name-separator' => 'article_with_desc_and_name_separator',
+ default => 'article_title_only',
+ };
+
+ // If template id is "article_with_desc_and_name_separator"
+ // set showSideBySide to 1 to replicate behaviour in v3 for marquee
+ $effect = $widget->getOptionValue('effect', null);
+ if (
+ $newTemplateId === 'article_with_desc_and_name_separator' &&
+ $effect === 'marqueeLeft' ||
+ $effect === 'marqueeRight' ||
+ $effect === 'marqueeUp' ||
+ $effect === 'marqueeDown'
+ ) {
+ $widget->setOptionValue('showSideBySide', 'attrib', 1);
+ }
+ }
+ $widget->setOptionValue('templateId', 'attrib', $newTemplateId);
+
+ // If the new templateId is custom, we need to parse the old template for image enclosures
+ if ($newTemplateId === 'article_custom_html') {
+ $template = $widget->getOptionValue('template', null);
+ if (!empty($template)) {
+ $modified = false;
+ $matches = [];
+ preg_match_all('/\[(.*?)\]/', $template, $matches);
+
+ for ($i = 0; $i < count($matches[1]); $i++) {
+ // We have a [Link] or a [xxx|image] tag
+ $match = $matches[1][$i];
+ if ($match === 'Link' || $match === 'Link|image') {
+ // This is a straight-up enclosure (which is the default).
+ $template = str_replace($matches[0][$i], '', $template);
+ $modified = true;
+ } else if (str_contains($match, '|image')) {
+ // [tag|image|attribute]
+ // Set the necessary options depending on how our tag is made up
+ $parts = explode('|', $match);
+ $tag = $parts[0];
+ $attribute = $parts[2] ?? null;
+
+ $widget->setOptionValue('imageSource', 'attrib', 'custom');
+ $widget->setOptionValue('imageSourceTag', 'attrib', $tag);
+ if (!empty($attribute)) {
+ $widget->setOptionValue('imageSourceAttribute', 'attrib', $attribute);
+ }
+
+ $template = str_replace($matches[0][$i], '', $template);
+ $modified = true;
+ }
+ }
+
+ if ($modified) {
+ $widget->setOptionValue('template', 'cdata', $template);
+ }
+ }
+ }
+
+ // Change some other options if they have been set.
+ foreach ($widget->widgetOptions as $option) {
+ $widgetChangeOption = null;
+ switch ($option->option) {
+ case 'background-color':
+ $widgetChangeOption = 'itemBackgroundColor';
+ break;
+
+ case 'title-color':
+ $widgetChangeOption = 'itemTitleColor';
+ break;
+
+ case 'name-color':
+ $widgetChangeOption = 'itemNameColor';
+ break;
+
+ case 'description-color':
+ $widgetChangeOption = 'itemDescriptionColor';
+ break;
+
+ case 'font-size':
+ $widgetChangeOption = 'itemFontSize';
+ break;
+
+ case 'image-fit':
+ $widgetChangeOption = 'itemImageFit';
+ break;
+
+ default:
+ break;
+ }
+
+ if (!empty($widgetChangeOption)) {
+ $widget->changeOption($option->option, $widgetChangeOption);
+ }
+ }
+
+ return true;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/SocialMediaWidgetCompatibility.php b/lib/Widget/Compatibility/SocialMediaWidgetCompatibility.php
new file mode 100644
index 0000000..f157476
--- /dev/null
+++ b/lib/Widget/Compatibility/SocialMediaWidgetCompatibility.php
@@ -0,0 +1,77 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class SocialMediaWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+ if ($overrideTemplate == 1) {
+ $newTemplateId = 'social_media_custom_html';
+ } else {
+ $newTemplateId = match ($widget->getOptionValue('templateId', '')) {
+ 'full-timeline-np' => 'social_media_static_1',
+ 'full-timeline' => 'social_media_static_2',
+ 'tweet-with-profileimage-left' => 'social_media_static_4',
+ 'tweet-with-profileimage-right' => 'social_media_static_5',
+ 'tweet-1' => 'social_media_static_6',
+ 'tweet-2' => 'social_media_static_7',
+ 'tweet-4' => 'social_media_static_8',
+ 'tweet-6NP' => 'social_media_static_9',
+ 'tweet-6PL' => 'social_media_static_10',
+ 'tweet-7' => 'social_media_static_11',
+ 'tweet-8' => 'social_media_static_12',
+ default => 'social_media_static_3',
+ };
+ }
+ $widget->setOptionValue('templateId', 'attrib', $newTemplateId);
+
+ // If overriden, we need to tranlate the legacy options to the new values
+ if ($overrideTemplate == 1) {
+ $widget->changeOption('widgetOriginalWidth', 'widgetDesignWidth');
+ $widget->changeOption('widgetOriginalHeight', 'widgetDesignHeight');
+ $widget->changeOption('widgetOriginalPadding', 'widgetDesignGap');
+ }
+
+ return true;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/StocksWidgetCompatibility.php b/lib/Widget/Compatibility/StocksWidgetCompatibility.php
new file mode 100644
index 0000000..b9b5ec3
--- /dev/null
+++ b/lib/Widget/Compatibility/StocksWidgetCompatibility.php
@@ -0,0 +1,66 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class StocksWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $upgraded = false;
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+
+ // If the widget has override template
+ if ($overrideTemplate == 1) {
+ $widget->setOptionValue('templateId', 'attrib', 'stocks_custom_html');
+ $upgraded = true;
+
+ // We need to tranlate the legacy options to the new values
+ $widget->changeOption('widgetOriginalWidth', 'widgetDesignWidth');
+ $widget->changeOption('widgetOriginalHeight', 'widgetDesignHeight');
+ }
+
+ // We need to change duration per page to duration per item
+ $widget->changeOption('durationIsPerPage', 'durationIsPerItem');
+
+ return $upgraded;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
+
diff --git a/lib/Widget/Compatibility/SubPlaylistWidgetCompatibility.php b/lib/Widget/Compatibility/SubPlaylistWidgetCompatibility.php
new file mode 100644
index 0000000..0b33677
--- /dev/null
+++ b/lib/Widget/Compatibility/SubPlaylistWidgetCompatibility.php
@@ -0,0 +1,109 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+use Xibo\Widget\SubPlaylistItem;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class SubPlaylistWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $upgraded = false;
+ $playlists = [];
+ $playlistIds = [];
+
+ // subPlaylistOptions and subPlaylistIds are no longer in use from 2.3
+ // we need to capture these options to support Layout with sub-playlist import from older CMS
+ foreach ($widget->widgetOptions as $option) {
+ if ($option->option === 'subPlaylists') {
+ $playlists = json_decode($widget->getOptionValue('subPlaylists', '[]'), true);
+ }
+
+ if ($option->option === 'subPlaylistIds') {
+ $playlistIds = json_decode($widget->getOptionValue('subPlaylistIds', '[]'), true);
+ }
+
+ if ($option->option === 'subPlaylistOptions') {
+ $subPlaylistOptions = json_decode($widget->getOptionValue('subPlaylistOptions', '[]'), true);
+ }
+ }
+
+ if (count($playlists) <= 0) {
+ $i = 0;
+ foreach ($playlistIds as $playlistId) {
+ $i++;
+ $playlists[] = [
+ 'rowNo' => $i,
+ 'playlistId' => $playlistId,
+ 'spotFill' => $subPlaylistOptions[$playlistId]['subPlaylistIdSpotFill'] ?? null,
+ 'spotLength' => $subPlaylistOptions[$playlistId]['subPlaylistIdSpotLength'] ?? null,
+ 'spots' => $subPlaylistOptions[$playlistId]['subPlaylistIdSpots'] ?? null,
+ ];
+ }
+
+ $playlistItems = [];
+ foreach ($playlists as $playlist) {
+ $item = new SubPlaylistItem();
+ $item->rowNo = intval($playlist['rowNo']);
+ $item->playlistId = $playlist['playlistId'];
+ $item->spotFill = $playlist['spotFill'] ?? '';
+ $item->spotLength = $playlist['spotLength'] !== '' ? intval($playlist['spotLength']) : '';
+ $item->spots = $playlist['spots'] !== '' ? intval($playlist['spots']) : '';
+
+ $playlistItems[] = $item;
+ }
+
+ if (count($playlistItems) > 0) {
+ $widget->setOptionValue('subPlaylists', 'attrib', json_encode($playlistItems));
+ $widget->removeOption('subPlaylistIds');
+ $widget->removeOption('subPlaylistOptions');
+ $upgraded = true;
+ }
+ } else {
+ $this->getLog()->debug(
+ 'upgradeWidget : subplaylist ' . $widget->widgetId .
+ ' with already updated widget options, save to update schema version'
+ );
+ $upgraded = true;
+ }
+
+ return $upgraded;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/WeatherWidgetCompatibility.php b/lib/Widget/Compatibility/WeatherWidgetCompatibility.php
new file mode 100644
index 0000000..2390505
--- /dev/null
+++ b/lib/Widget/Compatibility/WeatherWidgetCompatibility.php
@@ -0,0 +1,100 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class WeatherWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+ if ($overrideTemplate == 1) {
+ $newTemplateId = 'weather_custom_html';
+ } else {
+ $newTemplateId = match ($widget->getOptionValue('templateId', '')) {
+ 'weather-module0-singleday' => 'weather_2',
+ 'weather-module0-singleday2' => 'weather_3',
+ 'weather-module1l' => 'weather_4',
+ 'weather-module1p' => 'weather_5',
+ 'weather-module2l' => 'weather_6',
+ 'weather-module2p' => 'weather_7',
+ 'weather-module3l' => 'weather_8',
+ 'weather-module3p' => 'weather_9',
+ 'weather-module4l' => 'weather_10',
+ 'weather-module4p' => 'weather_11',
+ 'weather-module5l' => 'weather_12',
+ 'weather-module6h' => 'weather_13',
+ 'weather-module6v' => 'weather_14',
+ 'weather-module-7s' => 'weather_15',
+ 'weather-module-8s' => 'weather_16',
+ 'weather-module-9' => 'weather_17',
+ 'weather-module-10l' => 'weather_18',
+ default => 'weather_1',
+ };
+ }
+ $widget->setOptionValue('templateId', 'attrib', $newTemplateId);
+
+ // If overriden, we need to tranlate the legacy options to the new values
+ if ($overrideTemplate == 1) {
+ $widget->changeOption('widgetOriginalWidth', 'widgetDesignWidth');
+ $widget->changeOption('widgetOriginalHeight', 'widgetDesignHeight');
+ }
+
+ // Process the background image properties so that they are removed if empty
+ $this->removeOptionIfEquals($widget, 'cloudy-image');
+ $this->removeOptionIfEquals($widget, 'day-cloudy-image');
+ $this->removeOptionIfEquals($widget, 'day-sunny-image');
+ $this->removeOptionIfEquals($widget, 'fog-image');
+ $this->removeOptionIfEquals($widget, 'hail-image');
+ $this->removeOptionIfEquals($widget, 'night-clear-image');
+ $this->removeOptionIfEquals($widget, 'night-partly-cloudy-image');
+ $this->removeOptionIfEquals($widget, 'rain-image');
+ $this->removeOptionIfEquals($widget, 'snow-image');
+ $this->removeOptionIfEquals($widget, 'windy-image');
+ return true;
+ }
+
+ private function removeOptionIfEquals(Widget $widget, string $option): void
+ {
+ if ($widget->getOptionValue($option, null) === $option) {
+ $widget->removeOption($option);
+ }
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/Compatibility/WorldClockWidgetCompatibility.php b/lib/Widget/Compatibility/WorldClockWidgetCompatibility.php
new file mode 100644
index 0000000..63cfdc8
--- /dev/null
+++ b/lib/Widget/Compatibility/WorldClockWidgetCompatibility.php
@@ -0,0 +1,78 @@
+.
+ */
+
+namespace Xibo\Widget\Compatibility;
+
+use Xibo\Entity\Widget;
+use Xibo\Widget\Provider\WidgetCompatibilityInterface;
+use Xibo\Widget\Provider\WidgetCompatibilityTrait;
+
+/**
+ * Convert widget from an old schema to a new schema
+ */
+class WorldClockWidgetCompatibility implements WidgetCompatibilityInterface
+{
+ use WidgetCompatibilityTrait;
+
+ /** @inheritdoc
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool
+ {
+ $this->getLog()->debug('upgradeWidget: ' . $widget->getId() . ' from: ' . $fromSchema . ' to: ' . $toSchema);
+
+ $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0);
+ if ($overrideTemplate) {
+ $widget->type = 'worldclock-digital-custom';
+ } else {
+ $widget->type = match ($widget->getOptionValue('clockType', 1)) {
+ 2 => 'worldclock-analogue',
+ default => match ($widget->getOptionValue('templateId', '')) {
+ 'worldclock1' => 'worldclock-digital-text',
+ 'worldclock2' => 'worldclock-digital-date',
+ default => 'worldclock-digital-custom',
+ },
+ };
+ }
+
+ // We need to tranlate the legacy options to the new values
+ $widget->changeOption('clockCols', 'numCols');
+ $widget->changeOption('clockRows', 'numRows');
+
+ if ($overrideTemplate == 1) {
+ $widget->changeOption('mainTemplate', 'template_html');
+ $widget->changeOption('styleSheet', 'template_style');
+ $widget->changeOption('widgetOriginalWidth', 'widgetDesignWidth');
+ $widget->changeOption('widgetOriginalHeight', 'widgetDesignHeight');
+ }
+
+ // Always remove template id / clockType from world clock
+ $widget->removeOption('templateId');
+ $widget->removeOption('clockType');
+
+ return true;
+ }
+
+ public function saveTemplate(string $template, string $fileName): bool
+ {
+ return false;
+ }
+}
diff --git a/lib/Widget/CurrenciesAndStocksProvider.php b/lib/Widget/CurrenciesAndStocksProvider.php
new file mode 100644
index 0000000..b20e5f5
--- /dev/null
+++ b/lib/Widget/CurrenciesAndStocksProvider.php
@@ -0,0 +1,96 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * A widget provider for stocks and currencies, only used to correctly set the numItems
+ */
+class CurrenciesAndStocksProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ /**
+ * We want to pass this out to the event mechanism for 3rd party sources.
+ * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider
+ * @return \Xibo\Widget\Provider\WidgetProviderInterface
+ */
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $dataProvider->setIsUseEvent();
+ return $this;
+ }
+
+ /**
+ * Special handling for currencies and stocks where the number of data items is based on the quantity of
+ * items input in the `items` property.
+ * @param \Xibo\Widget\Provider\DurationProviderInterface $durationProvider
+ * @return \Xibo\Widget\Provider\WidgetProviderInterface
+ */
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchDuration: CurrenciesAndStocksProvider');
+
+ // Currencies and stocks are based on the number of items set in the respective fields.
+ $items = $durationProvider->getWidget()->getOptionValue('items', null);
+ if ($items === null) {
+ $this->getLog()->debug('fetchDuration: CurrenciesAndStocksProvider: no items set');
+ return $this;
+ }
+
+ if ($durationProvider->getWidget()->getOptionValue('durationIsPerItem', 0) == 0) {
+ $this->getLog()->debug('fetchDuration: CurrenciesAndStocksProvider: duration per item not set');
+ return $this;
+ }
+
+ $numItems = count(explode(',', $items));
+
+ $this->getLog()->debug('fetchDuration: CurrenciesAndStocksProvider: number of items: ' . $numItems);
+
+ if ($numItems > 1) {
+ // If we have paging involved then work out the page count.
+ $itemsPerPage = $durationProvider->getWidget()->getOptionValue('itemsPerPage', 0);
+ if ($itemsPerPage > 0) {
+ $numItems = ceil($numItems / $itemsPerPage);
+ }
+
+ $durationProvider->setDuration($durationProvider->getWidget()->calculatedDuration * $numItems);
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+}
diff --git a/lib/Widget/DashboardProvider.php b/lib/Widget/DashboardProvider.php
new file mode 100644
index 0000000..7033a73
--- /dev/null
+++ b/lib/Widget/DashboardProvider.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Event\DashboardDataRequestEvent;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+class DashboardProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchData: DashboardProvider passing to event');
+ $this->getDispatcher()->dispatch(
+ new DashboardDataRequestEvent($dataProvider),
+ DashboardDataRequestEvent::$NAME
+ );
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+}
diff --git a/lib/Widget/DataSetProvider.php b/lib/Widget/DataSetProvider.php
new file mode 100644
index 0000000..b85ea01
--- /dev/null
+++ b/lib/Widget/DataSetProvider.php
@@ -0,0 +1,97 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Event\DataSetDataRequestEvent;
+use Xibo\Event\DataSetModifiedDtRequestEvent;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Provides data from DataSets.
+ */
+class DataSetProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchData: DataSetProvider passing to event');
+ $this->getDispatcher()->dispatch(
+ new DataSetDataRequestEvent($dataProvider),
+ DataSetDataRequestEvent::$NAME
+ );
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ if ($durationProvider->getWidget()->getOptionValue('durationIsPerItem', 0) == 1) {
+ // Count of rows
+ $numItems = $durationProvider->getWidget()->getOptionValue('numItems', 0);
+
+ // Workaround: dataset static (from v3 dataset view) has rowsPerPage instead.
+ $rowsPerPage = $durationProvider->getWidget()->getOptionValue('rowsPerPage', 0);
+ $itemsPerPage = $durationProvider->getWidget()->getOptionValue('itemsPerPage', 0);
+
+ // If we have paging involved then work out the page count.
+ $itemsPerPage = max($itemsPerPage, $rowsPerPage);
+ if ($itemsPerPage > 0) {
+ $numItems = ceil($numItems / $itemsPerPage);
+ }
+
+ $durationProvider->setDuration($durationProvider->getWidget()->calculatedDuration * $numItems);
+
+ $this->getLog()->debug(sprintf(
+ 'fetchDuration: duration is per item, numItems: %s, rowsPerPage: %s, itemsPerPage: %s',
+ $numItems,
+ $rowsPerPage,
+ $itemsPerPage
+ ));
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ // No special cache key requirements.
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ $this->getLog()->debug('fetchData: DataSetProvider passing to modifiedDt request event');
+ $dataSetId = $dataProvider->getProperty('dataSetId');
+ if ($dataSetId !== null) {
+ // Raise an event to get the modifiedDt of this dataSet
+ $event = new DataSetModifiedDtRequestEvent($dataSetId);
+ $this->getDispatcher()->dispatch($event, DataSetModifiedDtRequestEvent::$NAME);
+ return max($event->getModifiedDt(), $dataProvider->getWidgetModifiedDt());
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/lib/Widget/DataType/Article.php b/lib/Widget/DataType/Article.php
new file mode 100644
index 0000000..9096367
--- /dev/null
+++ b/lib/Widget/DataType/Article.php
@@ -0,0 +1,80 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * An article, usually from a blog or news feed.
+ */
+class Article implements \JsonSerializable, DataTypeInterface
+{
+ public static $NAME = 'article';
+ public $title;
+ public $summary;
+ public $content;
+ public $author;
+ public $permalink;
+ public $link;
+ public $image;
+
+ /** @var \Carbon\Carbon */
+ public $date;
+
+ /** @var \Carbon\Carbon */
+ public $publishedDate;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'title' => $this->title,
+ 'summary' => $this->summary,
+ 'content' => $this->content,
+ 'author' => $this->author,
+ 'permalink' => $this->permalink,
+ 'link' => $this->link,
+ 'date' => $this->date->format('c'),
+ 'publishedDate' => $this->publishedDate->format('c'),
+ 'image' => $this->image,
+ ];
+ }
+
+ public function getDefinition(): DataType
+ {
+ $dataType = new DataType();
+ $dataType->id = self::$NAME;
+ $dataType->name = __('Article');
+ $dataType
+ ->addField('title', __('Title'), 'text')
+ ->addField('summary', __('Summary'), 'text')
+ ->addField('content', __('Content'), 'text')
+ ->addField('author', __('Author'), 'text')
+ ->addField('permalink', __('Permalink'), 'text')
+ ->addField('link', __('Link'), 'text')
+ ->addField('date', __('Created Date'), 'datetime')
+ ->addField('publishedDate', __('Published Date'), 'datetime')
+ ->addField('image', __('Image'), 'image');
+ return $dataType;
+ }
+}
diff --git a/lib/Widget/DataType/DataTypeInterface.php b/lib/Widget/DataType/DataTypeInterface.php
new file mode 100644
index 0000000..1702237
--- /dev/null
+++ b/lib/Widget/DataType/DataTypeInterface.php
@@ -0,0 +1,37 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * A class representation of a data type.
+ */
+interface DataTypeInterface
+{
+ /**
+ * Return the definition
+ * @return \Xibo\Widget\Definition\DataType
+ */
+ public function getDefinition(): DataType;
+}
diff --git a/lib/Widget/DataType/Event.php b/lib/Widget/DataType/Event.php
new file mode 100644
index 0000000..c721352
--- /dev/null
+++ b/lib/Widget/DataType/Event.php
@@ -0,0 +1,73 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * Event data type
+ */
+class Event implements \JsonSerializable, DataTypeInterface
+{
+ public static $NAME = 'event';
+ public $summary;
+ public $description;
+ public $location;
+
+ /** @var \Carbon\Carbon */
+ public $startDate;
+
+ /** @var \Carbon\Carbon */
+ public $endDate;
+
+ /** @var bool */
+ public $isAllDay = false;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'summary' => $this->summary,
+ 'description' => $this->description,
+ 'location' => $this->location,
+ 'startDate' => $this->startDate->format('c'),
+ 'endDate' => $this->endDate->format('c'),
+ 'isAllDay' => $this->isAllDay,
+ ];
+ }
+
+ public function getDefinition(): DataType
+ {
+ $dataType = new DataType();
+ $dataType->id = self::$NAME;
+ $dataType->name = __('Event');
+ $dataType
+ ->addField('summary', __('Summary'), 'text')
+ ->addField('description', __('Description'), 'text')
+ ->addField('location', __('Location'), 'text')
+ ->addField('startDate', __('Start Date'), 'datetime')
+ ->addField('endDate', __('End Date'), 'datetime')
+ ->addField('isAllDay', __('All Day Event'), 'boolean');
+ return $dataType;
+ }
+}
diff --git a/lib/Widget/DataType/Forecast.php b/lib/Widget/DataType/Forecast.php
new file mode 100644
index 0000000..06b1fd6
--- /dev/null
+++ b/lib/Widget/DataType/Forecast.php
@@ -0,0 +1,161 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * Forecast DataType
+ */
+class Forecast implements \JsonSerializable, DataTypeInterface
+{
+ public static $NAME = 'forecast';
+ public $time;
+ public $sunSet;
+ public $sunRise;
+ public $summary;
+ public $icon;
+ public $wicon;
+ public $temperature;
+ public $temperatureRound;
+ public $temperatureNight;
+ public $temperatureNightRound;
+ public $temperatureMorning;
+ public $temperatureMorningRound;
+ public $temperatureEvening;
+ public $temperatureEveningRound;
+ public $temperatureHigh;
+ public $temperatureMaxRound;
+ public $temperatureLow;
+ public $temperatureMinRound;
+ public $temperatureMean;
+ public $temperatureMeanRound;
+ public $apparentTemperature;
+ public $apparentTemperatureRound;
+ public $dewPoint;
+ public $humidity;
+ public $humidityPercent;
+ public $pressure;
+ public $windSpeed;
+ public $windBearing;
+ public $windDirection;
+ public $cloudCover;
+ public $uvIndex;
+ public $visibility;
+ public $ozone;
+ public $location;
+
+ public $temperatureUnit;
+ public $windSpeedUnit;
+ public $visibilityDistanceUnit;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'time' => $this->time,
+ 'sunSet' => $this->sunSet,
+ 'sunRise' => $this->sunRise,
+ 'summary' => $this->summary,
+ 'icon' => $this->icon,
+ 'wicon' => $this->wicon,
+ 'temperature' => $this->temperature,
+ 'temperatureRound' => $this->temperatureRound,
+ 'temperatureNight' => $this->temperatureNight,
+ 'temperatureNightRound' => $this->temperatureNightRound,
+ 'temperatureMorning' => $this->temperatureMorning,
+ 'temperatureMorningRound' => $this->temperatureMorningRound,
+ 'temperatureEvening' => $this->temperatureEvening,
+ 'temperatureEveningRound' => $this->temperatureEveningRound,
+ 'temperatureHigh' => $this->temperatureHigh,
+ 'temperatureMaxRound' => $this->temperatureMaxRound,
+ 'temperatureLow' => $this->temperatureLow,
+ 'temperatureMinRound' => $this->temperatureMinRound,
+ 'temperatureMean' => $this->temperatureMean,
+ 'temperatureMeanRound' => $this->temperatureMeanRound,
+ 'apparentTemperature' => $this->apparentTemperature,
+ 'apparentTemperatureRound' => $this->apparentTemperatureRound,
+ 'dewPoint' => $this->dewPoint,
+ 'humidity' => $this->humidity,
+ 'humidityPercent' => $this->humidityPercent,
+ 'pressure' => $this->pressure,
+ 'windSpeed' => $this->windSpeed,
+ 'windBearing' => $this->windBearing,
+ 'windDirection' => $this->windDirection,
+ 'cloudCover' => $this->cloudCover,
+ 'uvIndex' => $this->uvIndex,
+ 'visibility' => $this->visibility,
+ 'ozone' => $this->ozone,
+ 'location' => $this->location,
+ 'temperatureUnit' => $this->temperatureUnit,
+ 'windSpeedUnit' => $this->windSpeedUnit,
+ 'visibilityDistanceUnit' => $this->visibilityDistanceUnit
+ ];
+ }
+
+ public function getDefinition(): DataType
+ {
+ $dataType = new DataType();
+ $dataType->id = self::$NAME;
+ $dataType->name = __('Forecast');
+ $dataType
+ ->addField('time', 'Time', 'datetime')
+ ->addField('sunSet', 'Sun Set', 'datetime')
+ ->addField('sunRise', 'Sun Rise', 'datetime')
+ ->addField('summary', 'Summary', 'text')
+ ->addField('icon', 'Icon', 'text')
+ ->addField('wicon', 'Weather Icon', 'text')
+ ->addField('temperature', 'Temperature', 'number')
+ ->addField('temperatureRound', 'Temperature Round', 'number')
+ ->addField('temperatureNight', 'Temperature Night', 'number')
+ ->addField('temperatureNightRound', 'Temperature Night Round', 'number')
+ ->addField('temperatureMorning', 'Temperature Morning', 'number')
+ ->addField('temperatureMorningRound', 'Temperature Morning Round', 'number')
+ ->addField('temperatureEvening', 'Temperature Evening', 'number')
+ ->addField('temperatureEveningRound', 'Temperature Evening Round', 'number')
+ ->addField('temperatureHigh', 'Temperature High', 'number')
+ ->addField('temperatureMaxRound', 'Temperature Max Round', 'number')
+ ->addField('temperatureLow', 'Temperature Low', 'number')
+ ->addField('temperatureMinRound', 'Temperature Min Round', 'number')
+ ->addField('temperatureMean', 'Temperature Mean', 'number')
+ ->addField('temperatureMeanRound', 'Temperature Mean Round', 'number')
+ ->addField('apparentTemperature', 'Apparent Temperature', 'number')
+ ->addField('apparentTemperatureRound', 'Apparent Temperature Round', 'number')
+ ->addField('dewPoint', 'Dew Point', 'number')
+ ->addField('humidity', 'Humidity', 'number')
+ ->addField('humidityPercent', 'Humidity Percent', 'number')
+ ->addField('pressure', 'Pressure', 'number')
+ ->addField('windSpeed', 'Wind Speed', 'number')
+ ->addField('windBearing', 'Wind Bearing', 'number')
+ ->addField('windDirection', 'Wind Direction', 'text')
+ ->addField('cloudCover', 'Cloud Cover', 'number')
+ ->addField('uvIndex', 'Uv Index', 'number')
+ ->addField('visibility', 'Visibility', 'number')
+ ->addField('ozone', 'Ozone', 'number')
+ ->addField('location', 'Location', 'text')
+ ->addField('temperatureUnit', 'Temperature Unit', 'text')
+ ->addField('windSpeedUnit', 'WindSpeed Unit', 'text')
+ ->addField('visibilityDistanceUnit', 'VisibilityDistance Unit', 'text');
+ return $dataType;
+ }
+}
diff --git a/lib/Widget/DataType/Product.php b/lib/Widget/DataType/Product.php
new file mode 100644
index 0000000..c230157
--- /dev/null
+++ b/lib/Widget/DataType/Product.php
@@ -0,0 +1,70 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * Product DataType (primarily used for the Menu Board component)
+ */
+class Product implements \JsonSerializable, DataTypeInterface
+{
+ public $name;
+ public $price;
+ public $description;
+ public $availability;
+ public $allergyInfo;
+ public $calories;
+ public $image;
+ public $productOptions;
+
+ public function getDefinition(): DataType
+ {
+ $dataType = new DataType();
+ $dataType->id = 'product';
+ $dataType->name = __('Product');
+ $dataType->addField('name', __('Name'), 'string');
+ $dataType->addField('price', __('Price'), 'decimal');
+ $dataType->addField('description', __('Description'), 'string');
+ $dataType->addField('availability', __('Availability'), 'int');
+ $dataType->addField('allergyInfo', __('Allergy Information'), 'string');
+ $dataType->addField('calories', __('Calories'), 'string');
+ $dataType->addField('image', __('Image'), 'int');
+ $dataType->addField('productOptions', __('Product Options'), 'array');
+ return $dataType;
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'price' => $this->price,
+ 'description' => $this->description,
+ 'availability' => $this->availability,
+ 'calories' => $this->calories,
+ 'allergyInfo' => $this->allergyInfo,
+ 'image' => $this->image,
+ 'productOptions' => $this->productOptions,
+ ];
+ }
+}
diff --git a/lib/Widget/DataType/ProductCategory.php b/lib/Widget/DataType/ProductCategory.php
new file mode 100644
index 0000000..5c8b9d4
--- /dev/null
+++ b/lib/Widget/DataType/ProductCategory.php
@@ -0,0 +1,55 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * Product Category (primarily used for the Menu Board component)
+ */
+class ProductCategory implements \JsonSerializable, DataTypeInterface
+{
+ public $name;
+ public $description;
+ public $image;
+
+ public function getDefinition(): DataType
+ {
+ $dataType = new DataType();
+ $dataType->id = 'product-category';
+ $dataType->name = __('Product Category');
+ $dataType->addField('name', __('Name'), 'string');
+ $dataType->addField('description', __('Description'), 'string');
+ $dataType->addField('image', __('Image'), 'int');
+ return $dataType;
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'image' => $this->image,
+ ];
+ }
+}
diff --git a/lib/Widget/DataType/SocialMedia.php b/lib/Widget/DataType/SocialMedia.php
new file mode 100644
index 0000000..5e44b50
--- /dev/null
+++ b/lib/Widget/DataType/SocialMedia.php
@@ -0,0 +1,76 @@
+.
+ */
+
+namespace Xibo\Widget\DataType;
+
+use Xibo\Widget\Definition\DataType;
+
+/**
+ * Social Media DataType
+ */
+class SocialMedia implements \JsonSerializable, DataTypeInterface
+{
+ public static $NAME = 'social-media';
+ public $text;
+ public $user;
+ public $userProfileImage;
+ public $userProfileImageMini;
+ public $userProfileImageBigger;
+ public $location;
+ public $screenName;
+ public $date;
+ public $photo;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'text' => $this->text,
+ 'user' => $this->user,
+ 'userProfileImage' => $this->userProfileImage,
+ 'userProfileImageMini' => $this->userProfileImageMini,
+ 'userProfileImageBigger' => $this->userProfileImageBigger,
+ 'location' => $this->location,
+ 'screenName' => $this->screenName,
+ 'date' => $this->date,
+ 'photo' => $this->photo,
+ ];
+ }
+
+ public function getDefinition(): DataType
+ {
+ $dataType = new DataType();
+ $dataType->id = self::$NAME;
+ $dataType->name = __('Social Media');
+ $dataType
+ ->addField('text', __('Text'), 'text', true)
+ ->addField('user', __('User'), 'text')
+ ->addField('userProfileImage', __('Profile Image'), 'image')
+ ->addField('userProfileImageMini', __('Mini Profile Image'), 'image')
+ ->addField('userProfileImageBigger', __('Bigger Profile Image'), 'image')
+ ->addField('location', __('Location'), 'text')
+ ->addField('screenName', __('Screen Name'), 'text')
+ ->addField('date', __('Date'), 'datetime')
+ ->addField('photo', __('Photo'), 'image');
+ return $dataType;
+ }
+}
diff --git a/lib/Widget/Definition/Asset.php b/lib/Widget/Definition/Asset.php
new file mode 100644
index 0000000..10861f3
--- /dev/null
+++ b/lib/Widget/Definition/Asset.php
@@ -0,0 +1,196 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+use GuzzleHttp\Psr7\Stream;
+use Illuminate\Support\Str;
+use Intervention\Image\ImageManagerStatic as Img;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response;
+use Slim\Http\ServerRequest;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Xmds\Entity\Dependency;
+
+/**
+ * An asset
+ */
+class Asset implements \JsonSerializable
+{
+ public $id;
+ public $type;
+ public $alias;
+ public $path;
+ public $mimeType;
+
+ /** @var bool */
+ public $autoInclude;
+
+ /** @var bool */
+ public $cmsOnly;
+
+ public $assetNo;
+
+ private $fileSize;
+ private $md5;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'alias' => $this->alias,
+ 'type' => $this->type,
+ 'path' => $this->path,
+ 'mimeType' => $this->mimeType,
+ 'cmsOnly' => $this->cmsOnly,
+ 'autoInclude' => $this->autoInclude,
+ ];
+ }
+
+ /**
+ * Should this asset be sent to the player?
+ * @return bool
+ */
+ public function isSendToPlayer(): bool
+ {
+ return !($this->cmsOnly ?? false);
+ }
+
+ /**
+ * Should this asset be auto included in the HTML sent to the player
+ * @return bool
+ */
+ public function isAutoInclude(): bool
+ {
+ return $this->autoInclude && $this->isSendToPlayer();
+ }
+
+ /**
+ * @param string $libraryLocation
+ * @param bool $forceUpdate
+ * @return $this
+ * @throws GeneralException
+ */
+ public function updateAssetCache(string $libraryLocation, bool $forceUpdate = false): Asset
+ {
+ // Verify the asset is cached and update its path.
+ $assetPath = $libraryLocation . 'assets/' . $this->getFilename();
+ if (!file_exists($assetPath) || $forceUpdate) {
+ $result = @copy(PROJECT_ROOT . $this->path, $assetPath);
+ if (!$result) {
+ throw new GeneralException('Unable to copy asset');
+ }
+ $forceUpdate = true;
+ }
+
+ // Get the bundle MD5
+ $assetMd5CachePath = $assetPath . '.md5';
+ if (!file_exists($assetMd5CachePath) || $forceUpdate) {
+ $assetMd5 = md5_file($assetPath);
+ file_put_contents($assetMd5CachePath, $assetMd5);
+ } else {
+ $assetMd5 = file_get_contents($assetPath . '.md5');
+ }
+
+ $this->path = $assetPath;
+ $this->md5 = $assetMd5;
+ $this->fileSize = filesize($assetPath);
+
+ return $this;
+ }
+
+ /**
+ * Get this asset as a dependency.
+ * @return \Xibo\Xmds\Entity\Dependency
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function getDependency(): Dependency
+ {
+ // Check that this asset is valid.
+ if (!file_exists($this->path)) {
+ throw new NotFoundException(sprintf(__('Asset %s not found'), $this->path));
+ }
+
+ // Return a dependency
+ return new Dependency(
+ 'asset',
+ $this->id,
+ $this->getLegacyId(),
+ $this->path,
+ $this->fileSize,
+ $this->md5,
+ true
+ );
+ }
+
+ /**
+ * Get the file name for this asset
+ * @return string
+ */
+ public function getFilename(): string
+ {
+ return basename($this->path);
+ }
+
+ /**
+ * Generate a PSR response for this asset.
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function psrResponse(ServerRequest $request, Response $response, string $sendFileMode): ResponseInterface
+ {
+ // Make sure this asset exists
+ if (!file_exists($this->path)) {
+ throw new NotFoundException(__('Asset file does not exist'));
+ }
+
+ $response = $response->withHeader('Content-Length', $this->fileSize);
+ $response = $response->withHeader('Content-Type', $this->mimeType);
+
+ // Output the file
+ if ($sendFileMode === 'Apache') {
+ // Send via Apache X-Sendfile header?
+ $response = $response->withHeader('X-Sendfile', $this->path);
+ } else if ($sendFileMode === 'Nginx') {
+ // Send via Nginx X-Accel-Redirect?
+ $response = $response->withHeader('X-Accel-Redirect', '/download/assets/' . $this->getFilename());
+ } else if (Str::startsWith('image', $this->mimeType)) {
+ $response = Img::make('/' . $this->path)->psrResponse();
+ } else {
+ // Set the right content type.
+ $response = $response->withBody(new Stream(fopen($this->path, 'r')));
+ }
+ return $response;
+ }
+
+ /**
+ * Get Legacy ID for this asset on older players
+ * there is a risk that this ID will change as modules/templates with assets are added/removed in the system
+ * however, we have mitigated by ensuring that only one instance of any required file is added to rf return
+ * @return int
+ */
+ private function getLegacyId(): int
+ {
+ return (Dependency::LEGACY_ID_OFFSET_ASSET + $this->assetNo) * -1;
+ }
+}
diff --git a/lib/Widget/Definition/Condition.php b/lib/Widget/Definition/Condition.php
new file mode 100644
index 0000000..a2c1a15
--- /dev/null
+++ b/lib/Widget/Definition/Condition.php
@@ -0,0 +1,43 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * Represents a condition for a test
+ */
+class Condition implements \JsonSerializable
+{
+ public $field;
+ public $type;
+ public $value;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'field' => $this->field,
+ 'type' => $this->type,
+ 'value' => $this->value
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/DataType.php b/lib/Widget/Definition/DataType.php
new file mode 100644
index 0000000..f699761
--- /dev/null
+++ b/lib/Widget/Definition/DataType.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * A module data type
+ */
+class DataType implements \JsonSerializable
+{
+ public $id;
+ public $name;
+
+ /** @var \Xibo\Widget\Definition\Field[] */
+ public $fields = [];
+
+ public function addField(string $id, string $title, string $type, bool $isRequired = false): DataType
+ {
+ $field = new Field();
+ $field->id = $id;
+ $field->type = $type;
+ $field->title = $title;
+ $field->isRequired = $isRequired;
+ $this->fields[] = $field;
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ 'fields' => $this->fields,
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Element.php b/lib/Widget/Definition/Element.php
new file mode 100644
index 0000000..a5de1c9
--- /dev/null
+++ b/lib/Widget/Definition/Element.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * @SWG\Definition()
+ * A class representing an instance of an element template
+ */
+class Element implements \JsonSerializable
+{
+ public $id;
+ public $top;
+ public $left;
+ public $width;
+ public $height;
+ public $rotation;
+ public $layer;
+ public $elementGroupId;
+ public $properties = [];
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'top' => $this->top,
+ 'left' => $this->left,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'rotation' => $this->rotation,
+ 'layer' => $this->layer,
+ 'elementGroupId' => $this->elementGroupId,
+ 'properties' => $this->properties
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/ElementGroup.php b/lib/Widget/Definition/ElementGroup.php
new file mode 100644
index 0000000..56b7e7b
--- /dev/null
+++ b/lib/Widget/Definition/ElementGroup.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * A class representing an instance of a group of elements
+ * @SWG\Definition()
+ */
+class ElementGroup implements \JsonSerializable
+{
+ public $id;
+ public $top;
+ public $left;
+ public $width;
+ public $height;
+ public $layer;
+ public $title;
+ public $slot;
+ public $pinSlot;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'top' => $this->top,
+ 'left' => $this->left,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'layer' => $this->layer,
+ 'title' => $this->title,
+ 'slot' => $this->slot,
+ 'pinSlot' => $this->pinSlot
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Extend.php b/lib/Widget/Definition/Extend.php
new file mode 100644
index 0000000..c27503a
--- /dev/null
+++ b/lib/Widget/Definition/Extend.php
@@ -0,0 +1,46 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * @SWG\Definition()
+ * A class representing one template extending another
+ */
+class Extend implements \JsonSerializable
+{
+ public $template;
+ public $override;
+ public $with;
+ public $escapeHtml;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'template' => $this->template,
+ 'override' => $this->override,
+ 'with' => $this->with,
+ 'escapeHtml' => $this->escapeHtml,
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Field.php b/lib/Widget/Definition/Field.php
new file mode 100644
index 0000000..f91325d
--- /dev/null
+++ b/lib/Widget/Definition/Field.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * Class representing a data type field
+ */
+class Field implements \JsonSerializable
+{
+ public $id;
+ public $type;
+ public $title;
+ public $isRequired;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'type' => $this->type,
+ 'title' => $this->title,
+ 'isRequired' => $this->isRequired,
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/LegacyType.php b/lib/Widget/Definition/LegacyType.php
new file mode 100644
index 0000000..1917aaf
--- /dev/null
+++ b/lib/Widget/Definition/LegacyType.php
@@ -0,0 +1,42 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * A Legacy Type
+ * @SWG\Definition()
+ */
+class LegacyType implements \JsonSerializable
+{
+ public $name;
+ public $condition;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'condition' => $this->condition,
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Option.php b/lib/Widget/Definition/Option.php
new file mode 100644
index 0000000..3335e84
--- /dev/null
+++ b/lib/Widget/Definition/Option.php
@@ -0,0 +1,67 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * Option: typically used when paired with a dropdown
+ * @SWG\Definition()
+ */
+class Option implements \JsonSerializable
+{
+ /**
+ * @SWG\Property(description="Name")
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @SWG\Property(description="Image: optional image asset")
+ * @var string
+ */
+ public $image;
+
+ /**
+ * @SWG\Property(description="Set")
+ * @var string[]
+ */
+ public $set = [];
+
+ /**
+ * * @SWG\Property(description="Title: shown in the dropdown/select")
+ * @var string
+ */
+ public $title;
+
+ /**
+ * @inheritDoc
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'image' => $this->image,
+ 'set' => $this->set,
+ 'title' => $this->title
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/PlayerCompatibility.php b/lib/Widget/Definition/PlayerCompatibility.php
new file mode 100644
index 0000000..354516a
--- /dev/null
+++ b/lib/Widget/Definition/PlayerCompatibility.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * Player compatibility
+ */
+class PlayerCompatibility implements \JsonSerializable
+{
+ public $windows;
+ public $linux;
+ public $android;
+ public $webos;
+ public $tizen;
+ public $chromeos;
+ public $message;
+
+ /**
+ * @inheritDoc
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'windows' => $this->windows,
+ 'linux' => $this->linux,
+ 'android' => $this->android,
+ 'webos' => $this->webos,
+ 'tizen' => $this->tizen,
+ 'chromeos' => $this->chromeos,
+ 'message' => $this->message,
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Property.php b/lib/Widget/Definition/Property.php
new file mode 100644
index 0000000..ab4cac6
--- /dev/null
+++ b/lib/Widget/Definition/Property.php
@@ -0,0 +1,554 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+use Carbon\Carbon;
+use Carbon\CarbonInterval;
+use Illuminate\Support\Str;
+use Respect\Validation\Validator as v;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\ValueTooLargeException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * A Property
+ * @SWG\Definition()
+ */
+class Property implements \JsonSerializable
+{
+ /**
+ * @SWG\Property(description="ID, saved as a widget option")
+ * @var string
+ */
+ public $id;
+
+ /**
+ * @SWG\Property(description="Type, determines the field type")
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @SWG\Property(description="Title: shown in the property panel")
+ * @var string
+ */
+ public $title;
+
+ /**
+ * @SWG\Property(description="Help Text: shown in the property panel")
+ * @var string
+ */
+ public $helpText;
+
+ /** @var \Xibo\Widget\Definition\Rule */
+ public $validation;
+
+ /**
+ * @SWG\Property()
+ * @var string An optional default value
+ */
+ public $default;
+
+ /** @var \Xibo\Widget\Definition\Option[] */
+ public $options;
+
+ /** @var \Xibo\Widget\Definition\Test[] */
+ public $visibility = [];
+
+ /** @var string The element variant */
+ public $variant;
+
+ /** @var string The data format */
+ public $format;
+
+ /** @var bool Should library refs be permitted in the value? */
+ public $allowLibraryRefs = false;
+
+ /** @var bool Should asset refs be permitted in the value? */
+ public $allowAssetRefs = false;
+
+ /** @var bool Should translations be parsed in the value? */
+ public $parseTranslations = false;
+
+ /** @var bool Should the property be included in the XLF? */
+ public $includeInXlf = false;
+
+ /** @var bool Should the property be sent into Elements */
+ public $sendToElements = false;
+
+ /** @var bool Should the default value be written out to widget options */
+ public $saveDefault = false;
+
+ /** @var \Xibo\Widget\Definition\PlayerCompatibility */
+ public $playerCompatibility;
+
+ /** @var string HTML to populate a custom popover to be shown next to the input */
+ public $customPopOver;
+
+ /** @var string HTML selector of the element that this property depends on */
+ public $dependsOn;
+
+ /** @var string ID of the target element */
+ public $target;
+
+ /** @var string The mode of the property */
+ public $mode;
+
+ /** @var string The group ID of the property */
+ public $propertyGroupId;
+
+ /** @var mixed The value assigned to this property. This is set from widget options, or settings, never via XML */
+ public $value;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'value' => $this->value,
+ 'type' => $this->type,
+ 'variant' => $this->variant,
+ 'format' => $this->format,
+ 'title' => $this->title,
+ 'mode' => $this->mode,
+ 'target' => $this->target,
+ 'propertyGroupId' => $this->propertyGroupId,
+ 'helpText' => $this->helpText,
+ 'validation' => $this->validation,
+ 'default' => $this->default,
+ 'options' => $this->options,
+ 'customPopOver' => $this->customPopOver,
+ 'playerCompatibility' => $this->playerCompatibility,
+ 'visibility' => $this->visibility,
+ 'allowLibraryRefs' => $this->allowLibraryRefs,
+ 'allowAssetRefs' => $this->allowAssetRefs,
+ 'parseTranslations' => $this->parseTranslations,
+ 'saveDefault' => $this->saveDefault,
+ 'dependsOn' => $this->dependsOn,
+ 'sendToElements' => $this->sendToElements,
+ ];
+ }
+
+ /**
+ * Add an option
+ * @param string $name
+ * @param string $image
+ * @param array $set
+ * @param string $title
+ * @return $this
+ */
+ public function addOption(string $name, string $image, array $set, string $title): Property
+ {
+ $option = new Option();
+ $option->name = $name;
+ $option->image = $image;
+ $option->set = $set;
+ $option->title = __($title);
+ $this->options[] = $option;
+ return $this;
+ }
+
+ /**
+ * Add a visibility test
+ * @param string $type
+ * @param string|null $message
+ * @param array $conditions
+ * @return $this
+ */
+ public function addVisibilityTest(string $type, ?string $message, array $conditions): Property
+ {
+ $this->visibility[] = $this->parseTest($type, $message, $conditions);
+ return $this;
+ }
+
+ /**
+ * @param \Xibo\Support\Sanitizer\SanitizerInterface $params
+ * @param string|null $key
+ * @return \Xibo\Widget\Definition\Property
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function setDefaultByType(SanitizerInterface $params, ?string $key = null): Property
+ {
+ $this->default = $this->getByType($params, $key);
+ return $this;
+ }
+
+ /**
+ * @param SanitizerInterface $params
+ * @param string|null $key
+ * @param bool $ignoreDefault
+ * @return Property
+ * @throws InvalidArgumentException
+ */
+ public function setValueByType(
+ SanitizerInterface $params,
+ ?string $key = null,
+ bool $ignoreDefault = false
+ ): Property {
+ $value = $this->getByType($params, $key);
+ if ($value !== $this->default || $ignoreDefault || $this->saveDefault) {
+ $this->value = $value;
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $properties A key/value array of all properties for this entity (be it module or template)
+ * @param string $stage What stage are we at?
+ * @return Property
+ * @throws InvalidArgumentException
+ * @throws ValueTooLargeException
+ */
+ public function validate(array $properties, string $stage): Property
+ {
+ if (!empty($this->value) && strlen($this->value) > 67108864) {
+ throw new ValueTooLargeException(sprintf(__('Value too large for %s'), $this->title), $this->id);
+ }
+
+ // Skip if no validation.
+ if ($this->validation === null
+ || ($stage === 'save' && !$this->validation->onSave)
+ || ($stage === 'status' && !$this->validation->onStatus)
+ ) {
+ return $this;
+ }
+
+ foreach ($this->validation->tests as $test) {
+ // We have a test, evaulate its conditions.
+ $exceptions = [];
+
+ foreach ($test->conditions as $condition) {
+ try {
+ // Assume we're testing the field we belong to, and if that's empty use the default value
+ $testValue = $this->value ?? $this->default;
+
+ // What value are we testing against (only used by certain types)
+ if (empty($condition->field)) {
+ $valueToTestAgainst = $condition->value;
+ } else {
+ // If a field and a condition value is provided, test against those, ignoring my own field value
+ if (!empty($condition->value)) {
+ $testValue = $condition->value;
+ }
+
+ $valueToTestAgainst = $properties[$condition->field] ?? null;
+ }
+
+ // Do we have a message
+ $message = empty($test->message) ? null : __($test->message);
+
+ switch ($condition->type) {
+ case 'required':
+ // We will accept the default value here
+ if (empty($testValue) && empty($this->default)) {
+ throw new InvalidArgumentException(
+ $message ?? sprintf(__('Missing required property %s'), $this->title),
+ $this->id
+ );
+ }
+ break;
+
+ case 'uri':
+ if (!empty($testValue)
+ && !v::url()->validate($testValue)
+ ) {
+ throw new InvalidArgumentException(
+ $message ?? sprintf(__('%s must be a valid URI'), $this->title),
+ $this->id
+ );
+ }
+ break;
+
+ case 'windowsPath':
+ // Ensure the path is a valid Windows file path ending in a file, not a directory
+ $windowsPathRegex = '/^(?P[A-Za-z]:)(?P(?:\\\\[^<>:"\/\\\\|?*\r\n]+)+)(?P\\\\[^<>:"\/\\\\|?*\r\n]+)$/';
+
+ // Check if the test value is not empty and does not match the regular expression
+ if (!empty($testValue)
+ && !preg_match($windowsPathRegex, $testValue)
+ ) {
+ // Throw an InvalidArgumentException if the test value is not a valid Windows path
+ throw new InvalidArgumentException(
+ $message ?? sprintf(__('%s must be a valid Windows path'), $this->title),
+ $this->id
+ );
+ }
+
+ break;
+
+ case 'interval':
+ if (!empty($testValue)) {
+ // Try to create a date interval from it
+ $dateInterval = CarbonInterval::createFromDateString($testValue);
+ if ($dateInterval === false) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ __('That is not a valid date interval, please use natural language such as 1 week'),
+ 'customInterval'
+ );
+ }
+
+ // Use now and add the date interval to it
+ $now = Carbon::now();
+ $check = $now->copy()->add($dateInterval);
+
+ if ($now->equalTo($check)) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ $message ?? __('That is not a valid date interval, please use natural language such as 1 week'),
+ $this->id
+ );
+ }
+ }
+ break;
+
+ case 'eq':
+ if ($testValue != $valueToTestAgainst) {
+ throw new InvalidArgumentException(
+ $message ?? sprintf(__('%s must equal %s'), $this->title, $valueToTestAgainst),
+ $this->id,
+ );
+ }
+ break;
+
+ case 'neq':
+ if ($testValue == $valueToTestAgainst) {
+ throw new InvalidArgumentException(
+ $message ?? sprintf(__('%s must not equal %s'), $this->title, $valueToTestAgainst),
+ $this->id,
+ );
+ }
+ break;
+
+ case 'contains':
+ if (!empty($testValue) && !Str::contains($testValue, $valueToTestAgainst)) {
+ throw new InvalidArgumentException(
+ $message ?? sprintf(__('%s must contain %s'), $this->title, $valueToTestAgainst),
+ $this->id,
+ );
+ }
+ break;
+
+ case 'ncontains':
+ if (!empty($testValue) && Str::contains($testValue, $valueToTestAgainst)) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ $message ?? sprintf(__('%s must not contain %s'), $this->title, $valueToTestAgainst),
+ $this->id,
+ );
+ }
+ break;
+
+ case 'lt':
+ // Value must be < to the condition value, or field value
+ if (!($testValue < $valueToTestAgainst)) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ $message ?? sprintf(__('%s must be less than %s'), $this->title, $valueToTestAgainst),
+ $this->id
+ );
+ }
+ break;
+
+ case 'lte':
+ // Value must be <= to the condition value, or field value
+ if (!($testValue <= $valueToTestAgainst)) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ $message ?? sprintf(__('%s must be less than or equal to %s'), $this->title, $valueToTestAgainst),
+ $this->id
+ );
+ }
+ break;
+
+ case 'gte':
+ // Value must be >= to the condition value, or field value
+ if (!($testValue >= $valueToTestAgainst)) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ $message ?? sprintf(__('%s must be greater than or equal to %s'), $this->title, $valueToTestAgainst),
+ $this->id
+ );
+ }
+ break;
+
+ case 'gt':
+ // Value must be > to the condition value, or field value
+ if (!($testValue > $valueToTestAgainst)) {
+ throw new InvalidArgumentException(
+ // phpcs:ignore Generic.Files.LineLength
+ $message ?? sprintf(__('%s must be greater than %s'), $this->title, $valueToTestAgainst),
+ $this->id
+ );
+ }
+ break;
+
+ default:
+ // Nothing to validate
+ }
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ // If we are an AND test, all conditions must pass, so we know already to exception here.
+ if ($test->type === 'and') {
+ throw $invalidArgumentException;
+ }
+
+ // We're an OR
+ $exceptions[] = $invalidArgumentException;
+ }
+ }
+
+ // If we are an OR then make sure all conditions have failed.
+ $countOfFailures = count($exceptions);
+ if ($test->type === 'or' && $countOfFailures === count($test->conditions)) {
+ throw $exceptions[0];
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * @param \Xibo\Support\Sanitizer\SanitizerInterface $params
+ * @param string|null $key
+ * @return bool|float|int|string|null
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function getByType(SanitizerInterface $params, ?string $key = null)
+ {
+ $key = $key ?: $this->id;
+
+ if (!$params->hasParam($key) && $this->type !== 'checkbox') {
+ // Clear the stored value and therefore use the default
+ return null;
+ }
+
+ // Parse according to the type of field we're expecting
+ switch ($this->type) {
+ case 'checkbox':
+ return $params->getCheckbox($key);
+
+ case 'integer':
+ return $params->getInt($key);
+
+ case 'number':
+ return $params->getDouble($key);
+
+ case 'dropdown':
+ $value = $params->getString($key);
+ if ($value === null) {
+ return null;
+ }
+
+ $found = false;
+ foreach ($this->options as $option) {
+ if ($option->name === $value) {
+ $found = true;
+ break;
+ }
+ }
+ if ($found) {
+ return $value;
+ } else {
+ throw new InvalidArgumentException(
+ sprintf(__('%s is not a valid option'), $value),
+ $key
+ );
+ }
+
+ case 'code':
+ case 'richText':
+ return $params->getParam($key);
+ case 'text':
+ if ($this->variant === 'sql') {
+ // Handle raw SQL clauses
+ return str_ireplace(Sql::DISALLOWED_KEYWORDS, '', $params->getParam($key));
+ } else {
+ return $params->getString($key);
+ }
+ default:
+ return $params->getString($key);
+ }
+ }
+
+ /**
+ * Apply any filters on the data.
+ * @return void
+ */
+ public function applyFilters(): void
+ {
+ if ($this->variant === 'uri' || $this->type === 'commandBuilder') {
+ $this->value = urlencode($this->value);
+ }
+ }
+
+ /**
+ * Reverse filters
+ * @return void
+ */
+ public function reverseFilters(): void
+ {
+ $this->value = $this->reverseFiltersOnValue($this->value);
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed|string
+ */
+ public function reverseFiltersOnValue(mixed $value): mixed
+ {
+ if (($this->variant === 'uri' || $this->type === 'commandBuilder') && !empty($value)) {
+ $value = urldecode($value);
+ }
+ return $value;
+ }
+
+ /**
+ * Should this property be represented with CData
+ * @return bool
+ */
+ public function isCData(): bool
+ {
+ return $this->type === 'code' || $this->type === 'richText';
+ }
+
+ /**
+ * @param string $type
+ * @param string $message
+ * @param array $conditions
+ * @return Test
+ */
+ public function parseTest(string $type, string $message, array $conditions): Test
+ {
+ $test = new Test();
+ $test->type = $type ?: 'and';
+ $test->message = $message;
+
+ foreach ($conditions as $item) {
+ $condition = new Condition();
+ $condition->type = $item['type'];
+ $condition->field = $item['field'];
+ $condition->value = $item['value'];
+ $test->conditions[] = $condition;
+ }
+ return $test;
+ }
+}
diff --git a/lib/Widget/Definition/PropertyGroup.php b/lib/Widget/Definition/PropertyGroup.php
new file mode 100644
index 0000000..8b23095
--- /dev/null
+++ b/lib/Widget/Definition/PropertyGroup.php
@@ -0,0 +1,46 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * A class representing an instance of a group property to put a property in assigned Tab
+ * @SWG\Definition()
+ */
+class PropertyGroup implements \JsonSerializable
+{
+ public $id;
+ public $expanded;
+ public $title;
+ public $helpText;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'expanded' => $this->expanded,
+ 'title' => $this->title,
+ 'helpText' => $this->helpText
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Rule.php b/lib/Widget/Definition/Rule.php
new file mode 100644
index 0000000..3f08d69
--- /dev/null
+++ b/lib/Widget/Definition/Rule.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * A rule to apply to a property
+ * @SWG\Definition()
+ */
+class Rule implements \JsonSerializable
+{
+ public $onSave = true;
+
+ public $onStatus = true;
+
+ /** @var Test[] */
+ public $tests;
+
+ public function addRuleTest(Test $test): Rule
+ {
+ $this->tests[] = $test;
+ return $this;
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'onSave' => $this->onSave,
+ 'onStatus' => $this->onStatus,
+ 'tests' => $this->tests,
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Sql.php b/lib/Widget/Definition/Sql.php
new file mode 100644
index 0000000..65948c8
--- /dev/null
+++ b/lib/Widget/Definition/Sql.php
@@ -0,0 +1,46 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * SQL definitions
+ */
+class Sql
+{
+ const DISALLOWED_KEYWORDS = [
+ ';',
+ 'INSERT',
+ 'UPDATE',
+ 'SELECT',
+ 'FROM',
+ 'WHERE',
+ 'DELETE',
+ 'TRUNCATE',
+ 'TABLE',
+ 'ALTER',
+ 'GRANT',
+ 'REVOKE',
+ 'CREATE',
+ 'DROP',
+ ];
+}
diff --git a/lib/Widget/Definition/Stencil.php b/lib/Widget/Definition/Stencil.php
new file mode 100644
index 0000000..732012d
--- /dev/null
+++ b/lib/Widget/Definition/Stencil.php
@@ -0,0 +1,80 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * @SWG\Definition()
+ * A Stencil is a template which is rendered in the server and/or client
+ * it can optionally have properties and/or elements
+ */
+class Stencil implements \JsonSerializable
+{
+ /** @var \Xibo\Widget\Definition\Element[] */
+ public $elements = [];
+
+ /** @var string|null */
+ public $twig;
+
+ /** @var string|null */
+ public $hbs;
+
+ /** @var string|null */
+ public $head;
+
+ /** @var string|null */
+ public $style;
+
+ /** @var string|null */
+ public $hbsId;
+
+ /** @var double Optional positional information if contained as part of an element group */
+ public $width;
+
+ /** @var double Optional positional information if contained as part of an element group */
+ public $height;
+
+ /** @var double Optional positional information if contained as part of an element group */
+ public $gapBetweenHbs;
+
+ /**
+ * @SWG\Property(description="An array of element groups")
+ * @var \Xibo\Widget\Definition\ElementGroup[]
+ */
+ public $elementGroups = [];
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'hbsId' => $this->hbsId,
+ 'hbs' => $this->hbs,
+ 'head' => $this->head,
+ 'style' => $this->style,
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'gapBetweenHbs' => $this->gapBetweenHbs,
+ 'elements' => $this->elements,
+ 'elementGroups' => $this->elementGroups
+ ];
+ }
+}
diff --git a/lib/Widget/Definition/Test.php b/lib/Widget/Definition/Test.php
new file mode 100644
index 0000000..e24952e
--- /dev/null
+++ b/lib/Widget/Definition/Test.php
@@ -0,0 +1,49 @@
+.
+ */
+
+namespace Xibo\Widget\Definition;
+
+/**
+ * Represents a test/group of conditions
+ * @SWG\Definition()
+ */
+class Test implements \JsonSerializable
+{
+ /** @var string */
+ public $type;
+
+ /** @var Condition[] */
+ public $conditions;
+
+ /** @var string|null */
+ public $message;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'type' => $this->type,
+ 'message' => $this->message,
+ 'conditions' => $this->conditions,
+ ];
+ }
+}
diff --git a/lib/Widget/IcsProvider.php b/lib/Widget/IcsProvider.php
new file mode 100644
index 0000000..2f2a3f1
--- /dev/null
+++ b/lib/Widget/IcsProvider.php
@@ -0,0 +1,265 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use GuzzleHttp\Exception\RequestException;
+use ICal\ICal;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\ConfigurationException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\DataType\Event;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderNumItemsTrait;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Download and parse an ISC feed
+ */
+class IcsProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+ use DurationProviderNumItemsTrait;
+
+ /**
+ * Fetch the ISC feed and load its data.
+ * @inheritDoc
+ */
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ // Do we have a feed configured?
+ $uri = $dataProvider->getProperty('uri');
+ if (empty($uri)) {
+ throw new InvalidArgumentException(__('Please enter the URI to a valid ICS feed.'), 'uri');
+ }
+
+ // Create an ICal helper and pass it the contents of the file.
+ $iCalConfig = [
+ 'replaceWindowsTimeZoneIds' => ($dataProvider->getProperty('replaceWindowsTimeZoneIds', 0) == 1),
+ 'defaultSpan' => 1,
+ ];
+
+ // What event range are we interested in?
+ // Decide on the Range we're interested in
+ // $iCal->eventsFromInterval only works for future events
+ $excludeAllDay = $dataProvider->getProperty('excludeAllDay', 0) == 1;
+
+ $excludePastEvents = $dataProvider->getProperty('excludePast', 0) == 1;
+
+ $startOfDay = match ($dataProvider->getProperty('startIntervalFrom')) {
+ 'month' => Carbon::now()->startOfMonth(),
+ 'week' => Carbon::now()->startOfWeek(),
+ default => Carbon::now()->startOfDay(),
+ };
+
+ // Force timezone of each event?
+ $useEventTimezone = $dataProvider->getProperty('useEventTimezone', 1);
+
+ // do we use interval or provided date range?
+ if ($dataProvider->getProperty('useDateRange')) {
+ $rangeStart = $dataProvider->getProperty('rangeStart');
+ $rangeStart = empty($rangeStart)
+ ? Carbon::now()->startOfMonth()
+ : Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $rangeStart);
+
+ $rangeEnd = $dataProvider->getProperty('rangeEnd');
+ $rangeEnd = empty($rangeEnd)
+ ? Carbon::now()->endOfMonth()
+ : Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $rangeEnd);
+ } else {
+ $interval = $dataProvider->getProperty('customInterval');
+ $rangeStart = $startOfDay->copy();
+ $rangeEnd = $rangeStart->copy()->add(
+ \DateInterval::createFromDateString(empty($interval) ? '1 week' : $interval)
+ );
+ }
+
+ $this->getLog()->debug('fetchData: final range, start=' . $rangeStart->toAtomString()
+ . ', end=' . $rangeEnd->toAtomString());
+
+ // Set up fuzzy filtering supported by the ICal library. This is included for performance.
+ // https://github.com/u01jmg3/ics-parser?tab=readme-ov-file#variables
+ $iCalConfig['filterDaysBefore'] = $rangeStart->diffInDays(Carbon::now(), false) + 2;
+ $iCalConfig['filterDaysAfter'] = $rangeEnd->diffInDays(Carbon::now()) + 2;
+
+ $this->getLog()->debug('Range start: ' . $rangeStart->toDateTimeString()
+ . ', range end: ' . $rangeEnd->toDateTimeString()
+ . ', config: ' . var_export($iCalConfig, true));
+
+ try {
+ $iCal = new ICal(false, $iCalConfig);
+ $iCal->initString($this->downloadIcs($uri, $dataProvider));
+
+ $this->getLog()->debug('Feed initialised');
+
+ // Before we parse anything - should we use the calendar timezone as a base for our calculations?
+ if ($dataProvider->getProperty('useCalendarTimezone') == 1) {
+ $iCal->defaultTimeZone = $iCal->calendarTimeZone();
+ }
+
+ $this->getLog()->debug('Calendar timezone set to: ' . $iCal->defaultTimeZone);
+
+ // Get an array of events
+ /** @var \ICal\Event[] $events */
+ $events = $iCal->eventsFromRange($rangeStart, $rangeEnd);
+
+ // Go through each event returned
+ foreach ($events as $event) {
+ try {
+ // Parse the ICal Event into our own data type object.
+ $entry = new Event();
+ $entry->summary = $event->summary;
+ $entry->description = $event->description;
+ $entry->location = $event->location;
+
+ // Parse out the start/end dates.
+ if ($useEventTimezone === 1) {
+ // Use the timezone from the event.
+ $entry->startDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtstart_array[3]));
+ $entry->endDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtend_array[3]));
+ } else {
+ // Use the parser calculated timezone shift
+ $entry->startDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtstart_tz));
+ $entry->endDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtend_tz));
+ }
+
+ $this->getLog()->debug('Event: ' . $event->summary . ' with '
+ . $entry->startDate->format('c') . ' / ' . $entry->endDate->format('c'));
+
+ // Detect all day event
+ $isAllDay = false;
+
+ // If dtstart has value DATE
+ // (following RFC recommendations in https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.4 )
+ if (isset($event->dtstart_array[0])) {
+ // If it's a string
+ if (is_string($event->dtstart_array[0]) && strtoupper($event->dtstart_array[0]) === 'DATE') {
+ $isAllDay = true;
+ }
+
+ // If it's an array
+ if (is_array($event->dtstart_array[0]) &&
+ isset($event->dtstart_array[0]['VALUE']) &&
+ strtoupper($event->dtstart_array[0]['VALUE']) === 'DATE') {
+ $isAllDay = true;
+ }
+ }
+
+ // If MS extension flags it as all day
+ if (isset($event->x_microsoft_cdo_alldayevent) &&
+ is_string($event->x_microsoft_cdo_alldayevent) &&
+ strtoupper($event->x_microsoft_cdo_alldayevent) === 'TRUE') {
+ $isAllDay = true;
+ }
+
+ // Fallback: If both times are midnight and event is more than one day
+ if (!$isAllDay) {
+ $startAtMidnight = $entry->startDate->isStartOfDay();
+ $endsAtMidnight = $entry->endDate->isStartOfDay();
+ $diffDays = $entry->endDate->copy()->startOfDay()->diffInDays(
+ $entry->startDate->copy()->startOfDay()
+ );
+
+ $isAllDay = $startAtMidnight && $endsAtMidnight && $diffDays >= 1;
+ }
+
+ $entry->isAllDay = $isAllDay;
+
+ if ($excludeAllDay && $isAllDay) {
+ continue;
+ }
+
+ if ($excludePastEvents && $entry->endDate->isPast()) {
+ continue;
+ }
+
+ $dataProvider->addItem($entry);
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Unable to parse event. ' . var_export($event, true));
+ }
+ }
+
+ $dataProvider->setCacheTtl($dataProvider->getProperty('updateInterval', 60) * 60);
+ $dataProvider->setIsHandled();
+ } catch (\Exception $exception) {
+ $this->getLog()->error('iscProvider: fetchData: ' . $exception->getMessage());
+ $this->getLog()->debug($exception->getTraceAsString());
+
+ $dataProvider->addError(__('The iCal provided is not valid, please choose a valid feed'));
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ // No special cache key requirements.
+ return null;
+ }
+
+ /**
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function downloadIcs(string $uri, DataProviderInterface $dataProvider): string
+ {
+ // See if we have this ICS cached already.
+ $cache = $dataProvider->getPool()->getItem('/widget/' . $dataProvider->getDataType() . '/' . md5($uri));
+ $ics = $cache->get();
+
+ if ($cache->isMiss() || $ics === null) {
+ // Make a new request.
+ $this->getLog()->debug('downloadIcs: cache miss');
+
+ try {
+ // Create a Guzzle Client to get the Feed XML
+ $response = $dataProvider
+ ->getGuzzleClient([
+ 'timeout' => 20, // wait no more than 20 seconds
+ ])
+ ->get($uri);
+
+ $ics = $response->getBody()->getContents();
+
+ // Save the resonse to cache
+ $cache->set($ics);
+ $cache->expiresAfter($dataProvider->getSetting('cachePeriod', 1440) * 60);
+ $dataProvider->getPool()->saveDeferred($cache);
+ } catch (RequestException $requestException) {
+ // Log and return empty?
+ $this->getLog()->error('downloadIcs: Unable to get feed: ' . $requestException->getMessage());
+ $this->getLog()->debug($requestException->getTraceAsString());
+
+ throw new ConfigurationException(__('Unable to download feed'));
+ }
+ } else {
+ $this->getLog()->debug('downloadIcs: cache hit');
+ }
+
+ return $ics;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+}
diff --git a/lib/Widget/MastodonProvider.php b/lib/Widget/MastodonProvider.php
new file mode 100644
index 0000000..d90cbe3
--- /dev/null
+++ b/lib/Widget/MastodonProvider.php
@@ -0,0 +1,202 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use GuzzleHttp\Exception\RequestException;
+use Xibo\Widget\DataType\SocialMedia;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderNumItemsTrait;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Downloads a Mastodon feed and returns SocialMedia data types
+ */
+class MastodonProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+ use DurationProviderNumItemsTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $uri = $dataProvider->getSetting('defaultServerUrl', 'https://mastodon.social');
+
+ try {
+ $httpOptions = [
+ 'timeout' => 20, // wait no more than 20 seconds
+ ];
+
+ $queryOptions = [
+ 'limit' => $dataProvider->getProperty('numItems', 15)
+ ];
+
+ if ($dataProvider->getProperty('searchOn', 'all') === 'local') {
+ $queryOptions['local'] = true;
+ } elseif ($dataProvider->getProperty('searchOn', 'all') === 'remote') {
+ $queryOptions['remote'] = true;
+ }
+
+ // Media Only
+ if ($dataProvider->getProperty('onlyMedia', 0)) {
+ $queryOptions['only_media'] = true;
+ }
+
+ if (!empty($dataProvider->getProperty('serverUrl', ''))) {
+ $uri = $dataProvider->getProperty('serverUrl');
+ }
+
+ // Hashtag
+ $hashtag = trim($dataProvider->getProperty('hashtag', ''));
+
+ // when username is provided do not search in public timeline
+ if (!empty($dataProvider->getProperty('userName', ''))) {
+ // username search: get account ID, always returns one record
+ $accountId = $this->getAccountId($uri, $dataProvider->getProperty('userName'), $dataProvider);
+ $queryOptions['tagged'] = trim($hashtag, '#');
+ $queryOptions['exclude_replies'] = true; // exclude replies to other users
+ $queryOptions['exclude_reblogs'] = true; // exclude reposts/boosts
+ $uri = rtrim($uri, '/') . '/api/v1/accounts/' . $accountId . '/statuses?';
+ } else {
+ // Hashtag: When empty we should do a public search, when filled we should do a hashtag search
+ if (!empty($hashtag)) {
+ $uri = rtrim($uri, '/') . '/api/v1/timelines/tag/' . trim($hashtag, '#');
+ } else {
+ $uri = rtrim($uri, '/') . '/api/v1/timelines/public';
+ }
+ }
+
+ $response = $dataProvider
+ ->getGuzzleClient($httpOptions)
+ ->get($uri, [
+ 'query' => $queryOptions
+ ]);
+
+ $result = json_decode($response->getBody()->getContents(), true);
+
+ $this->getLog()->debug('Mastodon: uri: ' . $uri . ' httpOptions: ' . json_encode($httpOptions));
+
+ $this->getLog()->debug('Mastodon: count: ' . count($result));
+
+ // Expiry time for any media that is downloaded
+ $expires = Carbon::now()->addHours($dataProvider->getSetting('cachePeriodImages', 24))->format('U');
+
+ foreach ($result as $item) {
+ // Parse the mastodon
+ $mastodon = new SocialMedia();
+
+ $mastodon->text = strip_tags($item['content']);
+ $mastodon->user = $item['account']['acct'];
+ $mastodon->screenName = $item['account']['display_name'];
+ $mastodon->date = $item['created_at'];
+
+ // Original Default Image
+ $mastodon->userProfileImage = $dataProvider->addImage(
+ 'mastodon_' . $item['account']['id'],
+ $item['account']['avatar'],
+ $expires
+ );
+
+ // Mini image
+ $mastodon->userProfileImageMini = $mastodon->userProfileImage;
+
+ // Bigger image
+ $mastodon->userProfileImageBigger = $mastodon->userProfileImage;
+
+
+ // Photo
+ // See if there are any photos associated with this status.
+ if ((isset($item['media_attachments']) && count($item['media_attachments']) > 0)) {
+ // only take the first one
+ $mediaObject = $item['media_attachments'][0];
+
+ $photoUrl = $mediaObject['preview_url'];
+ if (!empty($photoUrl)) {
+ $mastodon->photo = $dataProvider->addImage(
+ 'mastodon_' . $mediaObject['id'],
+ $photoUrl,
+ $expires
+ );
+ }
+ }
+
+ // Add the mastodon topic.
+ $dataProvider->addItem($mastodon);
+ }
+
+ // If we've got data, then set our cache period.
+ $dataProvider->setCacheTtl($dataProvider->getSetting('cachePeriod', 3600));
+ $dataProvider->setIsHandled();
+ } catch (RequestException $requestException) {
+ // Log and return empty?
+ $this->getLog()->error('Mastodon: Unable to get posts: ' . $uri
+ . ', e: ' . $requestException->getMessage());
+ $dataProvider->addError(__('Unable to download posts'));
+ } catch (\Exception $exception) {
+ // Log and return empty?
+ $this->getLog()->error('Mastodon: ' . $exception->getMessage());
+ $this->getLog()->debug($exception->getTraceAsString());
+ $dataProvider->addError(__('Unknown issue getting posts'));
+ }
+
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ // No special cache key requirements.
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+
+ /**
+ * Get Mastodon Account Id from username
+ * @throws GuzzleException
+ */
+ private function getAccountId(string $uri, string $username, DataProviderInterface $dataProvider)
+ {
+ $uri = rtrim($uri, '/').'/api/v1/accounts/lookup?';
+
+ $httpOptions = [
+ 'timeout' => 20, // wait no more than 20 seconds
+ 'query' => [
+ 'acct' => $username
+ ],
+ ];
+ $response = $dataProvider
+ ->getGuzzleClient($httpOptions)
+ ->get($uri);
+
+ $result = json_decode($response->getBody()->getContents(), true);
+
+ $this->getLog()->debug('Mastodon: getAccountId: ID ' . $result['id']);
+
+ return $result['id'];
+ }
+}
diff --git a/lib/Widget/MenuBoardCategoryProvider.php b/lib/Widget/MenuBoardCategoryProvider.php
new file mode 100644
index 0000000..17150ef
--- /dev/null
+++ b/lib/Widget/MenuBoardCategoryProvider.php
@@ -0,0 +1,73 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Event\MenuBoardCategoryRequest;
+use Xibo\Event\MenuBoardModifiedDtRequest;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Menu Board Category Provider
+ */
+class MenuBoardCategoryProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchData: MenuBoardCategoryRequest passing to event');
+ $this->getDispatcher()->dispatch(new MenuBoardCategoryRequest($dataProvider), MenuBoardCategoryRequest::$NAME);
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ $this->getLog()->debug('fetchData: MenuBoardCategoryProvider passing to modifiedDt request event');
+
+ $menuId = $dataProvider->getProperty('menuId');
+
+ if ($menuId !== null) {
+ // Raise an event to get the modifiedDt of this dataSet
+ $event = new MenuBoardModifiedDtRequest($menuId);
+ $this->getDispatcher()->dispatch($event, MenuBoardModifiedDtRequest::$NAME);
+
+ return max($event->getModifiedDt(), $dataProvider->getWidgetModifiedDt());
+ }
+
+ return null;
+ }
+}
diff --git a/lib/Widget/MenuBoardProductProvider.php b/lib/Widget/MenuBoardProductProvider.php
new file mode 100644
index 0000000..286e6e0
--- /dev/null
+++ b/lib/Widget/MenuBoardProductProvider.php
@@ -0,0 +1,84 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Event\MenuBoardModifiedDtRequest;
+use Xibo\Event\MenuBoardProductRequest;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Menu Board Product Provider
+ */
+class MenuBoardProductProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchData: MenuBoardProductProvider passing to event');
+ $this->getDispatcher()->dispatch(new MenuBoardProductRequest($dataProvider), MenuBoardProductRequest::$NAME);
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ if ($durationProvider->getWidget()->getOptionValue('durationIsPerItem', 0) == 1) {
+ $this->getLog()->debug('fetchDuration: duration is per item');
+
+ $lowerLimit = $durationProvider->getWidget()->getOptionValue('lowerLimit', 0);
+ $upperLimit = $durationProvider->getWidget()->getOptionValue('upperLimit', 15);
+ $numItems = $upperLimit - $lowerLimit;
+
+ $itemsPerPage = $durationProvider->getWidget()->getOptionValue('itemsPerPage', 0);
+ if ($itemsPerPage > 0) {
+ $numItems = ceil($numItems / $itemsPerPage);
+ }
+
+ $durationProvider->setDuration($durationProvider->getWidget()->calculatedDuration * $numItems);
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ $this->getLog()->debug('fetchData: MenuBoardProductProvider passing to modifiedDt request event');
+ $menuId = $dataProvider->getProperty('menuId');
+ if ($menuId !== null) {
+ // Raise an event to get the modifiedDt of this dataSet
+ $event = new MenuBoardModifiedDtRequest($menuId);
+ $this->getDispatcher()->dispatch($event, MenuBoardModifiedDtRequest::$NAME);
+ return max($event->getModifiedDt(), $dataProvider->getWidgetModifiedDt());
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/lib/Widget/NotificationProvider.php b/lib/Widget/NotificationProvider.php
new file mode 100644
index 0000000..b644ff9
--- /dev/null
+++ b/lib/Widget/NotificationProvider.php
@@ -0,0 +1,59 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Event\NotificationDataRequestEvent;
+use Xibo\Event\NotificationModifiedDtRequestEvent;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderNumItemsTrait;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+class NotificationProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+ use DurationProviderNumItemsTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchData: NotificationProvider passing to event');
+ $this->getDispatcher()->dispatch(
+ new NotificationDataRequestEvent($dataProvider),
+ NotificationDataRequestEvent::$NAME
+ );
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ $event = new NotificationModifiedDtRequestEvent($dataProvider->getDisplayId());
+ $this->getDispatcher()->dispatch($event, NotificationModifiedDtRequestEvent::$NAME);
+ return $event->getModifiedDt();
+ }
+}
diff --git a/lib/Widget/PdfProvider.php b/lib/Widget/PdfProvider.php
new file mode 100644
index 0000000..f71354f
--- /dev/null
+++ b/lib/Widget/PdfProvider.php
@@ -0,0 +1,85 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Mpdf\Mpdf;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * PDF provider to calculate the duration if durationIsPerItem is selected.
+ */
+class PdfProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ $widget = $durationProvider->getWidget();
+ if ($widget->getOptionValue('durationIsPerItem', 0) == 1) {
+ // Do we already have an option stored for the number of pages?
+ $pageCount = 1;
+ $cachedPageCount = $widget->getOptionValue('pageCount', null);
+ if ($cachedPageCount === null) {
+ try {
+ $sourceFile = $widget->getPrimaryMediaPath();
+
+ $this->getLog()->debug('fetchDuration: loading PDF file to get the number of pages, file: '
+ . $sourceFile);
+
+ $mPdf = new Mpdf([
+ 'tempDir' => $widget->getLibraryTempPath(),
+ ]);
+ $pageCount = $mPdf->setSourceFile($sourceFile);
+
+ $widget->setOptionValue('pageCount', 'attrib', $pageCount);
+ } catch (\Exception $e) {
+ $this->getLog()->error('fetchDuration: unable to get PDF page count, e: ' . $e->getMessage());
+ }
+ } else {
+ $pageCount = $cachedPageCount;
+ }
+
+ $durationProvider->setDuration($durationProvider->getWidget()->calculatedDuration * $pageCount);
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+}
diff --git a/lib/Widget/Provider/DataProvider.php b/lib/Widget/Provider/DataProvider.php
new file mode 100644
index 0000000..53b5dff
--- /dev/null
+++ b/lib/Widget/Provider/DataProvider.php
@@ -0,0 +1,449 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+use Xibo\Factory\MediaFactory;
+use Xibo\Helper\SanitizerService;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Xibo default implementation of a Widget Data Provider
+ */
+class DataProvider implements DataProviderInterface
+{
+ /** @var \Xibo\Factory\MediaFactory */
+ private $mediaFactory;
+
+ /** @var boolean should we use the event? */
+ private $isUseEvent = false;
+
+ /** @var bool Is this data provider handled? */
+ private $isHandled = false;
+
+ /** @var array errors */
+ private $errors = [];
+
+ /** @var array the data */
+ private $data = [];
+
+ /** @var array the metadata */
+ private $meta = [];
+
+ /** @var \Xibo\Entity\Media[] */
+ private $media = [];
+
+ /** @var int the cache ttl in seconds - default to 7 days */
+ private $cacheTtl = 86400 * 7;
+
+ /** @var int the displayId */
+ private $displayId = 0;
+
+ /** @var float the display latitude */
+ private $latitude;
+
+ /** @var float the display longitude */
+ private $longitude;
+
+ /** @var bool Is this data provider in preview mode? */
+ private $isPreview = false;
+
+ /** @var \GuzzleHttp\Client */
+ private $client;
+
+ /** @var null cached property values. */
+ private $properties = null;
+
+ /** @var null cached setting values. */
+ private $settings = null;
+
+ /**
+ * Constructor
+ * @param Module $module
+ * @param Widget $widget
+ * @param array $guzzleProxy
+ * @param SanitizerService $sanitizer
+ * @param PoolInterface $pool
+ */
+ public function __construct(
+ private readonly Module $module,
+ private readonly Widget $widget,
+ private readonly array $guzzleProxy,
+ private readonly SanitizerService $sanitizer,
+ private readonly PoolInterface $pool
+ ) {
+ }
+
+ /**
+ * Set the latitude and longitude for this data provider.
+ * This is primary used if a widget is display specific
+ * @param $latitude
+ * @param $longitude
+ * @param int $displayId
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function setDisplayProperties($latitude, $longitude, int $displayId = 0): DataProviderInterface
+ {
+ $this->latitude = $latitude;
+ $this->longitude = $longitude;
+ $this->displayId = $displayId;
+ return $this;
+ }
+
+ /**
+ * @param \Xibo\Factory\MediaFactory $mediaFactory
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function setMediaFactory(MediaFactory $mediaFactory): DataProviderInterface
+ {
+ $this->mediaFactory = $mediaFactory;
+ return $this;
+ }
+
+ /**
+ * Set whether this data provider is in preview mode
+ * @param bool $isPreview
+ * @return DataProviderInterface
+ */
+ public function setIsPreview(bool $isPreview): DataProviderInterface
+ {
+ $this->isPreview = $isPreview;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDataSource(): string
+ {
+ return $this->module->type;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDataType(): string
+ {
+ return $this->module->dataType;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDisplayId(): int
+ {
+ return $this->displayId ?? 0;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDisplayLatitude(): ?float
+ {
+ return $this->latitude;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDisplayLongitude(): ?float
+ {
+ return $this->longitude;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isPreview(): bool
+ {
+ return $this->isPreview;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getWidgetId(): int
+ {
+ return $this->widget->widgetId;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getProperty(string $property, $default = null)
+ {
+ if ($this->properties === null) {
+ $this->properties = $this->module->getPropertyValues(false);
+ }
+
+ $value = $this->properties[$property] ?? $default;
+ if (is_integer($default)) {
+ return intval($value);
+ } else if (is_numeric($value)) {
+ return doubleval($value);
+ }
+ return $value;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSetting(string $setting, $default = null)
+ {
+ if ($this->settings === null) {
+ foreach ($this->module->settings as $item) {
+ $this->settings[$item->id] = $item->value ?: $item->default;
+ }
+ }
+
+ return $this->settings[$setting] ?? $default;
+ }
+
+ /**
+ * Is this data provider handled?
+ * @return bool
+ */
+ public function isHandled(): bool
+ {
+ return $this->isHandled;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setIsUseEvent(): DataProviderInterface
+ {
+ $this->isUseEvent = true;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setIsHandled(): DataProviderInterface
+ {
+ $this->isHandled = true;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getData(): array
+ {
+ return $this->data;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getMeta(): array
+ {
+ return $this->meta;
+ }
+
+ /**
+ * Get any errors recorded on this provider
+ * @return array
+ */
+ public function getErrors(): array
+ {
+ return $this->errors;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getWidgetModifiedDt(): ?Carbon
+ {
+ return Carbon::createFromTimestamp($this->widget->modifiedDt);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addError(string $errorMessage): DataProviderInterface
+ {
+ $this->errors[] = $errorMessage;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addItem($item): DataProviderInterface
+ {
+ if (!is_array($item) && !is_object($item)) {
+ throw new \RuntimeException('Item must be an array or an object');
+ }
+
+ if (is_object($item) && !($item instanceof \JsonSerializable)) {
+ throw new \RuntimeException('Item must be JSON serilizable');
+ }
+
+ $this->data[] = $item;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addItems(array $items): DataProviderInterface
+ {
+ foreach ($items as $item) {
+ $this->addItem($item);
+ }
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addOrUpdateMeta(string $key, $item): DataProviderInterface
+ {
+ if (!is_array($item) && (is_object($item) && !$item instanceof \JsonSerializable)) {
+ throw new \RuntimeException('Item must be an array or a JSON serializable object');
+ }
+
+ $this->meta[$key] = $item;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addImage(string $id, string $url, int $expiresAt): string
+ {
+ $media = $this->mediaFactory->queueDownload($id, $url, $expiresAt);
+ $this->media[] = $media;
+
+ return '[[mediaId=' . $media->mediaId . ']]';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function addLibraryFile(int $mediaId): string
+ {
+ $media = $this->mediaFactory->getById($mediaId);
+ $this->media[] = $media;
+
+ return '[[mediaId=' . $media->mediaId . ']]';
+ }
+
+ /**
+ * @return \Xibo\Entity\Media[]
+ */
+ public function getImages(): array
+ {
+ return $this->media;
+ }
+
+ /**
+ * @return int[]
+ */
+ public function getImageIds(): array
+ {
+ $mediaIds = [];
+ foreach ($this->getImages() as $media) {
+ $mediaIds[] = $media->mediaId;
+ }
+ return $mediaIds;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function clearData(): DataProviderInterface
+ {
+ $this->media = [];
+ $this->data = [];
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function clearMeta(): DataProviderInterface
+ {
+ $this->meta = [];
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isUseEvent(): bool
+ {
+ return $this->isUseEvent;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setCacheTtl(int $ttlSeconds): DataProviderInterface
+ {
+ $this->cacheTtl = $ttlSeconds;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getCacheTtl(): int
+ {
+ return $this->cacheTtl;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getGuzzleClient(array $requestOptions = []): Client
+ {
+ if ($this->client === null) {
+ $this->client = new Client(array_merge($this->guzzleProxy, $requestOptions));
+ }
+
+ return $this->client;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPool(): PoolInterface
+ {
+ return $this->pool;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSanitizer(array $params): SanitizerInterface
+ {
+ return $this->sanitizer->getSanitizer($params);
+ }
+}
diff --git a/lib/Widget/Provider/DataProviderInterface.php b/lib/Widget/Provider/DataProviderInterface.php
new file mode 100644
index 0000000..478bf32
--- /dev/null
+++ b/lib/Widget/Provider/DataProviderInterface.php
@@ -0,0 +1,205 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Data Provider
+ * -------------
+ * A data provider is passed to a Widget which specifies a class in its configuration file
+ * It should return data for the widget in the formated expected by the widgets datatype
+ *
+ * The widget might provid a class for other reasons and wish to use the widget.request.data event
+ * to supply its data. In which case it should set is `setIsUseEvent()`.
+ *
+ * void methods on the data provider are chainable.
+ */
+interface DataProviderInterface
+{
+ /**
+ * Get the data source expected by this provider
+ * This will be the Module type that requested the provider
+ * @return string
+ */
+ public function getDataSource(): string;
+
+ /**
+ * Get the datatype expected by this provider
+ * @return string
+ */
+ public function getDataType(): string;
+
+ /**
+ * Get the ID for this display
+ * @return int
+ */
+ public function getDisplayId(): int;
+
+ /**
+ * Get the latitude for this display
+ * @return float|null
+ */
+ public function getDisplayLatitude(): ?float;
+
+ /**
+ * Get the longitude for this display
+ * @return float|null
+ */
+ public function getDisplayLongitude(): ?float;
+
+ /**
+ * Get the preview flag
+ * @return bool
+ */
+ public function isPreview(): bool;
+
+ /**
+ * Get the ID for this Widget
+ * @return int
+ */
+ public function getWidgetId(): int;
+
+ /**
+ * Get a configured Guzzle client
+ * this will have its proxy configuration set and be ready to use.
+ * @param array $requestOptions An optional array of additional request options.
+ * @return Client
+ */
+ public function getGuzzleClient(array $requestOptions = []): Client;
+
+ /**
+ * Get a cache pool interface
+ * this will be a cache pool configured using the CMS settings.
+ * @return PoolInterface
+ */
+ public function getPool(): PoolInterface;
+
+ /**
+ * Get property
+ * Properties are set on Widgets and can be things like "feedUrl"
+ * the property must exist in module properties for this type of widget
+ * @param string $property The property name
+ * @param mixed $default An optional default value. The return will be cast to the datatype of this default value.
+ * @return mixed
+ */
+ public function getProperty(string $property, $default = null);
+
+ /**
+ * Get setting
+ * Settings are set on Modules and can be things like "apiKey"
+ * the setting must exist in module settings for this type of widget
+ * @param string $setting The setting name
+ * @param mixed $default An optional default value. The return will be cast to the datatype of this default value.
+ * @return mixed
+ */
+ public function getSetting(string $setting, $default = null);
+
+ /**
+ * Get a Santiziter
+ * @param array $params key/value array of variable to sanitize
+ * @return SanitizerInterface
+ */
+ public function getSanitizer(array $params): SanitizerInterface;
+
+ /**
+ * Get the widget modifiedDt
+ * @return \Carbon\Carbon|null
+ */
+ public function getWidgetModifiedDt(): ?Carbon;
+
+ /**
+ * Indicate that we should use the event mechanism to handle this event.
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function setIsUseEvent(): DataProviderInterface;
+
+ /**
+ * Indicate that this data provider has been handled.
+ * @return DataProviderInterface
+ */
+ public function setIsHandled(): DataProviderInterface;
+
+ /**
+ * Add an error to this data provider, if no other data providers handle this request, the error will be
+ * thrown as a configuration error.
+ * @param string $errorMessage
+ * @return DataProviderInterface
+ */
+ public function addError(string $errorMessage): DataProviderInterface;
+
+ /**
+ * Add an item to the provider
+ * You should ensure that you provide all properties required by the datatype you are returning
+ * example data types would be: article, social, event, menu, tabular
+ * @param array|object $item An array containing the item to render in any templates used by this data provider
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function addItem($item): DataProviderInterface;
+
+ /**
+ * Add items to the provider
+ * You should ensure that you provide all properties required by the datatype you are returning
+ * example data types would be: article, social, event, menu, tabular
+ * @param array $items An array containing the item to render in any templates used by this data provider
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function addItems(array $items): DataProviderInterface;
+
+ /**
+ * Add metadata to the provider
+ * This is a key/value array of metadata which should be delivered alongside the data
+ * @param string $key
+ * @param mixed $item An array/object containing the metadata, which must be JSON serializable
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function addOrUpdateMeta(string $key, $item): DataProviderInterface;
+
+ /**
+ * Add an image to the data provider and return the URL for that image
+ * @param string $id A unique ID for this image, we recommend adding a module/connector specific prefix
+ * @param string $url The URL on which this image should be downloaded
+ * @param int $expiresAt A unix timestamp for when this image should be removed - should be longer than cache ttl
+ * @return string
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addImage(string $id, string $url, int $expiresAt): string;
+
+ /**
+ * Add a library file
+ * @param int $mediaId The mediaId for this file.
+ * @return string
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function addLibraryFile(int $mediaId): string;
+
+ /**
+ * Set the cache TTL
+ * @param int $ttlSeconds The time to live in seconds
+ * @return \Xibo\Widget\Provider\DataProviderInterface
+ */
+ public function setCacheTtl(int $ttlSeconds): DataProviderInterface;
+}
diff --git a/lib/Widget/Provider/DurationProvider.php b/lib/Widget/Provider/DurationProvider.php
new file mode 100644
index 0000000..d858147
--- /dev/null
+++ b/lib/Widget/Provider/DurationProvider.php
@@ -0,0 +1,97 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+
+/**
+ * Xibo's default implementation of the Duration Provider
+ */
+class DurationProvider implements DurationProviderInterface
+{
+ /** @var Module */
+ private $module;
+
+ /** @var Widget */
+ private $widget;
+
+ /** @var int Duration in seconds */
+ private $duration;
+
+ /** @var bool Has the duration been set? */
+ private $isDurationSet = false;
+
+ /**
+ * Constructor
+ * @param Module $module
+ * @param Widget $widget
+ */
+ public function __construct(Module $module, Widget $widget)
+ {
+ $this->module = $module;
+ $this->widget = $widget;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setDuration(int $seconds): DurationProviderInterface
+ {
+ $this->isDurationSet = true;
+ $this->duration = $seconds;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDuration(): int
+ {
+ return $this->duration ?? 0;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isDurationSet(): bool
+ {
+ return $this->isDurationSet;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getModule(): Module
+ {
+ return $this->module;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getWidget(): Widget
+ {
+ return $this->widget;
+ }
+}
diff --git a/lib/Widget/Provider/DurationProviderInterface.php b/lib/Widget/Provider/DurationProviderInterface.php
new file mode 100644
index 0000000..c7d8c10
--- /dev/null
+++ b/lib/Widget/Provider/DurationProviderInterface.php
@@ -0,0 +1,62 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+
+/**
+ * A duration provider is used to return the duration for a Widget which has a media file
+ */
+interface DurationProviderInterface
+{
+ /**
+ * Get the Module
+ * @return Module
+ */
+ public function getModule(): Module;
+
+ /**
+ * Get the Widget
+ * @return Widget
+ */
+ public function getWidget(): Widget;
+
+ /**
+ * Get the duration
+ * @return int the duration in seconds
+ */
+ public function getDuration(): int;
+
+ /**
+ * Set the duration in seconds
+ * @param int $seconds the duration in seconds
+ * @return \Xibo\Widget\Provider\DurationProviderInterface
+ */
+ public function setDuration(int $seconds): DurationProviderInterface;
+
+ /**
+ * @return bool true if the duration has been set
+ */
+ public function isDurationSet(): bool;
+}
diff --git a/lib/Widget/Provider/DurationProviderNumItemsTrait.php b/lib/Widget/Provider/DurationProviderNumItemsTrait.php
new file mode 100644
index 0000000..5840bc9
--- /dev/null
+++ b/lib/Widget/Provider/DurationProviderNumItemsTrait.php
@@ -0,0 +1,50 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+/**
+ * A trait providing the duration for widgets using numItems, durationIsPerItem and itemsPerPage
+ */
+trait DurationProviderNumItemsTrait
+{
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ $this->getLog()->debug('fetchDuration: DurationProviderNumItemsTrait');
+
+ // Take some default action to cover the majourity of region specific widgets
+ // Duration can depend on the number of items per page for some widgets
+ // this is a legacy way of working, and our preference is to use elements
+ $numItems = $durationProvider->getWidget()->getOptionValue('numItems', 15);
+
+ if ($durationProvider->getWidget()->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) {
+ // If we have paging involved then work out the page count.
+ $itemsPerPage = $durationProvider->getWidget()->getOptionValue('itemsPerPage', 0);
+ if ($itemsPerPage > 0) {
+ $numItems = ceil($numItems / $itemsPerPage);
+ }
+
+ $durationProvider->setDuration($durationProvider->getWidget()->calculatedDuration * $numItems);
+ }
+ return $this;
+ }
+}
diff --git a/lib/Widget/Provider/WidgetCompatibilityInterface.php b/lib/Widget/Provider/WidgetCompatibilityInterface.php
new file mode 100644
index 0000000..cf63c6b
--- /dev/null
+++ b/lib/Widget/Provider/WidgetCompatibilityInterface.php
@@ -0,0 +1,61 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Psr\Log\LoggerInterface;
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Compatibility Interface should be implemented by custom widget upgrade classes.
+ * Takes necessary actions to make the existing widgets from v3 compatible with v4.
+ *
+ * The schema from and the schema to (currently set to 1 and 2, respectively).
+ * It also provides a method to save a template to the library in a sub-folder named templates/. This method
+ * is called whenever a widget is loaded with a different schema version.
+ *
+ */
+interface WidgetCompatibilityInterface
+{
+ public function getLog(): LoggerInterface;
+
+ public function setLog(LoggerInterface $logger): WidgetCompatibilityInterface;
+
+ /**
+ * Upgrade the given widget to be compatible with the specified schema version.
+ *
+ * @param Widget $widget The widget model to upgrade.
+ * @param int $fromSchema The version of the schema the widget is currently using.
+ * @param int $toSchema The version of the schema to upgrade the widget to.
+ * @return bool Whether the upgrade was successful
+ */
+ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): bool;
+
+ /**
+ * Save the given widget template to the templates/ subfolder.
+ *
+ * @param string $template The widget template to save.
+ * @param string $fileName The file name to save the template as.
+ * @return bool Returns true if the template was saved successfully, false otherwise.
+ */
+ public function saveTemplate(string $template, string $fileName): bool;
+}
diff --git a/lib/Widget/Provider/WidgetCompatibilityTrait.php b/lib/Widget/Provider/WidgetCompatibilityTrait.php
new file mode 100644
index 0000000..c3cf8f3
--- /dev/null
+++ b/lib/Widget/Provider/WidgetCompatibilityTrait.php
@@ -0,0 +1,48 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * A trait to set common objects on a Widget Compatibility Interface
+ */
+trait WidgetCompatibilityTrait
+{
+ private $log;
+
+ public function getLog(): LoggerInterface
+ {
+ if ($this->log === null) {
+ $this->log = new NullLogger();
+ }
+ return $this->log;
+ }
+
+ public function setLog(LoggerInterface $logger): WidgetCompatibilityInterface
+ {
+ $this->log = $logger;
+ return $this;
+ }
+}
diff --git a/lib/Widget/Provider/WidgetProviderInterface.php b/lib/Widget/Provider/WidgetProviderInterface.php
new file mode 100644
index 0000000..1f4de06
--- /dev/null
+++ b/lib/Widget/Provider/WidgetProviderInterface.php
@@ -0,0 +1,97 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Carbon\Carbon;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * The Widget Provider Interface should be implemented by any Widget which specifies a `class` in its Module
+ * configuration.
+ *
+ * The provider should be modified accordingly before returning $this
+ *
+ * If the widget does not need to fetch Data or fetch Duration, then it can return without
+ * modifying the provider.
+ */
+interface WidgetProviderInterface
+{
+ public function getLog(): LoggerInterface;
+ public function setLog(LoggerInterface $logger): WidgetProviderInterface;
+
+ /**
+ * Get the event dispatcher
+ * @return \Symfony\Component\EventDispatcher\EventDispatcherInterface
+ */
+ public function getDispatcher(): EventDispatcherInterface;
+
+ /**
+ * Set the event dispatcher
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $logger
+ * @return \Xibo\Widget\Provider\WidgetProviderInterface
+ */
+ public function setDispatcher(EventDispatcherInterface $logger): WidgetProviderInterface;
+
+ /**
+ * Fetch data
+ * The widget provider must either addItems to the data provider, or indicate that data is provided by
+ * an event instead by setting isUseEvent()
+ * If data is to be provided by an event, core will raise the `widget.request.data` event with parameters
+ * indicating this widget's datatype, name, settings and currently configured options
+ * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider
+ * @return \Xibo\Widget\Provider\WidgetProviderInterface
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface;
+
+ /**
+ * Fetch duration
+ * This is typically only relevant to widgets which have a media file associated, for example video or audio
+ * in cases where this is not appropriate, return without modifying to use the module default duration from
+ * module configuration.
+ * @param \Xibo\Widget\Provider\DurationProviderInterface $durationProvider
+ * @return \Xibo\Widget\Provider\WidgetProviderInterface
+ */
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface;
+
+ /**
+ * Get data cache key
+ * Use this method to return a cache key for this widget. This is typically only relevant when the data cache
+ * should be different based on the value of a setting. For example, if the tweetDistance is set on a Twitter
+ * widget, then the cache should be by displayId. If the cache is always by displayId, then you should supply
+ * the `dataCacheKey` via module config XML instead.
+ * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider
+ * @return string|null
+ */
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string;
+
+ /**
+ * Get data modified date
+ * Use this method to invalidate cache ahead of its expiry date/time by returning the date/time that the underlying
+ * data is expected to or has been modified
+ * @param \Xibo\Widget\Provider\DataProviderInterface $dataProvider
+ * @return \Carbon\Carbon|null
+ */
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon;
+}
diff --git a/lib/Widget/Provider/WidgetProviderTrait.php b/lib/Widget/Provider/WidgetProviderTrait.php
new file mode 100644
index 0000000..c845cb8
--- /dev/null
+++ b/lib/Widget/Provider/WidgetProviderTrait.php
@@ -0,0 +1,67 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * A trait to set common objects on a Widget Provider Interface
+ */
+trait WidgetProviderTrait
+{
+ private $log;
+ private $dispatcher;
+
+ public function getLog(): LoggerInterface
+ {
+ if ($this->log === null) {
+ $this->log = new NullLogger();
+ }
+ return $this->log;
+ }
+
+ public function setLog(LoggerInterface $logger): WidgetProviderInterface
+ {
+ $this->log = $logger;
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function getDispatcher(): EventDispatcherInterface
+ {
+ if ($this->dispatcher === null) {
+ $this->dispatcher = new EventDispatcher();
+ }
+ return $this->dispatcher;
+ }
+
+ /** @inheritDoc */
+ public function setDispatcher(EventDispatcherInterface $dispatcher): WidgetProviderInterface
+ {
+ $this->dispatcher = $dispatcher;
+ return $this;
+ }
+}
diff --git a/lib/Widget/Provider/WidgetValidatorInterface.php b/lib/Widget/Provider/WidgetValidatorInterface.php
new file mode 100644
index 0000000..6d25e52
--- /dev/null
+++ b/lib/Widget/Provider/WidgetValidatorInterface.php
@@ -0,0 +1,48 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Psr\Log\LoggerInterface;
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+
+/**
+ * Widget Validator Interface
+ * --------------------------
+ * Used to validate the properties of a module after it all of its individual properties and those of its
+ * template have been validated via their property rules.
+ */
+interface WidgetValidatorInterface
+{
+ public function getLog(): LoggerInterface;
+
+ public function setLog(LoggerInterface $logger): WidgetValidatorInterface;
+
+ /**
+ * Validate the widget provided
+ * @param Module $module The Module
+ * @param Widget $widget The Widget - this is read only
+ * @param string $stage Which stage are we validating, either `save` or `status`
+ */
+ public function validate(Module $module, Widget $widget, string $stage): void;
+}
diff --git a/lib/Widget/Provider/WidgetValidatorTrait.php b/lib/Widget/Provider/WidgetValidatorTrait.php
new file mode 100644
index 0000000..7343967
--- /dev/null
+++ b/lib/Widget/Provider/WidgetValidatorTrait.php
@@ -0,0 +1,48 @@
+.
+ */
+
+namespace Xibo\Widget\Provider;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * A trait to set common objects on a Widget Compatibility Interface
+ */
+trait WidgetValidatorTrait
+{
+ private $log;
+
+ public function getLog(): LoggerInterface
+ {
+ if ($this->log === null) {
+ $this->log = new NullLogger();
+ }
+ return $this->log;
+ }
+
+ public function setLog(LoggerInterface $logger): WidgetValidatorInterface
+ {
+ $this->log = $logger;
+ return $this;
+ }
+}
diff --git a/lib/Widget/Render/WidgetDataProviderCache.php b/lib/Widget/Render/WidgetDataProviderCache.php
new file mode 100644
index 0000000..55e8f42
--- /dev/null
+++ b/lib/Widget/Render/WidgetDataProviderCache.php
@@ -0,0 +1,517 @@
+.
+ */
+
+namespace Xibo\Widget\Render;
+
+use Carbon\Carbon;
+use Illuminate\Support\Str;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Stash\Interfaces\PoolInterface;
+use Stash\Invalidation;
+use Stash\Item;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\LinkSigner;
+use Xibo\Helper\ObjectVars;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Widget\Provider\DataProvider;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Xmds\Wsdl;
+
+/**
+ * Acts as a cache for the Widget data cache.
+ */
+class WidgetDataProviderCache
+{
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var \Stash\Interfaces\PoolInterface */
+ private $pool;
+
+ /** @var Item */
+ private $lock;
+
+ /** @var Item */
+ private $cache;
+
+ /** @var string The cache key */
+ private $key;
+
+ /** @var bool Is the cache a miss or old */
+ private $isMissOrOld = true;
+
+ private $cachedMediaIds;
+
+ /**
+ * @param \Stash\Interfaces\PoolInterface $pool
+ */
+ public function __construct(PoolInterface $pool)
+ {
+ $this->pool = $pool;
+ }
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return $this
+ */
+ public function useLogger(LoggerInterface $logger): WidgetDataProviderCache
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ /**
+ * @return \Psr\Log\LoggerInterface
+ */
+ private function getLog(): LoggerInterface
+ {
+ if ($this->logger === null) {
+ $this->logger = new NullLogger();
+ }
+
+ return $this->logger;
+ }
+
+ /**
+ * Decorate this data provider with cache
+ * @param DataProvider $dataProvider
+ * @param string $cacheKey
+ * @param Carbon|null $dataModifiedDt The date any associated data was modified.
+ * @param bool $isLockIfMiss Should the cache be locked if it's a miss? Defaults to true.
+ * @return bool
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function decorateWithCache(
+ DataProvider $dataProvider,
+ string $cacheKey,
+ ?Carbon $dataModifiedDt,
+ bool $isLockIfMiss = true,
+ ): bool {
+ // Construct a key
+ $this->key = '/widget/'
+ . ($dataProvider->getDataType() ?: $dataProvider->getDataSource())
+ . '/' . md5($cacheKey);
+
+ $this->getLog()->debug('decorateWithCache: key is ' . $this->key);
+
+ // Get the cache
+ $this->cache = $this->pool->getItem($this->key);
+
+ // Invalidation method old means that if this cache key is being regenerated concurrently to this request
+ // we return the old data we have stored already.
+ $this->cache->setInvalidationMethod(Invalidation::OLD);
+
+ // Get the data (this might be OLD data)
+ $data = $this->cache->get();
+ $cacheCreationDt = $this->cache->getCreation();
+
+ // Does the cache have data?
+ // we keep data 50% longer than we need to, so that it has a chance to be regenerated out of band
+ if ($data === null) {
+ $this->getLog()->debug('decorateWithCache: miss, no data');
+ $hasData = false;
+ } else {
+ $hasData = true;
+
+ // Clear the data provider and add the cached items back to it.
+ $dataProvider->clearData();
+ $dataProvider->clearMeta();
+ $dataProvider->addItems($data->data ?? []);
+
+ // Record any cached mediaIds
+ $this->cachedMediaIds = $data->media ?? [];
+
+ // Update any meta
+ foreach (($data->meta ?? []) as $key => $item) {
+ $dataProvider->addOrUpdateMeta($key, $item);
+ }
+
+ // Determine whether this cache is a miss (i.e. expired and being regenerated, expired, out of date)
+ // We use our own expireDt here because Stash will only return expired data with invalidation method OLD
+ // if the data is currently being regenerated and another process has called lock() on it
+ $expireDt = $dataProvider->getMeta()['expireDt'] ?? null;
+ if ($expireDt !== null) {
+ $expireDt = Carbon::createFromFormat('c', $expireDt);
+ } else {
+ $expireDt = $this->cache->getExpiration();
+ }
+
+ // Determine if the cache returned is a miss or older than the modified/expired dates
+ $this->isMissOrOld = $this->cache->isMiss()
+ || ($dataModifiedDt !== null && $cacheCreationDt !== false && $dataModifiedDt->isAfter($cacheCreationDt)
+ || ($expireDt->isBefore(Carbon::now()))
+ );
+
+ $this->getLog()->debug('decorateWithCache: cache has data, is miss or old: '
+ . var_export($this->isMissOrOld, true));
+ }
+
+ // If we do not have data/we're old/missed cache, and we have requested a lock, then we will be refreshing
+ // the cache, so lock the record
+ if ($isLockIfMiss && (!$hasData || $this->isMissOrOld)) {
+ $this->concurrentRequestLock();
+ }
+
+ return $hasData;
+ }
+
+ /**
+ * Is the cache a miss, or old data.
+ * @return bool
+ */
+ public function isCacheMissOrOld(): bool
+ {
+ return $this->isMissOrOld;
+ }
+
+ /**
+ * Get the cache date for this data provider and key
+ * @param DataProvider $dataProvider
+ * @param string $cacheKey
+ * @return Carbon|null
+ */
+ public function getCacheDate(DataProvider $dataProvider, string $cacheKey): ?Carbon
+ {
+ // Construct a key
+ $this->key = '/widget/'
+ . ($dataProvider->getDataType() ?: $dataProvider->getDataSource())
+ . '/' . md5($cacheKey);
+
+ $this->getLog()->debug('getCacheDate: key is ' . $this->key);
+
+ // Get the cache
+ $this->cache = $this->pool->getItem($this->key);
+ $cacheCreationDt = $this->cache->getCreation();
+
+ return $cacheCreationDt ? Carbon::instance($cacheCreationDt) : null;
+ }
+
+ /**
+ * @param DataProviderInterface $dataProvider
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function saveToCache(DataProviderInterface $dataProvider): void
+ {
+ if ($this->cache === null) {
+ throw new GeneralException('No cache to save');
+ }
+
+ // Set some cache dates so that we can track when this data provider was cached and when it should expire.
+ $dataProvider->addOrUpdateMeta('cacheDt', Carbon::now()->format('c'));
+ $dataProvider->addOrUpdateMeta(
+ 'expireDt',
+ Carbon::now()->addSeconds($dataProvider->getCacheTtl())->format('c')
+ );
+
+ // Set our cache from the data provider.
+ $object = new \stdClass();
+ $object->data = $dataProvider->getData();
+ $object->meta = $dataProvider->getMeta();
+ $object->media = $dataProvider->getImageIds();
+ $cached = $this->cache->set($object);
+
+ if (!$cached) {
+ throw new GeneralException('Cache failure');
+ }
+
+ // Keep the cache 50% longer than necessary
+ // The expireDt must always be 15 minutes to allow plenty of time for the WidgetSyncTask to regenerate.
+ $this->cache->expiresAfter(ceil(max($dataProvider->getCacheTtl() * 1.5, 900)));
+
+ // Save to the pool
+ $this->pool->save($this->cache);
+
+ $this->getLog()->debug('saveToCache: cached ' . $this->key
+ . ' for ' . $dataProvider->getCacheTtl() . ' seconds');
+ }
+
+ /**
+ * Finalise the cache process
+ */
+ public function finaliseCache(): void
+ {
+ $this->concurrentRequestRelease();
+ }
+
+ /**
+ * Return any cached mediaIds
+ * @return array
+ */
+ public function getCachedMediaIds(): array
+ {
+ return $this->cachedMediaIds ?? [];
+ }
+
+ /**
+ * Decorate for a preview
+ * @param array $data The data
+ * @param callable $urlFor
+ * @return array
+ */
+ public function decorateForPreview(array $data, callable $urlFor): array
+ {
+ foreach ($data as $row => $item) {
+ // This is either an object or an array
+ if (is_array($item)) {
+ foreach ($item as $key => $value) {
+ if (is_string($value)) {
+ $data[$row][$key] = $this->decorateMediaForPreview($urlFor, $value);
+ }
+ }
+ } else if (is_object($item)) {
+ foreach (ObjectVars::getObjectVars($item) as $key => $value) {
+ if (is_string($value)) {
+ $item->{$key} = $this->decorateMediaForPreview($urlFor, $value);
+ }
+ }
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * @param callable $urlFor
+ * @param string|null $data
+ * @return string|null
+ */
+ private function decorateMediaForPreview(callable $urlFor, ?string $data): ?string
+ {
+ if ($data === null) {
+ return null;
+ }
+ $matches = [];
+ preg_match_all('/\[\[(.*?)\]\]/', $data, $matches);
+ foreach ($matches[1] as $match) {
+ if (Str::startsWith($match, 'mediaId')) {
+ $value = explode('=', $match);
+ $data = str_replace(
+ '[[' . $match . ']]',
+ $urlFor('library.download', ['id' => $value[1], 'type' => 'image']),
+ $data
+ );
+ } else if (Str::startsWith($match, 'connector')) {
+ $value = explode('=', $match);
+ $data = str_replace(
+ '[[' . $match . ']]',
+ $urlFor('layout.preview.connector', [], ['token' => $value[1], 'isDebug' => 1]),
+ $data
+ );
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Decorate for a player
+ * @param \Xibo\Service\ConfigServiceInterface $configService
+ * @param \Xibo\Entity\Display $display
+ * @param string $encryptionKey
+ * @param array $data The data
+ * @param array $storedAs A keyed array of module files this widget has access to
+ * @return array
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function decorateForPlayer(
+ ConfigServiceInterface $configService,
+ Display $display,
+ string $encryptionKey,
+ array $data,
+ array $storedAs,
+ ): array {
+ $this->getLog()->debug('decorateForPlayer');
+
+ $cdnUrl = $configService->getSetting('CDN_URL');
+
+ foreach ($data as $row => $item) {
+ // Each data item can be an array or an object
+ if (is_array($item)) {
+ foreach ($item as $key => $value) {
+ if (is_string($value)) {
+ $data[$row][$key] = $this->decorateMediaForPlayer(
+ $cdnUrl,
+ $display,
+ $encryptionKey,
+ $storedAs,
+ $value,
+ );
+ }
+ }
+ } else if (is_object($item)) {
+ foreach (ObjectVars::getObjectVars($item) as $key => $value) {
+ if (is_string($value)) {
+ $item->{$key} = $this->decorateMediaForPlayer(
+ $cdnUrl,
+ $display,
+ $encryptionKey,
+ $storedAs,
+ $value
+ );
+ }
+ }
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * @param string|null $cdnUrl
+ * @param \Xibo\Entity\Display $display
+ * @param string $encryptionKey
+ * @param array $storedAs
+ * @param string|null $data
+ * @return string|null
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function decorateMediaForPlayer(
+ ?string $cdnUrl,
+ Display $display,
+ string $encryptionKey,
+ array $storedAs,
+ ?string $data,
+ ): ?string {
+ if ($data === null) {
+ return null;
+ }
+
+ // Do we need to add a URL prefix to the requests?
+ $prefix = $display->isPwa() ? '/pwa/' : '';
+
+ // Media substitutes
+ $matches = [];
+ preg_match_all('/\[\[(.*?)\]\]/', $data, $matches);
+ foreach ($matches[1] as $match) {
+ if (Str::startsWith($match, 'mediaId')) {
+ $value = explode('=', $match);
+ if (array_key_exists($value[1], $storedAs)) {
+ if ($display->isPwa()) {
+ $url = LinkSigner::generateSignedLink(
+ $display,
+ $encryptionKey,
+ $cdnUrl,
+ 'M',
+ $value[1],
+ $storedAs[$value[1]],
+ null,
+ true,
+ );
+ } else {
+ $url = $storedAs[$value[1]];
+ }
+ $data = str_replace('[[' . $match . ']]', $prefix . $url, $data);
+ } else {
+ $data = str_replace('[[' . $match . ']]', '', $data);
+ }
+ } else if (Str::startsWith($match, 'connector')) {
+ // We have WSDL here because this is only called from XMDS.
+ $value = explode('=', $match);
+ $data = str_replace(
+ '[[' . $match . ']]',
+ Wsdl::getRoot() . '?connector=true&token=' . $value[1],
+ $data
+ );
+ }
+ }
+ return $data;
+ }
+
+ //
+
+ /**
+ * Hold a lock on concurrent requests
+ * blocks if the request is locked
+ * @param int $ttl seconds
+ * @param int $wait seconds
+ * @param int $tries
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function concurrentRequestLock(int $ttl = 300, int $wait = 2, int $tries = 5)
+ {
+ if ($this->cache === null) {
+ throw new GeneralException('No cache to lock');
+ }
+
+ $this->lock = $this->pool->getItem('locks/concurrency/' . $this->cache->getKey());
+
+ // Set the invalidation method to simply return the value (not that we use it, but it gets us a miss on expiry)
+ // isMiss() returns false if the item is missing or expired, no exceptions.
+ $this->lock->setInvalidationMethod(Invalidation::NONE);
+
+ // Get the lock
+ // other requests will wait here until we're done, or we've timed out
+ $locked = $this->lock->get();
+
+ // Did we get a lock?
+ // if we're a miss, then we're not already locked
+ if ($this->lock->isMiss() || $locked === false) {
+ $this->getLog()->debug('Lock miss or false. Locking for ' . $ttl
+ . ' seconds. $locked is '. var_export($locked, true)
+ . ', key = ' . $this->cache->getKey());
+
+ // so lock now
+ $this->lock->set(true);
+ $this->lock->expiresAfter($ttl);
+ $this->lock->save();
+ } else {
+ // We are a hit - we must be locked
+ $this->getLog()->debug('LOCK hit for ' . $this->cache->getKey() . ' expires '
+ . $this->lock->getExpiration()->format(DateFormatHelper::getSystemFormat())
+ . ', created ' . $this->lock->getCreation()->format(DateFormatHelper::getSystemFormat()));
+
+ // Try again?
+ $tries--;
+
+ if ($tries <= 0) {
+ // We've waited long enough
+ throw new GeneralException('Concurrent record locked, time out.');
+ } else {
+ $this->getLog()->debug('Unable to get a lock, trying again. Remaining retries: ' . $tries);
+
+ // Hang about waiting for the lock to be released.
+ sleep($wait);
+
+ // Recursive request (we've decremented the number of tries)
+ $this->concurrentRequestLock($ttl, $wait, $tries);
+ }
+ }
+ }
+
+ /**
+ * Release a lock on concurrent requests
+ */
+ private function concurrentRequestRelease()
+ {
+ if ($this->lock !== null) {
+ $this->getLog()->debug('Releasing lock ' . $this->lock->getKey());
+
+ // Release lock
+ $this->lock->set(false);
+ $this->lock->expiresAfter(10); // Expire straight away (but give time to save)
+
+ $this->pool->save($this->lock);
+ }
+ }
+
+ //
+}
diff --git a/lib/Widget/Render/WidgetDownloader.php b/lib/Widget/Render/WidgetDownloader.php
new file mode 100644
index 0000000..2d20720
--- /dev/null
+++ b/lib/Widget/Render/WidgetDownloader.php
@@ -0,0 +1,340 @@
+.
+ */
+
+namespace Xibo\Widget\Render;
+
+use GuzzleHttp\Psr7\LimitStream;
+use GuzzleHttp\Psr7\Stream;
+use Intervention\Image\ImageManagerStatic as Img;
+use Psr\Log\LoggerInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Xibo\Entity\Media;
+use Xibo\Helper\HttpCacheProvider;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * A helper class to download widgets from the library (as media files)
+ */
+class WidgetDownloader
+{
+ /** @var LoggerInterface */
+ private LoggerInterface $logger;
+
+ /**
+ * @param string $libraryLocation Library location
+ * @param string $sendFileMode Send file mode
+ * @param int $resizeLimit CMS resize limit
+ */
+ public function __construct(
+ private readonly string $libraryLocation,
+ private readonly string $sendFileMode,
+ private readonly int $resizeLimit
+ ) {
+ }
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return $this
+ */
+ public function useLogger(LoggerInterface $logger): WidgetDownloader
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ /**
+ * Return File
+ * @param \Xibo\Entity\Media $media
+ * @param \Slim\Http\ServerRequest $request
+ * @param \Slim\Http\Response $response
+ * @param string|null $contentType An optional content type, if provided the attachment is ignored
+ * @param string|null $attachment An optional attachment, defaults to the stored file name (storedAs)
+ * @return \Slim\Http\Response
+ */
+ public function download(
+ Media $media,
+ Request $request,
+ Response $response,
+ ?string $contentType = null,
+ ?string $attachment = null
+ ): Response {
+ $this->logger->debug('widgetDownloader::download: Download for mediaId ' . $media->mediaId);
+
+ // The file path
+ $libraryPath = $this->libraryLocation . $media->storedAs;
+
+ $this->logger->debug('widgetDownloader::download: ' . $libraryPath . ', ' . $contentType);
+
+ // Set some headers
+ $headers = [];
+ $fileSize = filesize($libraryPath);
+ $headers['Content-Length'] = $fileSize;
+
+ // If we have been given a content type, then serve that to the browser.
+ if ($contentType !== null) {
+ $headers['Content-Type'] = $contentType;
+ } else {
+ // This widget is expected to output a file - usually this is for file based media
+ // Get the name with library
+ $attachmentName = empty($attachment) ? $media->storedAs : $attachment;
+
+ // Issue some headers
+ $response = HttpCacheProvider::withEtag($response, $media->md5);
+ $response = HttpCacheProvider::withExpires($response, '+1 week');
+
+ $headers['Content-Type'] = 'application/octet-stream';
+ $headers['Content-Transfer-Encoding'] = 'Binary';
+ $headers['Content-disposition'] = 'attachment; filename="' . $attachmentName . '"';
+ }
+
+ // Output the file
+ if ($this->sendFileMode === 'Apache') {
+ // Send via Apache X-Sendfile header?
+ $headers['X-Sendfile'] = $libraryPath;
+ } else if ($this->sendFileMode === 'Nginx') {
+ // Send via Nginx X-Accel-Redirect?
+ $headers['X-Accel-Redirect'] = '/download/' . $media->storedAs;
+ }
+
+ // Should we output the file via the application stack, or directly by reading the file.
+ if ($this->sendFileMode == 'Off') {
+ // Return the file with PHP
+ $this->logger->debug('download: Returning Stream with response body, sendfile off.');
+
+ $stream = new Stream(fopen($libraryPath, 'r'));
+ $start = 0;
+ $end = $fileSize - 1;
+
+ $rangeHeader = $request->getHeaderLine('Range');
+ if ($rangeHeader !== '') {
+ $this->logger->debug('download: Handling Range request, header: ' . $rangeHeader);
+
+ if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
+ $start = (int) $matches[1];
+ $end = $matches[2] !== '' ? (int) $matches[2] : $end;
+ if ($start > $end || $end >= $fileSize) {
+ return $response
+ ->withStatus(416)
+ ->withHeader('Content-Range', 'bytes */' . $fileSize);
+ }
+ }
+ $headers['Content-Range'] = 'bytes ' . $start . '-' . $end . '/' . $fileSize;
+ $headers['Content-Length'] = $end - $start + 1;
+ $response = $response
+ ->withBody(new LimitStream($stream, $end - $start + 1, $start))
+ ->withStatus(206);
+ } else {
+ $response = $response->withBody($stream);
+ }
+ } else {
+ $this->logger->debug('Using sendfile to return the file, only output headers.');
+ }
+
+ // Add the headers we've collected to our response
+ foreach ($headers as $header => $value) {
+ $response = $response->withHeader($header, $value);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Download a thumbnail for the given media
+ * @param \Xibo\Entity\Media $media
+ * @param \Slim\Http\Response $response
+ * @param string|null $errorThumb
+ * @return \Slim\Http\Response
+ */
+ public function thumbnail(
+ Media $media,
+ Response $response,
+ ?string $errorThumb = null
+ ): Response {
+ // Our convention is to upload media covers in {mediaId}_{mediaType}cover.png
+ // and then thumbnails in tn_{mediaId}_{mediaType}cover.png
+ // unless we are an image module, which is its own image, and would then have a thumbnail in
+ // tn_{mediaId}_{mediaType}cover.png
+ try {
+ $width = 120;
+ $height = 120;
+
+ if ($media->mediaType === 'image') {
+ $filePath = $this->libraryLocation . $media->storedAs;
+ $thumbnailFilePath = $this->libraryLocation . 'tn_' . $media->storedAs;
+ } else {
+ $filePath = $this->libraryLocation . $media->mediaId . '_'
+ . $media->mediaType . 'cover.png';
+ $thumbnailFilePath = $this->libraryLocation . 'tn_' . $media->mediaId . '_'
+ . $media->mediaType . 'cover.png';
+
+ // A video cover might not exist
+ if (!file_exists($filePath)) {
+ throw new NotFoundException();
+ }
+ }
+
+ // Does the thumbnail exist already?
+ Img::configure(['driver' => 'gd']);
+ $img = null;
+ $regenerate = true;
+ if (file_exists($thumbnailFilePath)) {
+ $img = Img::make($thumbnailFilePath);
+ if ($img->width() === $width || $img->height() === $height) {
+ // Correct cache
+ $regenerate = false;
+ }
+ $response = $response->withHeader('Content-Type', $img->mime());
+ }
+
+ if ($regenerate) {
+ // Check that our source image is not too large
+ $imageInfo = getimagesize($filePath);
+
+ // Make sure none of the sides are greater than allowed
+ if ($this->resizeLimit > 0
+ && ($imageInfo[0] > $this->resizeLimit || $imageInfo[1] > $this->resizeLimit)
+ ) {
+ throw new InvalidArgumentException(__('Image too large'));
+ }
+
+ // Get the full image and make a thumbnail
+ $img = Img::make($filePath);
+ $img->resize($width, $height, function ($constraint) {
+ $constraint->aspectRatio();
+ });
+ $img->save($thumbnailFilePath);
+ $response = $response->withHeader('Content-Type', $img->mime());
+ }
+
+ // Output Etag
+ $response = HttpCacheProvider::withEtag($response, md5_file($thumbnailFilePath));
+
+ $response->write($img->encode());
+ } catch (\Exception) {
+ $this->logger->debug('thumbnail: exception raised.');
+
+ if ($errorThumb !== null) {
+ $img = Img::make($errorThumb);
+ $response->write($img->encode());
+
+ // Output the mime type
+ $response = $response->withHeader('Content-Type', $img->mime());
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * Output an image preview
+ * @param \Xibo\Support\Sanitizer\SanitizerInterface $params
+ * @param string $filePath
+ * @param \Slim\Http\Response $response
+ * @param string|null $errorThumb
+ * @return \Slim\Http\Response
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function imagePreview(
+ SanitizerInterface $params,
+ string $filePath,
+ Response $response,
+ ?string $errorThumb = null
+ ): Response {
+ // Image previews call for dynamically generated images as various sizes
+ // for example a background image will stretch to the entire region
+ // an image widget may be aspect, fit or scale
+ try {
+ $filePath = $this->libraryLocation . $filePath;
+
+ // Does it exist?
+ if (!file_exists($filePath)) {
+ throw new NotFoundException(__('File not found'));
+ }
+
+ // Check that our source image is not too large
+ $imageInfo = getimagesize($filePath);
+
+ // Make sure none of the sides are greater than allowed
+ if ($this->resizeLimit > 0
+ && ($imageInfo[0] > $this->resizeLimit || $imageInfo[1] > $this->resizeLimit)
+ ) {
+ throw new InvalidArgumentException(__('Image too large'));
+ }
+
+ // Continue to output at the desired size
+ $width = intval($params->getDouble('width'));
+ $height = intval($params->getDouble('height'));
+ $proportional = !$params->hasParam('proportional')
+ || $params->getCheckbox('proportional') == 1;
+
+ $fit = $proportional && $params->getCheckbox('fit') === 1;
+
+ // only use upsize constraint, if we the requested dimensions are larger than resize limit.
+ $useUpsizeConstraint = max($width, $height) > $this->resizeLimit;
+
+ $this->logger->debug('Whole file: ' . $filePath
+ . ' requested with Width and Height ' . $width . ' x ' . $height
+ . ', proportional: ' . var_export($proportional, true)
+ . ', fit: ' . var_export($fit, true)
+ . ', upsizeConstraint ' . var_export($useUpsizeConstraint, true));
+
+ // Does the thumbnail exist already?
+ Img::configure(['driver' => 'gd']);
+ $img = Img::make($filePath);
+
+ // Output a specific width/height
+ if ($width > 0 && $height > 0) {
+ if ($fit) {
+ $img->fit($width, $height);
+ } else {
+ $img->resize($width, $height, function ($constraint) use ($proportional, $useUpsizeConstraint) {
+ if ($proportional) {
+ $constraint->aspectRatio();
+ }
+ if ($useUpsizeConstraint) {
+ $constraint->upsize();
+ }
+ });
+ }
+ }
+
+ $response->write($img->encode());
+ $response = HttpCacheProvider::withExpires($response, '+1 week');
+
+ $response = $response->withHeader('Content-Type', $img->mime());
+ } catch (\Exception $e) {
+ if ($errorThumb !== null) {
+ $img = Img::make($errorThumb);
+ $response->write($img->encode());
+ $response = $response->withHeader('Content-Type', $img->mime());
+ } else {
+ $this->logger->error('Cannot parse image: ' . $e->getMessage());
+ throw new InvalidArgumentException(__('Cannot parse image.'), 'storedAs');
+ }
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/Widget/Render/WidgetHtmlRenderer.php b/lib/Widget/Render/WidgetHtmlRenderer.php
new file mode 100644
index 0000000..70cacc1
--- /dev/null
+++ b/lib/Widget/Render/WidgetHtmlRenderer.php
@@ -0,0 +1,1057 @@
+.
+ */
+
+namespace Xibo\Widget\Render;
+
+use Carbon\Carbon;
+use FilesystemIterator;
+use Illuminate\Support\Str;
+use Psr\Http\Message\RequestInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Slim\Views\Twig;
+use Twig\Extension\SandboxExtension;
+use Twig\Sandbox\SecurityPolicy;
+use Twig\TwigFilter;
+use Xibo\Entity\Display;
+use Xibo\Entity\Module;
+use Xibo\Entity\ModuleTemplate;
+use Xibo\Entity\Region;
+use Xibo\Entity\Widget;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\LinkSigner;
+use Xibo\Helper\Translate;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class responsible for rendering out a widgets HTML, caching it if necessary
+ */
+class WidgetHtmlRenderer
+{
+ /** @var string Cache Path */
+ private $cachePath;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var \Slim\Views\Twig */
+ private $twig;
+
+ /** @var \Xibo\Service\ConfigServiceInterface */
+ private $config;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /**
+ * @param string $cachePath
+ * @param Twig $twig
+ * @param ConfigServiceInterface $config
+ * @param ModuleFactory $moduleFactory
+ */
+ public function __construct(
+ string $cachePath,
+ Twig $twig,
+ ConfigServiceInterface $config,
+ ModuleFactory $moduleFactory
+ ) {
+ $this->cachePath = $cachePath;
+ $this->twig = $twig;
+ $this->config = $config;
+ $this->moduleFactory = $moduleFactory;
+ }
+
+ /**
+ * @param \Psr\Log\LoggerInterface $logger
+ * @return $this
+ */
+ public function useLogger(LoggerInterface $logger): WidgetHtmlRenderer
+ {
+ $this->logger = $logger;
+ return $this;
+ }
+
+ private function getLog(): LoggerInterface
+ {
+ if ($this->logger === null) {
+ $this->logger = new NullLogger();
+ }
+
+ return $this->logger;
+ }
+
+ /**
+ * @param \Xibo\Entity\Module $module
+ * @param \Xibo\Entity\Region $region
+ * @param \Xibo\Entity\Widget $widget
+ * @param \Xibo\Support\Sanitizer\SanitizerInterface $params
+ * @param string $downloadUrl
+ * @param array $additionalContexts An array of additional key/value contexts for the templates
+ * @return string
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ */
+ public function preview(
+ Module $module,
+ Region $region,
+ Widget $widget,
+ SanitizerInterface $params,
+ string $downloadUrl,
+ array $additionalContexts = []
+ ): string {
+ if ($module->previewEnabled == 1) {
+ $twigSandbox = $this->getTwigSandbox();
+ $width = $params->getDouble('width', ['default' => 0]);
+ $height = $params->getDouble('height', ['default' => 0]);
+
+ if ($module->preview !== null) {
+ // Parse out our preview (which is always a stencil)
+ $module->decorateProperties($widget, true);
+ return $twigSandbox->fetchFromString(
+ $module->preview->twig,
+ array_merge(
+ [
+ 'width' => $width,
+ 'height' => $height,
+ 'params' => $params,
+ 'options' => $module->getPropertyValues(),
+ 'downloadUrl' => $downloadUrl,
+ 'calculatedDuration' => $widget->calculatedDuration,
+ ],
+ $module->getPropertyValues(),
+ $additionalContexts
+ )
+ );
+ } else if ($module->renderAs === 'html') {
+ // Modules without a preview should render out as HTML
+ return $this->twig->fetch(
+ 'module-html-preview.twig',
+ array_merge(
+ [
+ 'width' => $width,
+ 'height' => $height,
+ 'regionId' => $region->regionId,
+ 'widgetId' => $widget->widgetId,
+ 'calculatedDuration' => $widget->calculatedDuration,
+ ],
+ $module->getPropertyValues(),
+ $additionalContexts
+ )
+ );
+ }
+ }
+
+ // Render an icon.
+ return $this->twig->fetch('module-icon-preview.twig', [
+ 'moduleName' => $module->name,
+ 'moduleType' => $module->type,
+ 'moduleIcon' => $module->icon,
+ ]);
+ }
+
+ /**
+ * Render or cache.
+ * ----------------
+ * @param ModuleTemplate[] $moduleTemplates
+ * @param \Xibo\Entity\Widget[] $widgets
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\LoaderError
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function renderOrCache(
+ Region $region,
+ array $widgets,
+ array $moduleTemplates
+ ): string {
+ // HTML is cached per widget for regions of type zone/frame and playlist.
+ // HTML is cached per region for regions of type canvas.
+ $widgetModifiedDt = 0;
+
+ if ($region->type === 'canvas') {
+ foreach ($widgets as $item) {
+ $widgetModifiedDt = max($widgetModifiedDt, $item->modifiedDt);
+ if ($item->type === 'global') {
+ $widget = $item;
+ }
+ }
+
+ // If we don't have a global widget, just grab the first one.
+ $widget = $widget ?? $widgets[0];
+ } else {
+ $widget = $widgets[0];
+ $widgetModifiedDt = $widget->modifiedDt;
+ }
+
+ if (!file_exists($this->cachePath)) {
+ mkdir($this->cachePath, 0777, true);
+ }
+
+ // Cache File
+ // ----------
+ // Widgets may or may not appear in the same Region each time they are previewed due to them potentially
+ // being contained in a Playlist.
+ // Region width/height only changes in Draft state, so the FE is responsible for asserting the correct
+ // width/height relating scaling params when the preview first loads.
+ $cachePath = $this->cachePath . DIRECTORY_SEPARATOR
+ . $widget->widgetId
+ . '_'
+ . $region->regionId
+ . '.html';
+
+ // Changes to the Playlist should also invalidate Widget HTML caches
+ try {
+ $playlistModifiedDt = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $region->getPlaylist([
+ 'loadPermissions' => false,
+ 'loadWidgets' => false,
+ 'loadTags' => false,
+ 'loadActions' => false,
+ ])->modifiedDt);
+ } catch (\Exception) {
+ $this->getLog()->error('renderOrCache: cannot find playlist modifiedDt, using now');
+ $playlistModifiedDt = Carbon::now();
+ }
+
+ // Have we changed since we last cached this widget
+ $modifiedDt = max(Carbon::createFromTimestamp($widgetModifiedDt), $playlistModifiedDt);
+ $cachedDt = Carbon::createFromTimestamp(file_exists($cachePath) ? filemtime($cachePath) : 0);
+
+ $this->getLog()->debug('renderOrCache: Cache details - modifiedDt: '
+ . $modifiedDt->format(DateFormatHelper::getSystemFormat())
+ . ', cachedDt: ' . $cachedDt->format(DateFormatHelper::getSystemFormat())
+ . ', cachePath: ' . $cachePath);
+
+ if ($modifiedDt->greaterThan($cachedDt) || !file_get_contents($cachePath)) {
+ $this->getLog()->debug('renderOrCache: We will need to regenerate');
+
+ // Are we worried about concurrent requests here?
+ // these aren't providing any data anymore, so in theory it shouldn't be possible to
+ // get locked up here
+ // We don't clear cached media here, as that comes along with data.
+ if (file_exists($cachePath)) {
+ $this->getLog()->debug('renderOrCache: Deleting cache file ' . $cachePath . ' which already existed');
+ unlink($cachePath);
+ }
+
+ // Render
+ $output = $this->render($widget->widgetId, $region, $widgets, $moduleTemplates);
+
+ // Cache to the library
+ file_put_contents($cachePath, $output);
+
+ $this->getLog()->debug('renderOrCache: Generate complete');
+
+ return $output;
+ } else {
+ $this->getLog()->debug('renderOrCache: Serving from cache');
+ return file_get_contents($cachePath);
+ }
+ }
+
+ /**
+ * Decorate the HTML output for a preview
+ * @param \Xibo\Entity\Region $region
+ * @param string $output
+ * @param callable $urlFor
+ * @param \Psr\Http\Message\ServerRequestInterface $request
+ * @return string
+ */
+ public function decorateForPreview(
+ Region $region,
+ string $output,
+ callable $urlFor,
+ RequestInterface $request
+ ): string {
+ $matches = [];
+ preg_match_all('/\[\[(.*?)\]\]/', $output, $matches);
+ foreach ($matches[1] as $match) {
+ if ($match === 'PlayerBundle') {
+ $output = str_replace('[[PlayerBundle]]', $urlFor('layout.preview.bundle', []), $output);
+ } else if ($match === 'FontBundle') {
+ $output = str_replace('[[FontBundle]]', $urlFor('library.font.css', []), $output);
+ } else if ($match === 'ViewPortWidth') {
+ $output = str_replace('[[ViewPortWidth]]', $region->width, $output);
+ } else if (Str::startsWith($match, 'dataUrl')) {
+ $value = explode('=', $match);
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $urlFor('module.getData', ['regionId' => $region->regionId, 'id' => $value[1]]),
+ $output
+ );
+ } else if (Str::startsWith($match, 'data=')) {
+ // Not needed as this CMS is always capable of providing separate data.
+ $output = str_replace('"[[' . $match . ']]"', '[]', $output);
+ } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) {
+ $value = explode('=', $match);
+ $params = ['id' => $value[1]];
+ if (Str::startsWith($match, 'mediaId')) {
+ $params['type'] = 'image';
+ }
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $urlFor('library.download', $params) . '?preview=1',
+ $output
+ );
+ } else if (Str::startsWith($match, 'assetId')) {
+ $value = explode('=', $match);
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $urlFor('module.asset.download', ['assetId' => $value[1]]) . '?preview=1',
+ $output
+ );
+ } else if (Str::startsWith($match, 'assetAlias')) {
+ $value = explode('=', $match);
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $urlFor('module.asset.download', ['assetId' => $value[1]]) . '?preview=1&isAlias=1',
+ $output
+ );
+ }
+ }
+
+ // Handle CSP in preview
+ $html = new \DOMDocument();
+ $html->loadHTML($output, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_SCHEMA_CREATE);
+ foreach ($html->getElementsByTagName('script') as $node) {
+ // We add this requests cspNonce to every script tag
+ if ($node instanceof \DOMElement) {
+ $node->setAttribute('nonce', $request->getAttribute('cspNonce'));
+ }
+ }
+
+ return $html->saveHTML();
+ }
+
+ /**
+ * Decorate the HTML output for a player
+ * @param \Xibo\Entity\Display $display
+ * @param string $output
+ * @param array $storedAs A keyed array of library media this widget has access to
+ * @param bool $isSupportsDataUrl
+ * @param array $data A keyed array of data this widget has access to
+ * @param \Xibo\Widget\Definition\Asset[] $assets A keyed array of assets this widget has access to
+ * @return string
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function decorateForPlayer(
+ Display $display,
+ string $output,
+ array $storedAs,
+ bool $isSupportsDataUrl = true,
+ array $data = [],
+ array $assets = []
+ ): string {
+ // Do we need to add a URL prefix to the requests?
+ $auth = $display->isPwa()
+ ? '&v=7&serverKey=' . $this->config->getSetting('SERVER_KEY') . '&hardwareKey=' . $display->license
+ : null;
+ $encryptionKey = $this->config->getApiKeyDetails()['encryptionKey'];
+ $cdnUrl = $this->config->getSetting('CDN_URL');
+
+ $matches = [];
+ preg_match_all('/\[\[(.*?)\]\]/', $output, $matches);
+ foreach ($matches[1] as $match) {
+ if ($match === 'PlayerBundle') {
+ if ($display->isPwa()) {
+ $url = LinkSigner::generateSignedLink(
+ $display,
+ $encryptionKey,
+ $cdnUrl,
+ 'P',
+ 1,
+ 'bundle.min.js',
+ 'bundle',
+ true,
+ );
+ } else {
+ $url = 'bundle.min.js';
+ }
+ $output = str_replace(
+ '[[PlayerBundle]]',
+ $url,
+ $output,
+ );
+ } else if ($match === 'FontBundle') {
+ if ($display->isPwa()) {
+ $url = LinkSigner::generateSignedLink(
+ $display,
+ $encryptionKey,
+ $cdnUrl,
+ 'P',
+ 1,
+ 'fonts.css',
+ 'fontCss',
+ true,
+ );
+ } else {
+ $url = 'fonts.css';
+ }
+ $output = str_replace(
+ '[[FontBundle]]',
+ $url,
+ $output,
+ );
+ } else if ($match === 'ViewPortWidth') {
+ if ($display->isPwa()) {
+ $output = str_replace(
+ '[[ViewPortWidth]]',
+ explode('x', ($display->resolution ?: 'x'))[0],
+ $output,
+ );
+ }
+ } else if (Str::startsWith($match, 'dataUrl')) {
+ $value = explode('=', $match);
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $isSupportsDataUrl
+ ? ($display->isPwa()
+ ? '/pwa/getData?widgetId=' . $value[1] . $auth
+ : $value[1] . '.json')
+ : 'null',
+ $output,
+ );
+ } else if (Str::startsWith($match, 'data=')) {
+ $value = explode('=', $match);
+ $output = str_replace(
+ '"[[' . $match . ']]"',
+ isset($data[$value[1]])
+ ? json_encode($data[$value[1]])
+ : 'null',
+ $output,
+ );
+ } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) {
+ $value = explode('=', $match);
+ if (array_key_exists($value[1], $storedAs)) {
+ if ($display->isPwa()) {
+ $url = LinkSigner::generateSignedLink(
+ $display,
+ $encryptionKey,
+ $cdnUrl,
+ 'M',
+ $value[1],
+ $storedAs[$value[1]],
+ null,
+ true,
+ );
+ } else {
+ $url = $storedAs[$value[1]];
+ }
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $url,
+ $output,
+ );
+ } else {
+ $output = str_replace(
+ '[[' . $match . ']]',
+ '',
+ $output,
+ );
+ }
+ } else if (Str::startsWith($match, 'assetId')) {
+ $value = explode('=', $match);
+ if (array_key_exists($value[1], $assets)) {
+ $asset = $assets[$value[1]];
+ if ($display->isPwa()) {
+ $url = LinkSigner::generateSignedLink(
+ $display,
+ $encryptionKey,
+ $cdnUrl,
+ 'P',
+ $asset->id,
+ $asset->getFilename(),
+ 'asset',
+ true,
+ );
+ } else {
+ $url = $asset->getFilename();
+ }
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $url,
+ $output,
+ );
+ } else {
+ $output = str_replace(
+ '[[' . $match . ']]',
+ '',
+ $output,
+ );
+ }
+ } else if (Str::startsWith($match, 'assetAlias')) {
+ $value = explode('=', $match);
+ foreach ($assets as $asset) {
+ if ($asset->alias === $value[1]) {
+ if ($display->isPwa()) {
+ $url = LinkSigner::generateSignedLink(
+ $display,
+ $encryptionKey,
+ $cdnUrl,
+ 'P',
+ $asset->id,
+ $asset->getFilename(),
+ 'asset',
+ true,
+ );
+ } else {
+ $url = $asset->getFilename();
+ }
+ $output = str_replace(
+ '[[' . $match . ']]',
+ $url,
+ $output,
+ );
+ continue 2;
+ }
+ }
+ $output = str_replace('[[' . $match . ']]', '', $output);
+ }
+ }
+ return $output;
+ }
+
+ /**
+ * Render out the widgets HTML
+ * @param \Xibo\Entity\Widget[] $widgets
+ * @param ModuleTemplate[] $moduleTemplates
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\LoaderError
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function render(
+ int $widgetId,
+ Region $region,
+ array $widgets,
+ array $moduleTemplates
+ ): string {
+ // Build a Twig Sandbox
+ $twigSandbox = $this->getTwigSandbox();
+
+ // Build up some data for twig
+ $twig = [];
+ $twig['widgetId'] = $widgetId;
+ $twig['hbs'] = [];
+ $twig['twig'] = [];
+ $twig['style'] = [];
+ $twig['assets'] = [];
+ $twig['onRender'] = [];
+ $twig['onParseData'] = [];
+ $twig['onDataLoad'] = [];
+ $twig['onElementParseData'] = [];
+ $twig['onTemplateRender'] = [];
+ $twig['onTemplateVisible'] = [];
+ $twig['onInitialize'] = [];
+ $twig['templateProperties'] = [];
+ $twig['elements'] = [];
+ $twig['width'] = $region->width;
+ $twig['height'] = $region->height;
+ $twig['cmsDateFormat'] = $this->config->getSetting('DATE_FORMAT');
+ $twig['locale'] = Translate::GetJSLocale();
+
+ // Output some data for each widget.
+ $twig['data'] = [];
+
+ // Max duration
+ $duration = 0;
+ $numItems = 0;
+
+ // Grab any global elements in our templates
+ $globalElements = [];
+ foreach ($moduleTemplates as $moduleTemplate) {
+ if ($moduleTemplate->type === 'element' && $moduleTemplate->dataType === 'global') {
+ // Add global elements to an array of extendable elements
+ $globalElements[$moduleTemplate->templateId] = $moduleTemplate;
+ }
+ }
+
+ $this->getLog()->debug('render: there are ' . count($globalElements) . ' global elements');
+
+ // Extend any elements which need to be extended.
+ foreach ($moduleTemplates as $moduleTemplate) {
+ if ($moduleTemplate->type === 'element'
+ && !empty($moduleTemplate->extends)
+ && array_key_exists($moduleTemplate->extends->template, $globalElements)
+ ) {
+ $extends = $globalElements[$moduleTemplate->extends->template];
+
+ $this->getLog()->debug('render: extending template ' . $moduleTemplate->templateId);
+
+ // Merge properties
+ $moduleTemplate->properties = array_merge($extends->properties, $moduleTemplate->properties);
+
+ // Store on the object to use when we output the stencil
+ $moduleTemplate->setUnmatchedProperty('extends', $extends);
+ }
+ }
+
+ // Render each widget out into the html
+ foreach ($widgets as $widget) {
+ $this->getLog()->debug('render: widget to process is widgetId: ' . $widget->widgetId);
+ $this->getLog()->debug('render: ' . count($widgets) . ' widgets, '
+ . count($moduleTemplates) . ' templates');
+
+ // Get the module.
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // Decorate our module with the saved widget properties
+ // we include the defaults.
+ $module->decorateProperties($widget, true);
+
+ // templateId or "elements"
+ $templateId = $widget->getOptionValue('templateId', null);
+
+ // Validate this modules properties.
+ try {
+ $module->validateProperties('status');
+ $widget->isValid = 1;
+ } catch (InvalidArgumentException $invalidArgumentException) {
+ $widget->isValid = 0;
+ }
+
+ // Parse out some common properties.
+ $moduleLanguage = null;
+
+ foreach ($module->properties as $property) {
+ if ($property->type === 'languageSelector' && !empty($property->value)) {
+ $moduleLanguage = $property->value;
+ break;
+ }
+ }
+
+ // Get an array of the modules property values.
+ $modulePropertyValues = $module->getPropertyValues();
+
+ // Configure a translator for the module
+ // Note: We are using the language defined against the module and not from the module template
+ $translator = null;
+ if ($moduleLanguage !== null) {
+ $translator = Translate::getTranslationsFromLocale($moduleLanguage);
+ }
+
+ // Output some sample data and a data url.
+ $widgetData = [
+ 'widgetId' => $widget->widgetId,
+ 'templateId' => $templateId,
+ 'sample' => $module->sampleData,
+ 'properties' => $modulePropertyValues,
+ 'isValid' => $widget->isValid === 1,
+ 'isRepeatData' => $widget->getOptionValue('isRepeatData', 1) === 1,
+ 'duration' => $widget->useDuration ? $widget->duration : $module->defaultDuration,
+ 'calculatedDuration' => $widget->calculatedDuration,
+ 'isDataExpected' => $module->isDataProviderExpected(),
+ ];
+
+ // Should we expect data?
+ if ($module->isDataProviderExpected()) {
+ $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]';
+ $widgetData['data'] = '[[data=' . $widget->widgetId . ']]';
+ } else {
+ $widgetData['url'] = null;
+ $widgetData['data'] = null;
+ }
+
+ // Do we have a library file with this module?
+ if ($module->regionSpecific == 0) {
+ $widgetData['libraryId'] = '[[libraryId=' . $widget->getPrimaryMediaId() . ']]';
+ }
+
+ // Output event functions for this widget
+ if (!empty($module->onInitialize)) {
+ $twig['onInitialize'][$widget->widgetId] = $module->onInitialize;
+ }
+ if (!empty($module->onParseData)) {
+ $twig['onParseData'][$widget->widgetId] = $module->onParseData;
+ }
+ if (!empty($module->onDataLoad)) {
+ $twig['onDataLoad'][$widget->widgetId] = $module->onDataLoad;
+ }
+ if (!empty($module->onRender)) {
+ $twig['onRender'][$widget->widgetId] = $module->onRender;
+ }
+ if (!empty($module->onVisible)) {
+ $twig['onVisible'][$widget->widgetId] = $module->onVisible;
+ }
+
+ // Include any module assets.
+ foreach ($module->assets as $asset) {
+ if ($asset->isSendToPlayer()
+ && $asset->mimeType === 'text/css' || $asset->mimeType === 'text/javascript'
+ ) {
+ $twig['assets'][] = $asset;
+ }
+ }
+
+ // Find my template
+ if ($templateId !== 'elements') {
+ // Render out the `twig` from our specific static template
+ foreach ($moduleTemplates as $moduleTemplate) {
+ if ($moduleTemplate->templateId === $templateId) {
+ $moduleTemplate->decorateProperties($widget, true);
+ $widgetData['templateProperties'] = $moduleTemplate->getPropertyValues();
+
+ $this->getLog()->debug('render: Static template to include: ' . $moduleTemplate->templateId);
+ if ($moduleTemplate->stencil !== null) {
+ if ($moduleTemplate->stencil->twig !== null) {
+ $twig['twig'][] = $twigSandbox->fetchFromString(
+ $this->decorateTranslations($moduleTemplate->stencil->twig, $translator),
+ $widgetData['templateProperties'],
+ );
+ }
+ if ($moduleTemplate->stencil->style !== null) {
+ $twig['style'][] = [
+ 'content' => $twigSandbox->fetchFromString(
+ $moduleTemplate->stencil->style,
+ $widgetData['templateProperties'],
+ ),
+ 'type' => $moduleTemplate->type,
+ 'dataType' => $moduleTemplate->dataType,
+ 'templateId' => $moduleTemplate->templateId,
+ ];
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // Add to widgetData
+ $twig['data'][] = $widgetData;
+
+ // Watermark duration
+ $duration = max($duration, $widget->calculatedDuration);
+ // TODO: this won't always be right? can we make it right
+ $numItems = max($numItems, $widgetData['properties']['numItems'] ?? 0);
+
+ // What does our module have
+ if ($module->stencil !== null) {
+ // Stencils have access to any module properties
+ if ($module->stencil->twig !== null) {
+ $twig['twig'][] = $twigSandbox->fetchFromString(
+ $this->decorateTranslations($module->stencil->twig, null),
+ array_merge($modulePropertyValues, ['settings' => $module->getSettingsForOutput()]),
+ );
+ }
+ if ($module->stencil->hbs !== null) {
+ $twig['hbs']['module'] = [
+ 'content' => $this->decorateTranslations($module->stencil->hbs, null),
+ 'width' => $module->stencil->width,
+ 'height' => $module->stencil->height,
+ 'gapBetweenHbs' => $module->stencil->gapBetweenHbs,
+ ];
+ }
+ if ($module->stencil->head !== null) {
+ $twig['head'][] = $twigSandbox->fetchFromString(
+ $this->decorateTranslations($module->stencil->head, null),
+ $modulePropertyValues,
+ );
+ }
+ if ($module->stencil->style !== null) {
+ $twig['style'][] = [
+ 'content' => $twigSandbox->fetchFromString(
+ $module->stencil->style,
+ $modulePropertyValues,
+ ),
+ 'type' => $module->type,
+ 'dataType' => $module->dataType,
+ ];
+ }
+ }
+
+ // Include elements/element groups - they will already be JSON encoded.
+ $widgetElements = $widget->getOptionValue('elements', null);
+ if (!empty($widgetElements)) {
+ $this->getLog()->debug('render: there are elements to include');
+
+ // Elements will be JSON
+ $widgetElements = json_decode($widgetElements, true);
+
+ // Are any of the module properties marked for sending to elements?
+ $modulePropertiesToSend = [];
+ if (count($widgetElements) > 0) {
+ foreach ($module->properties as $property) {
+ if ($property->sendToElements) {
+ $modulePropertiesToSend[$property->id] = $modulePropertyValues[$property->id] ?? null;
+ }
+ }
+ }
+
+ // Join together the template properties for this element, and the element properties
+ foreach ($widgetElements as $widgetIndex => $widgetElement) {
+ // Assert the widgetId
+ $widgetElements[$widgetIndex]['widgetId'] = $widget->widgetId;
+
+ foreach (($widgetElement['elements'] ?? []) as $elementIndex => $element) {
+ $this->getLog()->debug('render: elements: processing widget index ' . $widgetIndex
+ . ', element index ' . $elementIndex . ' with id ' . $element['id']);
+
+ foreach ($moduleTemplates as $moduleTemplate) {
+ if ($moduleTemplate->templateId === $element['id']) {
+ $this->getLog()->debug('render: elements: found template for element '
+ . $element['id']);
+
+ // Merge the properties on the element with the properties on the template.
+ $widgetElements[$widgetIndex]['elements'][$elementIndex]['properties'] =
+ $moduleTemplate->getPropertyValues(
+ true,
+ $moduleTemplate->decoratePropertiesByArray(
+ $element['properties'] ?? [],
+ true
+ )
+ );
+
+ // Update any properties which match on the element
+ foreach ($modulePropertiesToSend as $propertyToSend => $valueToSend) {
+ $widgetElements[$widgetIndex]['elements']
+ [$elementIndex]['properties'][$propertyToSend] = $valueToSend;
+ }
+ }
+ }
+
+ // Check the element for a mediaId property and set it to
+ // [[mediaId=the_id_from_the_mediaId_property]]
+ if (!empty($element['mediaId'])) {
+ // Update the element so we output the mediaId replacement
+ $widgetElements[$widgetIndex]['elements'][$elementIndex]['properties']['mediaId']
+ = '[[mediaId=' . $element['mediaId'] . ']]';
+ }
+ }
+ }
+
+ $twig['elements'][] = json_encode($widgetElements);
+ }
+ }
+
+ // Render out HBS/style from templates
+ // we do not render Twig here
+ foreach ($moduleTemplates as $moduleTemplate) {
+ $this->getLog()->debug('render: outputting module template ' . $moduleTemplate->templateId);
+
+ // Handle extends.
+ $extension = $moduleTemplate->getUnmatchedProperty('extends');
+ $isExtensionHasHead = false;
+ $isExtensionHasStyle = false;
+
+ // Render out any hbs
+ if ($moduleTemplate->stencil !== null && $moduleTemplate->stencil->hbs !== null) {
+ // If we have an extension then look for %parent% and insert it.
+ if ($extension !== null && Str::contains('%parent%', $moduleTemplate->stencil->hbs)) {
+ $moduleTemplate->stencil->hbs = str_replace(
+ '%parent%',
+ $extension->stencil->hbs,
+ $moduleTemplate->stencil->hbs
+ );
+ }
+
+ // Output the hbs
+ $twig['hbs'][$moduleTemplate->templateId] = [
+ 'content' => $this->decorateTranslations($moduleTemplate->stencil->hbs, null),
+ 'width' => $moduleTemplate->stencil->width,
+ 'height' => $moduleTemplate->stencil->height,
+ 'gapBetweenHbs' => $moduleTemplate->stencil->gapBetweenHbs,
+ 'extends' => [
+ 'override' => $moduleTemplate->extends?->override,
+ 'with' => $moduleTemplate->extends?->with,
+ 'escapeHtml' => $moduleTemplate->extends?->escapeHtml ?? true,
+ ],
+ ];
+ } else if ($extension !== null) {
+ // Output the extension HBS instead
+ $twig['hbs'][$moduleTemplate->templateId] = [
+ 'content' => $this->decorateTranslations($extension->stencil->hbs, null),
+ 'width' => $extension->stencil->width,
+ 'height' => $extension->stencil->height,
+ 'gapBetweenHbs' => $extension->stencil->gapBetweenHbs,
+ 'extends' => [
+ 'override' => $moduleTemplate->extends?->override,
+ 'with' => $moduleTemplate->extends?->with,
+ 'escapeHtml' => $moduleTemplate->extends?->escapeHtml ?? true,
+ ],
+ ];
+
+ if ($extension->stencil->head !== null) {
+ $twig['head'][] = $extension->stencil->head;
+ $isExtensionHasHead = true;
+ }
+
+ if ($extension->stencil->style !== null) {
+ $twig['style'][] = [
+ 'content' => $extension->stencil->style,
+ 'type' => $moduleTemplate->type,
+ 'dataType' => $moduleTemplate->dataType,
+ 'templateId' => $moduleTemplate->templateId,
+ ];
+ $isExtensionHasStyle = true;
+ }
+ }
+
+ // Render the module template's head, if present and not already output by the extension
+ if ($moduleTemplate->stencil !== null
+ && $moduleTemplate->stencil->head !== null
+ && !$isExtensionHasHead
+ ) {
+ $twig['head'][] = $moduleTemplate->stencil->head;
+ }
+
+ // Render the module template's style, if present and not already output by the extension
+ if ($moduleTemplate->stencil !== null
+ && $moduleTemplate->stencil->style !== null
+ && !$isExtensionHasStyle
+ && $moduleTemplate->type === 'element'
+ ) {
+ // Add more info to the element style
+ // so we can use it to create CSS scope
+ $twig['style'][] = [
+ 'content' => $moduleTemplate->stencil->style,
+ 'type' => $moduleTemplate->type,
+ 'dataType' => $moduleTemplate->dataType,
+ 'templateId' => $moduleTemplate->templateId,
+ ];
+ }
+
+ if ($moduleTemplate->onTemplateRender !== null) {
+ $twig['onTemplateRender'][$moduleTemplate->templateId] = $moduleTemplate->onTemplateRender;
+ }
+
+ if ($moduleTemplate->onTemplateVisible !== null) {
+ $twig['onTemplateVisible'][$moduleTemplate->templateId] = $moduleTemplate->onTemplateVisible;
+ }
+
+ if ($moduleTemplate->onElementParseData !== null) {
+ $twig['onElementParseData'][$moduleTemplate->templateId] = $moduleTemplate->onElementParseData;
+ }
+
+ // Include any module template assets.
+ foreach ($moduleTemplate->assets as $asset) {
+ if ($asset->isSendToPlayer()
+ && $asset->mimeType === 'text/css' || $asset->mimeType === 'text/javascript'
+ ) {
+ $twig['assets'][] = $asset;
+ }
+ }
+ }
+
+ // Duration
+ $twig['duration'] = $duration;
+ $twig['numItems'] = $numItems;
+
+ // We use the default get resource template.
+ return $this->twig->fetch('widget-html-render.twig', $twig);
+ }
+
+ /**
+ * Decorate translations in template files.
+ * @param string $content
+ * @param \GetText\Translator $translator
+ * @return string
+ */
+ private function decorateTranslations(string $content, ?\Gettext\Translator $translator): string
+ {
+ $matches = [];
+ preg_match_all('/\|\|.*?\|\|/', $content, $matches);
+ foreach ($matches[0] as $sub) {
+ // Parse out the translateTag
+ $translateTag = str_replace('||', '', $sub);
+
+ // We have a valid translateTag to substitute
+ if ($translator !== null) {
+ $replace = $translator->gettext($translateTag);
+ } else {
+ $replace = __($translateTag);
+ }
+
+ // Substitute the replacement we have found (it might be '')
+ $content = str_replace($sub, $replace, $content);
+ }
+
+ return $content;
+ }
+
+ /**
+ * @param \Xibo\Entity\Widget $widget
+ * @return void
+ */
+ public function clearWidgetCache(Widget $widget)
+ {
+ $cachePath = $this->cachePath
+ . DIRECTORY_SEPARATOR
+ . $widget->widgetId
+ . DIRECTORY_SEPARATOR;
+
+ // Drop the cache
+ // there is a chance this may not yet exist
+ try {
+ $it = new \RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS);
+ $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
+ foreach ($files as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getRealPath());
+ } else {
+ unlink($file->getRealPath());
+ }
+ }
+ rmdir($cachePath);
+ } catch (\UnexpectedValueException $unexpectedValueException) {
+ $this->logger->debug('HTML cache doesn\'t exist yet or cannot be deleted. '
+ . $unexpectedValueException->getMessage());
+ }
+ }
+
+ /**
+ * Get a Twig Sandbox
+ * @return \Slim\Views\Twig
+ * @throws \Twig\Error\LoaderError
+ */
+ private function getTwigSandbox(): Twig
+ {
+ // Create a Twig Environment with a Sandbox
+ $sandbox = Twig::create([
+ PROJECT_ROOT . '/modules',
+ PROJECT_ROOT . '/custom',
+ ], [
+ 'cache' => false,
+ ]);
+
+ // Add missing filter
+ $sandbox->getEnvironment()->addFilter(new TwigFilter('url_decode', 'urldecode'));
+
+ // Configure a security policy
+ // Create a new security policy
+ $policy = new SecurityPolicy();
+
+ // Allowed tags
+ // import is allowed for weather static templates which import a macro
+ $policy->setAllowedTags(['if', 'for', 'set', 'macro', 'import']);
+
+ // Allowed filters
+ $policy->setAllowedFilters(['escape', 'raw', 'url_decode']);
+
+ // Create a Sandbox
+ $sandbox->addExtension(new SandboxExtension($policy, true));
+
+ return $sandbox;
+ }
+}
diff --git a/lib/Widget/RssProvider.php b/lib/Widget/RssProvider.php
new file mode 100644
index 0000000..b6d2e8a
--- /dev/null
+++ b/lib/Widget/RssProvider.php
@@ -0,0 +1,281 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use GuzzleHttp\Exception\GuzzleException;
+use PicoFeed\Config\Config;
+use PicoFeed\Logging\Logger;
+use PicoFeed\Parser\Item;
+use PicoFeed\PicoFeedException;
+use PicoFeed\Reader\Reader;
+use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
+use Xibo\Helper\Environment;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\DataType\Article;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderNumItemsTrait;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Downloads an RSS feed and returns Article data types
+ */
+class RssProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+ use DurationProviderNumItemsTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ $uri = $dataProvider->getProperty('uri');
+ if (empty($uri)) {
+ throw new InvalidArgumentException(__('Please enter the URI to a valid RSS feed.'), 'uri');
+ }
+
+ $picoFeedLoggingEnabled = Environment::isDevMode();
+
+ // Image expiry
+ $expiresImage = Carbon::now()
+ ->addMinutes($dataProvider->getProperty('updateIntervalImages', 1440))
+ ->format('U');
+
+ try {
+ // Get the feed
+ $response = $this->getFeed($dataProvider, $uri);
+
+ // Pull out the content type
+ $contentType = $response['contentType'];
+
+ $this->getLog()->debug('Feed returned content-type ' . $contentType);
+
+ // https://github.com/xibosignage/xibo/issues/1401
+ if (stripos($contentType, 'rss') === false
+ && stripos($contentType, 'xml') === false
+ && stripos($contentType, 'text') === false
+ && stripos($contentType, 'html') === false
+ ) {
+ // The content type isn't compatible
+ $this->getLog()->error('Incompatible content type: ' . $contentType);
+ return $this;
+ }
+
+ // Get the body, etc
+ $result = explode('charset=', $contentType);
+ $document['encoding'] = $result[1] ?? '';
+ $document['xml'] = $response['body'];
+
+ $this->getLog()->debug('Feed downloaded.');
+
+ // Load the feed XML document into a feed parser
+ // Enable logging if we need to
+ if ($picoFeedLoggingEnabled) {
+ $this->getLog()->debug('Setting Picofeed Logger to Enabled.');
+ Logger::enable();
+ }
+
+ // Client config
+ $clientConfig = new Config();
+
+ // Get the feed parser
+ $reader = new Reader($clientConfig);
+ $parser = $reader->getParser($uri, $document['xml'], $document['encoding']);
+
+ // Get a feed object
+ $feed = $parser->execute();
+
+ // Get all items
+ $feedItems = $feed->getItems();
+
+ // Disable date sorting?
+ if ($dataProvider->getProperty('disableDateSort') == 0
+ && $dataProvider->getProperty('randomiseItems', 0) == 0
+ ) {
+ // Sort the items array by date
+ usort($feedItems, function ($a, $b) {
+ /* @var Item $a */
+ /* @var Item $b */
+ return $b->getDate()->getTimestamp() - $a->getDate()->getTimestamp();
+ });
+ }
+
+ $sanitizer = null;
+ if ($dataProvider->getProperty('stripTags') != '') {
+ $sanitizer = (new HtmlSanitizerConfig())->allowSafeElements();
+
+ // Add the tags to strip
+ foreach (explode(',', $dataProvider->getProperty('stripTags')) as $forbidden) {
+ $this->getLog()->debug('fetchData: blocking element ' . $forbidden);
+ $sanitizer = $sanitizer->blockElement($forbidden);
+ }
+ }
+
+ // Where should we get images?
+ $imageSource = $dataProvider->getProperty('imageSource', 'enclosure');
+ $imageTag = match ($imageSource) {
+ 'mediaContent' => 'media:content',
+ 'image' => 'image',
+ 'custom' => $dataProvider->getProperty('imageSourceTag', 'image'),
+ default => 'enclosure'
+ };
+ $imageSourceAttribute = null;
+ if ($imageSource === 'mediaContent') {
+ $imageSourceAttribute = 'url';
+ } else if ($imageSource === 'custom') {
+ $imageSourceAttribute = $dataProvider->getProperty('imageSourceAttribute', null);
+ }
+
+ // Parse each item into an article
+ foreach ($feedItems as $item) {
+ /* @var Item $item */
+ $article = new Article();
+ $article->title = $item->getTitle();
+ $article->author = $item->getAuthor();
+ $article->link = $item->getUrl();
+ $article->date = Carbon::instance($item->getDate());
+ $article->publishedDate = Carbon::instance($item->getPublishedDate());
+
+ // Body safe HTML
+ $article->content = $dataProvider->getSanitizer(['content' => $item->getContent()])
+ ->getHtml('content', [
+ 'htmlSanitizerConfig' => $sanitizer
+ ]);
+
+ // RSS doesn't support a summary/excerpt tag.
+ $descriptionTag = $item->getTag('description');
+ $article->summary = trim($descriptionTag ? strip_tags($descriptionTag[0]) : $article->content);
+
+ // Do we have an image included?
+ $link = null;
+ if ($imageTag === 'enclosure') {
+ if (stripos($item->getEnclosureType(), 'image') > -1) {
+ $link = $item->getEnclosureUrl();
+ }
+ } else {
+ $link = $item->getTag($imageTag, $imageSourceAttribute)[0] ?? null;
+ }
+
+ if (!(empty($link))) {
+ $article->image = $dataProvider->addImage('ticker_' . md5($link), $link, $expiresImage);
+ } else {
+ $this->getLog()->debug('fetchData: no image found for image tag using ' . $imageTag);
+ }
+
+ if ($dataProvider->getProperty('decodeHtml') == 1) {
+ $article->content = htmlspecialchars_decode($article->content);
+ }
+
+ // Add the article.
+ $dataProvider->addItem($article);
+ }
+
+ $dataProvider->setCacheTtl($dataProvider->getProperty('updateInterval', 60) * 60);
+ $dataProvider->setIsHandled();
+ } catch (GuzzleException $requestException) {
+ // Log and return empty?
+ $this->getLog()->error('Unable to get feed: ' . $uri
+ . ', e: ' . $requestException->getMessage());
+ $dataProvider->addError(__('Unable to download feed'));
+ } catch (PicoFeedException $picoFeedException) {
+ // Output any PicoFeed logs
+ if ($picoFeedLoggingEnabled) {
+ $this->getLog()->debug('Outputting Picofeed Logs.');
+ foreach (Logger::getMessages() as $message) {
+ $this->getLog()->debug($message);
+ }
+ }
+
+ // Log and return empty?
+ $this->getLog()->error('Unable to parse feed: ' . $picoFeedException->getMessage());
+ $this->getLog()->debug($picoFeedException->getTraceAsString());
+ $dataProvider->addError(__('Unable to parse feed'));
+ }
+
+ // Output any PicoFeed logs
+ if ($picoFeedLoggingEnabled) {
+ foreach (Logger::getMessages() as $message) {
+ $this->getLog()->debug($message);
+ }
+ }
+
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ // No special cache key requirements.
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+
+ /**
+ * @param DataProviderInterface $dataProvider
+ * @param string $uri
+ * @return array body, contentType
+ * @throws GuzzleException
+ */
+ private function getFeed(DataProviderInterface $dataProvider, string $uri): array
+ {
+ // See if we have this feed cached already.
+ $cache = $dataProvider->getPool()->getItem('/widget/' . $dataProvider->getDataType() . '/' . md5($uri));
+ $body = $cache->get();
+
+ if ($cache->isMiss() || $body === null || !is_array($body)) {
+ // Make a new request.
+ $this->getLog()->debug('getFeed: cache miss');
+ $body = [];
+
+ $httpOptions = [
+ 'headers' => [
+ 'Accept' => 'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6,'
+ . 'application/xml;q=0.4, text/xml;q=0.4, text/html;q=0.2, text/*;q=0.1'
+ ],
+ 'timeout' => 20, // wait no more than 20 seconds
+ ];
+
+ if (!empty($dataProvider->getProperty('userAgent'))) {
+ $httpOptions['headers']['User-Agent'] = trim($dataProvider->getProperty('userAgent'));
+ }
+
+ $response = $dataProvider
+ ->getGuzzleClient($httpOptions)
+ ->get($uri);
+
+ $body['body'] = $response->getBody()->getContents();
+ $body['contentType'] = $response->getHeaderLine('Content-Type');
+
+ // Save the resonse to cache
+ $cache->set($body);
+ $cache->expiresAfter($dataProvider->getSetting('cachePeriod', 1440) * 60);
+ $dataProvider->getPool()->saveDeferred($cache);
+ } else {
+ $this->getLog()->debug('getFeed: cache hit');
+ }
+
+ return $body;
+ }
+}
diff --git a/lib/Widget/SubPlaylistItem.php b/lib/Widget/SubPlaylistItem.php
new file mode 100644
index 0000000..f70b124
--- /dev/null
+++ b/lib/Widget/SubPlaylistItem.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+class SubPlaylistItem implements \JsonSerializable
+{
+ /** @var int */
+ public $rowNo;
+
+ /** @var int */
+ public $playlistId;
+
+ /** @var string */
+ public $spotFill;
+
+ /** @var int */
+ public $spotLength;
+
+ /** @var ?int */
+ public $spots;
+
+ /** @inheritDoc */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'rowNo' => $this->rowNo,
+ 'playlistId' => $this->playlistId,
+ 'spotFill' => $this->spotFill,
+ 'spotLength' => $this->spotLength,
+ 'spots' => $this->spots,
+ ];
+ }
+}
diff --git a/lib/Widget/Validator/DisplayOrGeoValidator.php b/lib/Widget/Validator/DisplayOrGeoValidator.php
new file mode 100644
index 0000000..b99dc8d
--- /dev/null
+++ b/lib/Widget/Validator/DisplayOrGeoValidator.php
@@ -0,0 +1,64 @@
+.
+ */
+
+namespace Xibo\Widget\Validator;
+
+use Respect\Validation\Validator as v;
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\Provider\WidgetValidatorInterface;
+use Xibo\Widget\Provider\WidgetValidatorTrait;
+
+/**
+ * Validate that we either use display location or a lat/lng have been set
+ */
+class DisplayOrGeoValidator implements WidgetValidatorInterface
+{
+ use WidgetValidatorTrait;
+
+ /** @inheritDoc */
+ public function validate(Module $module, Widget $widget, string $stage): void
+ {
+ $useDisplayLocation = $widget->getOptionValue('useDisplayLocation', null);
+ if ($useDisplayLocation === null) {
+ foreach ($module->properties as $property) {
+ if ($property->id === 'useDisplayLocation') {
+ $useDisplayLocation = $property->default;
+ }
+ }
+ }
+ if ($useDisplayLocation === 0) {
+ // Validate lat/long
+ // only if they have been provided (our default is the CMS lat/long).
+ $lat = $widget->getOptionValue('latitude', null);
+ if (!empty($lat) && !v::latitude()->validate($lat)) {
+ throw new InvalidArgumentException(__('The latitude entered is not valid.'), 'latitude');
+ }
+
+ $lng = $widget->getOptionValue('longitude', null);
+ if (!empty($lng) && !v::longitude()->validate($lng)) {
+ throw new InvalidArgumentException(__('The longitude entered is not valid.'), 'longitude');
+ }
+ }
+ }
+}
diff --git a/lib/Widget/Validator/RemoteUrlsZeroDurationValidator.php b/lib/Widget/Validator/RemoteUrlsZeroDurationValidator.php
new file mode 100644
index 0000000..d43ad69
--- /dev/null
+++ b/lib/Widget/Validator/RemoteUrlsZeroDurationValidator.php
@@ -0,0 +1,64 @@
+.
+ */
+
+namespace Xibo\Widget\Validator;
+
+use Illuminate\Support\Str;
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\Provider\WidgetValidatorInterface;
+use Xibo\Widget\Provider\WidgetValidatorTrait;
+
+/**
+ * Validate that we have a duration greater than 0
+ */
+class RemoteUrlsZeroDurationValidator implements WidgetValidatorInterface
+{
+ use WidgetValidatorTrait;
+
+ /**
+ * @inheritDoc
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function validate(Module $module, Widget $widget, string $stage): void
+ {
+ $url = urldecode($widget->getOptionValue('uri', ''));
+ if ($widget->useDuration === 1
+ && $widget->duration <= 0
+ && !Str::startsWith($url, 'file://')
+ && Str::contains($url, '://')
+ ) {
+ // This is not a locally stored file, and so we should have a duration
+ throw new InvalidArgumentException(
+ __('The duration needs to be greater than 0 for remote URLs'),
+ 'duration'
+ );
+ } else if ($widget->useDuration === 1 && $widget->duration <= 0) {
+ // Locally stored file, still needs a positive duration.
+ throw new InvalidArgumentException(
+ __('The duration needs to be above 0 for a locally stored file '),
+ 'duration'
+ );
+ }
+ }
+}
diff --git a/lib/Widget/Validator/ShellCommandValidator.php b/lib/Widget/Validator/ShellCommandValidator.php
new file mode 100644
index 0000000..6a5fd00
--- /dev/null
+++ b/lib/Widget/Validator/ShellCommandValidator.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Widget\Validator;
+
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\Provider\WidgetValidatorInterface;
+use Xibo\Widget\Provider\WidgetValidatorTrait;
+
+/**
+ * Ensure a command has been entered somewhere in the widget
+ */
+class ShellCommandValidator implements WidgetValidatorInterface
+{
+ use WidgetValidatorTrait;
+
+ /** @inheritDoc */
+ public function validate(Module $module, Widget $widget, string $stage): void
+ {
+ if ($widget->getOptionValue('globalCommand', '') == ''
+ && $widget->getOptionValue('androidCommand', '') == ''
+ && $widget->getOptionValue('windowsCommand', '') == ''
+ && $widget->getOptionValue('linuxCommand', '') == ''
+ && $widget->getOptionValue('commandCode', '') == ''
+ && $widget->getOptionValue('webosCommand', '') == ''
+ && $widget->getOptionValue('tizenCommand', '') == ''
+ ) {
+ throw new InvalidArgumentException(__('You must enter a command'), 'command');
+ }
+ }
+}
diff --git a/lib/Widget/Validator/ZeroDurationValidator.php b/lib/Widget/Validator/ZeroDurationValidator.php
new file mode 100644
index 0000000..eec2dfe
--- /dev/null
+++ b/lib/Widget/Validator/ZeroDurationValidator.php
@@ -0,0 +1,52 @@
+.
+ */
+
+namespace Xibo\Widget\Validator;
+
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Widget\Provider\WidgetValidatorInterface;
+use Xibo\Widget\Provider\WidgetValidatorTrait;
+
+/**
+ * Validate that we have a duration greater than 0
+ */
+class ZeroDurationValidator implements WidgetValidatorInterface
+{
+ use WidgetValidatorTrait;
+
+ /**
+ * @inheritDoc
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function validate(Module $module, Widget $widget, string $stage): void
+ {
+ // Videos can have 0 durations (but not if useDuration is selected)
+ if ($widget->useDuration === 1 && $widget->duration <= 0) {
+ throw new InvalidArgumentException(
+ sprintf(__('Duration needs to be above 0 for %s'), $module->name),
+ 'duration'
+ );
+ }
+ }
+}
diff --git a/lib/Widget/VideoProvider.php b/lib/Widget/VideoProvider.php
new file mode 100644
index 0000000..0708ee1
--- /dev/null
+++ b/lib/Widget/VideoProvider.php
@@ -0,0 +1,68 @@
+.
+ */
+
+namespace Xibo\Widget;
+
+use Carbon\Carbon;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Provider\DataProviderInterface;
+use Xibo\Widget\Provider\DurationProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+use Xibo\Widget\Provider\WidgetProviderTrait;
+
+/**
+ * Handles setting the correct video duration.
+ */
+class VideoProvider implements WidgetProviderInterface
+{
+ use WidgetProviderTrait;
+
+ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
+ {
+ return $this;
+ }
+
+ public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface
+ {
+ // If we have not been provided a specific duration, we should use the duration stored in the library
+ try {
+ if ($durationProvider->getWidget()->useDuration === 0) {
+ $durationProvider->setDuration($durationProvider->getWidget()->getDurationForMedia());
+ }
+ } catch (NotFoundException) {
+ $this->getLog()->error('fetchDuration: video/audio without primaryMediaId. widgetId: '
+ . $durationProvider->getWidget()->getId());
+ }
+ return $this;
+ }
+
+ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
+ {
+ // No special cache key requirements.
+ return null;
+ }
+
+ public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
+ {
+ return null;
+ }
+}
diff --git a/lib/XMR/ChangeLayoutAction.php b/lib/XMR/ChangeLayoutAction.php
new file mode 100644
index 0000000..3d54a16
--- /dev/null
+++ b/lib/XMR/ChangeLayoutAction.php
@@ -0,0 +1,72 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+class ChangeLayoutAction extends PlayerAction
+{
+ public $layoutId;
+ public $duration;
+ public $downloadRequired;
+ public $changeMode;
+
+ public function __construct()
+ {
+ $this->setQos(10);
+ }
+
+ /**
+ * Set details for this layout
+ * @param int $layoutId the layoutId to change to
+ * @param int $duration the duration this layout should be shown
+ * @param bool|false $downloadRequired flag indicating whether a download is required before changing to the layout
+ * @param string $changeMode whether to queue or replace
+ * @return $this
+ */
+ public function setLayoutDetails($layoutId, $duration = 0, $downloadRequired = false, $changeMode = 'queue')
+ {
+ if ($duration === null) {
+ $duration = 0;
+ }
+
+ $this->layoutId = $layoutId;
+ $this->duration = $duration;
+ $this->downloadRequired = $downloadRequired;
+ $this->changeMode = $changeMode;
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getMessage(): string
+ {
+ $this->action = 'changeLayout';
+
+ if ($this->layoutId == 0) {
+ throw new PlayerActionException('Layout Details not provided');
+ }
+
+ return $this->serializeToJson(['layoutId', 'duration', 'downloadRequired', 'changeMode']);
+ }
+}
diff --git a/lib/XMR/ClearStatsAndLogsAction.php b/lib/XMR/ClearStatsAndLogsAction.php
new file mode 100644
index 0000000..4070ac7
--- /dev/null
+++ b/lib/XMR/ClearStatsAndLogsAction.php
@@ -0,0 +1,43 @@
+.
+ */
+
+
+namespace Xibo\XMR;
+
+/**
+ * Class ClearStatsAndLogsAction
+ * @package Xibo\XMR
+ */
+class ClearStatsAndLogsAction extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(2);
+ }
+
+ public function getMessage(): string
+ {
+ $this->action = 'clearStatsAndLogs';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/CollectNowAction.php b/lib/XMR/CollectNowAction.php
new file mode 100644
index 0000000..9897923
--- /dev/null
+++ b/lib/XMR/CollectNowAction.php
@@ -0,0 +1,46 @@
+.
+ */
+
+
+namespace Xibo\XMR;
+
+/**
+ * Class CollectNowAction
+ * @package Xibo\XMR
+ */
+class CollectNowAction extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(5);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getMessage()
+ {
+ $this->action = 'collectNow';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/CommandAction.php b/lib/XMR/CommandAction.php
new file mode 100644
index 0000000..1939fa9
--- /dev/null
+++ b/lib/XMR/CommandAction.php
@@ -0,0 +1,55 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+class CommandAction extends PlayerAction
+{
+ protected $commandCode;
+
+ public function __construct()
+ {
+ $this->setQos(8);
+ }
+
+ /**
+ * Set the command code
+ * @param string $code
+ * @return $this
+ */
+ public function setCommandCode($code)
+ {
+ $this->commandCode = $code;
+ return $this;
+ }
+
+ public function getMessage(): string
+ {
+ $this->action = 'commandAction';
+
+ if ($this->commandCode == '') {
+ throw new PlayerActionException('Missing Command Code');
+ }
+
+ return $this->serializeToJson(['commandCode']);
+ }
+}
diff --git a/lib/XMR/DataUpdateAction.php b/lib/XMR/DataUpdateAction.php
new file mode 100644
index 0000000..980008a
--- /dev/null
+++ b/lib/XMR/DataUpdateAction.php
@@ -0,0 +1,48 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+/**
+ * Class DataUpdateAction
+ * Used to indicate that a widget has been recently updated and should be downloaded again
+ * @package Xibo\XMR
+ */
+class DataUpdateAction extends PlayerAction
+{
+ /**
+ * @param int $widgetId The widgetId which has been updated
+ */
+ public function __construct(protected int $widgetId)
+ {
+ $this->setQos(5);
+ }
+
+ /** @inheritdoc */
+ public function getMessage(): string
+ {
+ $this->setQos(1);
+ $this->action = 'dataUpdate';
+
+ return $this->serializeToJson(['widgetId']);
+ }
+}
diff --git a/lib/XMR/LicenceCheckAction.php b/lib/XMR/LicenceCheckAction.php
new file mode 100644
index 0000000..52f4fcd
--- /dev/null
+++ b/lib/XMR/LicenceCheckAction.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+/**
+ * Class LicenceCheckAction
+ * @package Xibo\XMR
+ */
+class LicenceCheckAction extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(4);
+ }
+
+ /**
+ * @return string
+ */
+ public function getMessage(): string
+ {
+ $this->action = 'licenceCheck';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/OverlayLayoutAction.php b/lib/XMR/OverlayLayoutAction.php
new file mode 100644
index 0000000..e8fb449
--- /dev/null
+++ b/lib/XMR/OverlayLayoutAction.php
@@ -0,0 +1,74 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+/**
+ * Class OverlayLayoutAction
+ * @package Xibo\XMR
+ */
+class OverlayLayoutAction extends PlayerAction
+{
+ public $layoutId;
+ public $duration;
+ public $downloadRequired;
+ public $changeMode;
+
+ public function __construct()
+ {
+ $this->setQos(10);
+ }
+
+ /**
+ * Set details for this layout
+ * @param int $layoutId the layoutId to change to
+ * @param int $duration the duration this layout should be overlaid
+ * @param bool|false $downloadRequired flag indicating whether a download is required before changing to the layout
+ * @return $this
+ */
+ public function setLayoutDetails($layoutId, $duration, $downloadRequired = false)
+ {
+ $this->layoutId = $layoutId;
+ $this->duration = $duration;
+ $this->downloadRequired = $downloadRequired;
+
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getMessage(): string
+ {
+ $this->action = 'overlayLayout';
+
+ if ($this->layoutId == 0) {
+ throw new PlayerActionException(__('Layout Details not provided'));
+ }
+
+ if ($this->duration == 0) {
+ throw new PlayerActionException(__('Duration not provided'));
+ }
+
+ return $this->serializeToJson(['layoutId', 'duration', 'downloadRequired']);
+ }
+}
diff --git a/lib/XMR/PlayerAction.php b/lib/XMR/PlayerAction.php
new file mode 100644
index 0000000..c678a4a
--- /dev/null
+++ b/lib/XMR/PlayerAction.php
@@ -0,0 +1,178 @@
+.
+ */
+
+
+namespace Xibo\XMR;
+
+/**
+ * An adstract class which implements a Player Action Interface
+ * This class should be extended for each different action type
+ *
+ * When sending it will check that a default QOS/TTL has been configured. It is the responsibility
+ * of the extending class to set this on initialisation.
+ */
+abstract class PlayerAction implements PlayerActionInterface
+{
+ /** @var string The Action */
+ public $action;
+
+ /** @var string Created Date */
+ public $createdDt;
+
+ /** @var int TTL */
+ public $ttl;
+
+ /** @var int QOS */
+ private $qos;
+
+ /** @var string Channel */
+ private $channel;
+
+ /** @var string Public Key */
+ private $publicKey;
+
+ /** @var bool Should the message be encrypted? */
+ private bool $isEncrypted;
+
+ /**
+ * Set the identity of this Player Action
+ * @param string $channel
+ * @param bool $isEncrypted
+ * @param string|null $key
+ * @return $this
+ * @throws \Xibo\XMR\PlayerActionException if the key is invalid
+ */
+ final public function setIdentity(string $channel, bool $isEncrypted, ?string $key): PlayerActionInterface
+ {
+ $this->channel = $channel;
+ $this->isEncrypted = $isEncrypted;
+
+ if ($isEncrypted) {
+ $this->publicKey = openssl_get_publickey($key);
+ if (!$this->publicKey) {
+ throw new PlayerActionException('Invalid Public Key');
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the message TTL
+ * @param int $ttl
+ * @return $this
+ */
+ final public function setTtl(int $ttl = 120): PlayerAction
+ {
+ $this->ttl = $ttl;
+ return $this;
+ }
+
+ /**
+ * Set the message QOS
+ * @param int $qos
+ * @return $this
+ */
+ final public function setQos(int $qos = 1): PlayerAction
+ {
+ $this->qos = $qos;
+ return $this;
+ }
+
+ /**
+ * Serialize this object to its JSON representation
+ * @param array $include
+ * @return string
+ */
+ final public function serializeToJson(array $include = []): string
+ {
+ $include = array_merge(['action', 'createdDt', 'ttl'], $include);
+
+ $json = [];
+ foreach (get_object_vars($this) as $key => $value) {
+ if (in_array($key, $include)) {
+ $json[$key] = $value;
+ }
+ }
+ return json_encode($json);
+ }
+
+ /**
+ * Return the encrypted message and keys
+ * @return array
+ * @throws PlayerActionException
+ */
+ private function getEncryptedMessage(): array
+ {
+ $message = null;
+
+ $seal = openssl_seal($this->getMessage(), $message, $eKeys, [$this->publicKey], 'RC4');
+ if (!$seal) {
+ throw new PlayerActionException('Cannot seal message');
+ }
+
+ return [
+ 'key' => base64_encode($eKeys[0]),
+ 'message' => base64_encode($message)
+ ];
+ }
+
+ /**
+ * Finalise the message to be sent
+ * @throws \Xibo\XMR\PlayerActionException
+ */
+ final public function finaliseMessage(): array
+ {
+ // Set the message create date
+ $this->createdDt = date('c');
+
+ // Set the TTL if not already set
+ if (empty($this->ttl)) {
+ $this->setTtl();
+ }
+
+ // Set the QOS if not already set
+ if (empty($this->qos)) {
+ $this->setQos();
+ }
+
+ // Envelope our message
+ $message = [
+ 'channel' => $this->channel,
+ 'qos' => $this->qos,
+ ];
+
+ // Encrypt the message if needed.
+ if ($this->isEncrypted) {
+ $encrypted = $this->getEncryptedMessage();
+ $message['message'] = $encrypted['message'];
+ $message['key'] = $encrypted['key'];
+ $message['isWebSocket'] = false;
+ } else {
+ $message['message'] = $this->getMessage();
+ $message['key'] = 'none';
+ $message['isWebSocket'] = true;
+ }
+
+ return $message;
+ }
+}
diff --git a/lib/XMR/PlayerActionException.php b/lib/XMR/PlayerActionException.php
new file mode 100644
index 0000000..6a898a9
--- /dev/null
+++ b/lib/XMR/PlayerActionException.php
@@ -0,0 +1,15 @@
+.
+ */
+
+
+namespace Xibo\XMR;
+
+/**
+ * Interface PlayerActionInterface
+ * @package Xibo\XMR
+ */
+interface PlayerActionInterface
+{
+ /**
+ * Get the Message
+ * @return string
+ */
+ public function getMessage();
+}
diff --git a/lib/XMR/PurgeAllAction.php b/lib/XMR/PurgeAllAction.php
new file mode 100644
index 0000000..af14e83
--- /dev/null
+++ b/lib/XMR/PurgeAllAction.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+/**
+ * Class PurgeAllAction
+ * @package Xibo\XMR
+ */
+class PurgeAllAction extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(2);
+ }
+
+ /**
+ * @return string
+ */
+ public function getMessage(): string
+ {
+ $this->action = 'purgeAll';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/RekeyAction.php b/lib/XMR/RekeyAction.php
new file mode 100644
index 0000000..f6872cf
--- /dev/null
+++ b/lib/XMR/RekeyAction.php
@@ -0,0 +1,40 @@
+.
+ */
+
+
+namespace Xibo\XMR;
+
+
+class RekeyAction extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(1);
+ }
+
+ public function getMessage(): string
+ {
+ $this->action = 'rekeyAction';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/RevertToSchedule.php b/lib/XMR/RevertToSchedule.php
new file mode 100644
index 0000000..5023b4c
--- /dev/null
+++ b/lib/XMR/RevertToSchedule.php
@@ -0,0 +1,38 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+class RevertToSchedule extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(10);
+ }
+
+ public function getMessage(): string
+ {
+ $this->action = 'revertToSchedule';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/ScheduleCriteriaUpdateAction.php b/lib/XMR/ScheduleCriteriaUpdateAction.php
new file mode 100644
index 0000000..5b703ff
--- /dev/null
+++ b/lib/XMR/ScheduleCriteriaUpdateAction.php
@@ -0,0 +1,67 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+/**
+ * Class ScheduleCriteriaUpdateAction
+ * @package Xibo\XMR
+ */
+class ScheduleCriteriaUpdateAction extends PlayerAction
+{
+ /**
+ * @var array
+ */
+ public $criteriaUpdates = [];
+
+ public function __construct()
+ {
+ $this->setQos(10);
+ }
+
+ /**
+ * Set criteria updates
+ * @param array $criteriaUpdates an array of criteria updates
+ * @return $this
+ */
+ public function setCriteriaUpdates(array $criteriaUpdates)
+ {
+ $this->criteriaUpdates = $criteriaUpdates;
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getMessage(): string
+ {
+ $this->action = 'criteriaUpdate';
+
+ // Ensure criteriaUpdates array is not empty
+ if (empty($this->criteriaUpdates)) {
+ // Throw an exception if criteriaUpdates is not provided
+ throw new PlayerActionException(__('Criteria updates not provided.'));
+ }
+
+ return $this->serializeToJson(['criteriaUpdates']);
+ }
+}
diff --git a/lib/XMR/ScreenShotAction.php b/lib/XMR/ScreenShotAction.php
new file mode 100644
index 0000000..5a4c31e
--- /dev/null
+++ b/lib/XMR/ScreenShotAction.php
@@ -0,0 +1,38 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+class ScreenShotAction extends PlayerAction
+{
+ public function __construct()
+ {
+ $this->setQos(5);
+ }
+
+ public function getMessage(): string
+ {
+ $this->action = 'screenShot';
+
+ return $this->serializeToJson();
+ }
+}
diff --git a/lib/XMR/TriggerWebhookAction.php b/lib/XMR/TriggerWebhookAction.php
new file mode 100644
index 0000000..26a8c7d
--- /dev/null
+++ b/lib/XMR/TriggerWebhookAction.php
@@ -0,0 +1,53 @@
+.
+ */
+
+namespace Xibo\XMR;
+
+/**
+ * Trigger a web hook on the player. This is intended mainly for testing purposes as you'd
+ * expect the trigger to be sent locally on the player
+ */
+class TriggerWebhookAction extends PlayerAction
+{
+ public $triggerCode;
+
+ public function __construct($triggerCode)
+ {
+ $this->triggerCode = $triggerCode;
+ $this->setQos(3);
+ }
+
+ /**
+ * @return string
+ * @throws PlayerActionException
+ */
+ public function getMessage(): string
+ {
+ $this->action = 'triggerWebhook';
+
+ if ($this->triggerCode == '') {
+ throw new PlayerActionException('Layout Details not provided');
+ }
+
+ return $this->serializeToJson(['triggerCode']);
+ }
+}
diff --git a/lib/XTR/AnonymousUsageTask.php b/lib/XTR/AnonymousUsageTask.php
new file mode 100644
index 0000000..17bbdc3
--- /dev/null
+++ b/lib/XTR/AnonymousUsageTask.php
@@ -0,0 +1,326 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use Xibo\Helper\Environment;
+use Xibo\Storage\StorageServiceInterface;
+
+/**
+ * Collects anonymous usage stats
+ */
+class AnonymousUsageTask implements TaskInterface
+{
+ use TaskTrait;
+
+ private readonly string $url;
+
+ private StorageServiceInterface $db;
+
+ public function __construct()
+ {
+ $this->url = 'https://api.xibosignage.com/api/stats/usage';
+ }
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->db = $container->get('store');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $isCollectUsage = $this->getConfig()->getSetting('PHONE_HOME') == 1;
+ if (!$isCollectUsage) {
+ $this->appendRunMessage('Anonymous usage disabled');
+ return;
+ }
+
+ // Make sure we have a key
+ $key = $this->getConfig()->getSetting('PHONE_HOME_KEY');
+ if (empty($key)) {
+ $key = bin2hex(random_bytes(16));
+
+ // Save it.
+ $this->getConfig()->changeSetting('PHONE_HOME_KEY', $key);
+ }
+
+ // Set PHONE_HOME_TIME to NOW.
+ $this->getConfig()->changeSetting('PHONE_HOME_DATE', Carbon::now()->format('U'));
+
+ // Collect the data and report it.
+ $data = [
+ 'id' => $key,
+ 'version' => Environment::$WEBSITE_VERSION_NAME,
+ 'accountId' => defined('ACCOUNT_ID') ? constant('ACCOUNT_ID') : null,
+ ];
+
+ // What type of install are we?
+ $data['installType'] = 'custom';
+ if (isset($_SERVER['INSTALL_TYPE'])) {
+ $data['installType'] = $_SERVER['INSTALL_TYPE'];
+ } else if ($this->getConfig()->getSetting('cloud_demo') !== null) {
+ $data['installType'] = 'cloud';
+ }
+
+ // General settings
+ $data['calendarType'] = strtolower($this->getConfig()->getSetting('CALENDAR_TYPE'));
+ $data['defaultLanguage'] = $this->getConfig()->getSetting('DEFAULT_LANGUAGE');
+ $data['isDetectLanguage'] = $this->getConfig()->getSetting('DETECT_LANGUAGE') == 1 ? 1 : 0;
+
+ // Connectors
+ $data['isSspConnector'] = $this->runQuery('SELECT `isEnabled` FROM `connectors` WHERE `className` = :name', [
+ 'name' => '\\Xibo\\Connector\\XiboSspConnector'
+ ]) ?? 0;
+ $data['isDashboardConnector'] =
+ $this->runQuery('SELECT `isEnabled` FROM `connectors` WHERE `className` = :name', [
+ 'name' => '\\Xibo\\Connector\\XiboDashboardConnector'
+ ]) ?? 0;
+
+ // Most recent date any user log in happened
+ $data['dateSinceLastUserLogin'] = $this->getDateSinceLastUserLogin();
+
+ // Displays
+ $data = array_merge($data, $this->displayStats());
+ $data['countOfDisplays'] = $this->runQuery(
+ 'SELECT COUNT(*) AS countOf FROM `display` WHERE `lastaccessed` > :recently',
+ [
+ 'recently' => Carbon::now()->subDays(7)->format('U'),
+ ]
+ );
+ $data['countOfDisplaysTotal'] = $this->runQuery('SELECT COUNT(*) AS countOf FROM `display`');
+ $data['countOfDisplaysUnAuthorised'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `display` WHERE licensed = 0');
+ $data['countOfDisplayGroups'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `displaygroup` WHERE isDisplaySpecific = 0');
+
+ // Users
+ $data['countOfUsers'] = $this->runQuery('SELECT COUNT(*) AS countOf FROM `user`');
+ $data['countOfUsersActiveInLastTwentyFour'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE `lastAccessed` > :recently', [
+ 'recently' => Carbon::now()->subHours(24)->format('Y-m-d H:i:s'),
+ ]);
+ $data['countOfUserGroups'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `group` WHERE isUserSpecific = 0');
+ $data['countOfUsersWithStatusDashboard'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'statusdashboard.view\'');
+ $data['countOfUsersWithIconDashboard'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'icondashboard.view\'');
+ $data['countOfUsersWithMediaDashboard'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'mediamanager.view\'');
+ $data['countOfUsersWithPlaylistDashboard'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'playlistdashboard.view\'');
+
+ // Other objects
+ $data['countOfFolders'] = $this->runQuery('SELECT COUNT(*) AS countOf FROM `folder`');
+ $data['countOfLayouts'] = $this->runQuery('
+ SELECT COUNT(*) AS countOf
+ FROM `campaign`
+ WHERE `isLayoutSpecific` = 1
+ AND `campaignId` NOT IN (
+ SELECT `lkcampaignlayout`.`campaignId`
+ FROM `lkcampaignlayout`
+ INNER JOIN `lktaglayout`
+ ON `lktaglayout`.`layoutId` = `lkcampaignlayout`.`layoutId`
+ INNER JOIN `tag`
+ ON `lktaglayout`.tagId = `tag`.tagId
+ WHERE `tag`.`tag` = \'template\'
+ )
+ ');
+ $data['countOfLayoutsWithPlaylists'] = $this->runQuery('
+ SELECT COUNT(DISTINCT `region`.`layoutId`) AS countOf
+ FROM `widget`
+ INNER JOIN `playlist` ON `playlist`.`playlistId` = `widget`.`playlistId`
+ INNER JOIN `region` ON `playlist`.`regionId` = `region`.`regionId`
+ WHERE `widget`.`type` = \'subplaylist\'
+ ');
+ $data['countOfAdCampaigns'] =
+ $this->runQuery('
+ SELECT COUNT(*) AS countOf
+ FROM `campaign`
+ WHERE `type` = \'ad\'
+ AND `isLayoutSpecific` = 0
+ ');
+ $data['countOfListCampaigns'] =
+ $this->runQuery('
+ SELECT COUNT(*) AS countOf
+ FROM `campaign`
+ WHERE `type` = \'list\'
+ AND `isLayoutSpecific` = 0
+ ');
+ $data['countOfMedia'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `media`');
+ $data['countOfPlaylists'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `playlist` WHERE `regionId` IS NULL');
+ $data['countOfDataSets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `dataset`');
+ $data['countOfRemoteDataSets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `dataset` WHERE `isRemote` = 1');
+ $data['countOfDataConnectorDataSets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `dataset` WHERE `isRealTime` = 1');
+ $data['countOfApplications'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `oauth_clients`');
+ $data['countOfApplicationsUsingClientCredentials'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `oauth_clients` WHERE `clientCredentials` = 1');
+ $data['countOfApplicationsUsingUserCode'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `oauth_clients` WHERE `authCode` = 1');
+ $data['countOfScheduledReports'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `reportschedule`');
+ $data['countOfSavedReports'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `saved_report`');
+
+ // Widgets
+ $data['countOfImageWidgets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'image\'');
+ $data['countOfVideoWidgets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'video\'');
+ $data['countOfPdfWidgets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'pdf\'');
+ $data['countOfEmbeddedWidgets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'embedded\'');
+ $data['countOfCanvasWidgets'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'global\'');
+
+ // Schedules
+ $data['countOfSchedulesThisMonth'] = $this->runQuery('
+ SELECT COUNT(*) AS countOf
+ FROM `schedule`
+ WHERE `fromDt` <= :toDt AND `toDt` > :fromDt
+ ', [
+ 'fromDt' => Carbon::now()->startOfMonth()->unix(),
+ 'toDt' => Carbon::now()->endOfMonth()->unix(),
+ ]);
+ $data['countOfSyncSchedulesThisMonth'] = $this->runQuery('
+ SELECT COUNT(*) AS countOf
+ FROM `schedule`
+ WHERE `fromDt` <= :toDt AND `toDt` > :fromDt
+ AND `eventTypeId` = 9
+ ', [
+ 'fromDt' => Carbon::now()->startOfMonth()->unix(),
+ 'toDt' => Carbon::now()->endOfMonth()->unix(),
+ ]);
+ $data['countOfAlwaysSchedulesThisMonth'] = $this->runQuery('
+ SELECT COUNT(*) AS countOf
+ FROM `schedule`
+ INNER JOIN `daypart` ON `daypart`.dayPartId = `schedule`.`dayPartId`
+ WHERE `daypart`.`isAlways` = 1
+ ');
+ $data['countOfRecurringSchedules'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `schedule` WHERE IFNULL(recurrence_type, \'\') <> \'\'');
+ $data['countOfSchedulesWithCriteria'] =
+ $this->runQuery('SELECT COUNT(DISTINCT `eventId`) AS countOf FROM `schedule_criteria`');
+ $data['countOfDayParts'] =
+ $this->runQuery('SELECT COUNT(*) AS countOf FROM `daypart`');
+
+ // Finished collecting, send.
+ $this->getLogger()->debug('run: sending stats ' . json_encode($data));
+
+ try {
+ (new Client())->post(
+ $this->url,
+ $this->getConfig()->getGuzzleProxy([
+ 'json' => $data,
+ ])
+ );
+ } catch (\Exception $e) {
+ $this->appendRunMessage('Unable to send stats.');
+ $this->log->error('run: stats send failed, e=' . $e->getMessage());
+ }
+
+ $this->appendRunMessage('Completed');
+ }
+
+ private function displayStats(): array
+ {
+ // Retrieve number of displays
+ $stats = $this->db->select('
+ SELECT client_type, COUNT(*) AS cnt
+ FROM `display`
+ WHERE licensed = 1
+ GROUP BY client_type
+ ', []);
+
+ $counts = [
+ 'total' => 0,
+ 'android' => 0,
+ 'windows' => 0,
+ 'linux' => 0,
+ 'lg' => 0,
+ 'sssp' => 0,
+ 'chromeOS' => 0,
+ ];
+ foreach ($stats as $stat) {
+ $counts['total'] += intval($stat['cnt']);
+ $counts[$stat['client_type']] += intval($stat['cnt']);
+ }
+
+ return [
+ 'countOfDisplaysAuthorised' => $counts['total'],
+ 'countOfAndroid' => $counts['android'],
+ 'countOfLinux' => $counts['linux'],
+ 'countOfWebos' => $counts['lg'],
+ 'countOfWindows' => $counts['windows'],
+ 'countOfTizen' => $counts['sssp'],
+ 'countOfChromeOS' => $counts['chromeOS'],
+ ];
+ }
+
+ /**
+ * Run a query and return the value of a property
+ * @param string $sql
+ * @param string $property
+ * @param array $params
+ * @return string|null
+ */
+ private function runQuery(string $sql, array $params = [], string $property = 'countOf'): ?string
+ {
+ try {
+ $record = $this->db->select($sql, $params);
+ return $record[0][$property] ?? null;
+ } catch (\PDOException $PDOException) {
+ $this->getLogger()->debug('runQuery: error returning specific stat, e: ' . $PDOException->getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Get the most recent user login timestamp converted to UTC
+ * @return string|null
+ */
+ private function getDateSinceLastUserLogin(): string|null
+ {
+ $cmsTimezone = $this->getConfig()->getSetting('defaultTimezone');
+ $latestUserLoginDate = $this->runQuery(
+ 'SELECT MAX(`lastAccessed`) AS lastAccessed FROM `user`',
+ [],
+ 'lastAccessed'
+ );
+
+ return $latestUserLoginDate
+ ? Carbon::parse($latestUserLoginDate, $cmsTimezone)->setTimezone('UTC')->timestamp
+ : null;
+ }
+}
diff --git a/lib/XTR/AuditLogArchiveTask.php b/lib/XTR/AuditLogArchiveTask.php
new file mode 100644
index 0000000..c9ba8f5
--- /dev/null
+++ b/lib/XTR/AuditLogArchiveTask.php
@@ -0,0 +1,274 @@
+.
+ */
+
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Entity\User;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Exception\TaskRunException;
+
+/**
+ * Class StatsArchiveTask
+ * @package Xibo\XTR
+ */
+class AuditLogArchiveTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var User */
+ private $archiveOwner;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var \Xibo\Helper\SanitizerService */
+ private $sanitizerService;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->userFactory = $container->get('userFactory');
+ $this->sanitizerService = $container->get('sanitizerService');
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function run()
+ {
+ $maxPeriods = intval($this->getOption('maxPeriods', 1));
+ $maxAge = Carbon::now()
+ ->subMonths(intval($this->getOption('maxAgeMonths', 1)))
+ ->startOfDay();
+
+ $this->setArchiveOwner();
+
+ // Delete or Archive?
+ if ($this->getOption('deleteInstead', 1) == 1) {
+ $this->appendRunMessage('# ' . __('AuditLog Delete'));
+ $this->appendRunMessage('maxAge: ' . $maxAge->format(DateFormatHelper::getSystemFormat()));
+
+ // Delete all audit log messages older than the configured number of months
+ // use a prepared statement for this, and delete the records in a loop
+ $deleteStatement = $this->store->getConnection()->prepare('
+ DELETE FROM `auditlog`
+ WHERE `logDate` < :logDate
+ LIMIT :limit
+ ');
+
+ // Convert to a simple type so that we can pass by reference to bindParam.
+ // Load in other options for deleting
+ $maxage = $maxAge->format('U');
+ $limit = intval($this->getOption('deleteLimit', 1000));
+ $maxAttempts = intval($this->getOption('maxDeleteAttempts', -1));
+ $deleteSleep = intval($this->getOption('deleteSleep', 5));
+
+ // Bind params
+ $deleteStatement->bindParam(':logDate', $maxage, \PDO::PARAM_STR);
+ $deleteStatement->bindParam(':limit', $limit, \PDO::PARAM_INT);
+
+ try {
+ $i = 0;
+ $rows = 1;
+
+ while ($rows > 0) {
+ $i++;
+
+ // Run delete statement
+ $deleteStatement->execute();
+
+ // Find out how many rows we've deleted
+ $rows = $deleteStatement->rowCount();
+
+ // We shouldn't be in a transaction, but commit anyway just in case
+ $this->store->commitIfNecessary();
+
+ // Give SQL time to recover
+ if ($rows > 0) {
+ $this->log->debug('Archive delete effected ' . $rows . ' rows, sleeping.');
+ sleep($deleteSleep);
+ }
+
+ // Break if we've exceeded the maximum attempts, assuming that has been provided
+ if ($maxAttempts > -1 && $i >= $maxAttempts) {
+ break;
+ }
+ }
+ } catch (\PDOException $e) {
+ $this->log->error($e->getMessage());
+ throw new GeneralException('Archive rows cannot be deleted.');
+ }
+ } else {
+ $this->appendRunMessage('# ' . __('AuditLog Archive'));
+ $this->appendRunMessage('maxAge: ' . $maxAge->format(DateFormatHelper::getSystemFormat()));
+
+ // Get the earliest
+ $earliestDate = $this->store->select('
+ SELECT MIN(logDate) AS minDate FROM `auditlog` WHERE logDate < :logDate
+ ', [
+ 'logDate' => $maxAge->format('U')
+ ]);
+
+ if (count($earliestDate) <= 0 || $earliestDate[0]['minDate'] === null) {
+ $this->appendRunMessage(__('Nothing to archive'));
+ return;
+ }
+
+ // Take the earliest date and roll forward until the max age
+ $earliestDate = Carbon::createFromTimestamp($earliestDate[0]['minDate'])->startOfDay();
+ $i = 0;
+
+ // We only archive up until the max age, leaving newer records alone.
+ while ($earliestDate < $maxAge && $i <= $maxPeriods) {
+ $i++;
+
+ $this->log->debug('Running archive number ' . $i);
+
+ // Push forward
+ $fromDt = $earliestDate->copy();
+ $earliestDate->addMonth();
+
+ $this->exportAuditLogToLibrary($fromDt, $earliestDate);
+ $this->store->commitIfNecessary();
+ }
+ }
+
+ $this->runMessage .= __('Done') . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Export stats to the library
+ * @param Carbon $fromDt
+ * @param Carbon $toDt
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function exportAuditLogToLibrary($fromDt, $toDt)
+ {
+ $this->runMessage .= ' - ' . $fromDt . ' / ' . $toDt . PHP_EOL;
+
+ $sql = '
+ SELECT *
+ FROM `auditlog`
+ WHERE logDate >= :fromDt
+ AND logDate < :toDt
+ ';
+
+ $params = [
+ 'fromDt' => $fromDt->format('U'),
+ 'toDt' => $toDt->format('U')
+ ];
+
+ $sql .= ' ORDER BY 1 ';
+
+ $records = $this->store->select($sql, $params);
+
+ if (count($records) <= 0) {
+ $this->runMessage .= __('No audit log found for these dates') . PHP_EOL;
+ return;
+ }
+
+ // Create a temporary file for this
+ $fileName = $this->config->getSetting('LIBRARY_LOCATION') . 'temp/auditlog.csv';
+
+ $out = fopen($fileName, 'w');
+ fputcsv($out, ['logId', 'logDate', 'userId', 'message', 'entity', 'entityId', 'objectAfter']);
+
+ // Do some post-processing
+ foreach ($records as $row) {
+ $sanitizedRow = $this->getSanitizer($row);
+ // Read the columns
+ fputcsv($out, [
+ $sanitizedRow->getInt('logId'),
+ $sanitizedRow->getInt('logDate'),
+ $sanitizedRow->getInt('userId'),
+ $sanitizedRow->getString('message'),
+ $sanitizedRow->getString('entity'),
+ $sanitizedRow->getInt('entityId'),
+ $sanitizedRow->getString('objectAfter')
+ ]);
+ }
+
+ fclose($out);
+
+ $zip = new \ZipArchive();
+ $result = $zip->open($fileName . '.zip', \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+ if ($result !== true) {
+ throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
+ }
+
+ $zip->addFile($fileName, 'auditlog.csv');
+ $zip->close();
+
+ // Remove the CSV file
+ unlink($fileName);
+
+ // Upload to the library
+ $media = $this->mediaFactory->create(
+ __('AuditLog Export %s to %s', $fromDt->format('Y-m-d'), $toDt->format('Y-m-d')),
+ 'auditlog.csv.zip',
+ 'genericfile',
+ $this->archiveOwner->getId()
+ );
+ $media->save();
+
+ // Delete the logs
+ $this->store->update('DELETE FROM `auditlog` WHERE logDate >= :fromDt AND logDate < :toDt', $params);
+ }
+
+ /**
+ * Set the archive owner
+ * @throws TaskRunException
+ */
+ private function setArchiveOwner()
+ {
+ $archiveOwner = $this->getOption('archiveOwner', null);
+
+ if ($archiveOwner == null) {
+ $admins = $this->userFactory->getSuperAdmins();
+
+ if (count($admins) <= 0)
+ throw new TaskRunException(__('No super admins to use as the archive owner, please set one in the configuration.'));
+
+ $this->archiveOwner = $admins[0];
+
+ } else {
+ try {
+ $this->archiveOwner = $this->userFactory->getByName($archiveOwner);
+ } catch (NotFoundException $e) {
+ throw new TaskRunException(__('Archive Owner not found'));
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/CampaignSchedulerTask.php b/lib/XTR/CampaignSchedulerTask.php
new file mode 100644
index 0000000..4ae0249
--- /dev/null
+++ b/lib/XTR/CampaignSchedulerTask.php
@@ -0,0 +1,330 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use GeoJson\Feature\FeatureCollection;
+use GeoJson\GeoJson;
+use Xibo\Entity\DayPart;
+use Xibo\Entity\Schedule;
+
+/**
+ * Campaign Scheduler task
+ * This should be run once per hour to create interrupt schedules for applicable advertising campaigns.
+ * The schedules will be created for the following hour.
+ */
+class CampaignSchedulerTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var \Xibo\Factory\CampaignFactory */
+ private $campaignFactory;
+
+ /** @var \Xibo\Factory\ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var \Xibo\Factory\DayPartFactory */
+ private $dayPartFactory;
+
+ /** @var \Xibo\Factory\DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /** @var \Xibo\Factory\DisplayFactory */
+ private $displayFactory;
+
+ /** @var \Xibo\Service\DisplayNotifyServiceInterface */
+ private $displayNotifyService;
+
+ /** @var \Xibo\Entity\DayPart */
+ private $customDayPart = null;
+
+ /** @inheritDoc */
+ public function setFactories($container)
+ {
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->scheduleFactory = $container->get('scheduleFactory');
+ $this->dayPartFactory = $container->get('dayPartFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->displayNotifyService = $container->get('displayNotifyService');
+ return $this;
+ }
+
+ /** @inheritDoc */
+ public function run()
+ {
+ $nextHour = Carbon::now()->startOfHour()->addHour();
+ $nextHourEnd = $nextHour->copy()->addHour();
+ $activeCampaigns = $this->campaignFactory->query(null, [
+ 'disableUserCheck' => 1,
+ 'type' => 'ad',
+ 'startDt' => $nextHour->unix(),
+ 'endDt' => $nextHour->unix(),
+ ]);
+
+ // We will need to notify some displays at the end.
+ $notifyDisplayGroupIds = [];
+ $campaignsProcessed = 0;
+ $campaignsScheduled = 0;
+
+ // See what we can schedule for each one.
+ foreach ($activeCampaigns as $campaign) {
+ $campaignsProcessed++;
+ try {
+ $this->log->debug('campaignSchedulerTask: active campaign found, id: ' . $campaign->campaignId);
+
+ // What schedules should I create?
+ $activeLayouts = [];
+ foreach ($campaign->loadLayouts() as $layout) {
+ $this->log->debug('campaignSchedulerTask: layout assignment: ' . $layout->layoutId);
+
+ // Is the layout value
+ if ($layout->duration <= 0) {
+ $this->log->error('campaignSchedulerTask: layout without duration');
+ continue;
+ }
+
+ // Are we on an active day of the week?
+ if (!in_array($nextHour->dayOfWeekIso, explode(',', $layout->daysOfWeek))) {
+ $this->log->debug('campaignSchedulerTask: day of week not active');
+ continue;
+ }
+
+ // Is this on an active day part?
+ if ($layout->dayPartId != 0) {
+ $this->log->debug('campaignSchedulerTask: dayPartId set, testing');
+
+ // Check the day part
+ try {
+ $dayPart = $this->dayPartFactory->getById($layout->dayPartId);
+ $dayPart->adjustForDate($nextHour);
+ } catch (\Exception $exception) {
+ $this->log->debug('campaignSchedulerTask: invalid dayPart, e = '
+ . $exception->getMessage());
+ continue;
+ }
+
+ // Is this day part active
+ if (!$nextHour->betweenIncluded($dayPart->adjustedStart, $dayPart->adjustedEnd)) {
+ $this->log->debug('campaignSchedulerTask: dayPart not active');
+ continue;
+ }
+ }
+
+ $this->log->debug('campaignSchedulerTask: layout is valid and needs a schedule');
+ $activeLayouts[] = $layout;
+ }
+
+ $countActiveLayouts = count($activeLayouts);
+ $this->log->debug('campaignSchedulerTask: there are ' . $countActiveLayouts . ' active layouts');
+
+ if ($countActiveLayouts <= 0) {
+ $this->log->debug('campaignSchedulerTask: no active layouts for campaign');
+ continue;
+ }
+
+ // The campaign is active
+ // Display groups
+ $displayGroups = [];
+ $allDisplays = [];
+ $countDisplays = 0;
+ $costPerPlay = 0;
+ $impressionsPerPlay = 0;
+
+ // First pass uses only logged in displays from the display group
+ foreach ($campaign->loadDisplayGroupIds() as $displayGroupId) {
+ $displayGroups[] = $this->displayGroupFactory->getById($displayGroupId);
+
+ // Record ids to notify
+ if (!in_array($displayGroupId, $notifyDisplayGroupIds)) {
+ $notifyDisplayGroupIds[] = $displayGroupId;
+ }
+
+ foreach ($this->displayFactory->getByDisplayGroupId($displayGroupId) as $display) {
+ // Keep track of these in case we resolve 0 logged in displays
+ $allDisplays[] = $display;
+ if ($display->licensed === 1 && $display->loggedIn === 1) {
+ $countDisplays++;
+ $costPerPlay += $display->costPerPlay;
+ $impressionsPerPlay += $display->impressionsPerPlay;
+ }
+ }
+ }
+
+ $this->log->debug('campaignSchedulerTask: campaign has ' . $countDisplays
+ . ' logged in and authorised displays');
+
+ // If there are 0 displays, then process again ignoring the logged in status.
+ if ($countDisplays <= 0) {
+ $this->log->debug('campaignSchedulerTask: processing displays again ignoring logged in status');
+
+ foreach ($allDisplays as $display) {
+ if ($display->licensed === 1) {
+ $countDisplays++;
+ $costPerPlay += $display->costPerPlay;
+ $impressionsPerPlay += $display->impressionsPerPlay;
+ }
+ }
+ }
+
+ $this->log->debug('campaignSchedulerTask: campaign has ' . $countDisplays
+ . ' authorised displays');
+
+ if ($countDisplays <= 0) {
+ $this->log->debug('campaignSchedulerTask: skipping campaign due to no authorised displays');
+ continue;
+ }
+
+ // What is the total amount of time we want this campaign to play in this hour period?
+ // We work out how much we should have played vs how much we have played
+ $progress = $campaign->getProgress($nextHour->copy());
+
+ // A simple assessment of how much of the target we need in this hour period (we assume the campaign
+ // will play for 24 hours a day and that adjustments to later scheduling will solve any underplay)
+ $targetNeededPerDay = $progress->targetPerDay / 24;
+
+ // If we are more than 5% ahead of where we should be, or we are at 100% already, then don't
+ // schedule anything else
+ if ($progress->progressTarget >= 100) {
+ $this->log->debug('campaignSchedulerTask: campaign has completed, skipping');
+ continue;
+ } else if ($progress->progressTime > 0
+ && ($progress->progressTime - $progress->progressTarget + 5) <= 0
+ ) {
+ $this->log->debug('campaignSchedulerTask: campaign is 5% or more ahead of schedule, skipping');
+ continue;
+ }
+
+ if ($progress->progressTime > 0 && $progress->progressTarget > 0) {
+ // If we're behind, then increase our play rate accordingly
+ $ratio = $progress->progressTime / $progress->progressTarget;
+ $targetNeededPerDay = $targetNeededPerDay * $ratio;
+
+ $this->log->debug('campaignSchedulerTask: targetNeededPerDay is ' . $targetNeededPerDay
+ . ', adjusted by ' . $ratio);
+ }
+
+ // Spread across the layouts
+ $targetNeededPerLayout = $targetNeededPerDay / $countActiveLayouts;
+
+ // Modify the target depending on what units it is expressed in
+ // This also caters for spreading the target across the active displays because the
+ // cost/impressions/displays are sums.
+ if ($campaign->targetType === 'budget') {
+ $playsNeededPerLayout = $targetNeededPerLayout / $costPerPlay;
+ } else if ($campaign->targetType === 'imp') {
+ $playsNeededPerLayout = $targetNeededPerLayout / $impressionsPerPlay;
+ } else {
+ $playsNeededPerLayout = $targetNeededPerLayout / $countDisplays;
+ }
+
+ // Take the ceiling because we can't do part plays
+ $playsNeededPerLayout = intval(ceil($playsNeededPerLayout));
+
+ $this->log->debug('campaignSchedulerTask: targetNeededPerLayout is ' . $targetNeededPerLayout
+ . ', targetType: ' . $campaign->targetType
+ . ', playsNeededPerLayout: ' . $playsNeededPerLayout
+ . ', there are ' . $countDisplays . ' displays.');
+
+ foreach ($activeLayouts as $layout) {
+ // We are on an active day of the week and within an active day part
+ // create a scheduled event for all displays assigned.
+ // and for each geo fence
+ // Create our schedule
+ $schedule = $this->scheduleFactory->createEmpty();
+ $schedule->setCampaignFactory($this->campaignFactory);
+
+ // Date
+ $schedule->fromDt = $nextHour->unix();
+ $schedule->toDt = $nextHourEnd->unix();
+
+ // Displays
+ foreach ($displayGroups as $displayGroup) {
+ $schedule->assignDisplayGroup($displayGroup);
+ }
+
+ // Interrupt Layout
+ $schedule->eventTypeId = Schedule::$INTERRUPT_EVENT;
+ $schedule->userId = $campaign->ownerId;
+ $schedule->parentCampaignId = $campaign->campaignId;
+ $schedule->campaignId = $layout->layoutCampaignId;
+ $schedule->displayOrder = 0;
+ $schedule->isPriority = 0;
+ $schedule->dayPartId = $this->getCustomDayPart()->dayPartId;
+ $schedule->isGeoAware = 0;
+ $schedule->syncTimezone = 0;
+ $schedule->syncEvent = 0;
+
+ // We cap SOV at 3600
+ $schedule->shareOfVoice = min($playsNeededPerLayout * $layout->duration, 3600);
+ $schedule->maxPlaysPerHour = $playsNeededPerLayout;
+
+ // Do we have a geofence? (geo schedules do not count against totalSovAvailable)
+ if (!empty($layout->geoFence)) {
+ $this->log->debug('campaignSchedulerTask: layout has a geo fence');
+ $schedule->isGeoAware = 1;
+
+ // Get some GeoJSON and pull out each Feature (create a schedule for each one)
+ $geoJson = GeoJson::jsonUnserialize(json_decode($layout->geoFence, true));
+ if ($geoJson instanceof FeatureCollection) {
+ $this->log->debug('campaignSchedulerTask: layout has multiple features');
+ foreach ($geoJson->getFeatures() as $feature) {
+ $schedule->geoLocation = json_encode($feature->jsonSerialize());
+ $schedule->save(['notify' => false]);
+
+ // Clone a new one
+ $schedule = clone $schedule;
+ }
+ } else {
+ $schedule->geoLocation = $layout->geoFence;
+ $schedule->save(['notify' => false]);
+ }
+ } else {
+ $schedule->save(['notify' => false]);
+ }
+ }
+
+ // Handle notify
+ foreach ($notifyDisplayGroupIds as $displayGroupId) {
+ $this->displayNotifyService->notifyByDisplayGroupId($displayGroupId);
+ }
+
+ $campaignsScheduled++;
+ } catch (\Exception $exception) {
+ $this->log->error('campaignSchedulerTask: ' . $exception->getMessage());
+ $this->appendRunMessage($campaign->campaign . ' failed');
+ }
+ }
+
+ $this->appendRunMessage($campaignsProcessed . ' campaigns processed, of which ' . $campaignsScheduled
+ . ' were scheduled. Skipped ' . ($campaignsProcessed - $campaignsScheduled) . ' for various reasons');
+ }
+
+ private function getCustomDayPart(): DayPart
+ {
+ if ($this->customDayPart === null) {
+ $this->customDayPart = $this->dayPartFactory->getCustomDayPart();
+ }
+ return $this->customDayPart;
+ }
+}
diff --git a/lib/XTR/ClearCachedMediaDataTask.php b/lib/XTR/ClearCachedMediaDataTask.php
new file mode 100644
index 0000000..52f47ea
--- /dev/null
+++ b/lib/XTR/ClearCachedMediaDataTask.php
@@ -0,0 +1,85 @@
+.
+ */
+
+namespace Xibo\XTR;
+use Carbon\Carbon;
+use Xibo\Factory\MediaFactory;
+use Xibo\Helper\DateFormatHelper;
+
+/**
+ * Class ClearCachedMediaDataTask
+ * @package Xibo\XTR
+ */
+class ClearCachedMediaDataTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->mediaFactory = $container->get('mediaFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Clear Cached Media Data') . PHP_EOL . PHP_EOL;
+
+ // Long running task
+ set_time_limit(0);
+
+ $this->runClearCache();
+ }
+
+ /**
+ * Updates all md5/filesizes to empty for any image/module file created since 2.2.0 release date
+ */
+ private function runClearCache()
+ {
+
+ $cutOffDate = Carbon::createFromFormat('Y-m-d', '2019-11-26')->startOfDay()->format(DateFormatHelper::getSystemFormat());
+
+ // Update the MD5 and fileSize to null
+ $this->store->update('UPDATE `media` SET md5 = :md5, fileSize = :fileSize, modifiedDt = :modifiedDt
+ WHERE (`media`.type = \'image\' OR (`media`.type = \'module\' AND `media`.moduleSystemFile = 0)) AND createdDt >= :createdDt ', [
+ 'fileSize' => null,
+ 'md5' => null,
+ 'createdDt' => $cutOffDate,
+ 'modifiedDt' => date(DateFormatHelper::getSystemFormat())
+
+ ]);
+
+ // Disable the task
+ $this->appendRunMessage('# Disabling task.');
+
+ $this->getTask()->isActive = 0;
+ $this->getTask()->save();
+
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+
+
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/DataSetConvertTask.php b/lib/XTR/DataSetConvertTask.php
new file mode 100644
index 0000000..30e7989
--- /dev/null
+++ b/lib/XTR/DataSetConvertTask.php
@@ -0,0 +1,129 @@
+.
+ */
+
+
+namespace Xibo\XTR;
+use Xibo\Entity\DataSet;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Class DataSetConvertTask
+ * @package Xibo\XTR
+ */
+class DataSetConvertTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->dataSetFactory = $container->get('dataSetFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ // Protect against us having run before
+ if ($this->store->exists('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :name', [
+ 'schema' => $_SERVER['MYSQL_DATABASE'],
+ 'name' => 'datasetdata'
+ ])) {
+
+ // Get all DataSets
+ foreach ($this->dataSetFactory->query() as $dataSet) {
+ /* @var \Xibo\Entity\DataSet $dataSet */
+
+ // Rebuild the data table
+ $dataSet->rebuild();
+
+ // Load the existing data from datasetdata
+ foreach (self::getExistingData($dataSet) as $row) {
+ $dataSet->addRow($row);
+ }
+ }
+
+ // Drop data set data
+ $this->store->update('DROP TABLE `datasetdata`;', []);
+ }
+
+ // Disable the task
+ $this->getTask()->isActive = 0;
+
+ $this->appendRunMessage('Conversion Completed');
+ }
+
+ /**
+ * Data Set Results
+ * @param DataSet $dataSet
+ * @return array
+ * @throws GeneralException
+ */
+ public function getExistingData($dataSet)
+ {
+ $dbh = $this->store->getConnection();
+ $params = array('dataSetId' => $dataSet->dataSetId);
+
+ $selectSQL = '';
+ $outerSelect = '';
+
+ foreach ($dataSet->getColumn() as $col) {
+ /* @var \Xibo\Entity\DataSetColumn $col */
+ if ($col->dataSetColumnTypeId != 1)
+ continue;
+
+ $selectSQL .= sprintf("MAX(CASE WHEN DataSetColumnID = %d THEN `Value` ELSE null END) AS '%s', ", $col->dataSetColumnId, $col->heading);
+ $outerSelect .= sprintf(' `%s`,', $col->heading);
+ }
+
+ $outerSelect = rtrim($outerSelect, ',');
+
+ // We are ready to build the select and from part of the SQL
+ $SQL = "SELECT $outerSelect ";
+ $SQL .= " FROM ( ";
+ $SQL .= " SELECT $outerSelect ,";
+ $SQL .= " RowNumber ";
+ $SQL .= " FROM ( ";
+ $SQL .= " SELECT $selectSQL ";
+ $SQL .= " RowNumber ";
+ $SQL .= " FROM (";
+ $SQL .= " SELECT datasetcolumn.DataSetColumnID, datasetdata.RowNumber, datasetdata.`Value` ";
+ $SQL .= " FROM datasetdata ";
+ $SQL .= " INNER JOIN datasetcolumn ";
+ $SQL .= " ON datasetcolumn.DataSetColumnID = datasetdata.DataSetColumnID ";
+ $SQL .= " WHERE datasetcolumn.DataSetID = :dataSetId ";
+ $SQL .= " ) datasetdatainner ";
+ $SQL .= " GROUP BY RowNumber ";
+ $SQL .= " ) datasetdata ";
+ $SQL .= ' ) finalselect ';
+ $SQL .= " ORDER BY RowNumber ";
+
+ $sth = $dbh->prepare($SQL);
+ $sth->execute($params);
+
+ return $sth->fetchAll(\PDO::FETCH_ASSOC);
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/DropPlayerCacheTask.php b/lib/XTR/DropPlayerCacheTask.php
new file mode 100644
index 0000000..89048d2
--- /dev/null
+++ b/lib/XTR/DropPlayerCacheTask.php
@@ -0,0 +1,31 @@
+pool->deleteItem('display');
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/DynamicPlaylistSyncTask.php b/lib/XTR/DynamicPlaylistSyncTask.php
new file mode 100644
index 0000000..ecc1230
--- /dev/null
+++ b/lib/XTR/DynamicPlaylistSyncTask.php
@@ -0,0 +1,330 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Entity\Media;
+use Xibo\Entity\Playlist;
+use Xibo\Entity\Task;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class DynamicPlaylistSyncTask
+ * @package Xibo\XTR
+ *
+ * Keep dynamic Playlists in sync with changes to the Media table.
+ */
+class DynamicPlaylistSyncTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var WidgetFactory */
+ private $widgetFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->store = $container->get('store');
+ $this->playlistFactory = $container->get('playlistFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->moduleFactory = $container->get('moduleFactory');
+ $this->widgetFactory = $container->get('widgetFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ // If we're in the error state, then always run, otherwise check the dates we modified various triggers
+ if ($this->getTask()->lastRunStatus !== Task::$STATUS_ERROR) {
+ // Run a little query to get the last modified date from the media table
+ $lastMediaUpdate = $this->store->select('
+ SELECT MAX(modifiedDt) AS modifiedDt
+ FROM `media`
+ WHERE `type` <> \'module\' AND `type` <> \'genericfile\'
+ ', [])[0]['modifiedDt'];
+
+ $lastPlaylistUpdate = $this->store->select('
+ SELECT MAX(modifiedDt) AS modifiedDt
+ FROM `playlist`
+ ', [])[0]['modifiedDt'];
+
+ if (empty($lastMediaUpdate) || empty($lastPlaylistUpdate)) {
+ $this->appendRunMessage('No library media or Playlists to assess');
+ return;
+ }
+
+ $this->log->debug('Last media updated date is ' . $lastMediaUpdate);
+ $this->log->debug('Last playlist updated date is ' . $lastPlaylistUpdate);
+
+ $lastMediaUpdate = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $lastMediaUpdate);
+ $lastPlaylistUpdate = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $lastPlaylistUpdate);
+ $lastTaskRun = Carbon::createFromTimestamp($this->getTask()->lastRunDt);
+
+ if ($lastMediaUpdate->lessThanOrEqualTo($lastTaskRun)
+ && $lastPlaylistUpdate->lessThanOrEqualTo($lastTaskRun))
+ {
+ $this->appendRunMessage('No library media/playlist updates since we last ran');
+ return;
+ }
+ }
+
+ $count = 0;
+
+ // Get all Dynamic Playlists
+ foreach ($this->playlistFactory->query(null, ['isDynamic' => 1]) as $playlist) {
+ try {
+ // We want to detect any differences in what should be assigned to this Playlist.
+ $playlist->load(['checkDisplayOrder' => true]);
+
+ $this->log->debug('Assessing Playlist: ' . $playlist->name);
+
+ if (empty($playlist->filterMediaName) && empty($playlist->filterMediaTags) && empty($playlist->filterFolderId)) {
+ // if this Dynamic Playlist was populated will all Media in the system
+ // before we introduced measures against it, we need to go through and unassign all Widgets from it.
+ // if it is fresh Playlist added recently, it will not have any Widgets on it with empty filters.
+ if (!empty($playlist->widgets)) {
+ foreach ($playlist->widgets as $widget) {
+ $playlist->deleteWidget($widget);
+ }
+ }
+ $this->log->debug(sprintf(
+ 'Dynamic Playlist ID %d , with no filters set, skipping.',
+ $playlist->playlistId
+ ));
+ continue;
+ }
+
+ // Query for media which would be assigned to this Playlist and see if there are any differences
+ $media = [];
+ $mediaIds = [];
+ $displayOrder = [];
+ foreach ($this->mediaFactory->query(null, [
+ 'name' => $playlist->filterMediaName,
+ 'logicalOperatorName' => $playlist->filterMediaNameLogicalOperator,
+ 'tags' => $playlist->filterMediaTags,
+ 'exactTags' => $playlist->filterExactTags,
+ 'logicalOperator' => $playlist->filterMediaTagsLogicalOperator,
+ 'folderId' => !empty($playlist->filterFolderId) ? $playlist->filterFolderId : null,
+ 'userCheckUserId' => $playlist->getOwnerId(),
+ 'start' => 0,
+ 'length' => $playlist->maxNumberOfItems
+ ]) as $index => $item) {
+ $media[$item->mediaId] = $item;
+ $mediaIds[] = $item->mediaId;
+ // store the expected display order
+ $displayOrder[$item->mediaId] = $index + 1;
+ }
+
+ // Work out if the set of widgets is different or not.
+ // This is only the first loose check
+ $different = (count($playlist->widgets) !== count($media));
+
+ $this->log->debug('There are ' . count($media) . ' that should be assigned and '
+ . count($playlist->widgets) . ' currently assigned with max number of items set to '
+ . $playlist->maxNumberOfItems . ' First check difference is '
+ . var_export($different, true));
+
+ if (!$different) {
+ // Try a more complete check, using mediaIds
+ $compareMediaIds = $mediaIds;
+
+ // ordering should be the same, so the first time we get one out of order, we can stop
+ foreach ($playlist->widgets as $widget) {
+ try {
+ $widgetMediaId = $widget->getPrimaryMediaId();
+ if ($widgetMediaId !== $compareMediaIds[0]
+ || $widget->duration !== $media[$widgetMediaId]->duration
+ ) {
+ $different = true;
+ break;
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->log->error('Playlist ' . $playlist->getId()
+ . ' has a Widget without any associated media. widgetId = ' . $widget->getId());
+
+ // We ought to recalculate
+ $different = true;
+ break;
+ }
+
+ array_shift($compareMediaIds);
+ }
+ }
+
+ $this->log->debug('Second check difference is ' . var_export($different, true));
+
+ if ($different) {
+ // We will update this Playlist
+ $assignmentMade = false;
+ $count++;
+
+ // Remove the ones no-longer present, add the ones we're missing
+ // we don't delete and re-add the lot to avoid regenerating the widgetIds (makes stats harder to
+ // interpret)
+ foreach ($playlist->widgets as $widget) {
+ try {
+ $widgetMediaId = $widget->getPrimaryMediaId();
+
+ if (!in_array($widgetMediaId, $mediaIds)) {
+ $playlist->deleteWidget($widget);
+ } else {
+ // It's present in the array
+ // Check to see if the duration is different
+ if ($widget->duration !== $media[$widgetMediaId]->duration) {
+ // The media duration has changed, so update the widget
+ $widget->useDuration = 1;
+ $widget->duration = $media[$widgetMediaId]->duration;
+ $widget->calculatedDuration = $widget->duration;
+ $widget->save([
+ 'saveWidgetOptions' => false,
+ 'saveWidgetAudio' => false,
+ 'saveWidgetMedia' => false,
+ 'notify' => false,
+ 'notifyPlaylists' => false,
+ 'notifyDisplays' => false,
+ 'audit' => true,
+ 'alwaysUpdate' => true
+ ]);
+ }
+
+ // Pop it off the list of ones to assign.
+ $mediaIds = array_diff($mediaIds, [$widgetMediaId]);
+
+ // We do want to save the Playlist here.
+ $assignmentMade = true;
+ }
+ } catch (NotFoundException) {
+ // Delete it
+ $playlist->deleteWidget($widget);
+ }
+ }
+
+ // Do we have any mediaId's left which should be assigned and aren't?
+ // Add the ones we have left
+ foreach ($media as $item) {
+ if (in_array($item->mediaId, $mediaIds)) {
+ if (count($playlist->widgets) >= $playlist->maxNumberOfItems) {
+ $this->log->debug(
+ sprintf(
+ 'Dynamic Playlist ID %d, has reached the maximum number of items %d, finishing assignments',//phpcs:ignore
+ $playlist->playlistId,
+ $playlist->maxNumberOfItems
+ )
+ );
+ break;
+ }
+ $assignmentMade = true;
+ // make sure we pass the expected displayOrder for the new item we are about to add.
+ $this->createAndAssign($playlist, $item, $displayOrder[$item->mediaId]);
+ }
+ }
+
+ if ($assignmentMade) {
+ // We've made an assignment change, so audit this change
+ // don't audit any downstream save operations
+ $playlist->save([
+ 'auditPlaylist' => true,
+ 'audit' => false
+ ]);
+ }
+ } else {
+ $this->log->debug('No differences detected');
+ }
+ } catch (GeneralException $exception) {
+ $this->log->debug($exception->getTraceAsString());
+ $this->log->error('Problem with PlaylistId: ' . $playlist->getId()
+ . ', e = ' . $exception->getMessage());
+ $this->appendRunMessage('Error with Playlist: ' . $playlist->name);
+ }
+ }
+
+ $this->appendRunMessage('Updated ' . $count . ' Playlists');
+ }
+
+ /**
+ * @param Playlist $playlist
+ * @param Media $media
+ * @param int $displayOrder
+ * @throws NotFoundException
+ */
+ private function createAndAssign(Playlist $playlist, Media $media, int $displayOrder): void
+ {
+ $this->log->debug('Media Item needs to be assigned ' . $media->name . ' in sequence ' . $displayOrder);
+
+ // Create a module
+ try {
+ $module = $this->moduleFactory->getByType($media->mediaType);
+ } catch (NotFoundException) {
+ $this->log->error('createAndAssign: dynamic playlist matched missing module: ' . $media->mediaType);
+ return;
+ }
+
+ if ($module->assignable == 0) {
+ $this->log->error('createAndAssign: dynamic playlist matched unassignable media: ' . $media->mediaId);
+ return;
+ }
+
+ // Determine the duration
+ $mediaDuration = $media->duration;
+ if ($mediaDuration <= 0) {
+ $mediaDuration = $module->defaultDuration;
+ }
+
+ // Create a widget
+ $widget = $this->widgetFactory->create(
+ $playlist->getOwnerId(),
+ $playlist->playlistId,
+ $media->mediaType,
+ $mediaDuration,
+ $module->schemaVersion
+ );
+ $widget->useDuration = 1;
+ $widget->displayOrder = $displayOrder;
+ $widget->calculateDuration($module);
+ $widget->assignMedia($media->mediaId);
+
+ // Assign the widget to the playlist
+ // making sure we pass the displayOrder here, otherwise it would be added to the end of the array.
+ $playlist->assignWidget($widget, $displayOrder);
+ }
+}
diff --git a/lib/XTR/EmailNotificationsTask.php b/lib/XTR/EmailNotificationsTask.php
new file mode 100644
index 0000000..7418b43
--- /dev/null
+++ b/lib/XTR/EmailNotificationsTask.php
@@ -0,0 +1,207 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Slim\Views\Twig;
+use Xibo\Entity\UserNotification;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\UserNotificationFactory;
+
+/**
+ * Class EmailNotificationsTask
+ * @package Xibo\XTR
+ */
+class EmailNotificationsTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var Twig */
+ private $view;
+
+ /** @var UserNotificationFactory */
+ private $userNotificationFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->view = $container->get('view');
+ $this->userNotificationFactory = $container->get('userNotificationFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Email Notifications') . PHP_EOL . PHP_EOL;
+
+ $this->processQueue();
+ }
+
+ /** Process Queue of emails
+ * @throws \PHPMailer\PHPMailer\Exception
+ */
+ private function processQueue()
+ {
+ // Handle queue of notifications to email.
+ $this->runMessage .= '## ' . __('Email Notifications') . PHP_EOL;
+
+ $msgFrom = $this->config->getSetting('mail_from');
+ $msgFromName = $this->config->getSetting('mail_from_name');
+ $processedNotifications = [];
+
+ $this->log->debug('Notification Queue sending from ' . $msgFrom);
+
+ foreach ($this->userNotificationFactory->getEmailQueue() as $notification) {
+ $this->log->debug('Notification found: ' . $notification->notificationId);
+
+ if (!empty($notification->email) || $notification->isSystem == 1) {
+ $mail = new \PHPMailer\PHPMailer\PHPMailer();
+
+ $this->log->debug('Sending Notification email to ' . $notification->email);
+
+ if ($this->checkEmailPreferences($notification)) {
+ $mail->addAddress($notification->email);
+ }
+
+ // System notifications, add mail_to to addresses if set.
+ if ($notification->isSystem == 1) {
+ // We should send the system notification to:
+ // - all assigned users
+ // - the mail_to (if set)
+ $mailTo = $this->config->getSetting('mail_to');
+
+ // Make sure we've been able to resolve an address.
+ if (empty($notification->email) && empty($mailTo)) {
+ $this->log->error('Discarding NotificationId ' . $notification->notificationId
+ . ' as no email address could be resolved.');
+ continue;
+ }
+
+ // if mail_to is set and is different from user email, and we did not
+ // process this notification yet (the same notificationId will be returned for each assigned user)
+ // add it to addresses
+ if ($mailTo !== $notification->email
+ && !empty($mailTo)
+ && !in_array($notification->notificationId, $processedNotifications)
+ ) {
+ $this->log->debug('Sending Notification email to mailTo ' . $mailTo);
+ $mail->addAddress($mailTo);
+ }
+ }
+
+ // Email them
+ $mail->CharSet = 'UTF-8';
+ $mail->Encoding = 'base64';
+ $mail->From = $msgFrom;
+
+ // Add attachment
+ if ($notification->filename != null) {
+ $mail->addAttachment(
+ $this->config->getSetting('LIBRARY_LOCATION') . 'attachment/' . $notification->filename,
+ $notification->originalFileName
+ );
+ }
+
+ if (!empty($msgFromName)) {
+ $mail->FromName = $msgFromName;
+ }
+
+ $mail->Subject = $notification->subject;
+
+ $addresses = explode(',', $notification->nonusers);
+ foreach ($addresses as $address) {
+ $mail->AddAddress($address);
+ }
+
+ // Body
+ $mail->isHTML(true);
+ $mail->AltBody = $notification->body;
+ $mail->Body = $this->generateEmailBody($notification->subject, $notification->body);
+
+ if (!$mail->send()) {
+ $this->log->error('Unable to send email notification mail to ' . $notification->email);
+ $this->runMessage .= ' - E' . PHP_EOL;
+ $this->log->error('Unable to send email notification Error: ' . $mail->ErrorInfo);
+ } else {
+ $this->runMessage .= ' - A' . PHP_EOL;
+ }
+
+ $this->log->debug('Marking notification as sent');
+ } else {
+ $this->log->error('Discarding NotificationId ' . $notification->notificationId
+ . ' as no email address could be resolved.');
+ }
+
+ $processedNotifications[] = $notification->notificationId;
+ // Mark as sent
+ $notification->setEmailed(Carbon::now()->format('U'));
+ $notification->save();
+ }
+
+ $this->runMessage .= ' - Done' . PHP_EOL;
+ }
+
+ /**
+ * Generate an email body
+ * @param $subject
+ * @param $body
+ * @return string
+ */
+ private function generateEmailBody($subject, $body)
+ {
+ // Generate Body
+ // Start an object buffer
+ ob_start();
+
+ // Render the template
+ echo $this->view->fetch(
+ 'email-template.twig',
+ ['config' => $this->config, 'subject' => $subject, 'body' => $body]
+ );
+
+ $body = ob_get_contents();
+
+ ob_end_clean();
+
+ return $body;
+ }
+
+ /**
+ * Should we send email to this user?
+ * check relevant flag for the notification type on the user group.
+ * @param UserNotification $notification
+ * @return bool
+ */
+ private function checkEmailPreferences(UserNotification $notification): bool
+ {
+ return $this->userGroupFactory->checkNotificationEmailPreferences(
+ $notification->userId,
+ $notification->getTypeForGroup()
+ );
+ }
+}
diff --git a/lib/XTR/ImageProcessingTask.php b/lib/XTR/ImageProcessingTask.php
new file mode 100644
index 0000000..a2559af
--- /dev/null
+++ b/lib/XTR/ImageProcessingTask.php
@@ -0,0 +1,164 @@
+.
+ */
+
+namespace Xibo\XTR;
+use Carbon\Carbon;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Service\ImageProcessingServiceInterface;
+
+/**
+ * Class ImageProcessingTask
+ * @package Xibo\XTR
+ */
+class ImageProcessingTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var ImageProcessingServiceInterface */
+ private $imageProcessingService;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->imageProcessingService = $container->get('imageProcessingService');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Image Processing') . PHP_EOL . PHP_EOL;
+
+ // Long running task
+ set_time_limit(0);
+
+ $this->runImageProcessing();
+ }
+
+ /**
+ *
+ */
+ private function runImageProcessing()
+ {
+ $images = $this->mediaFactory->query(null, ['released' => 0, 'allModules' => 1, 'imageProcessing' => 1]);
+
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ $resizeThreshold = $this->config->getSetting('DEFAULT_RESIZE_THRESHOLD');
+ $count = 0;
+
+ // All displayIds
+ $displayIds = [];
+
+ // Get list of Images
+ foreach ($images as $media) {
+
+ $filePath = $libraryLocation . $media->storedAs;
+ list($imgWidth, $imgHeight) = @getimagesize($filePath);
+
+ // Orientation of the image
+ if ($imgWidth > $imgHeight) { // 'landscape';
+ $updatedImg = $this->imageProcessingService->resizeImage($filePath, $resizeThreshold, null);
+ } else { // 'portrait';
+ $updatedImg = $this->imageProcessingService->resizeImage($filePath, null, $resizeThreshold);
+ }
+
+ // Clears file status cache
+ clearstatcache(true, $updatedImg['filePath']);
+
+ $count++;
+
+ // Release image and save
+ $media->release(
+ md5_file($updatedImg['filePath']),
+ filesize($updatedImg['filePath']),
+ $updatedImg['height'],
+ $updatedImg['width']
+ );
+ $this->store->commitIfNecessary();
+
+ $mediaDisplays= [];
+ $sql = 'SELECT displayId FROM `requiredfile` WHERE itemId = :itemId';
+ foreach ($this->store->select($sql, ['itemId' => $media->mediaId]) as $row) {
+ $displayIds[] = $row['displayId'];
+ $mediaDisplays[] = $row['displayId'];
+ }
+
+ // Update Required Files
+ foreach ($mediaDisplays as $displayId) {
+
+ $this->store->update('UPDATE `requiredfile` SET released = :released, size = :size
+ WHERE `requiredfile`.displayId = :displayId AND `requiredfile`.itemId = :itemId ', [
+ 'released' => 1,
+ 'size' => $media->fileSize,
+ 'displayId' => $displayId,
+ 'itemId' => $media->mediaId
+ ]);
+ }
+
+ // Mark any effected Layouts to be rebuilt.
+ $this->store->update('
+ UPDATE `layout`
+ SET status = :status, `modifiedDT` = :modifiedDt
+ WHERE layoutId IN (
+ SELECT DISTINCT region.layoutId
+ FROM lkwidgetmedia
+ INNER JOIN widget
+ ON widget.widgetId = lkwidgetmedia.widgetId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.childId = widget.playlistId
+ INNER JOIN playlist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ INNER JOIN region
+ ON playlist.regionId = region.regionId
+ WHERE lkwidgetmedia.mediaId = :mediaId
+ )
+ ', [
+ 'status' => 3,
+ 'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ 'mediaId' => $media->mediaId
+ ]);
+ }
+
+ // Notify display
+ if ($count > 0) {
+ foreach (array_unique($displayIds) as $displayId) {
+
+ // Get display
+ $display = $this->displayFactory->getById($displayId);
+ $display->notify();
+ }
+ }
+
+ $this->appendRunMessage('Released and modified image count. ' . $count);
+
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/LayoutConvertTask.php b/lib/XTR/LayoutConvertTask.php
new file mode 100644
index 0000000..402d14b
--- /dev/null
+++ b/lib/XTR/LayoutConvertTask.php
@@ -0,0 +1,209 @@
+permissionFactory = $container->get('permissionFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->moduleFactory = $container->get('moduleFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ // lklayoutmedia is removed at the end of this task
+ if (!$this->store->exists('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name', [
+ 'name' => 'lklayoutmedia'
+ ])) {
+ $this->appendRunMessage('Already converted');
+
+ // Disable the task
+ $this->disableTask();
+
+ // Don't do anything further
+ return;
+ }
+
+ // Permissions handling
+ // -------------------
+ // Layout permissions should remain the same
+ // the lklayoutmediagroup table and lklayoutregiongroup table will be removed
+ // We do not have simple switch for the lklayoutmediagroup table as these are supposed to represent "Widgets"
+ // which did not exist at this point.
+ // Build a keyed array of existing widget permissions
+ $mediaPermissions = [];
+ foreach ($this->store->select('
+ SELECT `lklayoutmediagroup`.groupId, `lkwidgetmedia`.widgetId, `view`, `edit`, `del`
+ FROM `lklayoutmediagroup`
+ INNER JOIN `lkwidgetmedia`
+ ON `lklayoutmediagroup`.`mediaId` = `lkwidgetmedia`.widgetId
+ WHERE `lkwidgetmedia`.widgetId IN (
+ SELECT widget.widgetId
+ FROM `widget`
+ INNER JOIN `playlist`
+ ON `playlist`.playlistId = `widget`.playlistId
+ WHERE `playlist`.regionId = `lklayoutmediagroup`.regionId
+ )
+ ', []) as $row) {
+ $permission = $this->permissionFactory->create(
+ $row['groupId'],
+ Widget::class,
+ $row['widgetId'],
+ $row['view'],
+ $row['edit'],
+ $row['del']
+ );
+
+ $mediaPermissions[$row['mediaId']] = $permission;
+ }
+
+ // Build a keyed array of existing region permissions
+ $regionPermissions = [];
+ foreach ($this->store->select('SELECT groupId, layoutId, regionId, `view`, `edit`, `del` FROM `lklayoutregiongroup`', []) as $row) {
+ $permission = $this->permissionFactory->create(
+ $row['groupId'],
+ Region::class,
+ $row['regionId'],
+ $row['view'],
+ $row['edit'],
+ $row['del']
+ );
+
+ $regionPermissions[$row['regionId']] = $permission;
+ }
+
+ // Get the library location to store backups of existing XLF
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+
+ // We need to go through each layout, save the XLF as a backup in the library and then upgrade it.
+ // This task applies to Layouts which are schemaVersion 2 or lower. xibosignage/xibo#2056
+ foreach ($this->store->select('SELECT layoutId, xml FROM `layout` WHERE schemaVersion <= :schemaVersion', [
+ 'schemaVersion' => 2
+ ]) as $oldLayout) {
+
+ $oldLayoutId = intval($oldLayout['layoutId']);
+
+ try {
+ // Does this layout have any XML associated with it? If not, then it is an empty layout.
+ if (empty($oldLayout['xml'])) {
+ // This is frankly, odd, so we better log it
+ $this->log->critical('Layout upgrade without any existing XLF, i.e. empty. ID = ' . $oldLayoutId);
+
+ // Pull out the layout record, and set some best guess defaults
+ $layout = $this->layoutFactory->getById($oldLayoutId);
+
+ // We have to guess something here as we do not have any XML to go by. Default to landscape 1080p.
+ $layout->width = 1920;
+ $layout->height = 1080;
+
+ } else {
+ // Save off a copy of the XML in the library
+ file_put_contents($libraryLocation . 'archive_' . $oldLayoutId . '.xlf', $oldLayout['xml']);
+
+ // Create a new layout from the XML
+ $layout = $this->layoutFactory->loadByXlf($oldLayout['xml'], $this->layoutFactory->getById($oldLayoutId));
+ }
+
+ // We need one final pass through all widgets on the layout so that we can set the durations properly.
+ foreach ($layout->getRegionWidgets() as $widget) {
+ $widget->calculateDuration($this->moduleFactory->getByType($widget->type), true);
+
+ // Get global stat setting of widget to set to on/off/inherit
+ $widget->setOptionValue('enableStat', 'attrib', $this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT'));
+ }
+
+ // Save the layout
+ $layout->schemaVersion = Environment::$XLF_VERSION;
+ $layout->save(['notify' => false, 'audit' => false]);
+
+ // Now that we have new ID's we need to cross reference them with the old IDs and recreate the permissions
+ foreach ($layout->regions as $region) {
+ /* @var \Xibo\Entity\Region $region */
+ if (array_key_exists($region->tempId, $regionPermissions)) {
+ $permission = $regionPermissions[$region->tempId];
+ /* @var \Xibo\Entity\Permission $permission */
+ // Double check we are for the same layout
+ if ($permission->objectId == $layout->layoutId) {
+ $permission = clone $permission;
+ $permission->objectId = $region->regionId;
+ $permission->save();
+ }
+ }
+
+ /* @var \Xibo\Entity\Playlist $playlist */
+ foreach ($region->getPlaylist()->widgets as $widget) {
+ /* @var \Xibo\Entity\Widget $widget */
+ if (array_key_exists($widget->tempId, $mediaPermissions)) {
+ $permission = $mediaPermissions[$widget->tempId];
+ /* @var \Xibo\Entity\Permission $permission */
+ if ($permission->objectId == $layout->layoutId && $region->tempId == $permission->objectIdString) {
+ $permission = clone $permission;
+ $permission->objectId = $widget->widgetId;
+ $permission->save();
+ }
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ $this->appendRunMessage('Error upgrading Layout, this should be checked post-upgrade. ID: ' . $oldLayoutId);
+ $this->log->critical('Error upgrading Layout, this should be checked post-upgrade. ID: ' . $oldLayoutId);
+ $this->log->error($e->getMessage() . ' - ' . $e->getTraceAsString());
+ }
+ }
+
+ $this->appendRunMessage('Finished converting, dropping unnecessary tables.');
+
+ // Drop the permissions
+ $this->store->update('DROP TABLE `lklayoutmediagroup`;', []);
+ $this->store->update('DROP TABLE `lklayoutregiongroup`;', []);
+ $this->store->update('DROP TABLE lklayoutmedia', []);
+ $this->store->update('ALTER TABLE `layout` DROP `xml`;', []);
+
+ // Disable the task
+ $this->disableTask();
+
+ $this->appendRunMessage('Conversion Completed');
+ }
+
+ /**
+ * Disables and saves this task immediately
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function disableTask()
+ {
+ $this->getTask()->isActive = 0;
+ $this->getTask()->save();
+ $this->store->commitIfNecessary();
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/MaintenanceDailyTask.php b/lib/XTR/MaintenanceDailyTask.php
new file mode 100644
index 0000000..ff8aae4
--- /dev/null
+++ b/lib/XTR/MaintenanceDailyTask.php
@@ -0,0 +1,344 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Xibo\Controller\Module;
+use Xibo\Event\MaintenanceDailyEvent;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\FontFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\ModuleTemplateFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DatabaseLogHandler;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Service\MediaService;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class MaintenanceDailyTask
+ * @package Xibo\XTR
+ */
+class MaintenanceDailyTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var Module */
+ private $moduleController;
+
+ /** @var MediaServiceInterface */
+ private $mediaService;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var FontFactory */
+ private $fontFactory;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var ModuleTemplateFactory */
+ private $moduleTemplateFactory;
+
+ /** @var string */
+ private $libraryLocation;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->moduleController = $container->get('\Xibo\Controller\Module');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->userFactory = $container->get('userFactory');
+ $this->dataSetFactory = $container->get('dataSetFactory');
+ $this->mediaService = $container->get('mediaService');
+ $this->fontFactory = $container->get('fontFactory');
+ $this->moduleFactory = $container->get('moduleFactory');
+ $this->moduleTemplateFactory = $container->get('moduleTemplateFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Daily Maintenance') . PHP_EOL . PHP_EOL;
+
+ // Long-running task
+ set_time_limit(0);
+
+ // Make sure our library structure is as it should be
+ try {
+ $this->libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+ MediaService::ensureLibraryExists($this->libraryLocation);
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('Library structure invalid, e = ' . $exception->getMessage());
+ $this->appendRunMessage(__('Library structure invalid'));
+ }
+
+ // Import layouts
+ $this->importLayouts();
+
+ // Cycle the XMR Key
+ $this->cycleXmrKey();
+
+ try {
+ $this->appendRunMessage(__('## Build caches'));
+
+ // TODO: should we remove all bundle/asset cache before we start?
+ // Player bundle
+ $this->cachePlayerBundle();
+
+ // Cache Assets
+ $this->cacheAssets();
+
+ // Fonts
+ $this->mediaService->setUser($this->userFactory->getSystemUser())->updateFontsCss();
+ } catch (\Exception $exception) {
+ $this->getLogger()->error('Failure to build caches, e = ' . $exception->getMessage());
+ $this->appendRunMessage(__('Failure to build caches'));
+ }
+
+ // Tidy logs
+ $this->tidyLogs();
+
+ // Tidy Cache
+ $this->tidyCache();
+
+ // Dispatch an event so that consumers can hook into daily maintenance.
+ $event = new MaintenanceDailyEvent();
+ $this->getDispatcher()->dispatch($event, MaintenanceDailyEvent::$NAME);
+ foreach ($event->getMessages() as $message) {
+ $this->appendRunMessage($message);
+ }
+ }
+
+ /**
+ * Tidy the DB logs
+ */
+ private function tidyLogs()
+ {
+ $this->runMessage .= '## ' . __('Tidy Logs') . PHP_EOL;
+
+ $maxage = $this->config->getSetting('MAINTENANCE_LOG_MAXAGE');
+ if ($maxage != 0) {
+ // Run this in the log handler so that we share the same connection and don't deadlock.
+ DatabaseLogHandler::tidyLogs(
+ Carbon::now()
+ ->subDays(intval($maxage))
+ ->format(DateFormatHelper::getSystemFormat())
+ );
+
+ $this->runMessage .= ' - ' . __('Done') . PHP_EOL . PHP_EOL;
+ } else {
+ $this->runMessage .= ' - ' . __('Disabled') . PHP_EOL . PHP_EOL;
+ }
+ }
+
+ /**
+ * Tidy Cache
+ */
+ private function tidyCache()
+ {
+ $this->runMessage .= '## ' . __('Tidy Cache') . PHP_EOL;
+ $this->pool->purge();
+ $this->runMessage .= ' - ' . __('Done.') . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Import Layouts
+ * @throws GeneralException|\FontLib\Exception\FontNotFoundException
+ */
+ private function importLayouts()
+ {
+ $this->runMessage .= '## ' . __('Import Layouts and Fonts') . PHP_EOL;
+
+ if ($this->config->getSetting('DEFAULTS_IMPORTED') == 0) {
+ // Make sure the library exists
+ $this->mediaService->initLibrary();
+
+ // Import any layouts
+ $folder = $this->config->uri('layouts', true);
+
+ foreach (array_diff(scandir($folder), array('..', '.')) as $file) {
+ if (stripos($file, '.zip')) {
+ try {
+ $layout = $this->layoutFactory->createFromZip(
+ $folder . '/' . $file,
+ null,
+ $this->userFactory->getSystemUser()->getId(),
+ false,
+ false,
+ true,
+ false,
+ true,
+ $this->dataSetFactory,
+ null,
+ $this->mediaService,
+ 1
+ );
+
+ $layout->save([
+ 'audit' => false,
+ 'import' => true
+ ]);
+
+ if (!empty($layout->getUnmatchedProperty('thumbnail'))) {
+ rename($layout->getUnmatchedProperty('thumbnail'), $layout->getThumbnailUri());
+ }
+
+ try {
+ $this->layoutFactory->getById($this->config->getSetting('DEFAULT_LAYOUT'));
+ } catch (NotFoundException $exception) {
+ $this->config->changeSetting('DEFAULT_LAYOUT', $layout->layoutId);
+ }
+ } catch (\Exception $exception) {
+ $this->log->error('Unable to import layout: ' . $file . '. E = ' . $exception->getMessage());
+ $this->log->debug($exception->getTraceAsString());
+ }
+ }
+ }
+
+ // Fonts
+ // -----
+ // install fonts from the theme folder
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ $fontFolder = $this->config->uri('fonts', true);
+ foreach (array_diff(scandir($fontFolder), array('..', '.')) as $file) {
+ // check if we already have this font file
+ if (count($this->fontFactory->getByFileName($file)) <= 0) {
+ // if we don't add it
+ $filePath = $fontFolder . DIRECTORY_SEPARATOR . $file;
+ $fontLib = \FontLib\Font::load($filePath);
+
+ // check embed flag, just in case
+ $embed = intval($fontLib->getData('OS/2', 'fsType'));
+ // if it's not embeddable, log error and skip it
+ if ($embed != 0 && $embed != 8) {
+ $this->log->error('Unable to install default Font: ' . $file
+ . ' . Font file is not embeddable due to its permissions');
+ continue;
+ }
+
+ $font = $this->fontFactory->createEmpty();
+ $font->modifiedBy = $this->userFactory->getSystemUser()->userName;
+ $font->name = $fontLib->getFontName() . ' ' . $fontLib->getFontSubfamily();
+ $font->familyName = strtolower(preg_replace('/\s+/', ' ', preg_replace('/\d+/u', '', $font->name)));
+ $font->fileName = $file;
+ $font->size = filesize($filePath);
+ $font->md5 = md5_file($filePath);
+ $font->save();
+
+ $copied = copy($filePath, $libraryLocation . 'fonts/' . $file);
+ if (!$copied) {
+ $this->getLogger()->error('importLayouts: Unable to copy fonts to ' . $libraryLocation);
+ }
+ }
+ }
+
+ $this->config->changeSetting('DEFAULTS_IMPORTED', 1);
+
+ $this->runMessage .= ' - ' . __('Done.') . PHP_EOL . PHP_EOL;
+ } else {
+ $this->runMessage .= ' - ' . __('Not Required.') . PHP_EOL . PHP_EOL;
+ }
+ }
+
+ /**
+ * Refresh the cache of assets
+ * @return void
+ * @throws GeneralException
+ */
+ private function cacheAssets(): void
+ {
+ // Assets
+ $failedCount = 0;
+ $assets = array_merge($this->moduleFactory->getAllAssets(), $this->moduleTemplateFactory->getAllAssets());
+ foreach ($assets as $asset) {
+ try {
+ $asset->updateAssetCache($this->libraryLocation, true);
+ } catch (GeneralException $exception) {
+ $failedCount++;
+ $this->log->error('Unable to copy asset: ' . $asset->id . ', e: ' . $exception->getMessage());
+ }
+ }
+
+ $this->appendRunMessage(sprintf(__('Assets cached, %d failed.'), $failedCount));
+ }
+
+ /**
+ * Cache the player bundle.
+ * @return void
+ */
+ private function cachePlayerBundle(): void
+ {
+ // Output the player bundle
+ $bundlePath = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'assets/bundle.min.js';
+ $bundleMd5CachePath = $bundlePath . '.md5';
+
+ copy(PROJECT_ROOT . '/modules/bundle.min.js', $bundlePath);
+ file_put_contents($bundleMd5CachePath, md5_file($bundlePath));
+
+ $this->appendRunMessage(__('Player bundle cached'));
+ }
+
+ /**
+ * Once per day we cycle the XMR CMS key
+ * the old key should remain valid in XMR for up to 1 hour further to allow for cross over
+ * @return void
+ */
+ private function cycleXmrKey(): void
+ {
+ $this->log->debug('cycleXmrKey: adding new key');
+ try {
+ $key = Random::generateString(20, 'xmr_');
+
+ $this->getConfig()->changeSetting('XMR_CMS_KEY', $key);
+ $client = new Client($this->config->getGuzzleProxy([
+ 'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'),
+ ]));
+
+ $client->post('/', [
+ 'json' => [
+ 'id' => constant('SECRET_KEY'),
+ 'type' => 'keys',
+ 'key' => $key,
+ ],
+ ]);
+ $this->log->debug('cycleXmrKey: added new key');
+ } catch (GuzzleException | \Exception $e) {
+ $this->log->error('cycleXmrKey: failed. E = ' . $e->getMessage());
+ }
+ }
+}
diff --git a/lib/XTR/MaintenanceRegularTask.php b/lib/XTR/MaintenanceRegularTask.php
new file mode 100644
index 0000000..d9894e2
--- /dev/null
+++ b/lib/XTR/MaintenanceRegularTask.php
@@ -0,0 +1,685 @@
+.
+ */
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Xibo\Controller\Display;
+use Xibo\Event\DisplayGroupLoadEvent;
+use Xibo\Event\MaintenanceRegularEvent;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\PlaylistFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Profiler;
+use Xibo\Helper\Status;
+use Xibo\Helper\WakeOnLan;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class MaintenanceRegularTask
+ * @package Xibo\XTR
+ */
+class MaintenanceRegularTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var Display */
+ private $displayController;
+
+ /** @var MediaServiceInterface */
+ private $mediaService;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var DisplayGroupFactory */
+ private $displayGroupFactory;
+
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var PlaylistFactory */
+ private $playlistFactory;
+
+ /** @var ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Helper\SanitizerService */
+ private $sanitizerService;
+ /**
+ * @var ScheduleFactory
+ */
+ private $scheduleFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->displayController = $container->get('\Xibo\Controller\Display');
+ $this->mediaService = $container->get('mediaService');
+
+ $this->displayFactory = $container->get('displayFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->notificationFactory = $container->get('notificationFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->playlistFactory = $container->get('playlistFactory');
+ $this->moduleFactory = $container->get('moduleFactory');
+ $this->sanitizerService = $container->get('sanitizerService');
+ $this->scheduleFactory = $container->get('scheduleFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Regular Maintenance') . PHP_EOL . PHP_EOL;
+
+ $this->assertXmrKey();
+
+ $this->displayDownEmailAlerts();
+
+ $this->licenceSlotValidation();
+
+ $this->wakeOnLan();
+
+ $this->updatePlaylistDurations();
+
+ $this->buildLayouts();
+
+ $this->tidyLibrary();
+
+ $this->checkLibraryUsage();
+
+ $this->checkOverRequestedFiles();
+
+ $this->publishLayouts();
+
+ $this->assessDynamicDisplayGroups();
+
+ $this->tidyAdCampaignSchedules();
+
+ $this->tidyUnusedFullScreenLayout();
+
+ // Dispatch an event so that consumers can hook into regular maintenance.
+ $event = new MaintenanceRegularEvent();
+ $this->getDispatcher()->dispatch($event, MaintenanceRegularEvent::$NAME);
+ foreach ($event->getMessages() as $message) {
+ $this->appendRunMessage($message);
+ }
+ }
+
+ /**
+ * Display Down email alerts
+ * - just runs validate displays
+ */
+ private function displayDownEmailAlerts()
+ {
+ $this->runMessage .= '## ' . __('Email Alerts') . PHP_EOL;
+
+ $this->displayController->validateDisplays($this->displayFactory->query());
+
+ $this->appendRunMessage(__('Done'));
+ }
+
+ /**
+ * Licence Slot Validation
+ */
+ private function licenceSlotValidation()
+ {
+ $maxDisplays = $this->config->getSetting('MAX_LICENSED_DISPLAYS');
+
+ if ($maxDisplays > 0) {
+ $this->runMessage .= '## ' . __('Licence Slot Validation') . PHP_EOL;
+
+ // Get a list of all displays
+ try {
+ $dbh = $this->store->getConnection();
+ $sth = $dbh->prepare('SELECT displayId, display FROM `display` WHERE licensed = 1 ORDER BY lastAccessed');
+ $sth->execute();
+
+ $displays = $sth->fetchAll(\PDO::FETCH_ASSOC);
+
+ if (count($displays) > $maxDisplays) {
+ // :(
+ // We need to un-licence some displays
+ $difference = count($displays) - $maxDisplays;
+
+ $this->log->alert(sprintf('Max %d authorised displays exceeded, we need to un-authorise %d of %d displays', $maxDisplays, $difference, count($displays)));
+
+ $update = $dbh->prepare('UPDATE `display` SET licensed = 0 WHERE displayId = :displayId');
+
+ foreach ($displays as $display) {
+ $sanitizedDisplay = $this->getSanitizer($display);
+
+ // If we are down to 0 difference, then stop
+ if ($difference == 0) {
+ break;
+ }
+
+ $this->appendRunMessage(sprintf(__('Disabling %s'), $sanitizedDisplay->getString('display')));
+ $update->execute(['displayId' => $display['displayId']]);
+
+ $this->log->audit('Display', $display['displayId'], 'Regular Maintenance unauthorised display due to max number of slots exceeded.', ['display' => $display['display']]);
+
+ $difference--;
+ }
+ }
+
+ $this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
+ }
+ catch (\Exception $e) {
+ $this->log->error($e);
+ }
+ }
+ }
+
+ /**
+ * Wake on LAN
+ */
+ private function wakeOnLan()
+ {
+ $this->runMessage = '# ' . __('Wake On LAN') . PHP_EOL;
+
+ try {
+ // Get a list of all displays which have WOL enabled
+ foreach($this->displayFactory->query(null, ['wakeOnLan' => 1]) as $display) {
+ /** @var \Xibo\Entity\Display $display */
+ // Time to WOL (with respect to today)
+ $timeToWake = strtotime(date('Y-m-d') . ' ' . $display->wakeOnLanTime);
+ $timeNow = Carbon::now()->format('U');
+
+ // Should the display be awake?
+ if ($timeNow >= $timeToWake) {
+ // Client should be awake, so has this displays WOL time been passed
+ if ($display->lastWakeOnLanCommandSent < $timeToWake) {
+ // Call the Wake On Lan method of the display object
+ if ($display->macAddress == '' || $display->broadCastAddress == '') {
+ $this->log->error('This display has no mac address recorded against it yet. Make sure the display is running.');
+ $this->runMessage .= ' - ' . $display->display . ' Did not send MAC address yet' . PHP_EOL;
+ continue;
+ }
+
+ $this->log->notice('About to send WOL packet to ' . $display->broadCastAddress . ' with Mac Address ' . $display->macAddress);
+
+ try {
+ WakeOnLan::TransmitWakeOnLan($display->macAddress, $display->secureOn, $display->broadCastAddress, $display->cidr, '9', $this->log);
+ $this->runMessage .= ' - ' . $display->display . ' Sent WOL Message. Previous WOL send time: ' . Carbon::createFromTimestamp($display->lastWakeOnLanCommandSent)->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
+
+ $display->lastWakeOnLanCommandSent = Carbon::now()->format('U');
+ $display->save(['validate' => false, 'audit' => true]);
+ }
+ catch (\Exception $e) {
+ $this->runMessage .= ' - ' . $display->display . ' Error=' . $e->getMessage() . PHP_EOL;
+ }
+ }
+ else {
+ $this->runMessage .= ' - ' . $display->display . ' Display already awake. Previous WOL send time: ' . Carbon::createFromTimestamp($display->lastWakeOnLanCommandSent)->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
+ }
+ }
+ else {
+ $this->runMessage .= ' - ' . $display->display . ' Sleeping' . PHP_EOL;
+ }
+ }
+
+ $this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
+ }
+ catch (\PDOException $e) {
+ $this->log->error($e->getMessage());
+ $this->runMessage .= ' - Error' . PHP_EOL . PHP_EOL;
+ }
+ }
+
+ /**
+ * Build layouts
+ */
+ private function buildLayouts()
+ {
+ $this->runMessage .= '## ' . __('Build Layouts') . PHP_EOL;
+
+ // Build Layouts
+ // We do not want to build any draft Layouts - they are built in the Layout Designer or on Publish
+ foreach ($this->layoutFactory->query(null, ['status' => 3, 'showDrafts' => 0, 'disableUserCheck' => 1]) as $layout) {
+ /* @var \Xibo\Entity\Layout $layout */
+ try {
+ $layout = $this->layoutFactory->concurrentRequestLock($layout);
+ try {
+ $layout->xlfToDisk(['notify' => true]);
+
+ // Commit after each build
+ // https://github.com/xibosignage/xibo/issues/1593
+ $this->store->commitIfNecessary();
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+ } catch (\Exception $e) {
+ $this->log->error(sprintf('Maintenance cannot build Layout %d, %s.', $layout->layoutId, $e->getMessage()));
+ }
+ }
+
+ $this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Tidy library
+ */
+ private function tidyLibrary()
+ {
+ $this->runMessage .= '## ' . __('Tidy Library') . PHP_EOL;
+
+ // Keep tidy
+ $this->mediaService->removeExpiredFiles();
+ $this->mediaService->removeTempFiles();
+
+ $this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Check library usage
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function checkLibraryUsage()
+ {
+ $libraryLimit = $this->config->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
+
+ if ($libraryLimit <= 0) {
+ return;
+ }
+
+ $results = $this->store->select('SELECT IFNULL(SUM(FileSize), 0) AS SumSize FROM media', []);
+
+ $sanitizedResults = $this->getSanitizer($results);
+
+ $size = $sanitizedResults->getInt('SumSize');
+
+ if ($size >= $libraryLimit) {
+ // Create a notification if we don't already have one today for this display.
+ $subject = __('Library allowance exceeded');
+ $date = Carbon::now();
+ $notifications = $this->notificationFactory->getBySubjectAndDate(
+ $subject,
+ $date->startOfDay()->format('U'),
+ $date->addDay()->startOfDay()->format('U')
+ );
+
+ if (count($notifications) <= 0) {
+ $body = __(
+ sprintf(
+ 'Library allowance of %s exceeded. Used %s',
+ ByteFormatter::format($libraryLimit),
+ ByteFormatter::format($size)
+ )
+ );
+
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'library'
+ );
+
+ $notification->save();
+
+ $this->log->critical($subject);
+ }
+ }
+ }
+
+ /**
+ * Checks to see if there are any overrequested files.
+ * @throws \Xibo\Support\Exception\NotFoundException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function checkOverRequestedFiles()
+ {
+ $items = $this->store->select('
+ SELECT display.displayId,
+ display.display,
+ COUNT(*) AS countFiles
+ FROM `requiredfile`
+ INNER JOIN `display`
+ ON display.displayId = requiredfile.displayId
+ WHERE `bytesRequested` > 0
+ AND `requiredfile`.bytesRequested >= `requiredfile`.`size` * :factor
+ AND `requiredfile`.type NOT IN (\'W\', \'D\')
+ AND display.lastAccessed > :lastAccessed
+ AND `requiredfile`.complete = 0
+ GROUP BY display.displayId, display.display
+ ', [
+ 'factor' => 3,
+ 'lastAccessed' => Carbon::now()->subDay()->format('U'),
+ ]);
+
+ foreach ($items as $item) {
+ $sanitizedItem = $this->getSanitizer($item);
+ // Create a notification if we don't already have one today for this display.
+ $subject = sprintf(
+ __('%s is downloading %d files too many times'),
+ $sanitizedItem->getString('display'),
+ $sanitizedItem->getInt('countFiles')
+ );
+ $date = Carbon::now();
+ $notifications = $this->notificationFactory->getBySubjectAndDate(
+ $subject,
+ $date->startOfDay()->format('U'),
+ $date->addDay()->startOfDay()->format('U')
+ );
+
+ if (count($notifications) <= 0) {
+ $body = sprintf(
+ __('Please check the bandwidth graphs and display status for %s to investigate the issue.'),
+ $sanitizedItem->getString('display')
+ );
+
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'display'
+ );
+
+ $display = $this->displayFactory->getById($item['displayId']);
+
+ // Add in any displayNotificationGroups, with permissions
+ foreach ($this->userGroupFactory->getDisplayNotificationGroups($display->displayGroupId) as $group) {
+ $notification->assignUserGroup($group);
+ }
+
+ $notification->save();
+
+ $this->log->critical($subject);
+ }
+ }
+ }
+
+ /**
+ * Update Playlist Durations
+ */
+ private function updatePlaylistDurations()
+ {
+ $this->runMessage .= '## ' . __('Playlist Duration Updates') . PHP_EOL;
+
+ // Build Layouts
+ foreach ($this->playlistFactory->query(null, ['requiresDurationUpdate' => 1]) as $playlist) {
+ try {
+ $playlist->setModuleFactory($this->moduleFactory);
+ $playlist->updateDuration();
+ } catch (GeneralException $xiboException) {
+ $this->log->error(
+ 'Maintenance cannot update Playlist ' . $playlist->playlistId .
+ ', ' . $xiboException->getMessage()
+ );
+ }
+ }
+
+ $this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Publish layouts with set publishedDate
+ * @throws GeneralException
+ */
+ private function publishLayouts()
+ {
+ $this->runMessage .= '## ' . __('Publishing layouts with set publish dates') . PHP_EOL;
+
+ $layouts = $this->layoutFactory->query(
+ null,
+ ['havePublishDate' => 1, 'disableUserCheck' => 1, 'excludeTemplates' => -1]
+ );
+
+ // check if we have any layouts with set publish date
+ if (count($layouts) > 0) {
+ foreach ($layouts as $layout) {
+ // check if the layout should be published now according to the date
+ if (Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $layout->publishedDate)
+ ->isBefore(Carbon::now()->format(DateFormatHelper::getSystemFormat()))
+ ) {
+ try {
+ // publish the layout
+ $layout = $this->layoutFactory->concurrentRequestLock($layout, true);
+ try {
+ $draft = $this->layoutFactory->getByParentId($layout->layoutId);
+ if ($draft->status === Status::$STATUS_INVALID
+ && isset($draft->statusMessage)
+ && (
+ count($draft->getStatusMessage()) > 1 ||
+ count($draft->getStatusMessage()) === 1 &&
+ !$draft->checkForEmptyRegion()
+ )
+ ) {
+ throw new GeneralException(json_encode($draft->statusMessage));
+ }
+ $draft->publishDraft();
+ $draft->load();
+ $draft->xlfToDisk([
+ 'notify' => true,
+ 'exceptionOnError' => true,
+ 'exceptionOnEmptyRegion' => false
+ ]);
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout, true);
+ }
+ $this->log->info(
+ 'Published layout ID ' . $layout->layoutId . ' new layout id is ' . $draft->layoutId
+ );
+ } catch (GeneralException $e) {
+ $this->log->error(
+ 'Error publishing layout ID ' . $layout->layoutId .
+ ' with name ' . $layout->layout . ' Failed with message: ' . $e->getMessage()
+ );
+
+ // create a notification
+ $subject = __(sprintf('Error publishing layout ID %d', $layout->layoutId));
+ $date = Carbon::now();
+
+ $notifications = $this->notificationFactory->getBySubjectAndDate(
+ $subject,
+ $date->startOfDay()->format('U'),
+ $date->addDay()->startOfDay()->format('U')
+ );
+
+ if (count($notifications) <= 0) {
+ $body = __(
+ sprintf(
+ 'Publishing layout ID %d with name %s failed. With message %s',
+ $layout->layoutId,
+ $layout->layout,
+ $e->getMessage()
+ )
+ );
+
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'layout'
+ );
+ $notification->save();
+
+ $this->log->critical($subject);
+ }
+ }
+ } else {
+ $this->log->debug(
+ 'Layouts with published date were found, they are set to publish later than current time'
+ );
+ }
+ }
+ } else {
+ $this->log->debug('No layouts to publish.');
+ }
+
+ $this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Assess any eligible dynamic display groups if necessary
+ * @return void
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function assessDynamicDisplayGroups(): void
+ {
+ $this->runMessage .= '## ' . __('Assess Dynamic Display Groups') . PHP_EOL;
+
+ // Do we have a cache key set to say that dynamic display group assessment has been completed?
+ $cache = $this->pool->getItem('DYNAMIC_DISPLAY_GROUP_ASSESSED');
+ if ($cache->isMiss()) {
+ Profiler::start('RegularMaintenance::assessDynamicDisplayGroups', $this->log);
+
+ // Set the cache key with a long expiry and save.
+ $cache->set(true);
+ $cache->expiresAt(Carbon::now()->addYear());
+ $this->pool->save($cache);
+
+ // Process each dynamic display group
+ $count = 0;
+
+ foreach ($this->displayGroupFactory->getByIsDynamic(1) as $group) {
+ $count++;
+ try {
+ // Loads displays.
+ $this->getDispatcher()->dispatch(
+ new DisplayGroupLoadEvent($group),
+ DisplayGroupLoadEvent::$NAME
+ );
+ $group->save([
+ 'validate' => false,
+ 'saveGroup' => false,
+ 'saveTags' => false,
+ 'manageLinks' => false,
+ 'manageDisplayLinks' => false,
+ 'manageDynamicDisplayLinks' => true,
+ 'allowNotify' => true
+ ]);
+ } catch (GeneralException $exception) {
+ $this->log->error('assessDynamicDisplayGroups: Unable to manage group: '
+ . $group->displayGroup);
+ }
+ }
+ Profiler::end('RegularMaintenance::assessDynamicDisplayGroups', $this->log);
+ $this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
+ } else {
+ $this->runMessage .= ' - Done (not required)' . PHP_EOL . PHP_EOL;
+ }
+ }
+
+ private function tidyAdCampaignSchedules()
+ {
+ $this->runMessage .= '## ' . __('Tidy Ad Campaign Schedules') . PHP_EOL;
+ Profiler::start('RegularMaintenance::tidyAdCampaignSchedules', $this->log);
+ $count = 0;
+
+ foreach ($this->scheduleFactory->query(null, [
+ 'adCampaignsOnly' => 1,
+ 'toDt' => Carbon::now()->subDays(90)->unix()
+ ]) as $event) {
+ if (!empty($event->parentCampaignId)) {
+ $count++;
+ $this->log->debug('tidyAdCampaignSchedules : Found old Ad Campaign interrupt event ID '
+ . $event->eventId . ' deleting');
+ $event->delete(['notify' => false]);
+ }
+ }
+
+ $this->log->debug('tidyAdCampaignSchedules : Deleted ' . $count . ' events');
+ Profiler::end('RegularMaintenance::tidyAdCampaignSchedules', $this->log);
+ $this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
+ }
+
+ /**
+ * Once per hour assert the current XMR to push its expiry time with XMR
+ * this also reseeds the key if XMR restarts
+ * @return void
+ */
+ private function assertXmrKey(): void
+ {
+ $this->log->debug('assertXmrKey: asserting key');
+ try {
+ $key = $this->getConfig()->getSetting('XMR_CMS_KEY');
+ if (!empty($key)) {
+ $client = new Client($this->config->getGuzzleProxy([
+ 'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'),
+ ]));
+
+ $client->post('/', [
+ 'json' => [
+ 'id' => constant('SECRET_KEY'),
+ 'type' => 'keys',
+ 'key' => $key,
+ ],
+ ]);
+ $this->log->debug('assertXmrKey: asserted key');
+ } else {
+ $this->log->error('assertXmrKey: key empty');
+ }
+ } catch (GuzzleException | \Exception $e) {
+ $this->log->error('assertXmrKey: failed. E = ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Deletes unused full screen layouts
+ * @throws NotFoundException
+ * @throws GeneralException
+ */
+ private function tidyUnusedFullScreenLayout(): void
+ {
+ $this->runMessage .= '## ' . __('Tidy Unused FullScreen Layout') . PHP_EOL;
+ Profiler::start('RegularMaintenance::tidyUnusedFullScreenLayout', $this->log);
+ $count = 0;
+
+ foreach ($this->layoutFactory->query(null, [
+ 'filterLayoutStatusId' => 3,
+ 'isFullScreenCampaign' => 1
+ ]) as $layout) {
+ $count++;
+
+ $this->log->debug('tidyUnusedFullScreenLayout : Found unused fullscreen layout ID '
+ . $layout->layoutId . ' deleting');
+
+ $layout->delete();
+ }
+
+ $this->log->debug('tidyUnusedFullScreenLayout : Deleted ' . $count . ' layouts');
+
+ Profiler::end('RegularMaintenance::tidyUnusedFullScreenLayout', $this->log);
+
+ $this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
+ }
+}
diff --git a/lib/XTR/MediaOrientationTask.php b/lib/XTR/MediaOrientationTask.php
new file mode 100644
index 0000000..c0406da
--- /dev/null
+++ b/lib/XTR/MediaOrientationTask.php
@@ -0,0 +1,95 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Xibo\Factory\MediaFactory;
+
+class MediaOrientationTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /**
+ * @var MediaFactory
+ */
+ private $mediaFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->mediaFactory = $container->get('mediaFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Media Orientation') . PHP_EOL . PHP_EOL;
+
+ // Long running task
+ set_time_limit(0);
+
+ $this->setMediaOrientation();
+ }
+
+ private function setMediaOrientation()
+ {
+ $this->appendRunMessage('# Setting Media Orientation on Library Media files.');
+
+ // onlyMenuBoardAllowed filter means images and videos
+ $filesToCheck = $this->mediaFactory->query(null, ['requiresMetaUpdate' => 1, 'onlyMenuBoardAllowed' => 1]);
+ $count = 0;
+
+ foreach ($filesToCheck as $media) {
+ $count++;
+ $filePath = '';
+ $libraryFolder = $this->config->getSetting('LIBRARY_LOCATION');
+
+ if ($media->mediaType === 'image') {
+ $filePath = $libraryFolder . $media->storedAs;
+ } elseif ($media->mediaType === 'video' && file_exists($libraryFolder . $media->mediaId . '_videocover.png')) {
+ $filePath = $libraryFolder . $media->mediaId . '_videocover.png';
+ }
+
+ if (!empty($filePath)) {
+ list($imgWidth, $imgHeight) = @getimagesize($filePath);
+ $media->width = $imgWidth;
+ $media->height = $imgHeight;
+ $media->orientation = ($imgWidth >= $imgHeight) ? 'landscape' : 'portrait';
+ $media->save(['saveTags' => false, 'validate' => false]);
+ }
+ }
+ $this->appendRunMessage('Updated ' . $count . ' items');
+ $this->disableTask();
+ }
+
+ private function disableTask()
+ {
+ $this->appendRunMessage('# Disabling task.');
+ $this->log->debug('Disabling task.');
+
+ $this->getTask()->isActive = 0;
+ $this->getTask()->save();
+
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+ }
+}
diff --git a/lib/XTR/NotificationTidyTask.php b/lib/XTR/NotificationTidyTask.php
new file mode 100644
index 0000000..ed13736
--- /dev/null
+++ b/lib/XTR/NotificationTidyTask.php
@@ -0,0 +1,117 @@
+getOption('maxAgeDays', 7));
+ $systemOnly = intval($this->getOption('systemOnly', 1));
+ $readOnly = intval($this->getOption('readOnly', 0));
+
+ $this->runMessage = '# ' . __('Notification Tidy') . PHP_EOL . PHP_EOL;
+
+ $this->log->info('Deleting notifications older than ' . $maxAgeDays
+ . ' days. System Only: ' . $systemOnly
+ . '. Read Only' . $readOnly
+ );
+
+ // Where clause
+ $where = ' WHERE `releaseDt` < :releaseDt ';
+ if ($systemOnly == 1) {
+ $where .= ' AND `isSystem` = 1 ';
+ }
+
+ // Params for all deletes
+ $params = [
+ 'releaseDt' => Carbon::now()->subDays($maxAgeDays)->format('U')
+ ];
+
+ // Delete all notifications older than now minus X days
+ $sql = '
+ DELETE FROM `lknotificationdg`
+ WHERE `notificationId` IN (SELECT DISTINCT `notificationId` FROM `notification` ' . $where . ')
+ ';
+
+ if ($readOnly == 1) {
+ $sql .= ' AND `notificationId` IN (SELECT `notificationId` FROM `lknotificationuser` WHERE read <> 0) ';
+ }
+
+ $this->store->update($sql, $params);
+
+ // Delete notification groups
+ $sql = '
+ DELETE FROM `lknotificationgroup`
+ WHERE `notificationId` IN (SELECT DISTINCT `notificationId` FROM `notification` ' . $where . ')
+ ';
+
+ if ($readOnly == 1) {
+ $sql .= ' AND `notificationId` IN (SELECT `notificationId` FROM `lknotificationuser` WHERE read <> 0) ';
+ }
+
+ $this->store->update($sql, $params);
+
+ // Delete from notification user
+ $sql = '
+ DELETE FROM `lknotificationuser`
+ WHERE `notificationId` IN (SELECT DISTINCT `notificationId` FROM `notification` ' . $where . ')
+ ';
+
+ if ($readOnly == 1) {
+ $sql .= ' AND `read` <> 0 ';
+ }
+
+ $this->store->update($sql, $params);
+
+ // Remove the attached file
+ $sql = 'SELECT filename FROM `notification` ' . $where;
+
+ foreach ($this->store->select($sql, $params) as $row) {
+ $filename = $row['filename'];
+
+ /*Delete the attachment*/
+ if (!empty($filename)) {
+ // Library location
+ $attachmentLocation = $this->config->getSetting('LIBRARY_LOCATION'). 'attachment/';
+ if (file_exists($attachmentLocation . $filename)) {
+ unlink($attachmentLocation . $filename);
+ }
+ }
+ }
+
+ // Delete from notification
+ $sql = 'DELETE FROM `notification` ' . $where;
+
+ if ($readOnly == 1) {
+ $sql .= ' AND `notificationId` NOT IN (SELECT `notificationId` FROM `lknotificationuser`) ';
+ }
+
+ $this->store->update($sql, $params);
+
+ $this->runMessage .= __('Done') . PHP_EOL . PHP_EOL;
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/PurgeListCleanupTask.php b/lib/XTR/PurgeListCleanupTask.php
new file mode 100644
index 0000000..6d311ce
--- /dev/null
+++ b/lib/XTR/PurgeListCleanupTask.php
@@ -0,0 +1,61 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+
+class PurgeListCleanupTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->sanitizerService = $container->get('sanitizerService');
+ $this->store = $container->get('store');
+ $this->config = $container->get('configService');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->tidyPurgeList();
+ }
+
+ public function tidyPurgeList()
+ {
+ $this->runMessage = '# ' . __('Purge List Cleanup Start') . PHP_EOL . PHP_EOL;
+
+ $count = $this->store->update('DELETE FROM `purge_list` WHERE expiryDate < :expiryDate', [
+ 'expiryDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ ]);
+
+ if ($count <= 0) {
+ $this->appendRunMessage('# ' . __('Nothing to remove') . PHP_EOL . PHP_EOL);
+ } else {
+ $this->appendRunMessage('# ' . sprintf(__('Removed %d rows'), $count) . PHP_EOL . PHP_EOL);
+ }
+ }
+}
diff --git a/lib/XTR/RemoteDataSetFetchTask.php b/lib/XTR/RemoteDataSetFetchTask.php
new file mode 100644
index 0000000..0bdd16e
--- /dev/null
+++ b/lib/XTR/RemoteDataSetFetchTask.php
@@ -0,0 +1,280 @@
+.
+ */
+
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Entity\DataSet;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * Class RemoteDataSetFetchTask
+ * @package Xibo\XTR
+ */
+class RemoteDataSetFetchTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var DataSetFactory */
+ private $dataSetFactory;
+
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->dataSetFactory = $container->get('dataSetFactory');
+ $this->notificationFactory = $container->get('notificationFactory');
+ $this->userFactory = $container->get('userFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Fetching Remote-DataSets') . PHP_EOL . PHP_EOL;
+
+ $runTime = Carbon::now()->format('U');
+
+ /** @var DataSet $dataSet */
+ $dataSet = null;
+
+ // Process all Remote DataSets (and their dependants)
+ $dataSets = $this->orderDataSetsByDependency($this->dataSetFactory->query(null, ['isRemote' => 1]));
+
+ // Log the order.
+ $this->log->debug(
+ 'Order of processing: ' . json_encode(array_map(function ($element) {
+ return $element->dataSetId . ' - ' . $element->runsAfter;
+ }, $dataSets))
+ );
+
+ // Reorder this list according to which order we want to run in
+ foreach ($dataSets as $dataSet) {
+ $this->log->debug('Processing ' . $dataSet->dataSet . '. ID:' . $dataSet->dataSetId);
+ $hardRowLimit = $this->config->getSetting('DATASET_HARD_ROW_LIMIT');
+ $softRowLimit = $dataSet->rowLimit;
+ $limitPolicy = $dataSet->limitPolicy;
+ $currentNumberOfRows = intval($this->store->select('SELECT COUNT(*) AS total FROM `dataset_' . $dataSet->dataSetId . '`', [])[0]['total']);
+
+ try {
+ // Has this dataSet been accessed recently?
+ if (!$dataSet->isActive()) {
+ // Skipping dataSet due to it not being accessed recently
+ $this->log->info('Skipping dataSet ' . $dataSet->dataSetId . ' due to it not being accessed recently');
+ continue;
+ }
+
+ // Get all columns
+ $columns = $dataSet->getColumn();
+
+ // Filter columns where dataSetColumnType is "Remote"
+ $filteredColumns = array_filter($columns, function ($column) {
+ return $column->dataSetColumnTypeId == '3';
+ });
+
+ // Check if there are any remote columns defined in the dataset
+ if (count($filteredColumns) === 0) {
+ $this->log->info('Skipping dataSet ' . $dataSet->dataSetId . ': No remote columns defined in the dataset.');
+ continue;
+ }
+
+ $this->log->debug('Comparing run time ' . $runTime . ' to next sync time ' . $dataSet->getNextSyncTime());
+
+ if ($runTime >= $dataSet->getNextSyncTime()) {
+ // Getting the dependant DataSet to process the current DataSet on
+ $dependant = null;
+ if ($dataSet->runsAfter != null && $dataSet->runsAfter != $dataSet->dataSetId) {
+ $dependant = $this->dataSetFactory->getById($dataSet->runsAfter);
+ }
+
+ $this->log->debug('Fetch and process ' . $dataSet->dataSet);
+
+ $results = $this->dataSetFactory->callRemoteService($dataSet, $dependant);
+
+ if ($results->number > 0) {
+ // Truncate only if we also fetch new Data
+ if ($dataSet->isTruncateEnabled()
+ && $results->isEligibleToTruncate
+ && $runTime >= $dataSet->getNextClearTime()
+ ) {
+ $this->log->debug('Truncate ' . $dataSet->dataSet);
+ $dataSet->deleteData();
+
+ // Update the last clear time.
+ $dataSet->saveLastClear($runTime);
+ }
+
+ $rowsToAdd = $results->number;
+ $this->log->debug('Current number of rows in DataSet ID ' . $dataSet->dataSetId . ' is: ' . $currentNumberOfRows . ' number of records to add ' . $rowsToAdd);
+
+ // row limit reached
+ if ($currentNumberOfRows + $rowsToAdd >= $hardRowLimit || $softRowLimit != null && $currentNumberOfRows + $rowsToAdd >= $softRowLimit) {
+ // handle remote DataSets created before introduction of limit policy
+ if ($limitPolicy == null) {
+ $this->log->debug('No limit policy set, default to stop syncing.');
+ $limitPolicy = 'stop';
+ }
+
+ // which limit policy was set?
+ if ($limitPolicy === 'stop') {
+ $this->log->info('DataSet ID ' . $dataSet->dataSetId . ' reached the row limit, due to selected limit policy, it will stop syncing');
+ continue;
+ } elseif ($limitPolicy === 'fifo') {
+ // FiFo
+ $this->log->info('DataSet ID ' . $dataSet->dataSetId . ' reached the row limit, due to selected limit policy, oldest rows will be removed');
+
+ $this->store->update('DELETE FROM `dataset_' . $dataSet->dataSetId . '` ORDER BY id ASC LIMIT ' . $rowsToAdd, []);
+ } elseif ($limitPolicy === 'truncate') {
+ // truncate
+ $this->log->info('DataSet ID ' . $dataSet->dataSetId . ' reached the row limit, due to selected limit policy, we will truncate the DataSet data');
+ $dataSet->deleteData();
+
+ // Update the last clear time.
+ $dataSet->saveLastClear($runTime);
+ }
+ }
+
+ if ($dataSet->sourceId === 1) {
+ $this->dataSetFactory->processResults($dataSet, $results);
+ } else {
+ $this->dataSetFactory->processCsvEntries($dataSet, $results);
+ }
+
+ // notify here
+ $dataSet->notify();
+ } else if ($dataSet->truncateOnEmpty
+ && $results->isEligibleToTruncate
+ && $dataSet->isTruncateEnabled()
+ && $runTime >= $dataSet->getNextClearTime()
+ ) {
+ $this->log->debug('Truncate ' . $dataSet->dataSet);
+ $dataSet->deleteData();
+
+ // Update the last clear time.
+ $dataSet->saveLastClear($runTime);
+ $this->appendRunMessage(__('No results for %s, truncate with no new data enabled', $dataSet->dataSet));
+ } else {
+ $this->appendRunMessage(__('No results for %s', $dataSet->dataSet));
+ }
+
+ $dataSet->saveLastSync($runTime);
+ } else {
+ $this->log->debug('Sync not required for ' . $dataSet->dataSetId);
+ }
+ } catch (GeneralException $e) {
+ $this->appendRunMessage(__('Error syncing DataSet %s', $dataSet->dataSet));
+ $this->log->error('Error syncing DataSet ' . $dataSet->dataSetId . '. E = ' . $e->getMessage());
+ $this->log->debug($e->getTraceAsString());
+
+ // Send a notification to the dataSet owner, informing them of the failure.
+ $notification = $this->notificationFactory->createEmpty();
+ $notification->subject = __('Remote DataSet %s failed to synchronise', $dataSet->dataSet);
+ $notification->body = 'The error is: ' . $e->getMessage();
+ $notification->createDt = Carbon::now()->format('U');
+ $notification->releaseDt = $notification->createDt;
+ $notification->isInterrupt = 0;
+ $notification->userId = $this->user->userId;
+ $notification->type = 'dataset';
+
+ // Assign me
+ $dataSetUser = $this->userFactory->getById($dataSet->userId);
+ $notification->assignUserGroup($this->userGroupFactory->getById($dataSetUser->groupId));
+
+ // Send
+ $notification->save();
+
+ // You might say at this point that if there are other data sets further down the list, we shouldn't
+ // continue because they might depend directly on this one
+ // however, it is my opinion that they should be processed anyway with the current cache of data.
+ // hence continue
+ }
+ }
+
+ $this->appendRunMessage(__('Done'));
+ }
+
+ /**
+ * Order the list of DataSets to be processed so that it is dependent aware.
+ *
+ * @param DataSet[] $dataSets Reference to an Array which holds all not yet processed DataSets
+ * @return DataSet[] Ordered list of DataSets to process
+ *
+ *
+ * What is going on here: RemoteDataSets can depend on others, so we have to be sure to fetch
+ * the data from the dependant first.
+ * For Example (id, dependant): (1,4), (2,3), (3,4), (4,1), (5,2), (6,6)
+ * Should be processed like: 4, 1, 3, 2, 5, 6
+ *
+ */
+ private function orderDataSetsByDependency(array $dataSets)
+ {
+ // DataSets are in no particular order
+ // sort them according to their dependencies
+ usort($dataSets, function ($a, $b) {
+ /** @var DataSet $a */
+ /** @var DataSet $b */
+ // if a doesn't have a dependent, then a must be lower in the list (move b up)
+ if ($a->runsAfter === null) {
+ return -1;
+ }
+ // if b doesn't have a dependent, then a must be higher in the list (move b down)
+ if ($b->runsAfter === null) {
+ return 1;
+ }
+ // either a or b have a dependent
+ // if they are the same, keep them where they are
+ if ($a->runsAfter === $b->runsAfter) {
+ return 0;
+ }
+ // the dependents are different.
+ // if a depends on b, then move b up
+ if ($a->runsAfter === $b->dataSetId) {
+ return -1;
+ }
+ // if b depends on a, then move b down
+ if ($b->runsAfter === $a->dataSetId) {
+ return 1;
+ }
+ // Unsorted
+ return 0;
+ });
+
+ // Process in reverse order (LastIn-FirstOut)
+ return array_reverse($dataSets);
+ }
+}
diff --git a/lib/XTR/RemoveOldScreenshotsTask.php b/lib/XTR/RemoveOldScreenshotsTask.php
new file mode 100644
index 0000000..f5f1b45
--- /dev/null
+++ b/lib/XTR/RemoveOldScreenshotsTask.php
@@ -0,0 +1,73 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+
+use Carbon\Carbon;
+use Xibo\Factory\MediaFactory;
+
+class RemoveOldScreenshotsTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->mediaFactory = $container->get('mediaFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Remove Old Screenshots') . PHP_EOL . PHP_EOL;
+
+ $screenshotLocation = $this->config->getSetting('LIBRARY_LOCATION') . 'screenshots/';
+ $screenshotTTL = $this->config->getSetting('DISPLAY_SCREENSHOT_TTL');
+ $count = 0;
+
+ if ($screenshotTTL > 0) {
+ foreach (array_diff(scandir($screenshotLocation), ['..', '.']) as $file) {
+ $fileLocation = $screenshotLocation . $file;
+
+ $lastModified = Carbon::createFromTimestamp(filemtime($fileLocation));
+ $now = Carbon::now();
+ $diff = $now->diffInDays($lastModified);
+
+ if ($diff > $screenshotTTL) {
+ unlink($fileLocation);
+ $count++;
+
+ $this->log->debug('Removed old Display screenshot:' . $file);
+ }
+ }
+ $this->appendRunMessage(sprintf(__('Removed %d old Display screenshots'), $count));
+ } else {
+ $this->appendRunMessage(__('Display Screenshot Time to keep set to 0, nothing to remove.'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/ReportScheduleTask.php b/lib/XTR/ReportScheduleTask.php
new file mode 100644
index 0000000..8fdfd2a
--- /dev/null
+++ b/lib/XTR/ReportScheduleTask.php
@@ -0,0 +1,371 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Mpdf\Mpdf;
+use Mpdf\Output\Destination;
+use Slim\Views\Twig;
+use Xibo\Entity\ReportResult;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\ReportScheduleFactory;
+use Xibo\Factory\SavedReportFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Service\MediaService;
+use Xibo\Service\ReportServiceInterface;
+use Xibo\Support\Exception\InvalidArgumentException;
+
+/**
+ * Class ReportScheduleTask
+ * @package Xibo\XTR
+ */
+class ReportScheduleTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var Twig */
+ private $view;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var SavedReportFactory */
+ private $savedReportFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var ReportScheduleFactory */
+ private $reportScheduleFactory;
+
+ /** @var ReportServiceInterface */
+ private $reportService;
+
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->view = $container->get('view');
+ $this->userFactory = $container->get('userFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->savedReportFactory = $container->get('savedReportFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+ $this->reportScheduleFactory = $container->get('reportScheduleFactory');
+ $this->reportService = $container->get('reportService');
+ $this->notificationFactory = $container->get('notificationFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Report schedule') . PHP_EOL . PHP_EOL;
+
+ // Long running task
+ set_time_limit(0);
+
+ $this->runReportSchedule();
+ }
+
+ /**
+ * Run report schedule
+ * @throws InvalidArgumentException
+ * @throws \Xibo\Support\Exception\ConfigurationException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function runReportSchedule()
+ {
+
+ $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Make sure the library exists
+ MediaService::ensureLibraryExists($libraryFolder);
+ $reportSchedules = $this->reportScheduleFactory->query(null, ['isActive' => 1, 'disableUserCheck' => 1]);
+
+ // Get list of ReportSchedule
+ foreach ($reportSchedules as $reportSchedule) {
+ $cron = \Cron\CronExpression::factory($reportSchedule->schedule);
+ $nextRunDt = $cron->getNextRunDate(\DateTime::createFromFormat('U', $reportSchedule->lastRunDt))->format('U');
+ $now = Carbon::now()->format('U');
+
+ // if report start date is greater than now
+ // then dont run the report schedule
+ if ($reportSchedule->fromDt > $now) {
+ $this->log->debug('Report schedule start date is in future '. $reportSchedule->fromDt);
+ continue;
+ }
+
+ // if report end date is less than or equal to now
+ // then disable report schedule
+ if ($reportSchedule->toDt != 0 && $reportSchedule->toDt <= $now) {
+ $reportSchedule->message = 'Report schedule end date has passed';
+ $reportSchedule->isActive = 0;
+ }
+
+ if ($nextRunDt <= $now && $reportSchedule->isActive) {
+ // random run of report schedules
+ $skip = $this->skipReportRun($now, $nextRunDt);
+ if ($skip == true) {
+ continue;
+ }
+ // execute the report
+ $reportSchedule->previousRunDt = $reportSchedule->lastRunDt;
+ $reportSchedule->lastRunDt = Carbon::now()->format('U');
+
+ $this->log->debug('Last run date is updated to '. $reportSchedule->lastRunDt);
+
+ try {
+ // Get the generated saved as report name
+ $saveAs = $this->reportService->generateSavedReportName(
+ $reportSchedule->reportName,
+ $reportSchedule->filterCriteria
+ );
+
+ // Run the report to get results
+ // pass in the user who saved the report
+ $result = $this->reportService->runReport(
+ $reportSchedule->reportName,
+ $reportSchedule->filterCriteria,
+ $this->userFactory->getById($reportSchedule->userId)
+ );
+
+ $this->log->debug(__('Run report results: %s.', json_encode($result, JSON_PRETTY_PRINT)));
+
+ // Save the result in a json file
+ $fileName = tempnam($this->config->getSetting('LIBRARY_LOCATION') . '/temp/', 'reportschedule');
+ $out = fopen($fileName, 'w');
+ fwrite($out, json_encode($result));
+ fclose($out);
+
+ $savedReportFileName = 'rs_'.$reportSchedule->reportScheduleId. '_'. Carbon::now()->format('U');
+
+ // Create a ZIP file and add our temporary file
+ $zipName = $this->config->getSetting('LIBRARY_LOCATION') . 'savedreport/'.$savedReportFileName.'.zip';
+ $zip = new \ZipArchive();
+ $result = $zip->open($zipName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+
+ if ($result !== true) {
+ throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
+ }
+
+ $zip->addFile($fileName, 'reportschedule.json');
+ $zip->close();
+
+ // Remove the JSON file
+ unlink($fileName);
+ // Save Saved report
+ $savedReport = $this->savedReportFactory->create(
+ $saveAs,
+ $reportSchedule->reportScheduleId,
+ Carbon::now()->format('U'),
+ $reportSchedule->userId,
+ $savedReportFileName.'.zip',
+ filesize($zipName),
+ md5_file($zipName)
+ );
+ $savedReport->save();
+
+ $this->createPdfAndNotification($reportSchedule, $savedReport);
+
+ // Add the last savedreport in Report Schedule
+ $this->log->debug('Last savedReportId in Report Schedule: '. $savedReport->savedReportId);
+ $reportSchedule->lastSavedReportId = $savedReport->savedReportId;
+ $reportSchedule->message = null;
+ } catch (\Exception $error) {
+ $reportSchedule->isActive = 0;
+ $reportSchedule->message = $error->getMessage();
+ $this->log->error('Error: ' . $error->getMessage());
+ }
+ }
+
+ // Finally save schedule report
+ $reportSchedule->save();
+ }
+ }
+
+ /**
+ * Create the PDF and save a notification
+ * @param $reportSchedule
+ * @param $savedReport
+ * @throws \Twig\Error\LoaderError
+ * @throws \Twig\Error\RuntimeError
+ * @throws \Twig\Error\SyntaxError
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function createPdfAndNotification($reportSchedule, $savedReport)
+ {
+ /* @var ReportResult $savedReportData */
+ $savedReportData = $this->reportService->getSavedReportResults(
+ $savedReport->savedReportId,
+ $reportSchedule->reportName
+ );
+
+ // Get the report config
+ $report = $this->reportService->getReportByName($reportSchedule->reportName);
+
+ if ($report->output_type == 'both' || $report->output_type == 'chart') {
+ $quickChartUrl = $this->config->getSetting('QUICK_CHART_URL');
+ if (!empty($quickChartUrl)) {
+ $quickChartUrl .= '/chart?width=1000&height=300&c=';
+
+ $chartScript = $this->reportService->getReportChartScript(
+ $savedReport->savedReportId,
+ $reportSchedule->reportName
+ );
+
+ // Replace " with ' for the quick chart URL
+ $src = $quickChartUrl . str_replace('"', '\'', $chartScript);
+
+ // If multiple charts needs to be displayed
+ $multipleCharts = [];
+ $chartScriptArray = json_decode($chartScript, true);
+ foreach ($chartScriptArray as $key => $chartData) {
+ $multipleCharts[$key] = $quickChartUrl . str_replace('"', '\'', json_encode($chartData));
+ }
+ } else {
+ $placeholder = __('Chart could not be drawn because the CMS has not been configured with a Quick Chart URL.');
+ }
+ }
+
+ if ($report->output_type == 'both' || $report->output_type == 'table') {
+ $tableData = $savedReportData->table;
+ }
+
+ // Get report email template
+ $emailTemplate = $this->reportService->getReportEmailTemplate($reportSchedule->reportName);
+
+ if (!empty($emailTemplate)) {
+ // Save PDF attachment
+ ob_start();
+ echo $this->view->fetch(
+ $emailTemplate,
+ [
+ 'header' => $report->description,
+ 'logo' => $this->config->uri('img/xibologo.png', true),
+ 'title' => $savedReport->saveAs,
+ 'metadata' => $savedReportData->metadata,
+ 'tableData' => $tableData ?? null,
+ 'src' => $src ?? null,
+ 'multipleCharts' => $multipleCharts ?? null,
+ 'placeholder' => $placeholder ?? null
+ ]
+ );
+ $body = ob_get_contents();
+ ob_end_clean();
+
+ try {
+ $mpdf = new Mpdf([
+ 'tempDir' => $this->config->getSetting('LIBRARY_LOCATION') . '/temp',
+ 'orientation' => 'L',
+ 'mode' => 'c',
+ 'margin_left' => 20,
+ 'margin_right' => 20,
+ 'margin_top' => 20,
+ 'margin_bottom' => 20,
+ 'margin_header' => 5,
+ 'margin_footer' => 15
+ ]);
+ $mpdf->setFooter('Page {PAGENO}') ;
+ $mpdf->SetDisplayMode('fullpage');
+ $stylesheet = file_get_contents($this->config->uri('css/email-report.css', true));
+ $mpdf->WriteHTML($stylesheet, 1);
+ $mpdf->WriteHTML($body);
+ $mpdf->Output(
+ $this->config->getSetting('LIBRARY_LOCATION') . 'attachment/filename-'.$savedReport->savedReportId.'.pdf',
+ Destination::FILE
+ );
+
+ // Create email notification with attachment
+ $filters = json_decode($reportSchedule->filterCriteria, true);
+ $sendEmail = $filters['sendEmail'] ?? null;
+ $nonusers = $filters['nonusers'] ?? null;
+ if ($sendEmail) {
+ $notification = $this->notificationFactory->createEmpty();
+ $notification->subject = $report->description;
+ $notification->body = __('Attached please find the report for %s', $savedReport->saveAs);
+ $notification->createDt = Carbon::now()->format('U');
+ $notification->releaseDt = Carbon::now()->format('U');
+ $notification->isInterrupt = 0;
+ $notification->userId = $savedReport->userId; // event owner
+ $notification->filename = 'filename-'.$savedReport->savedReportId.'.pdf';
+ $notification->originalFileName = 'saved_report.pdf';
+ $notification->nonusers = $nonusers;
+ $notification->type = 'report';
+
+ // Get user group to create user notification
+ $notificationUser = $this->userFactory->getById($savedReport->userId);
+ $notification->assignUserGroup($this->userGroupFactory->getById($notificationUser->groupId));
+
+ $notification->save();
+ }
+ } catch (\Exception $error) {
+ $this->log->error($error->getMessage());
+ $this->runMessage .= $error->getMessage() . PHP_EOL . PHP_EOL;
+ }
+ }
+ }
+
+ private function skipReportRun($now, $nextRunDt)
+ {
+ $fourHoursInSeconds = 4 * 3600;
+ $threeHoursInSeconds = 3 * 3600;
+ $twoHoursInSeconds = 2 * 3600;
+ $oneHourInSeconds = 1 * 3600;
+
+ $diffFromNow = $now - $nextRunDt;
+
+ $range = 100;
+ $random = rand(1, $range);
+ if ($diffFromNow < $oneHourInSeconds) {
+ // don't run the report
+ if ($random <= 70) { // 70% chance of skipping
+ return true;
+ }
+ } elseif ($diffFromNow < $twoHoursInSeconds) {
+ // don't run the report
+ if ($random <= 50) { // 50% chance of skipping
+ return true;
+ }
+ } elseif ($diffFromNow < $threeHoursInSeconds) {
+ // don't run the report
+ if ($random <= 40) { // 40% chance of skipping
+ return true;
+ }
+ } elseif ($diffFromNow < $fourHoursInSeconds) {
+ // don't run the report
+ if ($random <= 25) { // 25% chance of skipping
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/lib/XTR/ScheduleReminderTask.php b/lib/XTR/ScheduleReminderTask.php
new file mode 100644
index 0000000..2722ee2
--- /dev/null
+++ b/lib/XTR/ScheduleReminderTask.php
@@ -0,0 +1,241 @@
+.
+ */
+
+namespace Xibo\XTR;
+use Carbon\Carbon;
+use Xibo\Entity\ScheduleReminder;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\ScheduleReminderFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class ScheduleReminderTask
+ * @package Xibo\XTR
+ */
+class ScheduleReminderTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var ScheduleFactory */
+ private $scheduleFactory;
+
+ /** @var CampaignFactory */
+ private $campaignFactory;
+
+ /** @var ScheduleReminderFactory */
+ private $scheduleReminderFactory;
+
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->userFactory = $container->get('userFactory');
+ $this->scheduleFactory = $container->get('scheduleFactory');
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->scheduleReminderFactory = $container->get('scheduleReminderFactory');
+ $this->notificationFactory = $container->get('notificationFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->runMessage = '# ' . __('Schedule reminder') . PHP_EOL . PHP_EOL;
+
+ $this->runScheduleReminder();
+ }
+
+ /**
+ *
+ */
+ private function runScheduleReminder()
+ {
+
+ $task = $this->getTask();
+ $nextRunDate = $task->nextRunDate();
+ $task->lastRunDt = Carbon::now()->format('U');
+ $task->save();
+
+ // Get those reminders that have reminderDt <= nextRunDate && reminderDt > lastReminderDt
+ // Those which have reminderDt < lastReminderDt exclude them
+ $reminders = $this->scheduleReminderFactory->getDueReminders($nextRunDate);
+
+ foreach($reminders as $reminder) {
+
+ // Get the schedule
+ $schedule = $this->scheduleFactory->getById($reminder->eventId);
+ $schedule->setCampaignFactory($this->campaignFactory);
+ $title = $schedule->getEventTitle();
+
+ switch ($reminder->type) {
+ case ScheduleReminder::$TYPE_MINUTE:
+ $type = ScheduleReminder::$MINUTE;
+ $typeText = 'Minute(s)';
+ break;
+ case ScheduleReminder::$TYPE_HOUR:
+ $type = ScheduleReminder::$HOUR;
+ $typeText = 'Hour(s)';
+ break;
+ case ScheduleReminder::$TYPE_DAY:
+ $type = ScheduleReminder::$DAY;
+ $typeText = 'Day(s)';
+ break;
+ case ScheduleReminder::$TYPE_WEEK:
+ $type = ScheduleReminder::$WEEK;
+ $typeText = 'Week(s)';
+ break;
+ case ScheduleReminder::$TYPE_MONTH:
+ $type = ScheduleReminder::$MONTH;
+ $typeText = 'Month(s)';
+ break;
+ default:
+ $this->log->error('Unknown schedule reminder type has been provided');
+ continue 2;
+ }
+
+ switch ($reminder->option) {
+ case ScheduleReminder::$OPTION_BEFORE_START:
+ $typeOptionText = 'starting';
+ break;
+ case ScheduleReminder::$OPTION_AFTER_START:
+ $typeOptionText = 'started';
+ break;
+ case ScheduleReminder::$OPTION_BEFORE_END:
+ $typeOptionText = 'ending';
+ break;
+ case ScheduleReminder::$OPTION_AFTER_END:
+ $typeOptionText = 'ended';
+ break;
+ default:
+ $this->log->error('Unknown schedule reminder option has been provided');
+ continue 2;
+ }
+
+ // Create a notification
+ $subject = sprintf(__("Reminder for %s"), $title);
+ if ($reminder->option == ScheduleReminder::$OPTION_BEFORE_START || $reminder->option == ScheduleReminder::$OPTION_BEFORE_END) {
+ $body = sprintf(__("The event (%s) is %s in %d %s"), $title, $typeOptionText, $reminder->value, $typeText);
+ } elseif ($reminder->option == ScheduleReminder::$OPTION_AFTER_START || $reminder->option == ScheduleReminder::$OPTION_AFTER_END) {
+ $body = sprintf(__("The event (%s) has %s %d %s ago"), $title, $typeOptionText, $reminder->value, $typeText);
+ }
+
+ // Is this schedule a recurring event?
+ if ($schedule->recurrenceType != '') {
+
+ $now = Carbon::now();
+ $remindSeconds = $reminder->value * $type;
+
+ // Get the next reminder date
+ $nextReminderDate = 0;
+ try {
+ $nextReminderDate = $schedule->getNextReminderDate($now, $reminder, $remindSeconds);
+ } catch (NotFoundException $error) {
+ $this->log->error('No next occurrence of reminderDt found.');
+ }
+
+ $i = 0;
+ $lastReminderDate = $reminder->reminderDt;
+ while ($nextReminderDate != 0 && $nextReminderDate < $nextRunDate) {
+
+ // Keep the last reminder date
+ $lastReminderDate = $nextReminderDate;
+
+ $now = Carbon::createFromTimestamp($nextReminderDate + 1);
+ try {
+ $nextReminderDate = $schedule->getNextReminderDate($now, $reminder, $remindSeconds);
+ } catch (NotFoundException $error) {
+ $nextReminderDate = 0;
+ $this->log->debug('No next occurrence of reminderDt found. ReminderDt set to 0.');
+ }
+
+ $this->createNotification($subject, $body, $reminder, $schedule, $lastReminderDate);
+
+ $i++;
+ }
+
+ if ($i == 0) {
+ // Create only 1 notification as the next event is outside the nextRunDt
+ $this->createNotification($subject, $body, $reminder, $schedule, $reminder->reminderDt);
+ $this->log->debug('Create only 1 notification as the next event is outside the nextRunDt.');
+
+ } else {
+ $this->log->debug($i. ' notifications created.');
+ }
+
+ $reminder->reminderDt = $nextReminderDate;
+ $reminder->lastReminderDt = $lastReminderDate;
+ $reminder->save();
+
+ } else { // one-off event
+
+ $this->createNotification($subject, $body, $reminder, $schedule, $reminder->reminderDt);
+
+ // Current reminderDt will be used as lastReminderDt
+ $reminder->lastReminderDt = $reminder->reminderDt;
+ }
+
+ // Save
+ $reminder->save();
+ }
+ }
+
+ /**
+ * @param $subject
+ * @param $body
+ * @param $reminder
+ * @param $schedule
+ * @param null $releaseDt
+ * @throws NotFoundException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ private function createNotification($subject, $body, $reminder, $schedule, $releaseDt = null) {
+
+ $notification = $this->notificationFactory->createEmpty();
+ $notification->subject = $subject;
+ $notification->body = $body;
+ $notification->createDt = Carbon::now()->format('U');
+ $notification->releaseDt = $releaseDt;
+ $notification->isInterrupt = 0;
+ $notification->userId = $schedule->userId; // event owner
+ $notification->type = 'schedule';
+
+ // Get user group to create user notification
+ $notificationUser = $this->userFactory->getById($schedule->userId);
+ $notification->assignUserGroup($this->userGroupFactory->getById($notificationUser->groupId));
+
+ $notification->save();
+ }
+}
diff --git a/lib/XTR/SeedDatabaseTask.php b/lib/XTR/SeedDatabaseTask.php
new file mode 100644
index 0000000..7384bab
--- /dev/null
+++ b/lib/XTR/SeedDatabaseTask.php
@@ -0,0 +1,923 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Exception;
+use Xibo\Entity\Display;
+use Xibo\Entity\Schedule;
+use Xibo\Factory\CampaignFactory;
+use Xibo\Factory\CommandFactory;
+use Xibo\Factory\DataSetColumnFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\DisplayGroupFactory;
+use Xibo\Factory\FolderFactory;
+use Xibo\Factory\FontFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\SyncGroupFactory;
+use Xibo\Factory\TaskFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\Random;
+use Xibo\Service\MediaServiceInterface;
+use Xibo\Support\Exception\DuplicateEntityException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class SeedDatabaseTask
+ * Run only once, by default disabled
+ * @package Xibo\XTR
+ */
+class SeedDatabaseTask implements TaskInterface
+{
+ use TaskTrait;
+
+ private ModuleFactory $moduleFactory;
+ private WidgetFactory $widgetFactory;
+ private LayoutFactory $layoutFactory;
+ private CampaignFactory $campaignFactory;
+ private TaskFactory $taskFactory;
+ private DisplayFactory $displayFactory;
+ private DataSetFactory $dataSetFactory;
+ private DataSetColumnFactory $dataSetColumnFactory;
+ private SyncGroupFactory $syncGroupFactory;
+ private ScheduleFactory $scheduleFactory;
+ private UserFactory $userFactory;
+ private UserGroupFactory $userGroupFactory;
+ private FontFactory $fontFactory;
+ private MediaServiceInterface $mediaService;
+ /** @var array The cache for layout */
+ private array $layoutCache = [];
+ private FolderFactory $folderFactory;
+ private CommandFactory $commandFactory;
+ private DisplayGroupFactory $displayGroupFactory;
+ private MediaFactory $mediaFactory;
+ private array $displayGroups;
+ private array $displays;
+ private array $layouts;
+ private array $parentCampaigns = [];
+ private array $syncGroups;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->moduleFactory = $container->get('moduleFactory');
+ $this->widgetFactory = $container->get('widgetFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->campaignFactory = $container->get('campaignFactory');
+ $this->taskFactory = $container->get('taskFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->displayGroupFactory = $container->get('displayGroupFactory');
+ $this->mediaService = $container->get('mediaService');
+ $this->userFactory = $container->get('userFactory');
+ $this->userGroupFactory = $container->get('userGroupFactory');
+ $this->fontFactory = $container->get('fontFactory');
+ $this->dataSetFactory = $container->get('dataSetFactory');
+ $this->dataSetColumnFactory = $container->get('dataSetColumnFactory');
+ $this->syncGroupFactory = $container->get('syncGroupFactory');
+ $this->scheduleFactory = $container->get('scheduleFactory');
+ $this->folderFactory = $container->get('folderFactory');
+ $this->commandFactory = $container->get('commandFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+
+ return $this;
+ }
+
+ /** @inheritdoc
+ * @throws Exception
+ */
+ public function run()
+ {
+ // This task should only be run once
+ $this->runMessage = '# ' . __('Seeding Database') . PHP_EOL . PHP_EOL;
+
+ // Create display groups
+ $this->createDisplayGroups();
+
+ // Create displays
+ $this->createDisplays();
+
+ // Assign displays to display groups
+ $this->assignDisplaysToDisplayGroups();
+
+ // Import layouts
+ $this->importLayouts();
+
+ // Create campaign
+ $this->createAdCampaigns();
+ $this->createListCampaigns();
+
+ // Create stats
+ $this->createStats();
+
+ // Create Schedules
+ $this->createSchedules();
+
+ // Create Sync Groups
+ $this->createSyncGroups();
+ $this->createSynchronizedSchedules();
+
+ // Create User
+ $this->createUsers();
+
+ // Create Folders
+ $this->createFolders();
+ $this->createCommands();
+
+ // Create bandwidth data display 1
+ $this->createBandwidthReportData();
+
+ // Create disconnected display event for yesterday for 10 minutes for display 1
+ $this->createDisconnectedDisplayEvent();
+
+ $this->runMessage .= ' - ' . __('Done.') . PHP_EOL . PHP_EOL;
+
+ $this->log->info('Task completed');
+ $this->appendRunMessage('Task completed');
+ }
+
+ /**
+ */
+ private function createDisplayGroups(): void
+ {
+ $displayGroups = [
+ 'POP Display Group',
+ 'Display Group 1',
+ 'Display Group 2',
+
+ // Display groups for displaygroups.cy.js test
+ 'disp5_dispgrp',
+ ];
+
+ foreach ($displayGroups as $displayGroupName) {
+ try {
+ // Don't create if the display group exists
+ $groups = $this->displayGroupFactory->query(null, ['displayGroup' => $displayGroupName]);
+ if (count($groups) > 0) {
+ foreach ($groups as $displayGroup) {
+ $this->displayGroups[$displayGroup->displayGroup] = $displayGroup->getId();
+ }
+ } else {
+ $displayGroup = $this->displayGroupFactory->createEmpty();
+ $displayGroup->displayGroup = $displayGroupName;
+ $displayGroup->userId = $this->userFactory->getSystemUser()->getId();
+ $displayGroup->save();
+ $this->store->commitIfNecessary();
+ // Cache
+ $this->displayGroups[$displayGroup->displayGroup] = $displayGroup->getId();
+ }
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating display group: '. $e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createDisplays(): void
+ {
+ // Create Displays
+ $displays = [
+ 'POP Display 1' => ['license' => Random::generateString(12, 'seed'), 'licensed' => false,
+ 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'POP Display 2' => ['license' => Random::generateString(12, 'seed'), 'licensed' => false,
+ 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'List Campaign Display 1' => ['license' => Random::generateString(12, 'seed'), 'licensed' => true,
+ 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'List Campaign Display 2' => ['license' => Random::generateString(12, 'seed'), 'licensed' => true,
+ 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+
+ // Displays for displays.cy.js test
+ 'dis_disp1' => ['license' => 'dis_disp1', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dis_disp2' => ['license' => 'dis_disp2', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dis_disp3' => ['license' => 'dis_disp3', 'licensed' => false, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dis_disp4' => ['license' => 'dis_disp4', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dis_disp5' => ['license' => 'dis_disp5', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+
+ // Displays for displaygroups.cy.js test
+ 'dispgrp_disp1' => ['license' => 'dispgrp_disp1', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dispgrp_disp2' => ['license' => 'dispgrp_disp2', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dispgrp_disp_dynamic1' => ['license' => 'dispgrp_disp_dynamic1', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'dispgrp_disp_dynamic2' => ['license' => 'dispgrp_disp_dynamic2', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+
+
+ // 6 displays for xmds
+ 'phpunitv7' => ['license' => 'PHPUnit7', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'phpunitwaiting' => ['license' => 'PHPUnitWaiting', 'licensed' => false, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
+ 'phpunitv6' => ['license' => 'PHPUnit6', 'licensed' => true, 'clientType' => 'windows', 'clientCode' => 304, 'clientVersion' => 3],
+ 'phpunitv5' => ['license' => 'PHPUnit5', 'licensed' => true, 'clientType' => 'windows', 'clientCode' => 304, 'clientVersion' => 3],
+ 'phpunitv4' => ['license' => 'PHPUnit4', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 217, 'clientVersion' => 2],
+ 'phpunitv3' => ['license' => 'PHPUnit3', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 217, 'clientVersion' => 2],
+ ];
+
+ foreach ($displays as $displayName => $displayData) {
+ try {
+ // Don't create if the display exists
+ $disps = $this->displayFactory->query(null, ['display' => $displayName]);
+ if (count($disps) > 0) {
+ foreach ($disps as $display) {
+ // Cache
+ $this->displays[$display->display] = $display->displayId;
+ }
+ } else {
+ $display = $this->displayFactory->createEmpty();
+ $display->display = $displayName;
+ $display->auditingUntil = 0;
+ $display->defaultLayoutId = $this->getConfig()->getSetting('DEFAULT_LAYOUT');
+ $display->license = $displayData['license'];
+ $display->licensed = $displayData['licensed'] ? 1 : 0; // Authorised?
+ $display->clientType = $displayData['clientType'];
+ $display->clientCode = $displayData['clientCode'];
+ $display->clientVersion = $displayData['clientVersion'];
+
+ $display->incSchedule = 0;
+ $display->clientAddress = '';
+
+ if (!$display->isDisplaySlotAvailable()) {
+ $display->licensed = 0;
+ }
+ $display->lastAccessed = Carbon::now()->format('U');
+ $display->loggedIn = 1;
+
+ $display->save(Display::$saveOptionsMinimum);
+ $this->store->commitIfNecessary();
+ // Cache
+ $this->displays[$display->display] = $display->displayId;
+ }
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating display: ' . $e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ private function assignDisplaysToDisplayGroups(): void
+ {
+ $displayGroup = $this->displayGroupFactory->getById($this->displayGroups['POP Display Group']);
+ $displayGroup->load();
+
+ $display = $this->displayFactory->getById($this->displays['POP Display 1']);
+
+ try {
+ $displayGroup->assignDisplay($display);
+ $displayGroup->save();
+ } catch (GeneralException $e) {
+ $this->log->error('Error assign display to display group: '. $e->getMessage());
+ }
+
+ $this->store->commitIfNecessary();
+ }
+
+ /**
+ * Import Layouts
+ * @throws GeneralException
+ */
+ private function importLayouts(): void
+ {
+ $this->runMessage .= '## ' . __('Import Layout To Seed Database') . PHP_EOL;
+
+ // Make sure the library exists
+ $this->mediaService->initLibrary();
+
+ // all layouts name and file name
+ $layoutNames = [
+ 'dataset test ' => 'export-dataset-test.zip',
+ 'layout_with_8_items_dataset' => 'export-layout-with-8-items-dataset.zip',
+ 'Image test' => 'export-image-test.zip',
+ 'Layout for Schedule 1' => 'export-layout-for-schedule-1.zip',
+ 'List Campaign Layout 1' => 'export-list-campaign-layout-1.zip',
+ 'List Campaign Layout 2' => 'export-list-campaign-layout-2.zip',
+ 'POP Layout 1' => 'export-pop-layout-1.zip',
+
+ // Layout for displaygroups.cy.js test
+ 'disp4_default_layout' => 'export-disp4-default-layout.zip',
+
+ // Layout editor tests
+ 'Audio-Video-PDF' => 'export-audio-video-pdf.zip'
+ ];
+
+ // Get all layouts
+ $importedLayouts = [];
+ foreach ($this->layoutFactory->query() as $layout) {
+ // cache
+ if (array_key_exists($layout->layout, $layoutNames)) {
+ $importedLayouts[] = $layoutNames[$layout->layout];
+ }
+
+ // Cache
+ $this->layouts[trim($layout->layout)] = $layout->layoutId;
+ }
+
+ // Import a layout
+ $folder = PROJECT_ROOT . '/tests/resources/seeds/layouts/';
+
+ foreach (array_diff(scandir($folder), array('..', '.')) as $file) {
+ // Check if the layout file has already been imported
+ if (!in_array($file, $importedLayouts)) {
+ if (stripos($file, '.zip')) {
+ try {
+ $layout = $this->layoutFactory->createFromZip(
+ $folder . '/' . $file,
+ null,
+ $this->userFactory->getSystemUser()->getId(),
+ false,
+ false,
+ true,
+ false,
+ true,
+ $this->dataSetFactory,
+ null,
+ $this->mediaService,
+ 1
+ );
+
+ $layout->save([
+ 'audit' => false,
+ 'import' => true
+ ]);
+
+ if (!empty($layout->getUnmatchedProperty('thumbnail'))) {
+ rename($layout->getUnmatchedProperty('thumbnail'), $layout->getThumbnailUri());
+ }
+
+ $this->store->commitIfNecessary();
+ // Update Cache
+ $this->layouts[trim($layout->layout)] = $layout->layoutId;
+ } catch (Exception $exception) {
+ $this->log->error('Seed Database: Unable to import layout: ' . $file . '. E = ' . $exception->getMessage());
+ $this->log->debug($exception->getTraceAsString());
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws DuplicateEntityException
+ */
+ private function createAdCampaigns(): void
+ {
+ $layoutId = $this->layouts['POP Layout 1'];
+
+ // Get All Ad Campaigns
+ $campaigns = $this->campaignFactory->query(null, ['type' => 'ad']);
+ foreach ($campaigns as $campaign) {
+ $this->parentCampaigns[$campaign->campaign] = $campaign->getId();
+ }
+
+ if (!array_key_exists('POP Ad Campaign 1', $this->parentCampaigns)) {
+ $campaign = $this->campaignFactory->create(
+ 'ad',
+ 'POP Ad Campaign 1',
+ $this->userFactory->getSystemUser()->getId(),
+ 1
+ );
+
+ $campaign->targetType = 'plays';
+ $campaign->target = 100;
+ $campaign->listPlayOrder = 'round';
+
+ try {
+ // Assign the layout
+ $campaign->assignLayout($layoutId);
+ $campaign->save(['validate' => false, 'saveTags' => false]);
+ $this->store->commitIfNecessary();
+ // Cache
+ $this->parentCampaigns[$campaign->campaign] = $campaign->getId();
+ } catch (GeneralException $e) {
+ $this->getLogger()->error('Save: ' . $e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws NotFoundException
+ * @throws DuplicateEntityException
+ */
+ private function createListCampaigns(): void
+ {
+ $campaignName = 'Campaign for Schedule 1';
+
+ // Get All List Campaigns
+ $campaigns = $this->campaignFactory->query(null, ['type' => 'list']);
+ foreach ($campaigns as $campaign) {
+ $this->parentCampaigns[$campaign->campaign] = $campaign->getId();
+ }
+
+ if (!array_key_exists($campaignName, $this->parentCampaigns)) {
+ $campaign = $this->campaignFactory->create(
+ 'list',
+ $campaignName,
+ $this->userFactory->getSystemUser()->getId(),
+ 1
+ );
+ $campaign->listPlayOrder = 'round';
+
+ try {
+ // Assign the layout
+ $campaign->save(['validate' => false, 'saveTags' => false]);
+ $this->store->commitIfNecessary();
+ // Cache
+ $this->parentCampaigns[$campaign->campaign] = $campaign->getId();
+ } catch (GeneralException $e) {
+ $this->getLogger()->error('Save: ' . $e->getMessage());
+ }
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ private function createStats(): void
+ {
+ // Delete Stats
+ $this->store->update('DELETE FROM stat WHERE displayId = :displayId', [
+ 'displayId' => $this->displays['POP Display 1']
+ ]);
+
+ // Get layout campaign Id
+ $campaignId = $this->layoutFactory->getById($this->layouts['POP Layout 1'])->campaignId;
+ $columns = 'type, statDate, scheduleId, displayId, campaignId, parentCampaignId, layoutId, mediaId, widgetId, `start`, `end`, tag, duration, `count`';
+ $values = ':type, :statDate, :scheduleId, :displayId, :campaignId, :parentCampaignId, :layoutId, :mediaId, :widgetId, :start, :end, :tag, :duration, :count';
+
+ // a layout stat for today
+ try {
+ $params = [
+ 'type' => 'layout',
+ 'statDate' => Carbon::now()->hour(12)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'parentCampaignId' => $this->parentCampaigns['POP Ad Campaign 1'],
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => null,
+ 'widgetId' => 0,
+ 'start' => Carbon::now()->hour(12)->format('U'),
+ 'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // a layout stat for lastweek
+ $params = [
+ 'type' => 'layout',
+ 'statDate' => Carbon::now()->subWeek()->hour(12)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'parentCampaignId' => $this->parentCampaigns['POP Ad Campaign 1'],
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => null,
+ 'widgetId' => 0,
+ 'start' => Carbon::now()->subWeek()->hour(12)->format('U'),
+ 'end' => Carbon::now()->subWeek()->hour(12)->addSeconds(60)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // Media stats
+ $columns = 'type, statDate, scheduleId, displayId, campaignId, layoutId, mediaId, widgetId, `start`, `end`, tag, duration, `count`';
+ $values = ':type, :statDate, :scheduleId, :displayId, :campaignId, :layoutId, :mediaId, :widgetId, :start, :end, :tag, :duration, :count';
+
+ // Get Layout
+ $layout = $this->layoutFactory->getById($this->layouts['POP Layout 1']);
+ $layout->load();
+
+ // Take a mediaId and widgetId of the layout
+ foreach ($layout->getAllWidgets() as $widget) {
+ $widgetId = $widget->widgetId;
+ $mediaId = $widget->mediaIds[0];
+ break;
+ }
+
+ // Get Media
+ $media = $this->mediaFactory->getById($mediaId);
+
+ // a media stat for today
+ $params = [
+ 'type' => 'media',
+ 'statDate' => Carbon::now()->hour(12)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => $media->mediaId,
+ 'widgetId' => $widgetId,
+ 'start' => Carbon::now()->hour(12)->format('U'),
+ 'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // another media stat for today
+ $params = [
+ 'type' => 'media',
+ 'statDate' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => $media->mediaId,
+ 'widgetId' => $widgetId,
+ 'start' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
+ 'end' => Carbon::now()->hour(12)->addSeconds(120)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // a media stat for lastweek
+ // Last week stats -
+ $params = [
+ 'type' => 'media',
+ 'statDate' => Carbon::now()->subWeek()->addDays(2)->hour(12)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => $media->mediaId,
+ 'widgetId' => $widgetId,
+ 'start' => Carbon::now()->subWeek()->hour(12)->format('U'),
+ 'end' => Carbon::now()->subWeek()->hour(12)->addSeconds(60)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // another media stat for lastweek
+ $params = [
+ 'type' => 'media',
+ 'statDate' => Carbon::now()->subWeek()->addDays(2)->hour(12)->addSeconds(60)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => $media->mediaId,
+ 'widgetId' => $widgetId,
+ 'start' => Carbon::now()->subWeek()->hour(12)->addSeconds(60)->format('U'),
+ 'end' => Carbon::now()->subWeek()->hour(12)->addSeconds(120)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // an widget stat for today
+ $params = [
+ 'type' => 'widget',
+ 'statDate' => Carbon::now()->hour(12)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => $campaignId,
+ 'layoutId' => $this->layouts['POP Layout 1'],
+ 'mediaId' => $media->mediaId,
+ 'widgetId' => $widgetId,
+ 'start' => Carbon::now()->hour(12)->format('U'),
+ 'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
+ 'tag' => null,
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+
+ // a event stat for today
+ $params = [
+ 'type' => 'event',
+ 'statDate' => Carbon::now()->hour(12)->format('U'),
+ 'scheduleId' => 0,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'campaignId' => 0,
+ 'layoutId' => 0,
+ 'mediaId' => null,
+ 'widgetId' => 0,
+ 'start' => Carbon::now()->hour(12)->format('U'),
+ 'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
+ 'tag' => 'Event123',
+ 'duration' => 60,
+ 'count' => 1,
+ ];
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+ } catch (GeneralException $e) {
+ $this->getLogger()->error('Error inserting stats: '. $e->getMessage());
+ }
+ $this->store->commitIfNecessary();
+ }
+
+ private function createSchedules(): void
+ {
+ // Don't create if the schedule exists
+ $schedules = $this->scheduleFactory->query(null, [
+ 'eventTypeId' => Schedule::$LAYOUT_EVENT,
+ 'campaignId' => $this->layouts['dataset test']
+ ]);
+
+ if (count($schedules) <= 0) {
+ try {
+ $schedule = $this->scheduleFactory->createEmpty();
+ $schedule->userId = $this->userFactory->getSystemUser()->getId();
+ $schedule->eventTypeId = Schedule::$LAYOUT_EVENT;
+ $schedule->dayPartId = 2;
+ $schedule->displayOrder = 0;
+ $schedule->isPriority = 0;
+ // Campaign Id
+ $schedule->campaignId = $this->layouts['dataset test'];
+ $schedule->syncTimezone = 0;
+ $schedule->syncEvent = 0;
+ $schedule->isGeoAware = 0;
+ $schedule->maxPlaysPerHour = 0;
+
+ $displays = $this->displayFactory->query(null, ['display' => 'phpunitv']);
+ foreach ($displays as $display) {
+ $displayGroupId = $display->displayGroupId;
+ $schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
+ }
+ $schedule->save(['notify' => false]);
+
+ $this->store->commitIfNecessary();
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating schedule : '. $e->getMessage());
+ }
+ }
+ }
+
+ private function createSyncGroups(): void
+ {
+ // Don't create if the sync group exists
+ $syncGroups = $this->syncGroupFactory->query(null, [
+ 'eventTypeId' => Schedule::$LAYOUT_EVENT,
+ 'campaignId' => $this->layouts['dataset test']
+ ]);
+
+ if (count($syncGroups) > 0) {
+ foreach ($syncGroups as $syncGroup) {
+ // Cache
+ $this->syncGroups[$syncGroup->name] = $syncGroup->getId();
+ }
+ } else {
+ // Create a SyncGroup - SyncGroup name `Simple Sync Group`
+ try {
+ $syncGroup = $this->syncGroupFactory->createEmpty();
+ $syncGroup->name = 'Simple Sync Group';
+ $syncGroup->ownerId = $this->userFactory->getSystemUser()->getId();
+ $syncGroup->syncPublisherPort = 9590;
+ $syncGroup->folderId = 1;
+ $syncGroup->permissionsFolderId = 1;
+ $syncGroup->save();
+ $this->store->update('UPDATE `display` SET `display`.syncGroupId = :syncGroupId WHERE `display`.displayId = :displayId', [
+ 'syncGroupId' => $syncGroup->syncGroupId,
+ 'displayId' => $this->displays['phpunitv6']
+ ]);
+
+ $this->store->update('UPDATE `display` SET `display`.syncGroupId = :syncGroupId WHERE `display`.displayId = :displayId', [
+ 'syncGroupId' => $syncGroup->syncGroupId,
+ 'displayId' => $this->displays['phpunitv7']
+ ]);
+
+ $syncGroup->leadDisplayId = $this->displays['phpunitv7'];
+ $syncGroup->save();
+ $this->store->commitIfNecessary();
+ // Cache
+ $this->syncGroups[$syncGroup->name] = $syncGroup->getId();
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating sync group: '. $e->getMessage());
+ }
+ }
+ }
+
+ private function createSynchronizedSchedules(): void
+ {
+ // Don't create if the schedule exists
+ $schedules = $this->scheduleFactory->query(null, [
+ 'eventTypeId' => Schedule::$SYNC_EVENT,
+ 'syncGroupId' => $this->syncGroups['Simple Sync Group']
+ ]);
+
+ if (count($schedules) <= 0) {
+ try {
+ $schedule = $this->scheduleFactory->createEmpty();
+ $schedule->userId = $this->userFactory->getSystemUser()->getId();
+ $schedule->eventTypeId = Schedule::$SYNC_EVENT;
+ $schedule->dayPartId = 2;
+
+ $schedule->displayOrder = 0;
+ $schedule->isPriority = 0;
+
+ // Campaign Id
+ $schedule->campaignId = null;
+ $schedule->syncTimezone = 0;
+ $schedule->syncEvent = 1;
+ $schedule->isGeoAware = 0;
+ $schedule->maxPlaysPerHour = 0;
+ $schedule->syncGroupId = $this->syncGroups['Simple Sync Group'];
+
+ $displayV7 = $this->displayFactory->getById($this->displays['phpunitv7']);
+ $schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayV7->displayGroupId));
+ $displayV6 = $this->displayFactory->getById($this->displays['phpunitv6']);
+ $schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayV6->displayGroupId));
+
+ $schedule->save(['notify' => false]);
+ $this->store->commitIfNecessary();
+ // Update Sync Links
+ $this->store->insert('INSERT INTO `schedule_sync` (`eventId`, `displayId`, `layoutId`)
+ VALUES(:eventId, :displayId, :layoutId) ON DUPLICATE KEY UPDATE layoutId = :layoutId', [
+ 'eventId' => $schedule->eventId,
+ 'displayId' => $this->displays['phpunitv7'],
+ 'layoutId' => $this->layouts['Image test']
+ ]);
+
+ $this->store->insert('INSERT INTO `schedule_sync` (`eventId`, `displayId`, `layoutId`)
+ VALUES(:eventId, :displayId, :layoutId) ON DUPLICATE KEY UPDATE layoutId = :layoutId', [
+ 'eventId' => $schedule->eventId,
+ 'displayId' => $this->displays['phpunitv6'],
+ 'layoutId' => $this->layouts['Image test']
+ ]);
+ $this->store->commitIfNecessary();
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating sync schedule: '. $e->getMessage());
+ }
+ }
+ }
+
+ private function createUsers(): void
+ {
+ // Don't create if exists
+ $users = $this->userFactory->query(null, [
+ 'exactUserName' => 'folder_user'
+ ]);
+
+ if (count($users) <= 0) {
+ // Create a user - user name `Simple User`
+ try {
+ $user = $this->userFactory->create();
+ $user->setChildAclDependencies($this->userGroupFactory);
+ $user->userName = 'folder_user';
+ $user->email = '';
+ $user->homePageId = 'icondashboard.view';
+ $user->libraryQuota = 20;
+ $user->setNewPassword('password');
+ $user->homeFolderId = 1;
+ $user->userTypeId = 3;
+ $user->isSystemNotification = 0;
+ $user->isDisplayNotification = 0;
+ $user->isPasswordChangeRequired = 0;
+ $user->firstName = 'test';
+ $user->lastName = 'user';
+ $user->save();
+ $this->store->commitIfNecessary();
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating user: '. $e->getMessage());
+ }
+ }
+ }
+
+ private function createFolders(): void
+ {
+ $folders = [
+ 'ChildFolder', 'FolderHome', 'EmptyFolder', 'ShareFolder', 'FolderWithContent', 'FolderWithImage', 'MoveToFolder', 'MoveFromFolder'
+ ];
+
+ foreach ($folders as $folderName) {
+ try {
+ // Don't create if the folder exists
+ $folds = $this->folderFactory->query(null, ['folderName' => $folderName]);
+ if (count($folds) <= 0) {
+ $folder = $this->folderFactory->createEmpty();
+ $folder->text = $folderName;
+ $folder->parentId = 1;
+ $folder->children = '';
+
+ $folder->save();
+ $this->store->commitIfNecessary();
+ }
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating folder: '. $e->getMessage());
+ }
+ }
+
+ // Place the media in folders
+ $folderWithImages = [
+ 'MoveToFolder' => 'test12',
+ 'MoveFromFolder' => 'test34',
+ 'FolderWithContent' => 'media_for_not_empty_folder',
+ 'FolderWithImage' => 'media_for_search_in_folder'
+ ];
+
+ foreach ($folderWithImages as $folderName => $mediaName) {
+ try {
+ $folders = $this->folderFactory->query(null, ['folderName' => $folderName]);
+ if (count($folders) == 1) {
+ $test12 = $this->mediaFactory->getByName($mediaName);
+ $test12->folderId = $folders[0]->getId(); // Get the folder id of FolderHome
+ $test12->save();
+ $this->store->commitIfNecessary();
+ }
+ } catch (GeneralException $e) {
+ $this->log->error('Error moving media ' . $mediaName . ' to the folder: ' . $folderName . ' ' . $e->getMessage());
+ }
+ }
+ }
+
+ private function createBandwidthReportData(): void
+ {
+ // Check if the record exists
+ $monthU = Carbon::now()->startOfDay()->hour(12)->format('U');
+ $record = $this->store->select('SELECT * FROM bandwidth WHERE type = 8 AND displayId = :displayId AND month = :month', [
+ 'displayId' => $this->displays['POP Display 1'],
+ 'month' => $monthU
+ ]);
+
+ if (count($record) <= 0) {
+ $this->store->insert('INSERT INTO `bandwidth` (Month, Type, DisplayID, Size) VALUES (:month, :type, :displayId, :size)', [
+ 'month' => $monthU,
+ 'type' => 8,
+ 'displayId' => $this->displays['POP Display 1'],
+ 'size' => 200
+ ]);
+ $this->store->commitIfNecessary();
+ }
+ }
+
+ private function createDisconnectedDisplayEvent(): void
+ {
+ // Delete if the record exists
+ $date = Carbon::now()->subDay()->format('U');
+ $this->store->update('DELETE FROM displayevent WHERE displayId = :displayId', [
+ 'displayId' => $this->displays['POP Display 1']
+ ]);
+
+ $this->store->insert('INSERT INTO `displayevent` (eventDate, start, end, displayID) VALUES (:eventDate, :start, :end, :displayId)', [
+ 'eventDate' => $date,
+ 'start' => $date,
+ 'end' => Carbon::now()->subDay()->addSeconds(600)->format('U'),
+ 'displayId' => $this->displays['POP Display 1']
+ ]);
+ $this->store->commitIfNecessary();
+ }
+
+ private function createCommands()
+ {
+ $commandName = 'Set Timezone';
+
+ // Don't create if exists
+ $commands = $this->commandFactory->query(null, [
+ 'command' => $commandName
+ ]);
+
+ if (count($commands) <= 0) {
+ // Create a user - user name `Simple User`
+ try {
+ $command = $this->commandFactory->create();
+ $command->command = $commandName;
+ $command->description = 'a command to test schedule';
+ $command->code = 'TIMEZONE';
+ $command->userId = $this->userFactory->getSystemUser()->getId();
+ $command->createAlertOn = 'never';
+ $command->save();
+ $this->store->commitIfNecessary();
+ } catch (GeneralException $e) {
+ $this->log->error('Error creating command: '. $e->getMessage());
+ }
+ }
+ }
+}
diff --git a/lib/XTR/StatsArchiveTask.php b/lib/XTR/StatsArchiveTask.php
new file mode 100644
index 0000000..657dc22
--- /dev/null
+++ b/lib/XTR/StatsArchiveTask.php
@@ -0,0 +1,337 @@
+.
+ */
+
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Entity\User;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Support\Exception\TaskRunException;
+
+/**
+ * Class StatsArchiveTask
+ * @package Xibo\XTR
+ */
+class StatsArchiveTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var User */
+ private $archiveOwner;
+
+ /** @var MediaFactory */
+ private $mediaFactory;
+
+ /** @var UserFactory */
+ private $userFactory;
+
+ /** @var Carbon */
+ private $lastArchiveDate = null;
+
+ /** @var \Xibo\Helper\SanitizerService */
+ private $sanitizerService;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->userFactory = $container->get('userFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->sanitizerService = $container->get('sanitizerService');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->archiveStats();
+ $this->tidyStats();
+ }
+
+ public function archiveStats()
+ {
+ $this->log->debug('Archive Stats');
+
+ $this->runMessage = '# ' . __('Stats Archive') . PHP_EOL . PHP_EOL;
+
+ if ($this->getOption('archiveStats', 'Off') == 'On') {
+ $this->log->debug('Archive Enabled');
+
+ // Archive tasks by week.
+ $periodSizeInDays = $this->getOption('periodSizeInDays', 7);
+ $maxPeriods = $this->getOption('maxPeriods', 4);
+ $periodsToKeep = $this->getOption('periodsToKeep', 1);
+ $this->setArchiveOwner();
+
+ // Get the earliest
+ $earliestDate = $this->timeSeriesStore->getEarliestDate();
+
+ if ($earliestDate === null) {
+ $this->log->debug('Earliest date is null, nothing to archive.');
+
+ $this->runMessage = __('Nothing to archive');
+ return;
+ }
+
+ // Wind back to the start of the day
+ $earliestDate = $earliestDate->copy()->setTime(0, 0, 0);
+
+ // Take the earliest date and roll forward until the current time
+ $now = Carbon::now()->subDays($periodSizeInDays * $periodsToKeep)->setTime(0, 0, 0);
+ $i = 0;
+
+ while ($earliestDate < $now && $i < $maxPeriods) {
+ $i++;
+
+ // Push forward
+ $fromDt = $earliestDate->copy();
+ $earliestDate->addDays($periodSizeInDays);
+
+ $this->log->debug('Running archive number ' . $i
+ . 'for ' . $fromDt->toAtomString() . ' - ' . $earliestDate->toAtomString());
+
+ try {
+ $this->exportStatsToLibrary($fromDt, $earliestDate);
+ } catch (\Exception $exception) {
+ $this->log->error('Export error for Archive Number ' . $i . ', e = ' . $exception->getMessage());
+
+ // Throw out to the task handler to record the error.
+ throw $exception;
+ }
+
+ $this->store->commitIfNecessary();
+
+ $this->log->debug('Export success for Archive Number ' . $i);
+
+ // Grab the last from date for use in tidy stats
+ $this->lastArchiveDate = $fromDt;
+ }
+
+ $this->runMessage .= ' - ' . __('Done') . PHP_EOL . PHP_EOL;
+ } else {
+ $this->log->debug('Archive not enabled');
+ $this->runMessage .= ' - ' . __('Disabled') . PHP_EOL . PHP_EOL;
+ }
+
+ $this->log->debug('Finished archive stats, last archive date is '
+ . ($this->lastArchiveDate == null ? 'null' : $this->lastArchiveDate->toAtomString()));
+ }
+
+ /**
+ * Export stats to the library
+ * @param Carbon $fromDt
+ * @param Carbon $toDt
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ private function exportStatsToLibrary($fromDt, $toDt)
+ {
+ $this->log->debug('Export period: ' . $fromDt->toAtomString() . ' - ' . $toDt->toAtomString());
+ $this->runMessage .= ' - ' . $fromDt->format(DateFormatHelper::getSystemFormat()) . ' / ' . $toDt->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
+
+ $resultSet = $this->timeSeriesStore->getStats([
+ 'fromDt'=> $fromDt,
+ 'toDt'=> $toDt,
+ ]);
+
+ $this->log->debug('Get stats');
+
+ // Create a temporary file for this
+ $fileName = tempnam(sys_get_temp_dir(), 'stats');
+
+ $out = fopen($fileName, 'w');
+ fputcsv($out, ['Stat Date', 'Type', 'FromDT', 'ToDT', 'Layout', 'Display', 'Media', 'Tag', 'Duration', 'Count', 'DisplayId', 'LayoutId', 'WidgetId', 'MediaId', 'Engagements']);
+
+ $hasStatsToArchive = false;
+ while ($row = $resultSet->getNextRow()) {
+ $hasStatsToArchive = true;
+ $sanitizedRow = $this->getSanitizer($row);
+
+ if ($this->timeSeriesStore->getEngine() == 'mongodb') {
+ $statDate = isset($row['statDate']) ? Carbon::createFromTimestamp($row['statDate']->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat()) : null;
+ $start = Carbon::createFromTimestamp($row['start']->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat());
+ $end = Carbon::createFromTimestamp($row['end']->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat());
+ $engagements = isset($row['engagements']) ? json_encode($row['engagements']) : '[]';
+ } else {
+ $statDate = isset($row['statDate']) ? Carbon::createFromTimestamp($row['statDate'])->format(DateFormatHelper::getSystemFormat()) : null;
+ $start = Carbon::createFromTimestamp($row['start'])->format(DateFormatHelper::getSystemFormat());
+ $end = Carbon::createFromTimestamp($row['end'])->format(DateFormatHelper::getSystemFormat());
+ $engagements = isset($row['engagements']) ? $row['engagements'] : '[]';
+ }
+
+ // Read the columns
+ fputcsv($out, [
+ $statDate,
+ $sanitizedRow->getString('type'),
+ $start,
+ $end,
+ isset($row['layout']) ? $sanitizedRow->getString('layout') :'',
+ isset($row['display']) ? $sanitizedRow->getString('display') :'',
+ isset($row['media']) ? $sanitizedRow->getString('media') :'',
+ isset($row['tag']) ? $sanitizedRow->getString('tag') :'',
+ $sanitizedRow->getInt('duration'),
+ $sanitizedRow->getInt('count'),
+ $sanitizedRow->getInt('displayId'),
+ isset($row['layoutId']) ? $sanitizedRow->getInt('layoutId') :'',
+ isset($row['widgetId']) ? $sanitizedRow->getInt('widgetId') :'',
+ isset($row['mediaId']) ? $sanitizedRow->getInt('mediaId') :'',
+ $engagements
+ ]);
+ }
+
+ fclose($out);
+
+ if ($hasStatsToArchive) {
+ $this->log->debug('Temporary file written, zipping');
+
+ // Create a ZIP file and add our temporary file
+ $zipName = $this->config->getSetting('LIBRARY_LOCATION') . 'temp/stats.csv.zip';
+ $zip = new \ZipArchive();
+ $result = $zip->open($zipName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+ if ($result !== true) {
+ throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
+ }
+
+ $zip->addFile($fileName, 'stats.csv');
+ $zip->close();
+
+ $this->log->debug('Zipped to ' . $zipName);
+
+ // This all might have taken a long time indeed, so lets see if we need to reconnect MySQL
+ $this->store->select('SELECT 1', [], 'default', true);
+
+ $this->log->debug('MySQL connection refreshed if necessary');
+
+ // Upload to the library
+ $media = $this->mediaFactory->create(
+ __('Stats Export %s to %s - %s', $fromDt->format('Y-m-d'), $toDt->format('Y-m-d'), Random::generateString(5)),
+ 'stats.csv.zip',
+ 'genericfile',
+ $this->archiveOwner->getId()
+ );
+ $media->save();
+
+ $this->log->debug('Media saved as ' . $media->name);
+
+ // Commit before the delete (the delete might take a long time)
+ $this->store->commitIfNecessary();
+
+ // Set max attempts to -1 so that we continue deleting until we've removed all of the stats that we've exported
+ $options = [
+ 'maxAttempts' => -1,
+ 'statsDeleteSleep' => 1,
+ 'limit' => 1000
+ ];
+
+ $this->log->debug('Delete stats for period: ' . $fromDt->toAtomString() . ' - ' . $toDt->toAtomString());
+
+ // Delete the stats, incrementally
+ $this->timeSeriesStore->deleteStats($toDt, $fromDt, $options);
+
+ // This all might have taken a long time indeed, so lets see if we need to reconnect MySQL
+ $this->store->select('SELECT 1', [], 'default', true);
+
+ $this->log->debug('MySQL connection refreshed if necessary');
+
+ $this->log->debug('Delete stats completed, export period completed.');
+ } else {
+ $this->log->debug('There are no stats to archive');
+ }
+
+ // Remove the CSV file
+ unlink($fileName);
+ }
+
+ /**
+ * Set the archive owner
+ * @throws TaskRunException
+ */
+ private function setArchiveOwner()
+ {
+ $archiveOwner = $this->getOption('archiveOwner', null);
+
+ if ($archiveOwner == null) {
+ $admins = $this->userFactory->getSuperAdmins();
+
+ if (count($admins) <= 0) {
+ throw new TaskRunException(__('No super admins to use as the archive owner, please set one in the configuration.'));
+ }
+
+ $this->archiveOwner = $admins[0];
+ } else {
+ try {
+ $this->archiveOwner = $this->userFactory->getByName($archiveOwner);
+ } catch (NotFoundException $e) {
+ throw new TaskRunException(__('Archive Owner not found'));
+ }
+ }
+ }
+
+ /**
+ * Tidy Stats
+ */
+ private function tidyStats()
+ {
+ $this->log->debug('Tidy stats');
+
+ $this->runMessage .= '## ' . __('Tidy Stats') . PHP_EOL;
+
+ $maxAge = intval($this->config->getSetting('MAINTENANCE_STAT_MAXAGE'));
+
+ if ($maxAge != 0) {
+ $this->log->debug('Max Age is ' . $maxAge);
+
+ // Set the max age to maxAgeDays from now, or if we've archived, from the archive date
+ $maxAgeDate = ($this->lastArchiveDate === null)
+ ? Carbon::now()->subDays($maxAge)
+ : $this->lastArchiveDate;
+
+ // Control the flow of the deletion
+ $options = [
+ 'maxAttempts' => $this->getOption('statsDeleteMaxAttempts', 10),
+ 'statsDeleteSleep' => $this->getOption('statsDeleteSleep', 3),
+ 'limit' => $this->getOption('limit', 10000) // Note: for mongo we dont use $options['limit'] anymore
+ ];
+
+ try {
+ $this->log->debug('Calling delete stats with max age: ' . $maxAgeDate->toAtomString());
+
+ $countDeleted = $this->timeSeriesStore->deleteStats($maxAgeDate, null, $options);
+
+ $this->log->debug('Delete Stats complete');
+
+ $this->runMessage .= ' - ' . sprintf(__('Done - %d deleted.'), $countDeleted) . PHP_EOL . PHP_EOL;
+ } catch (\Exception $exception) {
+ $this->log->error('Unexpected error running stats tidy. e = ' . $exception->getMessage());
+ $this->runMessage .= ' - ' . __('Error.') . PHP_EOL . PHP_EOL;
+ }
+ } else {
+ $this->runMessage .= ' - ' . __('Disabled') . PHP_EOL . PHP_EOL;
+ }
+
+ $this->log->debug('Tidy stats complete');
+ }
+}
diff --git a/lib/XTR/StatsMigrationTask.php b/lib/XTR/StatsMigrationTask.php
new file mode 100644
index 0000000..b049815
--- /dev/null
+++ b/lib/XTR/StatsMigrationTask.php
@@ -0,0 +1,679 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Entity\Task;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\TaskFactory;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class StatsMigrationTask
+ * @package Xibo\XTR
+ */
+class StatsMigrationTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var TaskFactory */
+ private $taskFactory;
+
+ /** @var DisplayFactory */
+ private $displayFactory;
+
+ /** @var LayoutFactory */
+ private $layoutFactory;
+
+ /** @var bool Does the Archive Table exist? */
+ private $archiveExist;
+
+ /** @var array Cache of displayIds found */
+ private $displays = [];
+
+ /** @var array Cache of displayIds not found */
+ private $displaysNotFound = [];
+
+ /** @var Task The Stats Archive Task */
+ private $archiveTask;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->taskFactory = $container->get('taskFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $this->migrateStats();
+ }
+
+ /**
+ * Migrate Stats
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function migrateStats()
+ {
+ // Config options
+ $options = [
+ 'killSwitch' => (int)$this->getOption('killSwitch', 0),
+ 'numberOfRecords' => (int)$this->getOption('numberOfRecords', 10000),
+ 'numberOfLoops' => (int)$this->getOption('numberOfLoops', 1000),
+ 'pauseBetweenLoops' => (int)$this->getOption('pauseBetweenLoops', 10),
+ 'optimiseOnComplete' => (int)$this->getOption('optimiseOnComplete', 1),
+ ];
+
+ // read configOverride
+ $configOverrideFile = $this->getOption('configOverride', '');
+ if (!empty($configOverrideFile) && file_exists($configOverrideFile)) {
+ $options = array_merge($options, json_decode(file_get_contents($configOverrideFile), true));
+ }
+
+ if ($options['killSwitch'] == 0) {
+ // Stat Archive Task
+ $this->archiveTask = $this->taskFactory->getByClass('\Xibo\XTR\\StatsArchiveTask');
+
+ // Check stat_archive table exists
+ $this->archiveExist = $this->store->exists('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name', [
+ 'name' => 'stat_archive'
+ ]);
+
+ // Get timestore engine
+ $timeSeriesStore = $this->timeSeriesStore->getEngine();
+
+ if ($timeSeriesStore == 'mongodb') {
+ // If no records in both the stat and stat_archive then disable the task
+ $statSql = $this->store->getConnection()->prepare('SELECT statId FROM stat LIMIT 1');
+ $statSql->execute();
+
+ $statArchiveSqlCount = 0;
+ if ($this->archiveExist) {
+ /** @noinspection SqlResolve */
+ $statArchiveSql = $this->store->getConnection()->prepare('SELECT statId FROM stat_archive LIMIT 1');
+ $statArchiveSql->execute();
+ $statArchiveSqlCount = $statArchiveSql->rowCount();
+ }
+
+ if (($statSql->rowCount() == 0) && ($statArchiveSqlCount == 0)) {
+ $this->runMessage = '## Stat migration to Mongo' . PHP_EOL ;
+ $this->appendRunMessage('- Both stat_archive and stat is empty. '. PHP_EOL);
+
+ // Disable the task and Enable the StatsArchiver task
+ $this->log->debug('Stats migration task is disabled as stat_archive and stat is empty');
+ $this->disableTask();
+
+ if ($this->archiveTask->isActive == 0) {
+ $this->archiveTask->isActive = 1;
+ $this->archiveTask->save();
+ $this->store->commitIfNecessary();
+
+ $this->appendRunMessage('Enabling Stats Archive Task.');
+ $this->log->debug('Enabling Stats Archive Task.');
+ }
+ } else {
+ // if any of the two tables contain any records
+ $this->quitMigrationTaskOrDisableStatArchiveTask();
+ }
+
+ $this->moveStatsToMongoDb($options);
+ }
+
+ // If when the task runs it finds that MongoDB is disabled,
+ // and there isn't a stat_archive table, then it should disable itself and not run again
+ // (work is considered to be done at that point).
+ else {
+ if ($this->archiveExist) {
+ $this->runMessage = '## Moving from stat_archive to stat (MySQL)' . PHP_EOL;
+
+ $this->quitMigrationTaskOrDisableStatArchiveTask();
+
+ // Start migration
+ $this->moveStatsFromStatArchiveToStatMysql($options);
+ } else {
+ // Disable the task
+
+ $this->runMessage = '## Moving from stat_archive to stat (MySQL)' . PHP_EOL ;
+ $this->appendRunMessage('- Table stat_archive does not exist.' . PHP_EOL);
+
+ $this->log->debug('Table stat_archive does not exist.');
+ $this->disableTask();
+
+ // Enable the StatsArchiver task
+ if ($this->archiveTask->isActive == 0) {
+ $this->archiveTask->isActive = 1;
+ $this->archiveTask->save();
+ $this->store->commitIfNecessary();
+
+ $this->appendRunMessage('Enabling Stats Archive Task.');
+ $this->log->debug('Enabling Stats Archive Task.');
+ }
+ }
+ }
+ }
+ }
+
+ public function moveStatsFromStatArchiveToStatMysql($options)
+ {
+
+ $fileName = $this->config->getSetting('LIBRARY_LOCATION') . '.watermark_stat_archive_mysql.txt';
+
+ // Get low watermark from file
+ $watermark = $this->getWatermarkFromFile($fileName, 'stat_archive');
+
+ $numberOfLoops = 0;
+
+ while ($watermark > 0) {
+ $count = 0;
+ /** @noinspection SqlResolve */
+ $stats = $this->store->getConnection()->prepare('
+ SELECT statId, type, statDate, scheduleId, displayId, layoutId, mediaId, widgetId, start, `end`, tag
+ FROM stat_archive
+ WHERE statId < :watermark
+ ORDER BY statId DESC LIMIT :limit
+ ');
+ $stats->bindParam(':watermark', $watermark, \PDO::PARAM_INT);
+ $stats->bindParam(':limit', $options['numberOfRecords'], \PDO::PARAM_INT);
+
+ // Run the select
+ $stats->execute();
+
+ // Keep count how many stats we've inserted
+ $recordCount = $stats->rowCount();
+ $count+= $recordCount;
+
+ // End of records
+ if ($this->checkEndOfRecords($recordCount, $fileName) === true) {
+ $this->appendRunMessage(PHP_EOL. '# End of records.' . PHP_EOL. '- Dropping stat_archive.');
+ $this->log->debug('End of records in stat_archive (migration to MYSQL). Dropping table.');
+
+ // Drop the stat_archive table
+ /** @noinspection SqlResolve */
+ $this->store->update('DROP TABLE `stat_archive`;', []);
+
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+
+ // Disable the task
+ $this->disableTask();
+
+ // Enable the StatsArchiver task
+ if ($this->archiveTask->isActive == 0) {
+ $this->archiveTask->isActive = 1;
+ $this->archiveTask->save();
+ $this->store->commitIfNecessary();
+
+ $this->appendRunMessage('Enabling Stats Archive Task.');
+ $this->log->debug('Enabling Stats Archive Task.');
+ }
+
+ break;
+ }
+
+ // Loops limit end - task will need to rerun again to start from the saved watermark
+ if ($this->checkLoopLimits($numberOfLoops, $options['numberOfLoops'], $fileName, $watermark) === true) {
+ break;
+ }
+ $numberOfLoops++;
+
+ $temp = [];
+
+ $statIgnoredCount = 0;
+ foreach ($stats->fetchAll() as $stat) {
+ $watermark = $stat['statId'];
+
+ $columns = 'type, statDate, scheduleId, displayId, campaignId, layoutId, mediaId, widgetId, `start`, `end`, tag, duration, `count`';
+ $values = ':type, :statDate, :scheduleId, :displayId, :campaignId, :layoutId, :mediaId, :widgetId, :start, :end, :tag, :duration, :count';
+
+ // Get campaignId
+ if (($stat['type'] != 'event') && ($stat['layoutId'] != null)) {
+ try {
+ // Search the campaignId in the temp array first to reduce query in layouthistory
+ if (array_key_exists($stat['layoutId'], $temp)) {
+ $campaignId = $temp[$stat['layoutId']];
+ } else {
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($stat['layoutId']);
+ $temp[$stat['layoutId']] = $campaignId;
+ }
+ } catch (NotFoundException $error) {
+ $statIgnoredCount+= 1;
+ $count = $count - 1;
+ continue;
+ }
+ } else {
+ $campaignId = 0;
+ }
+
+ $params = [
+ 'type' => $stat['type'],
+ 'statDate' => Carbon::createFromTimestamp($stat['statDate'])->format('U'),
+ 'scheduleId' => (int) $stat['scheduleId'],
+ 'displayId' => (int) $stat['displayId'],
+ 'campaignId' => $campaignId,
+ 'layoutId' => (int) $stat['layoutId'],
+ 'mediaId' => (int) $stat['mediaId'],
+ 'widgetId' => (int) $stat['widgetId'],
+ 'start' => Carbon::createFromTimestamp($stat['start'])->format('U'),
+ 'end' => Carbon::createFromTimestamp($stat['end'])->format('U'),
+ 'tag' => $stat['tag'],
+ 'duration' => isset($stat['duration']) ? (int) $stat['duration'] : Carbon::createFromTimestamp($stat['end'])->format('U') - Carbon::createFromTimestamp($stat['start'])->format('U'),
+ 'count' => isset($stat['count']) ? (int) $stat['count'] : 1,
+ ];
+
+ // Do the insert
+ $this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
+ $this->store->commitIfNecessary();
+ }
+
+ if ($statIgnoredCount > 0) {
+ $this->appendRunMessage($statIgnoredCount. ' stat(s) were ignored while migrating');
+ }
+
+ // Give SQL time to recover
+ if ($watermark > 0) {
+ $this->appendRunMessage('- '. $count. ' rows migrated.');
+
+ $this->log->debug('MYSQL stats migration from stat_archive to stat. '.$count.' rows effected, sleeping.');
+ sleep($options['pauseBetweenLoops']);
+ }
+ }
+ }
+
+ public function moveStatsToMongoDb($options)
+ {
+
+ // Migration from stat table to Mongo
+ $this->migrationStatToMongo($options);
+
+ // Migration from stat_archive table to Mongo
+ // After migration delete only stat_archive
+ if ($this->archiveExist) {
+ $this->migrationStatArchiveToMongo($options);
+ }
+ }
+
+ function migrationStatToMongo($options)
+ {
+
+ $this->appendRunMessage('## Moving from stat to Mongo');
+
+ $fileName = $this->config->getSetting('LIBRARY_LOCATION') . '.watermark_stat_mongo.txt';
+
+ // Get low watermark from file
+ $watermark = $this->getWatermarkFromFile($fileName, 'stat');
+
+ $numberOfLoops = 0;
+
+ while ($watermark > 0) {
+ $count = 0;
+ $stats = $this->store->getConnection()
+ ->prepare('SELECT * FROM stat WHERE statId < :watermark ORDER BY statId DESC LIMIT :limit');
+ $stats->bindParam(':watermark', $watermark, \PDO::PARAM_INT);
+ $stats->bindParam(':limit', $options['numberOfRecords'], \PDO::PARAM_INT);
+
+ // Run the select
+ $stats->execute();
+
+ // Keep count how many stats we've inserted
+ $recordCount = $stats->rowCount();
+ $count+= $recordCount;
+
+ // End of records
+ if ($this->checkEndOfRecords($recordCount, $fileName) === true) {
+ $this->appendRunMessage(PHP_EOL. '# End of records.' . PHP_EOL. '- Truncating and Optimising stat.');
+ $this->log->debug('End of records in stat table. Truncate and Optimise.');
+
+ // Truncate stat table
+ $this->store->update('TRUNCATE TABLE stat', []);
+
+ // Optimize stat table
+ if ($options['optimiseOnComplete'] == 1) {
+ $this->store->update('OPTIMIZE TABLE stat', []);
+ }
+
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+
+ break;
+ }
+
+ // Loops limit end - task will need to rerun again to start from the saved watermark
+ if ($this->checkLoopLimits($numberOfLoops, $options['numberOfLoops'], $fileName, $watermark) === true) {
+ break;
+ }
+ $numberOfLoops++;
+
+ $statCount = 0;
+ foreach ($stats->fetchAll() as $stat) {
+ // We get the watermark now because if we skip the records our watermark will never reach 0
+ $watermark = $stat['statId'];
+
+ // Get display
+ $display = $this->getDisplay((int) $stat['displayId']);
+
+ if (empty($display)) {
+ $this->log->error('Display not found. Display Id: '. $stat['displayId']);
+ continue;
+ }
+
+ $entry = [];
+
+ $entry['statDate'] = Carbon::createFromTimestamp($stat['statDate']);
+ $entry['type'] = $stat['type'];
+ $entry['fromDt'] = Carbon::createFromTimestamp($stat['start']);
+ $entry['toDt'] = Carbon::createFromTimestamp($stat['end']);
+ $entry['scheduleId'] = (int) $stat['scheduleId'];
+ $entry['mediaId'] = (int) $stat['mediaId'];
+ $entry['layoutId'] = (int) $stat['layoutId'];
+ $entry['display'] = $display;
+ $entry['campaignId'] = (int) $stat['campaignId'];
+ $entry['tag'] = $stat['tag'];
+ $entry['widgetId'] = (int) $stat['widgetId'];
+ $entry['duration'] = (int) $stat['duration'];
+ $entry['count'] = (int) $stat['count'];
+
+ // Add stats in store $this->stats
+ $this->timeSeriesStore->addStat($entry);
+ $statCount++;
+ }
+
+ // Write stats
+ if ($statCount > 0) {
+ $this->timeSeriesStore->addStatFinalize();
+ } else {
+ $this->appendRunMessage('No stat to migrate from stat to mongo');
+ $this->log->debug('No stat to migrate from stat to mongo');
+ }
+
+ // Give Mongo time to recover
+ if ($watermark > 0) {
+ $this->appendRunMessage('- '. $count. ' rows migrated.');
+ $this->log->debug('Mongo stats migration from stat. '.$count.' rows effected, sleeping.');
+ sleep($options['pauseBetweenLoops']);
+ }
+ }
+ }
+
+ function migrationStatArchiveToMongo($options)
+ {
+
+ $this->appendRunMessage(PHP_EOL. '## Moving from stat_archive to Mongo');
+ $fileName = $this->config->getSetting('LIBRARY_LOCATION') . '.watermark_stat_archive_mongo.txt';
+
+ // Get low watermark from file
+ $watermark = $this->getWatermarkFromFile($fileName, 'stat_archive');
+
+ $numberOfLoops = 0;
+
+ while ($watermark > 0) {
+ $count = 0;
+ /** @noinspection SqlResolve */
+ $stats = $this->store->getConnection()->prepare('
+ SELECT statId, type, statDate, scheduleId, displayId, layoutId, mediaId, widgetId, start, `end`, tag
+ FROM stat_archive
+ WHERE statId < :watermark
+ ORDER BY statId DESC LIMIT :limit
+ ');
+ $stats->bindParam(':watermark', $watermark, \PDO::PARAM_INT);
+ $stats->bindParam(':limit', $options['numberOfRecords'], \PDO::PARAM_INT);
+
+ // Run the select
+ $stats->execute();
+
+ // Keep count how many stats we've processed
+ $recordCount = $stats->rowCount();
+ $count+= $recordCount;
+
+ // End of records
+ if ($this->checkEndOfRecords($recordCount, $fileName) === true) {
+ $this->appendRunMessage(PHP_EOL. '# End of records.' . PHP_EOL. '- Dropping stat_archive.');
+ $this->log->debug('End of records in stat_archive (migration to Mongo). Dropping table.');
+
+ // Drop the stat_archive table
+ /** @noinspection SqlResolve */
+ $this->store->update('DROP TABLE `stat_archive`;', []);
+
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+
+ break;
+ }
+
+ // Loops limit end - task will need to rerun again to start from the saved watermark
+ if ($this->checkLoopLimits($numberOfLoops, $options['numberOfLoops'], $fileName, $watermark) === true) {
+ break;
+ }
+ $numberOfLoops++;
+ $temp = [];
+
+ $statIgnoredCount = 0;
+ $statCount = 0;
+ foreach ($stats->fetchAll() as $stat) {
+ // We get the watermark now because if we skip the records our watermark will never reach 0
+ $watermark = $stat['statId'];
+
+ // Get display
+ $display = $this->getDisplay((int) $stat['displayId']);
+
+ if (empty($display)) {
+ $this->log->error('Display not found. Display Id: '. $stat['displayId']);
+ $statIgnoredCount+= 1;
+ continue;
+ }
+
+ $entry = [];
+
+ // Get campaignId
+ if (($stat['type'] != 'event') && ($stat['layoutId'] != null)) {
+ try {
+ // Search the campaignId in the temp array first to reduce query in layouthistory
+ if (array_key_exists($stat['layoutId'], $temp)) {
+ $campaignId = $temp[$stat['layoutId']];
+ } else {
+ $campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($stat['layoutId']);
+ $temp[$stat['layoutId']] = $campaignId;
+ }
+ } catch (NotFoundException $error) {
+ $statIgnoredCount+= 1;
+ continue;
+ }
+ } else {
+ $campaignId = 0;
+ }
+
+ $statDate = Carbon::createFromTimestamp($stat['statDate']);
+ $start = Carbon::createFromTimestamp($stat['start']);
+ $end = Carbon::createFromTimestamp($stat['end']);
+
+ $entry['statDate'] = $statDate;
+ $entry['type'] = $stat['type'];
+ $entry['fromDt'] = $start;
+ $entry['toDt'] = $end;
+ $entry['scheduleId'] = (int) $stat['scheduleId'];
+ $entry['display'] = $display;
+ $entry['campaignId'] = (int) $campaignId;
+ $entry['layoutId'] = (int) $stat['layoutId'];
+ $entry['mediaId'] = (int) $stat['mediaId'];
+ $entry['tag'] = $stat['tag'];
+ $entry['widgetId'] = (int) $stat['widgetId'];
+ $entry['duration'] = (int) $end->diffInSeconds($start);
+ $entry['count'] = isset($stat['count']) ? (int) $stat['count'] : 1;
+
+ // Add stats in store $this->stats
+ $this->timeSeriesStore->addStat($entry);
+ $statCount++;
+ }
+
+ if ($statIgnoredCount > 0) {
+ $this->appendRunMessage($statIgnoredCount. ' stat(s) were ignored while migrating');
+ }
+
+ // Write stats
+ if ($statCount > 0) {
+ $this->timeSeriesStore->addStatFinalize();
+ } else {
+ $this->appendRunMessage('No stat to migrate from stat archive to mongo');
+ $this->log->debug('No stat to migrate from stat archive to mongo');
+ }
+
+ // Give Mongo time to recover
+ if ($watermark > 0) {
+ if ($statCount > 0) {
+ $this->appendRunMessage('- '. $count. ' rows processed. ' . $statCount. ' rows migrated');
+ $this->log->debug('Mongo stats migration from stat_archive. '.$count.' rows effected, sleeping.');
+ }
+ sleep($options['pauseBetweenLoops']);
+ }
+ }
+ }
+
+ // Get low watermark from file
+ function getWatermarkFromFile($fileName, $tableName)
+ {
+
+ if (file_exists($fileName)) {
+ $file = fopen($fileName, 'r');
+ $line = fgets($file);
+ fclose($file);
+ $watermark = (int) $line;
+ } else {
+ // Save mysql low watermark in file if .watermark.txt file is not found
+ /** @noinspection SqlResolve */
+ $statId = $this->store->select('SELECT MAX(statId) as statId FROM '.$tableName, []);
+ $watermark = (int) $statId[0]['statId'];
+
+ $out = fopen($fileName, 'w');
+ fwrite($out, $watermark);
+ fclose($out);
+ }
+
+ // We need to increase it
+ $watermark+= 1;
+ $this->appendRunMessage('- Initial watermark is '.$watermark);
+
+ return $watermark;
+ }
+
+ // Check if end of records
+ function checkEndOfRecords($recordCount, $fileName)
+ {
+
+ if ($recordCount == 0) {
+ // No records in stat, save watermark in file
+ $watermark = -1;
+
+ $out = fopen($fileName, 'w');
+ fwrite($out, $watermark);
+ fclose($out);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ // Check loop limits
+ function checkLoopLimits($numberOfLoops, $optionsNumberOfLoops, $fileName, $watermark)
+ {
+
+ if ($numberOfLoops == $optionsNumberOfLoops) {
+ // Save watermark in file
+ $watermark = $watermark - 1;
+ $this->log->debug(' Loop reached limit. Watermark is now '.$watermark);
+
+ $out = fopen($fileName, 'w');
+ fwrite($out, $watermark);
+ fclose($out);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ // Disable the task
+ function disableTask()
+ {
+
+ $this->appendRunMessage('# Disabling task.');
+ $this->log->debug('Disabling task.');
+
+ $this->getTask()->isActive = 0;
+ $this->getTask()->save();
+
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+
+ return;
+ }
+
+ // Disable the task
+ function quitMigrationTaskOrDisableStatArchiveTask()
+ {
+
+ // Quit the migration task if stat archive task is running
+ if ($this->archiveTask->status == Task::$STATUS_RUNNING) {
+ $this->appendRunMessage('Quitting the stat migration task as stat archive task is running');
+ $this->log->debug('Quitting the stat migration task as stat archive task is running.');
+ return;
+ }
+
+ // Mark the Stats Archiver as disabled if it is active
+ if ($this->archiveTask->isActive == 1) {
+ $this->archiveTask->isActive = 0;
+ $this->archiveTask->save();
+ $this->store->commitIfNecessary();
+
+ $this->appendRunMessage('Disabling Stats Archive Task.');
+ $this->log->debug('Disabling Stats Archive Task.');
+ }
+
+ return;
+ }
+
+ // Cahce/Get display
+ function getDisplay($displayId)
+ {
+
+ // Get display if in memory
+ if (array_key_exists($displayId, $this->displays)) {
+ $display = $this->displays[$displayId];
+ } elseif (array_key_exists($displayId, $this->displaysNotFound)) {
+ // Display not found
+ return false;
+ } else {
+ try {
+ $display = $this->displayFactory->getById($displayId);
+
+ // Cache display
+ $this->displays[$displayId] = $display;
+ } catch (NotFoundException $error) {
+ // Cache display not found
+ $this->displaysNotFound[$displayId] = $displayId;
+ return false;
+ }
+ }
+
+ return $display;
+ }
+}
diff --git a/lib/XTR/TaskInterface.php b/lib/XTR/TaskInterface.php
new file mode 100644
index 0000000..6167ec0
--- /dev/null
+++ b/lib/XTR/TaskInterface.php
@@ -0,0 +1,119 @@
+.
+ */
+
+
+namespace Xibo\XTR;
+use Psr\Container\ContainerInterface;
+use Stash\Interfaces\PoolInterface;
+use Xibo\Entity\Task;
+use Xibo\Entity\User;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Interface TaskInterface
+ * @package Xibo\XTR
+ */
+interface TaskInterface
+{
+ /**
+ * Set the app options
+ * @param ConfigServiceInterface $config
+ * @return $this
+ */
+ public function setConfig($config);
+
+ /**
+ * @param LogServiceInterface $logger
+ * @return $this
+ */
+ public function setLogger($logger);
+
+ /**
+ * @param SanitizerInterface $sanitizer
+ * @return $this
+ */
+ public function setSanitizer($sanitizer);
+
+ /**
+ * @param $array
+ * @return SanitizerInterface
+ */
+ public function getSanitizer($array);
+
+ /**
+ * Set the task
+ * @param Task $task
+ * @return $this
+ */
+ public function setTask($task);
+
+ /**
+ * @param StorageServiceInterface $store
+ * @return $this
+ */
+ public function setStore($store);
+
+ /**
+ * @param TimeSeriesStoreInterface $timeSeriesStore
+ * @return $this
+ */
+ public function setTimeSeriesStore($timeSeriesStore);
+
+ /**
+ * @param PoolInterface $pool
+ * @return $this
+ */
+ public function setPool($pool);
+
+ /**
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function setDispatcher($dispatcher);
+
+ /**
+ * @param User $user
+ * @return $this
+ */
+ public function setUser($user);
+
+ /**
+ * @param ContainerInterface $container
+ * @return $this
+ */
+ public function setFactories($container);
+
+ /**
+ * @return $this
+ */
+ public function run();
+
+ /**
+ * Get the run message
+ * @return string
+ */
+ public function getRunMessage();
+}
\ No newline at end of file
diff --git a/lib/XTR/TaskTrait.php b/lib/XTR/TaskTrait.php
new file mode 100644
index 0000000..fa0ff9d
--- /dev/null
+++ b/lib/XTR/TaskTrait.php
@@ -0,0 +1,216 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Psr\Log\LoggerInterface;
+use Stash\Interfaces\PoolInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Xibo\Entity\Task;
+use Xibo\Entity\User;
+use Xibo\Helper\SanitizerService;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+
+/**
+ * Class TaskTrait
+ * @package Xibo\XTR
+ */
+trait TaskTrait
+{
+ /** @var LogServiceInterface */
+ private $log;
+
+ /** @var ConfigServiceInterface */
+ private $config;
+
+ /** @var SanitizerService */
+ private $sanitizerService;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var TimeSeriesStoreInterface */
+ private $timeSeriesStore;
+
+ /** @var PoolInterface */
+ private $pool;
+
+ /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */
+ private $dispatcher;
+
+ /** @var User */
+ private $user;
+
+ /** @var Task */
+ private $task;
+
+ /** @var array */
+ private $options;
+
+ /** @var string */
+ private $runMessage;
+
+ /** @inheritdoc */
+ public function setConfig($config)
+ {
+ $this->config = $config;
+ return $this;
+ }
+
+ /**
+ * @return \Xibo\Service\ConfigServiceInterface
+ */
+ protected function getConfig(): ConfigServiceInterface
+ {
+ return $this->config;
+ }
+
+ /** @inheritdoc */
+ public function setLogger($logger)
+ {
+ $this->log = $logger;
+ return $this;
+ }
+
+ /**
+ * @return \Psr\Log\LoggerInterface
+ */
+ private function getLogger(): LoggerInterface
+ {
+ return $this->log->getLoggerInterface();
+ }
+
+ /**
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ public function getSanitizer($array)
+ {
+ return $this->sanitizerService->getSanitizer($array);
+ }
+
+ /** @inheritdoc */
+ public function setSanitizer($sanitizer)
+ {
+ $this->sanitizerService = $sanitizer;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function setTask($task)
+ {
+ $options = $task->options;
+
+ if (property_exists($this, 'defaultConfig'))
+ $options = array_merge($this->defaultConfig, $options);
+
+ $this->task = $task;
+ $this->options = $options;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function setStore($store)
+ {
+ $this->store = $store;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function setTimeSeriesStore($timeSeriesStore)
+ {
+ $this->timeSeriesStore = $timeSeriesStore;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function setPool($pool)
+ {
+ $this->pool = $pool;
+ return $this;
+ }
+
+ /**
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @return $this
+ */
+ public function setDispatcher($dispatcher)
+ {
+ $this->dispatcher = $dispatcher;
+ return $this;
+ }
+
+ /**
+ * @return \Symfony\Component\EventDispatcher\EventDispatcherInterface
+ */
+ protected function getDispatcher(): EventDispatcherInterface
+ {
+ return $this->dispatcher;
+ }
+
+ /** @inheritdoc */
+ public function setUser($user)
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function getRunMessage()
+ {
+ return $this->runMessage;
+ }
+
+ /**
+ * Get task
+ * @return Task
+ */
+ private function getTask()
+ {
+ return $this->task;
+ }
+
+ /**
+ * @param $option
+ * @param $default
+ * @return mixed
+ */
+ private function getOption($option, $default)
+ {
+ return $this->options[$option] ?? $default;
+ }
+
+ /**
+ * Append Run Message
+ * @param $message
+ */
+ private function appendRunMessage($message)
+ {
+ if ($this->runMessage === null)
+ $this->runMessage = '';
+
+ $this->runMessage .= $message . PHP_EOL;
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/UpdateEmptyVideoDurations.php b/lib/XTR/UpdateEmptyVideoDurations.php
new file mode 100644
index 0000000..a79358f
--- /dev/null
+++ b/lib/XTR/UpdateEmptyVideoDurations.php
@@ -0,0 +1,52 @@
+mediaFactory = $container->get('mediaFactory');
+ $this->moduleFactory = $container->get('moduleFactory');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ $libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
+ $module = $this->moduleFactory->getByType('video');
+ $videos = $this->mediaFactory->getByMediaType($module->type);
+
+ foreach ($videos as $video) {
+ if ($video->duration == 0) {
+ // Update
+ $video->duration = $module->fetchDurationOrDefaultFromFile($libraryLocation . $video->storedAs);
+ $video->save(['validate' => false]);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/XTR/WidgetCompatibilityTask.php b/lib/XTR/WidgetCompatibilityTask.php
new file mode 100644
index 0000000..069ff19
--- /dev/null
+++ b/lib/XTR/WidgetCompatibilityTask.php
@@ -0,0 +1,248 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class WidgetCompatibilityTask
+ * Run only once when upgrading widget from v3 to v4
+ * @package Xibo\XTR
+ */
+class WidgetCompatibilityTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var \Xibo\Factory\ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Factory\WidgetFactory */
+ private $widgetFactory;
+
+ /** @var \Xibo\Factory\LayoutFactory */
+ private $layoutFactory;
+
+ /** @var \Xibo\Factory\playlistFactory */
+ private $playlistFactory;
+
+ /** @var \Xibo\Factory\TaskFactory */
+ private $taskFactory;
+ /** @var \Xibo\Factory\RegionFactory */
+ private $regionFactory;
+
+ /** @var array The cache for layout */
+ private $layoutCache = [];
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->moduleFactory = $container->get('moduleFactory');
+ $this->widgetFactory = $container->get('widgetFactory');
+ $this->layoutFactory = $container->get('layoutFactory');
+ $this->playlistFactory = $container->get('playlistFactory');
+ $this->taskFactory = $container->get('taskFactory');
+ $this->regionFactory = $container->get('regionFactory');
+ return $this;
+ }
+
+ /** @inheritdoc
+ */
+ public function run()
+ {
+ // This task should only be run once when upgrading for the first time from v3 to v4.
+ // If the Widget Compatibility class is defined, it needs to be executed to upgrade the widgets.
+ $this->runMessage = '# ' . __('Widget Compatibility') . PHP_EOL . PHP_EOL;
+
+ // Get all modules
+ $modules = $this->moduleFactory->getAll();
+
+ // For each module we should get all widgets which are < the schema version of the module installed, and
+ // upgrade them to the schema version of the module installed
+ foreach ($modules as $module) {
+ // Run upgrade - Part 1
+ // Upgrade a widget having the same module type
+ $this->getLogger()->debug('run: finding widgets for ' . $module->type
+ . ' with schema version less than ' . $module->schemaVersion);
+
+ $statement = $this->executeStatement($module->type, $module->schemaVersion);
+ $this->upgradeWidget($statement);
+
+ // Run upgrade - Part 2
+ // Upgrade a widget having the old style module type/legacy type
+ $legacyTypes = [];
+ if (count($module->legacyTypes) > 0) {
+ // Get the name of the module legacy types
+ $legacyTypes = array_column($module->legacyTypes, 'name'); // TODO Make this efficient
+ }
+
+ // Get module legacy type and update matched widgets
+ foreach ($legacyTypes as $legacyType) {
+ $statement = $this->executeStatement($legacyType, $module->schemaVersion);
+ $this->upgradeWidget($statement);
+ }
+ }
+
+ // Get Widget Compatibility Task
+ $compatibilityTask = $this->taskFactory->getByClass('\Xibo\XTR\\WidgetCompatibilityTask');
+
+ // Mark the task as disabled if it is active
+ if ($compatibilityTask->isActive == 1) {
+ $compatibilityTask->isActive = 0;
+ $compatibilityTask->save();
+ $this->store->commitIfNecessary();
+
+ $this->appendRunMessage('Disabling widget compatibility task.');
+ $this->log->debug('Disabling widget compatibility task.');
+ }
+ $this->appendRunMessage(__('Done.'. PHP_EOL));
+ }
+
+ /**
+ *
+ * @param string $type
+ * @param int $version
+ * @return false|\PDOStatement
+ */
+ private function executeStatement(string $type, int $version): bool|\PDOStatement
+ {
+ $sql = '
+ SELECT widget.widgetId
+ FROM `widget`
+ WHERE `widget`.`type` = :type
+ and `widget`.schemaVersion < :version
+ ';
+ $connection = $this->store->getConnection();
+ $connection->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
+
+ // Prepare the statement
+ $statement = $connection->prepare($sql);
+
+ // Execute
+ $statement->execute([
+ 'type' => $type,
+ 'version' => $version
+ ]);
+
+ return $statement;
+ }
+
+ private function upgradeWidget(\PDOStatement $statement): void
+ {
+ // Load each widget and its options
+ // Then run upgrade
+ while ($row = $statement->fetch(\PDO::FETCH_ASSOC)) {
+ try {
+ $widget = $this->widgetFactory->getById((int) $row['widgetId']);
+ $widget->loadMinimum();
+
+ $this->log->debug('WidgetCompatibilityTask: Get Widget: ' . $row['widgetId']);
+
+ // Form conditions from the widget's option and value, e.g, templateId==worldclock1
+ $widgetConditionMatch = [];
+ foreach ($widget->widgetOptions as $option) {
+ $widgetConditionMatch[] = $option->option . '==' . $option->value;
+ }
+
+ // Get module
+ try {
+ $module = $this->moduleFactory->getByType($widget->type, $widgetConditionMatch);
+ } catch (NotFoundException $e) {
+ $this->log->error('Module not found for widget: ' . $widget->type);
+ $this->appendRunMessage('Module not found for widget: '. $widget->widgetId);
+ continue;
+ }
+
+ // Run upgrade
+ if ($module->isWidgetCompatibilityAvailable()) {
+ // Grab a widget compatibility interface, if there is one
+ $widgetCompatibilityInterface = $module->getWidgetCompatibilityOrNull();
+ if ($widgetCompatibilityInterface !== null) {
+ try {
+ // Pass the widget through the compatibility interface.
+ $upgraded = $widgetCompatibilityInterface->upgradeWidget(
+ $widget,
+ $widget->schemaVersion,
+ $module->schemaVersion
+ );
+
+ // Save widget version
+ if ($upgraded) {
+ $widget->schemaVersion = $module->schemaVersion;
+
+ // Assert the module type, unless the widget has already changed it.
+ if (!$widget->hasPropertyChanged('type')) {
+ $widget->type = $module->type;
+ }
+
+ $widget->save(['alwaysUpdate' => true, 'upgrade' => true]);
+ $this->log->debug('WidgetCompatibilityTask: Upgraded');
+ }
+ } catch (\Exception $e) {
+ $this->log->error('Failed to upgrade for widgetId: ' . $widget->widgetId .
+ ', message: ' . $e->getMessage());
+ $this->appendRunMessage('Failed to upgrade for widgetId: : '. $widget->widgetId);
+ }
+ }
+
+ try {
+ // Get the layout of the widget and set it to rebuild.
+ $playlist = $this->playlistFactory->getById($widget->playlistId);
+
+ // check if the Widget was assigned to a region playlist
+ if ($playlist->isRegionPlaylist()) {
+ $playlist->load();
+ $region = $this->regionFactory->getById($playlist->regionId);
+
+ // set the region type accordingly
+ if ($region->isDrawer === 1) {
+ $regionType = 'drawer';
+ } else if (count($playlist->widgets) === 1 && $widget->type !== 'subplaylist') {
+ $regionType = 'frame';
+ } else if (count($playlist->widgets) === 0) {
+ $regionType = 'zone';
+ } else {
+ $regionType = 'playlist';
+ }
+
+ $region->type = $regionType;
+ $region->save(['notify' => false]);
+ }
+ $playlist->notifyLayouts();
+ } catch (\Exception $e) {
+ $this->log->error('Failed to set layout rebuild for widgetId: ' . $widget->widgetId .
+ ', message: ' . $e->getMessage());
+ $this->appendRunMessage('Layout rebuild error for widgetId: : '. $widget->widgetId);
+ }
+ } else {
+ $this->getLogger()->debug('upgradeWidget: no compatibility task available for ' . $widget->type);
+ }
+ } catch (GeneralException $e) {
+ $this->log->debug($e->getTraceAsString());
+ $this->log->error('WidgetCompatibilityTask: Cannot process widget');
+ }
+ }
+
+ $this->store->commitIfNecessary();
+ }
+}
diff --git a/lib/XTR/WidgetSyncTask.php b/lib/XTR/WidgetSyncTask.php
new file mode 100644
index 0000000..a77423a
--- /dev/null
+++ b/lib/XTR/WidgetSyncTask.php
@@ -0,0 +1,493 @@
+.
+ */
+
+namespace Xibo\XTR;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Entity\Module;
+use Xibo\Entity\Widget;
+use Xibo\Event\WidgetDataRequestEvent;
+use Xibo\Factory\WidgetDataFactory;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Widget\Provider\WidgetProviderInterface;
+
+/**
+ * Class WidgetSyncTask
+ * Keep all widgets which have data up to date
+ * @package Xibo\XTR
+ */
+class WidgetSyncTask implements TaskInterface
+{
+ use TaskTrait;
+
+ /** @var \Xibo\Factory\ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Factory\WidgetFactory */
+ private $widgetFactory;
+
+ /** @var \Xibo\Factory\WidgetDataFactory */
+ private WidgetDataFactory $widgetDataFactory;
+
+ /** @var \Xibo\Factory\MediaFactory */
+ private $mediaFactory;
+
+ /** @var \Xibo\Factory\DisplayFactory */
+ private $displayFactory;
+
+ /** @var \Symfony\Component\EventDispatcher\EventDispatcher */
+ private $eventDispatcher;
+
+ /** @inheritdoc */
+ public function setFactories($container)
+ {
+ $this->moduleFactory = $container->get('moduleFactory');
+ $this->widgetFactory = $container->get('widgetFactory');
+ $this->widgetDataFactory = $container->get('widgetDataFactory');
+ $this->mediaFactory = $container->get('mediaFactory');
+ $this->displayFactory = $container->get('displayFactory');
+ $this->eventDispatcher = $container->get('dispatcher');
+ return $this;
+ }
+
+ /** @inheritdoc */
+ public function run()
+ {
+ // Track the total time we've spent caching
+ $timeCaching = 0.0;
+ $countWidgets = 0;
+ $cutOff = Carbon::now()->subHours(2);
+
+ // Update for widgets which are active on displays, or for displays which have been active recently.
+ $sql = '
+ SELECT DISTINCT `requiredfile`.itemId, `requiredfile`.complete
+ FROM `requiredfile`
+ INNER JOIN `display`
+ ON `display`.displayId = `requiredfile`.displayId
+ WHERE `requiredfile`.type = \'D\'
+ AND (`display`.loggedIn = 1 OR `display`.lastAccessed > :lastAccessed)
+ ORDER BY `requiredfile`.complete DESC, `requiredfile`.itemId
+ ';
+
+ $smt = $this->store->getConnection()->prepare($sql);
+ $smt->execute(['lastAccessed' => $cutOff->unix()]);
+
+ $row = true;
+ while ($row) {
+ $row = $smt->fetch(\PDO::FETCH_ASSOC);
+ try {
+ if ($row !== false) {
+ $widgetId = (int)$row['itemId'];
+
+ $this->getLogger()->debug('widgetSyncTask: processing itemId ' . $widgetId);
+
+ // What type of widget do we have here.
+ $widget = $this->widgetFactory->getById($widgetId)->load();
+
+ // Get the module
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // If this widget's module expects data to be provided (i.e. has a datatype) then make sure that
+ // data is cached ahead of time here.
+ // This also refreshes any library or external images referenced by the data so that they aren't
+ // considered for removal.
+ if ($module->isDataProviderExpected()) {
+ $this->getLogger()->debug('widgetSyncTask: data provider expected.');
+
+ // Record start time
+ $countWidgets++;
+ $startTime = microtime(true);
+
+ // Grab a widget interface, if there is one
+ $widgetInterface = $module->getWidgetProviderOrNull();
+
+ // Is the cache key display specific?
+ $dataProvider = $module->createDataProvider($widget);
+ $cacheKey = $widgetInterface?->getDataCacheKey($dataProvider);
+
+ if ($cacheKey === null) {
+ $cacheKey = $module->dataCacheKey;
+ }
+
+ // Refresh the cache if needed.
+ $isDisplaySpecific = str_contains($cacheKey, '%displayId%')
+ || (str_contains($cacheKey, '%useDisplayLocation%')
+ && $dataProvider->getProperty('useDisplayLocation') == 1);
+
+ // We're either assigning all media to all displays, or we're assigning them one by one
+ if ($isDisplaySpecific) {
+ $this->getLogger()->debug('widgetSyncTask: cache is display specific');
+
+ // We need to run the cache for every display this widget is assigned to.
+ foreach ($this->getDisplays($widget) as $display) {
+ $cacheData = $this->cache(
+ $module,
+ $widget,
+ $widgetInterface,
+ $display,
+ );
+
+ $this->linkDisplays(
+ $widget->widgetId,
+ [$display],
+ $cacheData['mediaIds'],
+ $cacheData['fromCache']
+ );
+ }
+ } else {
+ $this->getLogger()->debug('widgetSyncTask: cache is not display specific');
+
+ // Just a single run will do it.
+ $cacheData = $this->cache($module, $widget, $widgetInterface, null);
+
+ $this->linkDisplays(
+ $widget->widgetId,
+ $this->getDisplays($widget),
+ $cacheData['mediaIds'],
+ $cacheData['fromCache']
+ );
+ }
+
+ // Record end time and aggregate for final total
+ $duration = (microtime(true) - $startTime);
+ $timeCaching = $timeCaching + $duration;
+ $this->log->debug('widgetSyncTask: Took ' . $duration
+ . ' seconds to check and/or cache widgetId ' . $widget->widgetId);
+
+ // Commit so that any images we've downloaded have their cache times updated for the
+ // next request, this makes sense because we've got a file cache that is already written
+ // out.
+ $this->store->commitIfNecessary();
+ }
+ }
+ } catch (GeneralException $xiboException) {
+ $this->log->debug($xiboException->getTraceAsString());
+ $this->log->error('widgetSyncTask: Cannot process widget ' . $widgetId
+ . ', E = ' . $xiboException->getMessage());
+ }
+ }
+
+ // Remove display_media records which have not been touched for a defined period of time.
+ $this->removeOldDisplayLinks($cutOff);
+
+ $this->log->info('Total time spent caching is ' . $timeCaching . ', synced ' . $countWidgets . ' widgets');
+
+ $this->appendRunMessage('Synced ' . $countWidgets . ' widgets');
+ }
+
+ /**
+ * @param Module $module
+ * @param Widget $widget
+ * @param WidgetProviderInterface|null $widgetInterface
+ * @param Display|null $display
+ * @return array
+ * @throws GeneralException
+ */
+ private function cache(
+ Module $module,
+ Widget $widget,
+ ?WidgetProviderInterface $widgetInterface,
+ ?Display $display
+ ): array {
+ $this->getLogger()->debug('cache: ' . $widget->widgetId . ' for display: ' . ($display?->displayId ?? 0));
+
+ // Each time we call this we use a new provider
+ $dataProvider = $module->createDataProvider($widget);
+ $dataProvider->setMediaFactory($this->mediaFactory);
+
+ // Set our provider up for the display
+ $dataProvider->setDisplayProperties(
+ $display?->latitude ?: $this->getConfig()->getSetting('DEFAULT_LAT'),
+ $display?->longitude ?: $this->getConfig()->getSetting('DEFAULT_LONG'),
+ $display?->displayId ?? 0
+ );
+
+ $widgetDataProviderCache = $this->moduleFactory->createWidgetDataProviderCache();
+
+ // Get the cache key
+ $cacheKey = $this->moduleFactory->determineCacheKey(
+ $module,
+ $widget,
+ $display?->displayId ?? 0,
+ $dataProvider,
+ $widgetInterface
+ );
+
+ // Get the data modified date
+ $dataModifiedDt = null;
+ if ($widgetInterface !== null) {
+ $dataModifiedDt = $widgetInterface->getDataModifiedDt($dataProvider);
+
+ if ($dataModifiedDt !== null) {
+ $this->getLogger()->debug('cache: data modifiedDt is ' . $dataModifiedDt->toAtomString());
+ }
+ }
+
+ // Will we use fallback data if available?
+ $showFallback = $widget->getOptionValue('showFallback', 'never');
+ if ($showFallback !== 'never') {
+ // What data type are we dealing with?
+ try {
+ $dataTypeFields = [];
+ foreach ($this->moduleFactory->getDataTypeById($module->dataType)->fields as $field) {
+ $dataTypeFields[$field->id] = $field->type;
+ }
+
+ // Potentially we will, so get the modifiedDt of this fallback data.
+ $fallbackModifiedDt = $this->widgetDataFactory->getModifiedDtForWidget($widget->widgetId);
+
+ if ($fallbackModifiedDt !== null) {
+ $this->getLogger()->debug('cache: fallback modifiedDt is ' . $fallbackModifiedDt->toAtomString());
+
+ $dataModifiedDt = max($dataModifiedDt, $fallbackModifiedDt);
+ }
+ } catch (NotFoundException) {
+ $this->getLogger()->info('cache: widget will fallback set where the module does not support it');
+ $dataTypeFields = null;
+ }
+ } else {
+ $dataTypeFields = null;
+ }
+
+ // Is this data from cache?
+ $fromCache = false;
+
+ if (!$widgetDataProviderCache->decorateWithCache($dataProvider, $cacheKey, $dataModifiedDt)
+ || $widgetDataProviderCache->isCacheMissOrOld()
+ ) {
+ $this->getLogger()->debug('cache: Cache expired, pulling fresh: key: ' . $cacheKey);
+
+ $dataProvider->clearData();
+ $dataProvider->clearMeta();
+ $dataProvider->addOrUpdateMeta('showFallback', $showFallback);
+
+ try {
+ if ($widgetInterface !== null) {
+ $widgetInterface->fetchData($dataProvider);
+ } else {
+ $dataProvider->setIsUseEvent();
+ }
+
+ if ($dataProvider->isUseEvent()) {
+ $this->getDispatcher()->dispatch(
+ new WidgetDataRequestEvent($dataProvider),
+ WidgetDataRequestEvent::$NAME
+ );
+ }
+
+ // Before caching images, check to see if the data provider is handled
+ $isFallback = false;
+ if ($showFallback !== 'never'
+ && $dataTypeFields !== null
+ && (
+ count($dataProvider->getErrors()) > 0
+ || count($dataProvider->getData()) <= 0
+ || $showFallback === 'always'
+ )
+ ) {
+ // Error or no data.
+ // Pull in the fallback data
+ foreach ($this->widgetDataFactory->getByWidgetId($dataProvider->getWidgetId()) as $item) {
+ // Handle any special data types in the fallback data
+ foreach ($item->data as $itemId => $itemData) {
+ if (!empty($itemData)
+ && array_key_exists($itemId, $dataTypeFields)
+ && $dataTypeFields[$itemId] === 'image'
+ ) {
+ $item->data[$itemId] = $dataProvider->addLibraryFile($itemData);
+ }
+ }
+
+ $dataProvider->addItem($item->data);
+
+ // Indicate we've been handled by fallback data
+ $isFallback = true;
+ }
+
+ if ($isFallback) {
+ $dataProvider->addOrUpdateMeta('includesFallback', true);
+ }
+ }
+
+ // Remove fallback data from the cache if no-longer needed
+ if (!$isFallback) {
+ $dataProvider->addOrUpdateMeta('includesFallback', false);
+ }
+
+ // Do we have images?
+ // They could be library images (i.e. they already exist) or downloads
+ $mediaIds = $dataProvider->getImageIds();
+ if (count($mediaIds) > 0) {
+ // Process the downloads.
+ $this->mediaFactory->processDownloads(function ($media) {
+ /** @var \Xibo\Entity\Media $media */
+ // Success
+ // We don't need to do anything else, references to mediaId will be built when we decorate
+ // the HTML.
+ $this->getLogger()->debug('cache: Successfully downloaded ' . $media->mediaId);
+ }, function ($media) use (&$mediaIds) {
+ /** @var \Xibo\Entity\Media $media */
+ // Error
+ // Remove it
+ unset($mediaIds[$media->mediaId]);
+ });
+ }
+
+ // Save to cache
+ if ($dataProvider->isHandled() || $isFallback) {
+ $widgetDataProviderCache->saveToCache($dataProvider);
+ }
+ } finally {
+ $widgetDataProviderCache->finaliseCache();
+ }
+ } else {
+ $this->getLogger()->debug('cache: Cache still valid, key: ' . $cacheKey);
+
+ $fromCache = true;
+
+ // Get the existing mediaIds so that we can maintain the links to displays.
+ $mediaIds = $widgetDataProviderCache->getCachedMediaIds();
+ }
+
+ return [
+ 'mediaIds' => $mediaIds,
+ 'fromCache' => $fromCache
+ ];
+ }
+
+ /**
+ * @param \Xibo\Entity\Widget $widget
+ * @return Display[]
+ */
+ private function getDisplays(Widget $widget): array
+ {
+ $sql = '
+ SELECT DISTINCT `requiredfile`.`displayId`
+ FROM `requiredfile`
+ WHERE itemId = :widgetId
+ AND type = \'D\'
+ ';
+
+ $displayIds = [];
+ foreach ($this->store->select($sql, ['widgetId' => $widget->widgetId]) as $record) {
+ $displayId = intval($record['displayId']);
+ try {
+ $displayIds[] = $this->displayFactory->getById($displayId);
+ } catch (NotFoundException) {
+ $this->getLogger()->error('getDisplayIds: unknown displayId: ' . $displayId);
+ }
+ }
+
+ return $displayIds;
+ }
+
+ /**
+ * Link an array of displays with an array of media
+ * @param int $widgetId
+ * @param Display[] $displays
+ * @param int[] $mediaIds
+ * @param bool $fromCache
+ * @return void
+ */
+ private function linkDisplays(int $widgetId, array $displays, array $mediaIds, bool $fromCache): void
+ {
+ $this->getLogger()->debug('linkDisplays: ' . count($displays) . ' displays, ' . count($mediaIds) . ' media');
+
+ $sql = '
+ INSERT INTO `display_media` (`displayId`, `mediaId`, `modifiedAt`)
+ VALUES (:displayId, :mediaId, CURRENT_TIMESTAMP)
+ ON DUPLICATE KEY UPDATE `modifiedAt` = CURRENT_TIMESTAMP
+ ';
+
+ // Run invididual updates so that we can see if we've made a change.
+ // With ON DUPLICATE KEY UPDATE, the affected-rows value per row is
+ // 1 if the row is inserted as a new row,
+ // 2 if an existing row is updated and
+ // 0 if the existing row is set to its current values.
+ foreach ($displays as $display) {
+ $shouldNotify = false;
+ foreach ($mediaIds as $mediaId) {
+ try {
+ $affected = $this->store->update($sql, [
+ 'displayId' => $display->displayId,
+ 'mediaId' => $mediaId
+ ]);
+
+ if ($affected == 1) {
+ $shouldNotify = true;
+ }
+ } catch (\PDOException) {
+ // We link what we can, and log any failures.
+ $this->getLogger()->error('linkDisplays: unable to link displayId: ' . $display->displayId
+ . ' to mediaId: ' . $mediaId . ', most likely the media has since gone');
+ }
+ }
+
+ // When should we notify?
+ // ----------------------
+ // Newer displays (>= v4) should clear their cache only if linked media has changed
+ // Older displays (< v4) should check in immediately on change
+ if ($display->clientCode >= 400) {
+ if ($shouldNotify) {
+ $this->displayFactory->getDisplayNotifyService()->collectLater()
+ ->notifyByDisplayId($display->displayId);
+ }
+
+ if (!$fromCache) {
+ $this->displayFactory->getDisplayNotifyService()
+ ->notifyDataUpdate($display, $widgetId);
+ }
+ } else {
+ $this->displayFactory->getDisplayNotifyService()->collectNow()
+ ->notifyByDisplayId($display->displayId);
+ }
+ }
+ }
+
+ /**
+ * Remove any display/media links which have expired.
+ * @param Carbon $cutOff
+ * @return void
+ */
+ private function removeOldDisplayLinks(Carbon $cutOff): void
+ {
+ $sql = '
+ DELETE
+ FROM `display_media`
+ WHERE `modifiedAt` < :modifiedAt
+ AND `display_media`.`displayId` IN (
+ SELECT `displayId`
+ FROM `display`
+ WHERE `display`.`loggedIn` = 1
+ OR `display`.`lastAccessed` > :lastAccessed
+ )
+ ';
+
+ $this->store->update($sql, [
+ 'modifiedAt' => Carbon::now()->subDay()->format(DateFormatHelper::getSystemFormat()),
+ 'lastAccessed' => $cutOff->unix(),
+ ]);
+ }
+}
diff --git a/lib/Xmds/Entity/Dependency.php b/lib/Xmds/Entity/Dependency.php
new file mode 100644
index 0000000..c1e4631
--- /dev/null
+++ b/lib/Xmds/Entity/Dependency.php
@@ -0,0 +1,75 @@
+.
+ */
+
+namespace Xibo\Xmds\Entity;
+
+/**
+ * XMDS Depedency
+ * represents a player dependency
+ */
+class Dependency
+{
+ const LEGACY_ID_OFFSET_FONT = 100000000;
+ const LEGACY_ID_OFFSET_PLAYER_SOFTWARE = 200000000;
+ const LEGACY_ID_OFFSET_ASSET = 300000000;
+ const LEGACY_ID_OFFSET_DATA_CONNECTOR = 400000000;
+
+ public $fileType;
+ public $legacyId;
+ public $id;
+ public $path;
+ public $size;
+ public $md5;
+ public $isAvailableOverHttp;
+
+ /**
+ * Prior versions of XMDS need to use a legacyId to download the file via GetFile.
+ * This is a negative number in a range (to avoid collisions with existing IDs). Each dependency type should
+ * resolve to a different negative number range.
+ * The "real id" set on $this->id is saved in required files as the realId and used to resolve requests for this
+ * type of file.
+ * @param string $fileType
+ * @param string|int $id
+ * @param int $legacyId
+ * @param string $path
+ * @param int $size
+ * @param string $md5
+ * @param bool $isAvailableOverHttp
+ */
+ public function __construct(
+ string $fileType,
+ $id,
+ int $legacyId,
+ string $path,
+ int $size,
+ string $md5,
+ bool $isAvailableOverHttp = true
+ ) {
+ $this->fileType = $fileType;
+ $this->id = $id;
+ $this->legacyId = $legacyId;
+ $this->path = $path;
+ $this->size = $size;
+ $this->md5 = $md5;
+ $this->isAvailableOverHttp = $isAvailableOverHttp;
+ }
+}
diff --git a/lib/Xmds/Listeners/XmdsAssetsListener.php b/lib/Xmds/Listeners/XmdsAssetsListener.php
new file mode 100644
index 0000000..076d8b0
--- /dev/null
+++ b/lib/Xmds/Listeners/XmdsAssetsListener.php
@@ -0,0 +1,82 @@
+.
+ */
+
+namespace Xibo\Xmds\Listeners;
+
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\ModuleTemplateFactory;
+use Xibo\Listener\ListenerConfigTrait;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Listener which handles requests for assets.
+ */
+class XmdsAssetsListener
+{
+ use ListenerLoggerTrait;
+ use ListenerConfigTrait;
+
+ /** @var \Xibo\Factory\ModuleFactory */
+ private $moduleFactory;
+
+ /** @var \Xibo\Factory\ModuleTemplateFactory */
+ private $moduleTemplateFactory;
+
+ /**
+ * @param \Xibo\Factory\ModuleFactory $moduleFactory
+ * @param \Xibo\Factory\ModuleTemplateFactory $moduleTemplateFactory
+ */
+ public function __construct(ModuleFactory $moduleFactory, ModuleTemplateFactory $moduleTemplateFactory)
+ {
+ $this->moduleFactory = $moduleFactory;
+ $this->moduleTemplateFactory = $moduleTemplateFactory;
+ }
+
+ public function onDependencyRequest(XmdsDependencyRequestEvent $event): void
+ {
+ $this->getLogger()->debug('onDependencyRequest: XmdsAssetsListener');
+
+ if ($event->getFileType() === 'asset') {
+ // Get the asset using only the assetId.
+ try {
+ $asset = $this->moduleFactory
+ ->getAssetsFromAnywhereById($event->getRealId(), $this->moduleTemplateFactory);
+
+ if ($asset->isSendToPlayer()) {
+ // Make sure the asset cache is there
+ $asset->updateAssetCache($this->getConfig()->getSetting('LIBRARY_LOCATION'));
+
+ // Return the full path to this asset
+ $event->setRelativePathToLibrary('assets/' . $asset->getFilename());
+ $event->stopPropagation();
+ } else {
+ $this->getLogger()->debug('onDependencyRequest: asset found but is cms only');
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->getLogger()->info('onDependencyRequest: No asset found for assetId: '
+ . $event->getRealId());
+ }
+ }
+ }
+}
diff --git a/lib/Xmds/Listeners/XmdsDataConnectorListener.php b/lib/Xmds/Listeners/XmdsDataConnectorListener.php
new file mode 100644
index 0000000..14eb0f8
--- /dev/null
+++ b/lib/Xmds/Listeners/XmdsDataConnectorListener.php
@@ -0,0 +1,76 @@
+.
+ */
+
+namespace Xibo\Xmds\Listeners;
+
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Listener\ListenerConfigTrait;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Xmds\Entity\Dependency;
+
+/**
+ * Listener to handle dependency requests for data connectors.
+ */
+class XmdsDataConnectorListener
+{
+ use ListenerLoggerTrait;
+ use ListenerConfigTrait;
+
+ public function onDependencyRequest(XmdsDependencyRequestEvent $event)
+ {
+ // Can we return this type of file?
+ if ($event->getFileType() === 'data_connector') {
+ // Set the path
+ $event->setRelativePathToLibrary('data_connectors/dataSet_' . $event->getRealId() . '.js');
+
+ // No need to carry on, we've found it.
+ $event->stopPropagation();
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ public static function getDataConnectorDependency(string $libraryLocation, int $dataSetId): Dependency
+ {
+ // Check that this asset is valid.
+ $path = $libraryLocation
+ . 'data_connectors' . DIRECTORY_SEPARATOR
+ . 'dataSet_' . $dataSetId . '.js';
+
+ if (!file_exists($path)) {
+ throw new NotFoundException(sprintf(__('Data Connector %s not found'), $path));
+ }
+
+ // Return a dependency
+ return new Dependency(
+ 'data_connector',
+ $dataSetId,
+ (Dependency::LEGACY_ID_OFFSET_DATA_CONNECTOR + $dataSetId) * -1,
+ $path,
+ filesize($path),
+ file_get_contents($path . '.md5'),
+ true
+ );
+ }
+}
diff --git a/lib/Xmds/Listeners/XmdsFontsListener.php b/lib/Xmds/Listeners/XmdsFontsListener.php
new file mode 100644
index 0000000..8b9fc97
--- /dev/null
+++ b/lib/Xmds/Listeners/XmdsFontsListener.php
@@ -0,0 +1,93 @@
+.
+ */
+
+namespace Xibo\Xmds\Listeners;
+
+use Xibo\Event\XmdsDependencyListEvent;
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Factory\FontFactory;
+use Xibo\Listener\ListenerConfigTrait;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Xmds\Entity\Dependency;
+
+/**
+ * A listener to supply fonts as dependencies to players.
+ */
+class XmdsFontsListener
+{
+ use ListenerLoggerTrait;
+ use ListenerConfigTrait;
+
+ use XmdsListenerTrait;
+
+ /**
+ * @var FontFactory
+ */
+ private $fontFactory;
+
+ public function __construct(FontFactory $fontFactory)
+ {
+ $this->fontFactory = $fontFactory;
+ }
+
+ public function onDependencyList(XmdsDependencyListEvent $event): void
+ {
+ $this->getLogger()->debug('onDependencyList: XmdsFontsListener');
+
+ foreach ($this->fontFactory->query() as $font) {
+ $event->addDependency(
+ 'font',
+ $font->id,
+ 'fonts/' . $font->fileName,
+ $font->size,
+ $font->md5,
+ true,
+ $this->getLegacyId($font->id, Dependency::LEGACY_ID_OFFSET_FONT)
+ );
+ }
+
+ // Always add fonts.css to the list.
+ // This can have the ID of 1 because it is a different file type and will therefore be unique.
+ $fontsCssPath = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'fonts/fonts.css';
+ $event->addDependency(
+ 'fontCss',
+ 1,
+ 'fonts/fonts.css',
+ filesize($fontsCssPath),
+ md5_file($fontsCssPath),
+ true,
+ $this->getLegacyId(0, Dependency::LEGACY_ID_OFFSET_FONT)
+ );
+ }
+
+ public function onDependencyRequest(XmdsDependencyRequestEvent $event): void
+ {
+ $this->getLogger()->debug('onDependencyRequest: XmdsFontsListener');
+
+ if ($event->getFileType() === 'font') {
+ $font = $this->fontFactory->getById($event->getRealId());
+ $event->setRelativePathToLibrary('fonts/' . $font->fileName);
+ } else if ($event->getFileType() === 'fontCss') {
+ $event->setRelativePathToLibrary('fonts/fonts.css');
+ }
+ }
+}
diff --git a/lib/Xmds/Listeners/XmdsListenerTrait.php b/lib/Xmds/Listeners/XmdsListenerTrait.php
new file mode 100644
index 0000000..0d6cf0f
--- /dev/null
+++ b/lib/Xmds/Listeners/XmdsListenerTrait.php
@@ -0,0 +1,40 @@
+.
+ */
+
+namespace Xibo\Xmds\Listeners;
+
+/**
+ * A trait added to all Xmds Listeners
+ */
+trait XmdsListenerTrait
+{
+ /**
+ * Get a Legacy ID we can use for older XMDS schema versions
+ * @param int $id
+ * @param int $offset
+ * @return int
+ */
+ private function getLegacyId(int $id, int $offset): int
+ {
+ return ($id + $offset) * -1;
+ }
+}
diff --git a/lib/Xmds/Listeners/XmdsPlayerBundleListener.php b/lib/Xmds/Listeners/XmdsPlayerBundleListener.php
new file mode 100644
index 0000000..10734e0
--- /dev/null
+++ b/lib/Xmds/Listeners/XmdsPlayerBundleListener.php
@@ -0,0 +1,88 @@
+.
+ */
+
+namespace Xibo\Xmds\Listeners;
+
+use Xibo\Event\XmdsDependencyListEvent;
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Listener\ListenerCacheTrait;
+use Xibo\Listener\ListenerConfigTrait;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Support\Exception\GeneralException;
+
+/**
+ * XMDS player bundle listener
+ * responsible for adding the player bundle to the list of required files, and for returning the player bundle
+ * when requested.
+ */
+class XmdsPlayerBundleListener
+{
+ use ListenerLoggerTrait;
+ use ListenerConfigTrait;
+
+ public function onDependencyList(XmdsDependencyListEvent $event)
+ {
+ $this->getLogger()->debug('onDependencyList: XmdsPlayerBundleListener');
+
+ // Output the player bundle
+ $forceUpdate = false;
+ $bundlePath = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'assets/bundle.min.js';
+ if (!file_exists($bundlePath)) {
+ $result = @copy(PROJECT_ROOT . '/modules/bundle.min.js', $bundlePath);
+ if (!$result) {
+ throw new GeneralException('Unable to copy asset');
+ }
+ $forceUpdate = true;
+ }
+
+ // Get the bundle MD5
+ $bundleMd5CachePath = $bundlePath . '.md5';
+ if (!file_exists($bundleMd5CachePath) || $forceUpdate) {
+ $bundleMd5 = md5_file($bundlePath);
+ file_put_contents($bundleMd5CachePath, $bundleMd5);
+ } else {
+ $bundleMd5 = file_get_contents($bundlePath . '.md5');
+ }
+
+ $event->addDependency(
+ 'bundle',
+ 1,
+ 'assets/bundle.min.js',
+ filesize($bundlePath),
+ $bundleMd5,
+ true,
+ -1
+ );
+ }
+
+ public function onDependencyRequest(XmdsDependencyRequestEvent $event)
+ {
+ // Can we return this type of file?
+ if ($event->getFileType() === 'bundle' && $event->getRealId() == 1) {
+ // Set the path
+ $event->setRelativePathToLibrary('assets/bundle.min.js');
+
+ // No need to carry on, we've found it.
+ $event->stopPropagation();
+ }
+ }
+}
diff --git a/lib/Xmds/Listeners/XmdsPlayerVersionListener.php b/lib/Xmds/Listeners/XmdsPlayerVersionListener.php
new file mode 100644
index 0000000..d745d4e
--- /dev/null
+++ b/lib/Xmds/Listeners/XmdsPlayerVersionListener.php
@@ -0,0 +1,95 @@
+.
+ */
+
+namespace Xibo\Xmds\Listeners;
+
+use Xibo\Event\XmdsDependencyListEvent;
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Listener\ListenerLoggerTrait;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Xmds\Entity\Dependency;
+
+/**
+ * A listener to supply player version files to players
+ */
+class XmdsPlayerVersionListener
+{
+ use ListenerLoggerTrait;
+
+ use XmdsListenerTrait;
+
+ /**
+ * @var PlayerVersionFactory
+ */
+ private $playerVersionFactory;
+
+ public function __construct(PlayerVersionFactory $playerVersionFactory)
+ {
+ $this->playerVersionFactory = $playerVersionFactory;
+ }
+
+ public function onDependencyList(XmdsDependencyListEvent $event): void
+ {
+ $this->getLogger()->debug('onDependencyList: XmdsPlayerVersionListener');
+
+ // We do not supply a dependency to SSSP players.
+ if ($event->getDisplay()->clientType === 'sssp') {
+ return;
+ }
+
+ try {
+ $playerVersionMediaId = $event->getDisplay()
+ ->getSetting('versionMediaId', null, ['displayOverride' => true]);
+
+ // If it isn't set, then we have nothing to do.
+ if (empty($playerVersionMediaId)) {
+ return;
+ }
+
+ $version = $this->playerVersionFactory->getById($playerVersionMediaId);
+ $event->addDependency(
+ 'playersoftware',
+ $version->versionId,
+ 'playersoftware/' . $version->fileName,
+ $version->size,
+ $version->md5,
+ true,
+ $this->getLegacyId($playerVersionMediaId, Dependency::LEGACY_ID_OFFSET_PLAYER_SOFTWARE)
+ );
+ } catch (NotFoundException $notFoundException) {
+ // Ignore this
+ $this->getLogger()->error('onDependencyList: player version not found for displayId '
+ . $event->getDisplay()->displayId);
+ }
+ }
+
+ public function onDependencyRequest(XmdsDependencyRequestEvent $event)
+ {
+ $this->getLogger()->debug('onDependencyRequest: XmdsPlayerVersionListener');
+
+ if ($event->getFileType() === 'playersoftware') {
+ $version = $this->playerVersionFactory->getById($event->getRealId());
+ $event->setRelativePathToLibrary('playersoftware/' . $version->fileName);
+ }
+ }
+}
diff --git a/lib/Xmds/LogProcessor.php b/lib/Xmds/LogProcessor.php
new file mode 100644
index 0000000..f7fd14f
--- /dev/null
+++ b/lib/Xmds/LogProcessor.php
@@ -0,0 +1,120 @@
+.
+ */
+
+
+namespace Xibo\Xmds;
+
+use Monolog\Logger;
+use Xibo\Helper\DatabaseLogHandler;
+
+/**
+ * Class LogProcessor
+ * @package Xibo\Xmds
+ */
+class LogProcessor
+{
+ /** @var Logger */
+ private $log;
+ private $displayId;
+ private $route;
+ private $method;
+ private $uid;
+
+ /**
+ * Log Processor
+ * @param Logger $log
+ * @param $uid
+ * @param string $method
+ */
+ public function __construct($log, $uid, $method = 'POST')
+ {
+ $this->log = $log;
+ $this->uid = $uid;
+ $this->method = $method;
+ }
+
+ /**
+ * @param $route
+ */
+ public function setRoute($route)
+ {
+ $this->route = $route;
+ }
+
+ /**
+ * @param int $displayId
+ * @param bool $isAuditing
+ */
+ public function setDisplay($displayId, $isAuditing)
+ {
+ if ($isAuditing) {
+ foreach ($this->log->getHandlers() as $handler) {
+ if ($handler instanceof DatabaseLogHandler) {
+ $handler->setLevel(\Xibo\Service\LogService::resolveLogLevel('debug'));
+ }
+ }
+ }
+
+ $this->displayId = $displayId;
+ }
+
+ /**
+ * Get Log Level
+ * @return int
+ */
+ public function getLevel()
+ {
+ $level = Logger::ERROR;
+
+ foreach ($this->log->getHandlers() as $handler) {
+ if ($handler instanceof DatabaseLogHandler) {
+ $level = $handler->getLevel();
+ } else {
+ $this->log->error('Log level not set in DatabaseLogHandler');
+ }
+ }
+
+ return $level;
+ }
+
+ /**
+ * Get UID
+ * @return string
+ */
+ public function getUid()
+ {
+ return $this->uid;
+ }
+
+ /**
+ * @param array $record
+ * @return array
+ */
+ public function __invoke(array $record)
+ {
+ $record['extra']['displayId'] = $this->displayId;
+ $record['extra']['route'] = $this->route;
+ $record['extra']['method'] = $this->method;
+
+ return $record;
+ }
+}
diff --git a/lib/Xmds/Soap.php b/lib/Xmds/Soap.php
new file mode 100644
index 0000000..b95719c
--- /dev/null
+++ b/lib/Xmds/Soap.php
@@ -0,0 +1,3138 @@
+.
+ */
+
+namespace Xibo\Xmds;
+
+use Carbon\Carbon;
+use Monolog\Logger;
+use Stash\Interfaces\PoolInterface;
+use Stash\Invalidation;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Xibo\Entity\Bandwidth;
+use Xibo\Entity\Display;
+use Xibo\Entity\Region;
+use Xibo\Entity\RequiredFile;
+use Xibo\Entity\Schedule;
+use Xibo\Event\DataConnectorScriptRequestEvent;
+use Xibo\Event\XmdsDependencyListEvent;
+use Xibo\Factory\BandwidthFactory;
+use Xibo\Factory\DataSetFactory;
+use Xibo\Factory\DayPartFactory;
+use Xibo\Factory\DisplayEventFactory;
+use Xibo\Factory\DisplayFactory;
+use Xibo\Factory\LayoutFactory;
+use Xibo\Factory\MediaFactory;
+use Xibo\Factory\ModuleFactory;
+use Xibo\Factory\NotificationFactory;
+use Xibo\Factory\PlayerFaultFactory;
+use Xibo\Factory\PlayerVersionFactory;
+use Xibo\Factory\RegionFactory;
+use Xibo\Factory\RequiredFileFactory;
+use Xibo\Factory\ScheduleFactory;
+use Xibo\Factory\SyncGroupFactory;
+use Xibo\Factory\UserFactory;
+use Xibo\Factory\UserGroupFactory;
+use Xibo\Factory\WidgetFactory;
+use Xibo\Helper\ByteFormatter;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\LinkSigner;
+use Xibo\Helper\SanitizerService;
+use Xibo\Helper\Status;
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\LogServiceInterface;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Storage\TimeSeriesStoreInterface;
+use Xibo\Support\Exception\ControllerNotImplemented;
+use Xibo\Support\Exception\DeadlockException;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Xmds\Entity\Dependency;
+use Xibo\Xmds\Listeners\XmdsDataConnectorListener;
+
+/**
+ * Class Soap
+ * @package Xibo\Xmds
+ */
+class Soap
+{
+ /**
+ * @var Display
+ */
+ protected $display;
+
+ /** @var Carbon */
+ protected $fromFilter;
+ /** @var Carbon */
+ protected $toFilter;
+ /** @var Carbon */
+ protected $localFromFilter;
+ /** @var Carbon */
+ protected $localToFilter;
+
+ /**
+ * @var LogProcessor
+ */
+ protected $logProcessor;
+
+ /** @var PoolInterface */
+ protected $pool;
+
+ /** @var StorageServiceInterface */
+ private $store;
+
+ /** @var TimeSeriesStoreInterface */
+ private $timeSeriesStore;
+
+ /** @var LogServiceInterface */
+ private $logService;
+
+ /** @var SanitizerService */
+ private $sanitizerService;
+
+ /** @var ConfigServiceInterface */
+ protected $configService;
+
+ /** @var RequiredFileFactory */
+ protected $requiredFileFactory;
+
+ /** @var ModuleFactory */
+ protected $moduleFactory;
+
+ /** @var LayoutFactory */
+ protected $layoutFactory;
+
+ /** @var DataSetFactory */
+ protected $dataSetFactory;
+
+ /** @var DisplayFactory */
+ protected $displayFactory;
+
+ /** @var UserGroupFactory */
+ protected $userGroupFactory;
+
+ /** @var BandwidthFactory */
+ protected $bandwidthFactory;
+
+ /** @var MediaFactory */
+ protected $mediaFactory;
+
+ /** @var WidgetFactory */
+ protected $widgetFactory;
+
+ /** @var RegionFactory */
+ protected $regionFactory;
+
+ /** @var NotificationFactory */
+ protected $notificationFactory;
+
+ /** @var DisplayEventFactory */
+ protected $displayEventFactory;
+
+ /** @var ScheduleFactory */
+ protected $scheduleFactory;
+
+ /** @var DayPartFactory */
+ protected $dayPartFactory;
+
+ /** @var PlayerVersionFactory */
+ protected $playerVersionFactory;
+
+ /** @var \Xibo\Factory\SyncGroupFactory */
+ protected $syncGroupFactory;
+
+ /**
+ * @var EventDispatcher
+ */
+ private $dispatcher;
+
+ /** @var \Xibo\Factory\CampaignFactory */
+ private $campaignFactory;
+
+ /** @var PlayerFaultFactory */
+ protected $playerFaultFactory;
+
+ /**
+ * Soap constructor.
+ * @param LogProcessor $logProcessor
+ * @param PoolInterface $pool
+ * @param StorageServiceInterface $store
+ * @param TimeSeriesStoreInterface $timeSeriesStore
+ * @param LogServiceInterface $log
+ * @param SanitizerService $sanitizer
+ * @param ConfigServiceInterface $config
+ * @param RequiredFileFactory $requiredFileFactory
+ * @param ModuleFactory $moduleFactory
+ * @param LayoutFactory $layoutFactory
+ * @param DataSetFactory $dataSetFactory
+ * @param DisplayFactory $displayFactory
+ * @param UserFactory $userGroupFactory
+ * @param BandwidthFactory $bandwidthFactory
+ * @param MediaFactory $mediaFactory
+ * @param WidgetFactory $widgetFactory
+ * @param RegionFactory $regionFactory
+ * @param NotificationFactory $notificationFactory
+ * @param DisplayEventFactory $displayEventFactory
+ * @param ScheduleFactory $scheduleFactory
+ * @param DayPartFactory $dayPartFactory
+ * @param PlayerVersionFactory $playerVersionFactory
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ * @param \Xibo\Factory\CampaignFactory $campaignFactory
+ * @param SyncGroupFactory $syncGroupFactory
+ */
+ public function __construct(
+ $logProcessor,
+ $pool,
+ $store,
+ $timeSeriesStore,
+ $log,
+ $sanitizer,
+ $config,
+ $requiredFileFactory,
+ $moduleFactory,
+ $layoutFactory,
+ $dataSetFactory,
+ $displayFactory,
+ $userGroupFactory,
+ $bandwidthFactory,
+ $mediaFactory,
+ $widgetFactory,
+ $regionFactory,
+ $notificationFactory,
+ $displayEventFactory,
+ $scheduleFactory,
+ $dayPartFactory,
+ $playerVersionFactory,
+ $dispatcher,
+ $campaignFactory,
+ $syncGroupFactory,
+ $playerFaultFactory
+ ) {
+ $this->logProcessor = $logProcessor;
+ $this->pool = $pool;
+ $this->store = $store;
+ $this->timeSeriesStore = $timeSeriesStore;
+ $this->logService = $log;
+ $this->sanitizerService = $sanitizer;
+ $this->configService = $config;
+ $this->requiredFileFactory = $requiredFileFactory;
+ $this->moduleFactory = $moduleFactory;
+ $this->layoutFactory = $layoutFactory;
+ $this->dataSetFactory = $dataSetFactory;
+ $this->displayFactory = $displayFactory;
+ $this->userGroupFactory = $userGroupFactory;
+ $this->bandwidthFactory = $bandwidthFactory;
+ $this->mediaFactory = $mediaFactory;
+ $this->widgetFactory = $widgetFactory;
+ $this->regionFactory = $regionFactory;
+ $this->notificationFactory = $notificationFactory;
+ $this->displayEventFactory = $displayEventFactory;
+ $this->scheduleFactory = $scheduleFactory;
+ $this->dayPartFactory = $dayPartFactory;
+ $this->playerVersionFactory = $playerVersionFactory;
+ $this->dispatcher = $dispatcher;
+ $this->campaignFactory = $campaignFactory;
+ $this->syncGroupFactory = $syncGroupFactory;
+ $this->playerFaultFactory = $playerFaultFactory;
+ }
+
+ /**
+ * Get Cache Pool
+ * @return \Stash\Interfaces\PoolInterface
+ */
+ protected function getPool()
+ {
+ return $this->pool;
+ }
+
+ /**
+ * Get Store
+ * @return StorageServiceInterface
+ */
+ protected function getStore()
+ {
+ return $this->store;
+ }
+
+ /**
+ * Get Time Series Store
+ * @return TimeSeriesStoreInterface
+ */
+ protected function getTimeSeriesStore()
+ {
+ return $this->timeSeriesStore;
+ }
+
+ /**
+ * Get Log
+ * @return LogServiceInterface
+ */
+ protected function getLog()
+ {
+ return $this->logService;
+ }
+
+ /**
+ * @param $array
+ * @return \Xibo\Support\Sanitizer\SanitizerInterface
+ */
+ protected function getSanitizer($array)
+ {
+ return $this->sanitizerService->getSanitizer($array);
+ }
+
+ /**
+ * Get Config
+ * @return ConfigServiceInterface
+ */
+ protected function getConfig()
+ {
+ return $this->configService;
+ }
+
+ /**
+ * @return EventDispatcher
+ */
+ public function getDispatcher(): EventDispatcher
+ {
+ if ($this->dispatcher === null) {
+ $this->getLog()->error('getDispatcher: [soap] No dispatcher found, returning an empty one');
+ $this->dispatcher = new EventDispatcher();
+ }
+
+ return $this->dispatcher;
+ }
+
+ /**
+ * Get Required Files (common)
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param bool $httpDownloads
+ * @param bool $isSupportsDataUrl Does the callee support data URLs in widgets?
+ * @param bool $isSupportsDependency Does the callee support a separate dependency call?
+ * @return string
+ * @throws \DOMException
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ protected function doRequiredFiles(
+ $serverKey,
+ $hardwareKey,
+ bool $httpDownloads,
+ bool $isSupportsDataUrl = false,
+ bool $isSupportsDependency = false
+ ) {
+ $this->logProcessor->setRoute('RequiredFiles');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Sender', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ // Check the cache
+ $cache = $this->getPool()->getItem($this->display->getCacheKey() . '/requiredFiles');
+ $cache->setInvalidationMethod(Invalidation::OLD);
+
+ $output = $cache->get();
+
+ // Required files are cached for 4 hours
+ if ($cache->isHit()) {
+ $this->getLog()->info('Returning required files from Cache for display ' . $this->display->display);
+
+ // Resign HTTP links and extend expiry
+ $document = new \DOMDocument('1.0');
+ $document->loadXML($output);
+
+ $cdnUrl = $this->configService->getSetting('CDN_URL');
+
+ foreach ($document->documentElement->childNodes as $node) {
+ if ($node instanceof \DOMElement) {
+ if ($node->getAttribute('download') === 'http') {
+ $type = match ($node->getAttribute('type')) {
+ 'layout' => 'L',
+ 'media' => 'M',
+ default => 'P',
+ };
+
+ // HTTP download for a v3 player will have saved the type and fileType as M and media
+ // respectively, which is different to what we use in the URL.
+ $assetType = $node->getAttribute('assetType');
+ if (!empty($assetType)) {
+ $type = 'P';
+ $fileType = $assetType;
+ } else {
+ $fileType = $node->getAttribute('fileType');
+ }
+
+ // Use the realId if we have it.
+ $realId = $node->getAttribute('realId');
+ if (empty($realId)) {
+ $realId = $node->getAttribute('id');
+ }
+
+ // Generate a new URL.
+ $newUrl = LinkSigner::generateSignedLink(
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $cdnUrl,
+ $type,
+ $realId,
+ $node->getAttribute('saveAs'),
+ $fileType,
+ );
+
+ $node->setAttribute('path', $newUrl);
+ }
+ }
+ }
+
+ $output = $document->saveXML();
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$RF, strlen($output));
+
+ return $output;
+ }
+
+ // We need to regenerate
+ // Lock the cache
+ $cache->lock(120);
+
+ // Get all required files for this display.
+ // we will use this to drop items from the requirefile table if they are no longer in required files
+ $rfIds = array_map(function ($element) {
+ return intval($element['rfId']);
+ }, $this->getStore()->select('SELECT rfId FROM `requiredfile` WHERE displayId = :displayId', [
+ 'displayId' => $this->display->displayId
+ ]));
+ $newRfIds = [];
+
+ // Build a new RF
+ $requiredFilesXml = new \DOMDocument('1.0');
+ $fileElements = $requiredFilesXml->createElement('files');
+ $requiredFilesXml->appendChild($fileElements);
+
+ // Filter criteria
+ $this->setDateFilters();
+
+ // Add the filter dates to the RF xml document
+ $fileElements->setAttribute('generated', Carbon::now()->format(DateFormatHelper::getSystemFormat()));
+ $fileElements->setAttribute('fitlerFrom', $this->fromFilter->format(DateFormatHelper::getSystemFormat()));
+ $fileElements->setAttribute('fitlerTo', $this->toFilter->format(DateFormatHelper::getSystemFormat()));
+
+ // Player dependencies
+ // -------------------
+ // Output player dependencies such as the player bundle, fonts, etc.
+ // 1) get a list of dependencies.
+ $dependencyListEvent = new XmdsDependencyListEvent($this->display);
+
+ $this->getDispatcher()->dispatch($dependencyListEvent, 'xmds.dependency.list');
+
+ // 2) Each dependency returned needs to be added to RF.
+ foreach ($dependencyListEvent->getDependencies() as $dependency) {
+ // Add a new required file for this.
+ $this->addDependency(
+ $newRfIds,
+ $requiredFilesXml,
+ $fileElements,
+ $httpDownloads,
+ $isSupportsDependency,
+ $dependency
+ );
+ }
+ // ------------
+ // Dependencies finished
+
+ // Default Layout
+ $defaultLayoutId = ($this->display->defaultLayoutId === null || $this->display->defaultLayoutId === 0)
+ ? $this->getConfig()->getSetting('DEFAULT_LAYOUT')
+ : $this->display->defaultLayoutId;
+
+ // Get a list of all layout ids in the schedule right now
+ // including any layouts that have been associated to our Display Group
+ try {
+ $dbh = $this->getStore()->getConnection();
+
+ $SQL = '
+ SELECT DISTINCT `lklayoutdisplaygroup`.layoutId
+ FROM `lklayoutdisplaygroup`
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lklayoutdisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ INNER JOIN `layout`
+ ON `layout`.layoutID = `lklayoutdisplaygroup`.layoutId
+ WHERE lkdisplaydg.DisplayID = :displayId
+ ORDER BY layoutId
+ ';
+
+ $params = [
+ 'displayId' => $this->display->displayId
+ ];
+
+ $this->getLog()->sql($SQL, $params);
+
+ $sth = $dbh->prepare($SQL);
+ $sth->execute($params);
+
+ // Build a list of Layouts
+ $layouts = [];
+
+ // Our layout list will always include the default layout
+ if ($defaultLayoutId != null) {
+ $layouts[] = $defaultLayoutId;
+ }
+
+ // Build up the other layouts into an array
+ foreach ($sth->fetchAll() as $row) {
+ $parsedRow = $this->getSanitizer($row);
+ $layouts[] = $parsedRow->getInt('layoutId');
+ }
+
+ // Also look at the schedule
+ foreach ($this->scheduleFactory->getForXmds(
+ $this->display->displayId,
+ $this->fromFilter,
+ $this->toFilter
+ ) as $row) {
+ $parsedRow = $this->getSanitizer($row);
+ $schedule = $this->scheduleFactory->createEmpty()->hydrate($row);
+
+ // Is this scheduled event a synchronised timezone?
+ // if it is, then we get our events with respect to the timezone of the display
+ $isSyncTimezone = ($schedule->syncTimezone == 1 && !empty($this->display->timeZone));
+
+ try {
+ if ($isSyncTimezone) {
+ $scheduleEvents = $schedule->getEvents($this->localFromFilter, $this->localToFilter);
+ } else {
+ $scheduleEvents = $schedule->getEvents($this->fromFilter, $this->toFilter);
+ }
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Unable to getEvents for ' . $schedule->eventId);
+ continue;
+ }
+
+ if (count($scheduleEvents) <= 0) {
+ continue;
+ }
+
+ $this->getLog()->debug(count($scheduleEvents) . ' events for eventId ' . $schedule->eventId);
+
+ // Sync events
+ $layoutId = ($schedule->eventTypeId == Schedule::$SYNC_EVENT)
+ ? $parsedRow->getInt('syncLayoutId')
+ : $parsedRow->getInt('layoutId');
+
+ // Layout codes (action events)
+ $layoutCode = $parsedRow->getString('actionLayoutCode');
+ if ($layoutId != null &&
+ (
+ $schedule->eventTypeId == Schedule::$LAYOUT_EVENT ||
+ $schedule->eventTypeId == Schedule::$OVERLAY_EVENT ||
+ $schedule->eventTypeId == Schedule::$INTERRUPT_EVENT ||
+ $schedule->eventTypeId == Schedule::$CAMPAIGN_EVENT ||
+ $schedule->eventTypeId == Schedule::$MEDIA_EVENT ||
+ $schedule->eventTypeId == Schedule::$PLAYLIST_EVENT ||
+ $schedule->eventTypeId == Schedule::$SYNC_EVENT
+ )
+ ) {
+ $layouts[] = $layoutId;
+ }
+
+ if (!empty($layoutCode) && $schedule->eventTypeId == Schedule::$ACTION_EVENT) {
+ $actionEventLayout = $this->layoutFactory->getByCode($layoutCode);
+ if ($actionEventLayout->status <= 3) {
+ $layouts[] = $actionEventLayout->layoutId;
+ } else {
+ $this->getLog()->error(sprintf(__('Scheduled Action Event ID %d contains an invalid Layout linked to it by the Layout code.'), $schedule->eventId));
+ }
+ }
+
+ // Data Connectors
+ if ($isSupportsDependency && $schedule->eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) {
+ $dataSet = $this->dataSetFactory->getById($row['dataSetId']);
+
+ if ($dataSet->dataConnectorSource != 'user_defined') {
+ // Dispatch an event to save the data connector javascript from the connector
+ $dataConnectorScriptRequestEvent = new DataConnectorScriptRequestEvent($dataSet);
+ $this->getDispatcher()
+ ->dispatch($dataConnectorScriptRequestEvent, DataConnectorScriptRequestEvent::$NAME);
+ }
+
+ $this->addDependency(
+ $newRfIds,
+ $requiredFilesXml,
+ $fileElements,
+ $httpDownloads,
+ $isSupportsDependency,
+ XmdsDataConnectorListener::getDataConnectorDependency($libraryLocation, $row['dataSetId']),
+ );
+ }
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Unable to get a list of layouts. ' . $e->getMessage());
+ return new \SoapFault('Sender', 'Unable to get a list of layouts');
+ }
+
+ // workout if any of the layouts we have in our list has Actions pointing to another Layout.
+ $actionLayoutIds = [];
+ $processedLayoutIds = [];
+ foreach ($layouts as $layoutId) {
+ // this is recursive function, as we need to get 2nd level nesting and beyond
+ $this->layoutFactory->getActionPublishedLayoutIds($layoutId, $actionLayoutIds, $processedLayoutIds);
+ }
+
+ // merge the Action layouts to our array, we need the player to download all resources on them
+ if (!empty($actionLayoutIds)) {
+ $layouts = array_unique(array_merge($layouts, $actionLayoutIds));
+ }
+
+ // Create a comma separated list to pass into the query which gets file nodes
+ $layoutIdList = implode(',', $layouts);
+
+ try {
+ $dbh = $this->getStore()->getConnection();
+
+ // Run a query to get all required files for this display.
+ // Include the following:
+ // DownloadOrder:
+ // 1 - Media Linked to Displays
+ // 2 - Media Linked to Widgets in the Scheduled Layouts (linked through Playlists)
+ // 3 - Background Images for all Scheduled Layouts
+ // 4 - Media linked to display profile (linked through PlayerSoftware)
+ $SQL = "
+ SELECT 1 AS DownloadOrder,
+ `media`.storedAs AS path,
+ `media`.mediaID AS id,
+ `media`.`MD5`,
+ `media`.FileSize,
+ `media`.released
+ FROM `media`
+ INNER JOIN `display_media`
+ ON `display_media`.mediaid = `media`.mediaId
+ WHERE `display_media`.displayId = :displayId
+ UNION ALL
+ SELECT 2 AS DownloadOrder,
+ `media`.storedAs AS path,
+ `media`.mediaID AS id,
+ `media`.`MD5`,
+ `media`.FileSize,
+ `media`.released
+ FROM `media`
+ INNER JOIN `lkmediadisplaygroup`
+ ON lkmediadisplaygroup.mediaid = media.MediaID
+ INNER JOIN `lkdgdg`
+ ON `lkdgdg`.parentId = `lkmediadisplaygroup`.displayGroupId
+ INNER JOIN `lkdisplaydg`
+ ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
+ WHERE lkdisplaydg.DisplayID = :displayId
+ UNION ALL
+ SELECT 3 AS DownloadOrder,
+ `media`.storedAs AS path,
+ `media`.mediaID AS id,
+ `media`.`MD5`,
+ `media`.FileSize,
+ `media`.released
+ FROM region
+ INNER JOIN playlist
+ ON playlist.regionId = region.regionId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ INNER JOIN widget
+ ON widget.playlistId = lkplaylistplaylist.childId
+ INNER JOIN lkwidgetmedia
+ ON widget.widgetId = lkwidgetmedia.widgetId
+ INNER JOIN media
+ ON media.mediaId = lkwidgetmedia.mediaId
+ WHERE region.layoutId IN (%s)
+ UNION ALL
+ SELECT 4 AS DownloadOrder,
+ `media`.storedAs AS path,
+ `media`.mediaId AS id,
+ `media`.`MD5`,
+ `media`.FileSize,
+ `media`.released
+ FROM `media`
+ WHERE `media`.mediaID IN (
+ SELECT backgroundImageId
+ FROM `layout`
+ WHERE layoutId IN (%s)
+ )
+ ";
+
+ $params = ['displayId' => $this->display->displayId];
+
+ $SQL .= ' ORDER BY DownloadOrder ';
+
+ // Sub layoutId list
+ $SQL = sprintf($SQL, $layoutIdList, $layoutIdList);
+
+ $this->getLog()->sql($SQL, $params);
+
+ $sth = $dbh->prepare($SQL);
+ $sth->execute($params);
+
+ // Prepare a SQL statement in case we need to update the MD5 and FileSize on media nodes.
+ $mediaSth = $dbh->prepare('UPDATE media SET `MD5` = :md5, FileSize = :size WHERE MediaID = :mediaid');
+
+ // Keep a list of path names added to RF to prevent duplicates
+ $pathsAdded = [];
+ $cdnUrl = $this->configService->getSetting('CDN_URL');
+
+ foreach ($sth->fetchAll() as $row) {
+ $parsedRow = $this->getSanitizer($row);
+ // Media
+ $path = $parsedRow->getString('path');
+ $id = $parsedRow->getParam('id');
+ $md5 = $row['MD5'];
+ $fileSize = $parsedRow->getInt('FileSize');
+ $released = $parsedRow->getInt('released');
+
+ // Check we haven't added this before
+ if (in_array($path, $pathsAdded)) {
+ continue;
+ }
+
+ // Do we need to calculate a new MD5?
+ // If they are empty calculate them and save them back to the media.
+ if ($md5 == '' || $fileSize == 0) {
+ $md5 = md5_file($libraryLocation . $path);
+ $fileSize = filesize($libraryLocation . $path);
+
+ // Update the media record with this information
+ $mediaSth->execute(['md5' => $md5, 'size' => $fileSize, 'mediaid' => $id]);
+ }
+
+ // Add nonce
+ $mediaNonce = $this->requiredFileFactory
+ ->createForMedia($this->display->displayId, $id, $fileSize, $path, $released)
+ ->save();
+
+ // skip media which has released == 0 or 2
+ if ($released == 0 || $released == 2) {
+ continue;
+ }
+
+ $newRfIds[] = $mediaNonce->rfId;
+
+ // Add the file node
+ $file = $requiredFilesXml->createElement('file');
+ $file->setAttribute('type', 'media');
+ $file->setAttribute('id', $id);
+ $file->setAttribute('size', $fileSize);
+ $file->setAttribute('md5', $md5);
+
+ if ($httpDownloads) {
+ // Serve a link instead (standard HTTP link)
+ $file->setAttribute('path', LinkSigner::generateSignedLink(
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $cdnUrl,
+ 'M',
+ $id,
+ $path,
+ ));
+ $file->setAttribute('saveAs', $path);
+ $file->setAttribute('download', 'http');
+ } else {
+ $file->setAttribute('download', 'xmds');
+ $file->setAttribute('path', $path);
+ }
+
+ $fileElements->appendChild($file);
+
+ // Add to paths added
+ $pathsAdded[] = $path;
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Unable to get a list of required files. ' . $e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+ return new \SoapFault('Sender', 'Unable to get a list of files');
+ }
+
+ // Get an array of modules to use
+ $modules = $this->moduleFactory->getKeyedArrayOfModules();
+
+ // Reset the paths added array to start again with layouts
+ $pathsAdded = [];
+
+ $cdnUrl = $this->configService->getSetting('CDN_URL');
+
+ // Go through each layout and see if we need to supply any resource nodes.
+ foreach ($layouts as $layoutId) {
+ try {
+ // Check we haven't added this before
+ if (in_array($layoutId, $pathsAdded)) {
+ continue;
+ }
+
+ // Load this layout
+ $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->loadById($layoutId));
+ try {
+ $layout->loadPlaylists();
+
+ // Make sure its XLF is up-to-date
+ $path = $layout->xlfToDisk(['notify' => false]);
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+
+ // If the status is *still* 4, then we skip this layout as it cannot build
+ if ($layout->status === Status::$STATUS_INVALID) {
+ $this->getLog()->debug('Skipping layoutId ' . $layout->layoutId . ' which wont build');
+ continue;
+ }
+
+ // For layouts the MD5 column is the layout xml
+ $fileSize = filesize($path);
+ $md5 = md5_file($path);
+ $fileName = basename($path);
+
+ // Log
+ $this->getLog()->debug('MD5 for layoutid ' . $layoutId . ' is: [' . $md5 . ']');
+
+ // Add nonce
+ $layoutNonce = $this->requiredFileFactory
+ ->createForLayout($this->display->displayId, $layoutId, $fileSize, $fileName)
+ ->save();
+ $newRfIds[] = $layoutNonce->rfId;
+
+ // Add the Layout file element
+ $file = $requiredFilesXml->createElement('file');
+ $file->setAttribute('type', 'layout');
+ $file->setAttribute('id', $layoutId);
+ $file->setAttribute('size', $fileSize);
+ $file->setAttribute('md5', $md5);
+
+ // add Layout code only if code identifier is set on the Layout.
+ if ($layout->code != null) {
+ $file->setAttribute('code', $layout->code);
+ }
+
+ // Permissive check for http layouts - always allow unless windows and <= 120
+ $supportsHttpLayouts = !($this->display->clientType == 'windows' && $this->display->clientCode <= 120);
+
+ if ($httpDownloads && $supportsHttpLayouts) {
+ // Serve a link instead (standard HTTP link)
+ $file->setAttribute('path', LinkSigner::generateSignedLink(
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $cdnUrl,
+ 'L',
+ $layoutId,
+ $fileName,
+ ));
+ $file->setAttribute('saveAs', $fileName);
+ $file->setAttribute('download', 'http');
+ } else {
+ $file->setAttribute('download', 'xmds');
+ $file->setAttribute('path', $layoutId);
+ }
+
+ // Get the Layout Modified Date
+ // To cover for instances where modifiedDt isn't correctly recorded
+ // use the createdDt instead.
+ $layoutModifiedDt = Carbon::createFromFormat(
+ DateFormatHelper::getSystemFormat(),
+ $layout->modifiedDt ?? $layout->createdDt
+ );
+
+ // merge regions and drawers
+ /** @var Region[] $allRegions */
+ $allRegions = array_merge($layout->regions, $layout->drawers);
+
+ // Load the layout XML and work out if we have any ticker / text / dataset media items
+ // Append layout resources before layout, so they are downloaded first.
+ // If layouts are set to expire immediately, the new layout will use the old resources if
+ // the layout is downloaded first.
+ foreach ($allRegions as $region) {
+ $playlist = $region->getPlaylist();
+ $playlist->setModuleFactory($this->moduleFactory);
+
+ // Playlists might mean we include a widget more than once per region
+ // if so, we only want to download a single copy of its resource node
+ // if it is included in 2 regions - we most likely want a copy for each
+ $resourcesAdded = [];
+
+ foreach ($playlist->expandWidgets() as $widget) {
+ if (!array_key_exists($widget->type, $modules)) {
+ $this->getLog()->debug('Unknown type of widget: ' . $widget->type);
+ continue;
+ }
+
+ // Any global widgets, html based widgets or region specific widgets
+ if ($widget->type === 'global'
+ || $modules[$widget->type]->renderAs === 'html'
+ || $modules[$widget->type]->regionSpecific == 1
+ ) {
+ // If we've already parsed this widget in this region, then don't bother doing it again
+ // we will only generate the same details.
+ if (in_array($widget->widgetId, $resourcesAdded)) {
+ continue;
+ }
+
+ // We've added this widget already
+ $resourcesAdded[] = $widget->widgetId;
+
+ // Get the widget modified date
+ // we will use the latter of this vs the layout modified date as the updated attribute
+ // on required files
+ $widgetModifiedDt = Carbon::createFromTimestamp($widget->modifiedDt);
+
+ // Updated date is the greatest of layout/widget modified date
+ $updatedDt = ($layoutModifiedDt->greaterThan($widgetModifiedDt))
+ ? $layoutModifiedDt
+ : $widgetModifiedDt;
+
+ // If this is a canvas region, then only send the data, unless we're the global
+ // widget
+ $isShouldSendHtml = $region->type !== 'canvas' || $widget->type === 'global';
+ if ($isShouldSendHtml) {
+ // Add the resource node to the XML for this widfget.
+ $getResourceRf = $this->requiredFileFactory
+ ->createForGetResource($this->display->displayId, $widget->widgetId)
+ ->save();
+ $newRfIds[] = $getResourceRf->rfId;
+
+ // Append this item to required files
+ $resourceFile = $requiredFilesXml->createElement('file');
+ $resourceFile->setAttribute('type', 'resource');
+ $resourceFile->setAttribute('id', $widget->widgetId);
+ $resourceFile->setAttribute('layoutid', $layoutId);
+ $resourceFile->setAttribute('regionid', $region->regionId);
+ $resourceFile->setAttribute('mediaid', $widget->widgetId);
+ }
+
+ // Get the module
+ $dataModule = $modules[$widget->type];
+
+ // Does this also have an associated data file?
+ // we add this for < XMDS v7 as well, because the record is used by the widget sync task
+ // the player shouldn't receive it.
+ if ($dataModule->isDataProviderExpected()) {
+ // A node specifically for the widget data.
+ if ($isSupportsDataUrl) {
+ // Newer player (v4 onward), add widget node for returning data
+ $dataFile = $requiredFilesXml->createElement('file');
+ $dataFile->setAttribute('type', 'widget');
+ $dataFile->setAttribute('id', $widget->widgetId);
+ $dataFile->setAttribute(
+ 'updateInterval',
+ $widget->getOptionValue(
+ 'updateInterval',
+ $dataModule->getPropertyDefault('updateInterval') ?? 120,
+ )
+ );
+ $fileElements->appendChild($dataFile);
+ } else if ($isShouldSendHtml) {
+ // Older player, needs to change the updated date on the resource node
+ // Has our widget been updated recently?
+ // TODO: Does this need to be the most recent updated date for all the widgets in
+ // this region?
+ $dataProvider = $dataModule->createDataProvider($widget);
+ $dataProvider->setDisplayProperties(
+ $this->display->latitude ?: $this->getConfig()->getSetting('DEFAULT_LAT'),
+ $this->display->longitude ?: $this->getConfig()->getSetting('DEFAULT_LONG'),
+ $this->display->displayId
+ );
+
+ try {
+ $widgetDataProviderCache = $this->moduleFactory
+ ->createWidgetDataProviderCache();
+ $cacheKey = $this->moduleFactory->determineCacheKey(
+ $dataModule,
+ $widget,
+ $this->display->displayId,
+ $dataProvider,
+ $dataModule->getWidgetProviderOrNull()
+ );
+
+ // We do not pass a modifiedDt in here because we always expect to be cached
+ $cacheDt = $widgetDataProviderCache->getCacheDate($dataProvider, $cacheKey);
+ if ($cacheDt !== null) {
+ $updatedDt = ($cacheDt->greaterThan($updatedDt))
+ ? $cacheDt
+ : $updatedDt;
+ }
+ } catch (\Exception) {
+ $this->getLog()->error(
+ 'doRequiredFiles: Failed to get data cache modified date for widgetId '
+ . $widget->widgetId
+ );
+ }
+ }
+
+ // Used by WidgetSync to understand when to keep the cache warm
+ $getDataRf = $this->requiredFileFactory
+ ->createForGetData($this->display->displayId, $widget->widgetId)
+ ->save();
+ $newRfIds[] = $getDataRf->rfId;
+ }
+
+ if ($isShouldSendHtml) {
+ // Append our resource node.
+ $resourceFile->setAttribute('updated', $updatedDt->format('U'));
+ $fileElements->appendChild($resourceFile);
+ }
+ }
+
+ // Add any assets from this widget/template (unless assetId already added)
+ if (!in_array('module_' . $widget->type, $resourcesAdded)) {
+ foreach ($modules[$widget->type]->getAssets() as $asset) {
+ // Do not send assets if they are CMS only
+ if (!$asset->isSendToPlayer()) {
+ continue;
+ }
+
+ $asset->updateAssetCache($libraryLocation);
+
+ // Add a new required file for this.
+ try {
+ $this->addDependency(
+ $newRfIds,
+ $requiredFilesXml,
+ $fileElements,
+ $httpDownloads,
+ $isSupportsDependency,
+ $asset->getDependency()
+ );
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Invalid asset: ' . $exception->getMessage());
+ }
+ }
+
+ $resourcesAdded[] = 'module_' . $widget->type;
+ }
+
+ // Templates (there should only be one)
+ if ($modules[$widget->type]->isTemplateExpected()) {
+ $templateId = $widget->getOptionValue('templateId', null);
+ if ($templateId !== null && !in_array('template_' . $templateId, $resourcesAdded)) {
+ // Get this template and its assets
+ $templates = $this->widgetFactory->getTemplatesForWidgets(
+ $modules[$widget->type],
+ [$widget]
+ );
+
+ foreach ($templates as $template) {
+ foreach ($template->getAssets() as $asset) {
+ // Do not send assets if they are CMS only
+ if (!$asset->isSendToPlayer()) {
+ continue;
+ }
+
+ $asset->updateAssetCache($libraryLocation);
+
+ // Add a new required file for this.
+ try {
+ $this->addDependency(
+ $newRfIds,
+ $requiredFilesXml,
+ $fileElements,
+ $httpDownloads,
+ $isSupportsDependency,
+ $asset->getDependency()
+ );
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Invalid asset: ' . $exception->getMessage());
+ }
+ }
+ }
+
+ $resourcesAdded[] = 'template_' . $templateId;
+ }
+ }
+ }
+ }
+
+ // Append Layout
+ $fileElements->appendChild($file);
+
+ // Add to paths added
+ $pathsAdded[] = $layoutId;
+ } catch (NotFoundException) {
+ $this->getLog()->error('Layout not found - ID: ' . $layoutId . ', skipping');
+ continue;
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Cannot generate layout - ID: ' . $layoutId
+ . ', skipping, e = ' . $e->getMessage());
+ continue;
+ }
+ }
+
+ // Add Purge List node
+ $purgeList = $requiredFilesXml->createElement('purge');
+ $fileElements->appendChild($purgeList);
+
+ try {
+ $dbh = $this->getStore()->getConnection();
+
+ // get list of mediaId/storedAs that should be purged from the Player storage
+ // records in that table older than provided expiryDate, should be removed by the task
+ $sth = $dbh->prepare('SELECT mediaId, storedAs FROM purge_list');
+ $sth->execute();
+
+ // Add a purge list item for each file
+ foreach ($sth->fetchAll() as $row) {
+ $item = $requiredFilesXml->createElement('item');
+ $item->setAttribute('id', $row['mediaId']);
+ $item->setAttribute('storedAs', $row['storedAs']);
+
+ $purgeList->appendChild($item);
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Unable to get a list of purge_list files. ' . $e->getMessage());
+ return new \SoapFault('Sender', 'Unable to get purge list files');
+ }
+
+ $this->getLog()->debug($requiredFilesXml->saveXML());
+
+ // Return the results of requiredFiles()
+ $requiredFilesXml->formatOutput = true;
+ $output = $requiredFilesXml->saveXML();
+
+ // Cache
+ $cache->set($output);
+
+ // RF cache expires every 4 hours
+ $cache->expiresAfter(3600*4);
+ $this->getPool()->saveDeferred($cache);
+
+ // Remove any required files that remain in the array of rfIds
+ $rfIds = array_values(array_diff($rfIds, $newRfIds));
+ if (count($rfIds) > 0) {
+ $this->getLog()->debug('Removing ' . count($rfIds) . ' from requiredfiles');
+
+ try {
+ // Execute this on the default connection
+ $this->getStore()->updateWithDeadlockLoop(
+ 'DELETE FROM `requiredfile` WHERE rfId IN ('
+ . implode(',', array_fill(0, count($rfIds), '?')) . ')',
+ $rfIds
+ );
+ } catch (DeadlockException $deadlockException) {
+ $this->getLog()->error('Deadlock when deleting required files - ignoring and continuing with request');
+ }
+ }
+
+ // Set any remaining required files to have 0 bytes requested (as we've generated a new nonce)
+ $this->getStore()->update('
+ UPDATE `requiredfile`
+ SET `bytesRequested` = 0
+ WHERE `displayId` = :displayId
+ AND `type` NOT IN (\'W\', \'D\')
+ ', [
+ 'displayId' => $this->display->displayId
+ ]);
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$RF, strlen($output));
+
+ return $output;
+ }
+
+ /**
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param array $options
+ * @return mixed
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ protected function doSchedule($serverKey, $hardwareKey, $options = [])
+ {
+ $this->logProcessor->setRoute('Schedule');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+ $options = array_merge(['dependentsAsNodes' => false, 'includeOverlays' => false], $options);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Sender', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ // Check the cache
+ $cache = $this->getPool()->getItem($this->display->getCacheKey() . '/schedule');
+ $cache->setInvalidationMethod(Invalidation::OLD);
+
+ $output = $cache->get();
+
+ if ($cache->isHit()) {
+ $this->getLog()->info(sprintf(
+ 'Returning Schedule from Cache for display %s. Options %s.',
+ $this->display->display,
+ json_encode($options)
+ ));
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$SCHEDULE, strlen($output));
+
+ return $output;
+ }
+
+ // We need to regenerate
+ // Lock the cache
+ $cache->lock(120);
+
+ // Generate the Schedule XML
+ $scheduleXml = new \DOMDocument('1.0');
+ $layoutElements = $scheduleXml->createElement('schedule');
+ $scheduleXml->appendChild($layoutElements);
+
+ // Filter criteria
+ $this->setDateFilters();
+
+ // Add the filter dates to the RF xml document
+ $layoutElements->setAttribute('generated', Carbon::now()->format(DateFormatHelper::getSystemFormat()));
+ $layoutElements->setAttribute('filterFrom', $this->fromFilter->format(DateFormatHelper::getSystemFormat()));
+ $layoutElements->setAttribute('filterTo', $this->toFilter->format(DateFormatHelper::getSystemFormat()));
+
+ // Default Layout
+ $defaultLayoutId = ($this->display->defaultLayoutId === null || $this->display->defaultLayoutId === 0)
+ ? intval($this->getConfig()->getSetting('DEFAULT_LAYOUT', 0))
+ : $this->display->defaultLayoutId;
+
+ try {
+ // Dependencies
+ // ------------
+ $moduleDependents = [];
+ $dependencyListEvent = new XmdsDependencyListEvent($this->display);
+ $this->getDispatcher()->dispatch($dependencyListEvent, 'xmds.dependency.list');
+
+ // Add each resolved dependency to our list of global dependents.
+ foreach ($dependencyListEvent->getDependencies() as $dependency) {
+ $moduleDependents[] = basename($dependency->path);
+ }
+
+ // Add file nodes to the $fileElements
+ // Firstly get all the scheduled layouts
+ $events = $this->scheduleFactory->getForXmds(
+ $this->display->displayId,
+ $this->fromFilter,
+ $this->toFilter,
+ $options
+ );
+
+ // If our dependents are nodes, then build a list of layouts we can use to query for nodes
+ $layoutDependents = [];
+
+ // Layouts
+ $layoutIds = [];
+
+ // Add the default layout if it isn't empty.
+ if ($defaultLayoutId !== 0) {
+ $layoutIds[] = $defaultLayoutId;
+ }
+
+ // Preparse events
+ foreach ($events as $event) {
+ $layoutId = ($event['eventTypeId'] == Schedule::$SYNC_EVENT) ? $event['syncLayoutId'] : $event['layoutId'];
+ if (!empty($layoutId) && !in_array($layoutId, $layoutIds)) {
+ $layoutIds[] = $layoutId;
+ }
+ }
+
+ $SQL = '
+ SELECT DISTINCT `region`.layoutId, `media`.storedAs
+ FROM region
+ INNER JOIN playlist
+ ON playlist.regionId = region.regionId
+ INNER JOIN lkplaylistplaylist
+ ON lkplaylistplaylist.parentId = playlist.playlistId
+ INNER JOIN widget
+ ON widget.playlistId = lkplaylistplaylist.childId
+ INNER JOIN lkwidgetmedia
+ ON widget.widgetId = lkwidgetmedia.widgetId
+ INNER JOIN media
+ ON media.mediaId = lkwidgetmedia.mediaId
+ WHERE region.layoutId IN (' . implode(',', $layoutIds) . ')
+ AND media.type <> \'module\'
+ ';
+
+ foreach ($this->getStore()->select($SQL, []) as $row) {
+ if (!array_key_exists($row['layoutId'], $layoutDependents))
+ $layoutDependents[$row['layoutId']] = [];
+
+ $layoutDependents[$row['layoutId']][] = $row['storedAs'];
+ }
+
+ $this->getLog()->debug(sprintf('Resolved dependents for Schedule: %s.', json_encode($layoutDependents, JSON_PRETTY_PRINT)));
+
+ // Additional nodes.
+ $overlayNodes = null;
+ $actionNodes = null;
+ $dataConnectorNodes = null;
+
+ // We must have some results in here by this point
+ foreach ($events as $row) {
+ $parsedRow = $this->getSanitizer($row);
+ $schedule = $this->scheduleFactory->createEmpty()->hydrate($row);
+
+ // Is this scheduled event a synchronised timezone?
+ // if it is, then we get our events with respect to the timezone of the display
+ $isSyncTimezone = ($schedule->syncTimezone == 1 && !empty($this->display->timeZone));
+
+ try {
+ if ($isSyncTimezone) {
+ $scheduleEvents = $schedule->getEvents($this->localFromFilter, $this->localToFilter);
+ } else {
+ $scheduleEvents = $schedule->getEvents($this->fromFilter, $this->toFilter);
+ }
+ } catch (GeneralException $e) {
+ $this->getLog()->error('Unable to getEvents for ' . $schedule->eventId);
+ continue;
+ }
+
+ $this->getLog()->debug(count($scheduleEvents) . ' events for eventId ' . $schedule->eventId);
+
+ // Load the whole schedule object if we have some events attached
+ if (count($scheduleEvents) > 0) {
+ $schedule->load(['loadDisplayGroups' => false]);
+ }
+
+ foreach ($scheduleEvents as $scheduleEvent) {
+ $eventTypeId = $row['eventTypeId'];
+
+ if ($row['eventTypeId'] == Schedule::$SYNC_EVENT) {
+ $layoutId = $row['syncLayoutId'];
+ $status = intval($row['syncLayoutStatus']);
+ $duration = $row['syncLayoutDuration'];
+ } else {
+ $layoutId = $row['layoutId'];
+ $status = intval($row['status']);
+ $duration = $row['duration'];
+ }
+
+ $commandCode = $row['code'];
+
+ // Handle the from/to date of the events we have been returned (they are all returned with respect to
+ // the current CMS timezone)
+ // Does the Display have a timezone?
+ if ($isSyncTimezone) {
+ $fromDt = Carbon::createFromTimestamp($scheduleEvent->fromDt, $this->display->timeZone)->format(DateFormatHelper::getSystemFormat());
+ $toDt = Carbon::createFromTimestamp($scheduleEvent->toDt, $this->display->timeZone)->format(DateFormatHelper::getSystemFormat());
+ } else {
+ $fromDt = Carbon::createFromTimestamp($scheduleEvent->fromDt)->format(DateFormatHelper::getSystemFormat());
+ $toDt = Carbon::createFromTimestamp($scheduleEvent->toDt)->format(DateFormatHelper::getSystemFormat());
+ }
+
+ $scheduleId = $row['eventId'];
+ $is_priority = $parsedRow->getInt('isPriority');
+
+ // Criteria
+ $criteriaNodes = [];
+ foreach ($schedule->criteria as $scheduleCriteria) {
+ $criteriaNode = $scheduleXml->createElement('criteria');
+ $criteriaNode->setAttribute('metric', $scheduleCriteria->metric);
+ $criteriaNode->setAttribute('condition', $scheduleCriteria->condition);
+ $criteriaNode->setAttribute('type', $scheduleCriteria->type);
+ $criteriaNode->textContent = $scheduleCriteria->value;
+ $criteriaNodes[] = $criteriaNode;
+ }
+
+ // Handle event type
+ if ($eventTypeId == Schedule::$LAYOUT_EVENT ||
+ $eventTypeId == Schedule::$INTERRUPT_EVENT ||
+ $eventTypeId == Schedule::$CAMPAIGN_EVENT ||
+ $eventTypeId == Schedule::$MEDIA_EVENT ||
+ $eventTypeId == Schedule::$PLAYLIST_EVENT ||
+ $eventTypeId == Schedule::$SYNC_EVENT
+ ) {
+ // Ensure we have a layoutId (we may not if an empty campaign is assigned)
+ // https://github.com/xibosignage/xibo/issues/894
+ if ($layoutId == 0 || empty($layoutId)) {
+ $this->getLog()->info(sprintf('Player has empty event scheduled. Display = %s, EventId = %d', $this->display->display, $scheduleId));
+ continue;
+ }
+
+ // Check the layout status
+ // https://github.com/xibosignage/xibo/issues/743
+ if ($status > 3) {
+ $this->getLog()->info(sprintf('Player has invalid layout scheduled. Display = %s, LayoutId = %d', $this->display->display, $layoutId));
+ continue;
+ }
+
+ // Add a layout node to the schedule
+ $layout = $scheduleXml->createElement('layout');
+ $layout->setAttribute('file', $layoutId);
+ $layout->setAttribute('fromdt', $fromDt);
+ $layout->setAttribute('todt', $toDt);
+ $layout->setAttribute('scheduleid', $scheduleId);
+ $layout->setAttribute('priority', $is_priority);
+ $layout->setAttribute('syncEvent', ($row['eventTypeId'] == Schedule::$SYNC_EVENT) ? 1 : 0);
+ $layout->setAttribute('shareOfVoice', $row['shareOfVoice'] ?? 0);
+ $layout->setAttribute('duration', $duration ?? 0);
+ $layout->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0);
+ $layout->setAttribute('geoLocation', $row['geoLocation'] ?? null);
+ $layout->setAttribute('cyclePlayback', $row['cyclePlayback'] ?? 0);
+ $layout->setAttribute('groupKey', $row['groupKey'] ?? 0);
+ $layout->setAttribute('playCount', $row['playCount'] ?? 0);
+ $layout->setAttribute('maxPlaysPerHour', $row['maxPlaysPerHour'] ?? 0);
+
+ // Handle dependents
+ if (array_key_exists($layoutId, $layoutDependents)) {
+ if ($options['dependentsAsNodes']) {
+ // Add the dependents to the layout as new nodes
+ $dependentNode = $scheduleXml->createElement('dependents');
+
+ foreach ($layoutDependents[$layoutId] as $storedAs) {
+ $fileNode = $scheduleXml->createElement('file', $storedAs);
+
+ $dependentNode->appendChild($fileNode);
+ }
+
+ $layout->appendChild($dependentNode);
+ } else {
+ // Add the dependents to the layout as an attribute
+ $layout->setAttribute('dependents', implode(',', $layoutDependents[$layoutId]));
+ }
+ }
+
+ // Add criteria notes
+ if (count($criteriaNodes) > 0) {
+ foreach ($criteriaNodes as $criteriaNode) {
+ $layout->appendChild($criteriaNode);
+ }
+ }
+
+ $layoutElements->appendChild($layout);
+ } elseif ($eventTypeId == Schedule::$COMMAND_EVENT) {
+ // Add a command node to the schedule
+ $command = $scheduleXml->createElement('command');
+ $command->setAttribute('date', $fromDt);
+ $command->setAttribute('scheduleid', $scheduleId);
+ $command->setAttribute('code', $commandCode);
+
+ // Add criteria notes
+ if (count($criteriaNodes) > 0) {
+ foreach ($criteriaNodes as $criteriaNode) {
+ $command->appendChild($criteriaNode);
+ }
+ }
+
+ $layoutElements->appendChild($command);
+ } elseif ($eventTypeId == Schedule::$OVERLAY_EVENT && $options['includeOverlays']) {
+ // Ensure we have a layoutId (we may not if an empty campaign is assigned)
+ // https://github.com/xibosignage/xibo/issues/894
+ if ($layoutId == 0 || empty($layoutId)) {
+ $this->getLog()->error(sprintf('Player has empty event scheduled. Display = %s, EventId = %d', $this->display->display, $scheduleId));
+ continue;
+ }
+
+ // Check the layout status
+ // https://github.com/xibosignage/xibo/issues/743
+ if (intval($row['status']) > 3) {
+ $this->getLog()->error(sprintf('Player has invalid layout scheduled. Display = %s, LayoutId = %d', $this->display->display, $layoutId));
+ continue;
+ }
+
+ if ($overlayNodes == null) {
+ $overlayNodes = $scheduleXml->createElement('overlays');
+ }
+
+ $overlay = $scheduleXml->createElement('overlay');
+ $overlay->setAttribute('file', $layoutId);
+ $overlay->setAttribute('fromdt', $fromDt);
+ $overlay->setAttribute('todt', $toDt);
+ $overlay->setAttribute('scheduleid', $scheduleId);
+ $overlay->setAttribute('priority', $is_priority);
+ $overlay->setAttribute('duration', $row['duration'] ?? 0);
+ $overlay->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0);
+ $overlay->setAttribute('geoLocation', $row['geoLocation'] ?? null);
+
+ // Add criteria notes
+ if (count($criteriaNodes) > 0) {
+ foreach ($criteriaNodes as $criteriaNode) {
+ $overlay->appendChild($criteriaNode);
+ }
+ }
+
+ // Add to the overlays node list
+ $overlayNodes->appendChild($overlay);
+ } elseif ($eventTypeId == Schedule::$ACTION_EVENT) {
+ if ($actionNodes == null) {
+ $actionNodes = $scheduleXml->createElement('actions');
+ }
+ $action = $scheduleXml->createElement('action');
+ $action->setAttribute('fromdt', $fromDt);
+ $action->setAttribute('todt', $toDt);
+ $action->setAttribute('scheduleid', $scheduleId);
+ $action->setAttribute('priority', $is_priority);
+ $action->setAttribute('duration', $row['duration'] ?? 0);
+ $action->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0);
+ $action->setAttribute('geoLocation', $row['geoLocation'] ?? null);
+ $action->setAttribute('triggerCode', $row['actionTriggerCode']);
+ $action->setAttribute('actionType', $row['actionType']);
+ $action->setAttribute('layoutCode', $row['actionLayoutCode']);
+ $action->setAttribute('commandCode', $commandCode);
+
+ // Add criteria notes
+ if (count($criteriaNodes) > 0) {
+ foreach ($criteriaNodes as $criteriaNode) {
+ $action->appendChild($criteriaNode);
+ }
+ }
+
+ $actionNodes->appendChild($action);
+ } else if ($eventTypeId === Schedule::$DATA_CONNECTOR_EVENT) {
+ if ($dataConnectorNodes == null) {
+ $dataConnectorNodes = $scheduleXml->createElement('dataConnectors');
+ }
+
+ $dataConnector = $scheduleXml->createElement('connector');
+ $dataConnector->setAttribute('fromdt', $fromDt);
+ $dataConnector->setAttribute('todt', $toDt);
+ $dataConnector->setAttribute('scheduleid', $scheduleId);
+ $dataConnector->setAttribute('priority', $is_priority);
+ $dataConnector->setAttribute('duration', $row['duration'] ?? 0);
+ $dataConnector->setAttribute('isGeoAware', $row['isGeoAware'] ?? 0);
+ $dataConnector->setAttribute('geoLocation', $row['geoLocation'] ?? null);
+ $dataConnector->setAttribute('dataSetId', $row['dataSetId']);
+ $dataConnector->setAttribute('dataParams', urlencode($row['dataSetParams']));
+ $dataConnector->setAttribute('js', 'dataSet_' . $row['dataSetId'] . '.js');
+
+ // Add criteria notes
+ if (count($criteriaNodes) > 0) {
+ foreach ($criteriaNodes as $criteriaNode) {
+ $dataConnector->appendChild($criteriaNode);
+ }
+ }
+
+ $dataConnectorNodes->appendChild($dataConnector);
+ }
+ }
+ }
+
+ // Add the overlay nodes if we had any
+ if ($overlayNodes != null) {
+ $layoutElements->appendChild($overlayNodes);
+ }
+
+ // Add Actions nodes if we had any
+ if ($actionNodes != null) {
+ $layoutElements->appendChild($actionNodes);
+ }
+
+ // Add Data Connector nodes if we had any
+ if ($dataConnectorNodes != null) {
+ $layoutElements->appendChild($dataConnectorNodes);
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error('Error getting the schedule. ' . $e->getMessage());
+ return new \SoapFault('Sender', 'Unable to get the schedule');
+ }
+
+ // Default Layout
+ try {
+ // is it valid?
+ $defaultLayout = $this->layoutFactory->getById($defaultLayoutId);
+
+ if ($defaultLayout->status >= Status::$STATUS_INVALID) {
+ $this->getLog()->error(sprintf('Player has invalid default Layout. Display = %s, LayoutId = %d',
+ $this->display->display,
+ $defaultLayout->layoutId));
+ }
+
+ // Are we interleaving the default? And is the default valid?
+ if ($this->display->incSchedule == 1 && $defaultLayout->status < Status::$STATUS_INVALID) {
+ // Add as a node at the end of the schedule.
+ $layout = $scheduleXml->createElement("layout");
+
+ $layout->setAttribute("file", $defaultLayoutId);
+ $layout->setAttribute("fromdt", '2000-01-01 00:00:00');
+ $layout->setAttribute("todt", '2030-01-19 00:00:00');
+ $layout->setAttribute("scheduleid", 0);
+ $layout->setAttribute("priority", 0);
+ $layout->setAttribute('duration', $defaultLayout->duration);
+
+ if ($options['dependentsAsNodes'] && array_key_exists($defaultLayoutId, $layoutDependents)) {
+ $dependentNode = $scheduleXml->createElement("dependents");
+
+ foreach ($layoutDependents[$defaultLayoutId] as $storedAs) {
+ $fileNode = $scheduleXml->createElement("file", $storedAs);
+
+ $dependentNode->appendChild($fileNode);
+ }
+
+ $layout->appendChild($dependentNode);
+ }
+
+ $layoutElements->appendChild($layout);
+ }
+
+ // Add on the default layout node
+ $default = $scheduleXml->createElement("default");
+ $default->setAttribute("file", $defaultLayoutId);
+ $default->setAttribute('duration', $defaultLayout->duration);
+
+ if ($options['dependentsAsNodes'] && array_key_exists($defaultLayoutId, $layoutDependents)) {
+ $dependentNode = $scheduleXml->createElement("dependents");
+
+ foreach ($layoutDependents[$defaultLayoutId] as $storedAs) {
+ $fileNode = $scheduleXml->createElement("file", $storedAs);
+
+ $dependentNode->appendChild($fileNode);
+ }
+
+ $default->appendChild($dependentNode);
+ }
+
+ $layoutElements->appendChild($default);
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Default Layout Invalid: ' . $exception->getMessage());
+
+ // Add the splash screen on as the default layout (ID 0)
+ $default = $scheduleXml->createElement('default');
+ $default->setAttribute('file', 0);
+ $layoutElements->appendChild($default);
+ }
+
+ // Add on a list of global dependants
+ $globalDependents = $scheduleXml->createElement('dependants');
+
+ foreach ($moduleDependents as $dep) {
+ $dependent = $scheduleXml->createElement('file', $dep);
+ $globalDependents->appendChild($dependent);
+ }
+ $layoutElements->appendChild($globalDependents);
+
+ // Format the output
+ $scheduleXml->formatOutput = true;
+
+ $this->getLog()->debug($scheduleXml->saveXML());
+
+ $output = $scheduleXml->saveXML();
+
+ // Cache
+ $cache->set($output);
+ $cache->expiresAt($this->toFilter);
+ $this->getPool()->saveDeferred($cache);
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$SCHEDULE, strlen($output));
+
+ return $output;
+ }
+
+ /**
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param $mediaId
+ * @param $type
+ * @param $reason
+ * @return bool|\SoapFault
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ protected function doBlackList($serverKey, $hardwareKey, $mediaId, $type, $reason)
+ {
+ return true;
+ }
+
+ /**
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param $logXml
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ protected function doSubmitLog($serverKey, $hardwareKey, $logXml)
+ {
+ $this->logProcessor->setRoute('SubmitLog');
+
+ // Sanitize
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Sender', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ // Load the XML into a DOMDocument
+ $document = new \DOMDocument('1.0');
+
+ if (!$document->loadXML($logXml)) {
+ $this->getLog()->error(
+ 'Malformed XML from Player, this will be discarded. The Raw XML String provided is: ' . $logXml
+ );
+ $this->getLog()->debug('XML log: ' . $logXml);
+ return true;
+ }
+
+ // Current log level
+ $logLevel = \Xibo\Service\LogService::resolveLogLevel($this->display->getLogLevel());
+ $discardedLogs = 0;
+
+ // Store processed logs in an array
+ $logs = [];
+
+ foreach ($document->documentElement->childNodes as $node) {
+ /* @var \DOMElement $node */
+ // Make sure we don't consider any text nodes
+ if ($node->nodeType == XML_TEXT_NODE) {
+ continue;
+ }
+ // Zero out the common vars
+ $scheduleId = '';
+ $layoutId = '';
+ $mediaId = '';
+ $method = '';
+ $thread = '';
+ $type = '';
+
+ // This will be a bunch of trace nodes
+ $message = $node->textContent;
+
+ // Each element should have a category and a date
+ $date = $node->getAttribute('date');
+ $cat = strtolower($node->getAttribute('category'));
+
+ if ($date == '' || $cat == '') {
+ $this->getLog()->error('Log submitted without a date or category attribute');
+ continue;
+ }
+
+ // special handling for event
+ // this will create record in displayevent table
+ // and is not added to the logs.
+ if ($cat == 'event') {
+ $this->createDisplayAlert($node);
+ continue;
+ }
+
+ // Does this meet the current log level?
+ if ($cat == 'error') {
+ $recordLogLevel = Logger::ERROR;
+ $levelName = 'ERROR';
+ } else if ($cat == 'audit' || $cat == 'trace') {
+ $recordLogLevel = Logger::DEBUG;
+ $levelName = 'DEBUG';
+ } else if ($cat == 'debug') {
+ $recordLogLevel = Logger::INFO;
+ $levelName = 'INFO';
+ } else {
+ $recordLogLevel = Logger::NOTICE;
+ $levelName = 'NOTICE';
+ }
+
+ if ($recordLogLevel < $logLevel) {
+ $discardedLogs++;
+ continue;
+ }
+
+ // Adjust the date according to the display timezone
+ $date = $this->adjustDisplayLogDate($date, DateFormatHelper::getSystemFormat());
+
+ // Get the date and the message (all log types have these)
+ foreach ($node->childNodes as $nodeElements) {
+ if ($nodeElements->nodeName == 'scheduleID') {
+ $scheduleId = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'layoutID') {
+ $layoutId = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'mediaID') {
+ $mediaId = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'type') {
+ $type = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'method') {
+ $method = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'message') {
+ $message = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'thread') {
+ if ($nodeElements->textContent != '') {
+ $thread = '[' . $nodeElements->textContent . '] ';
+ }
+ }
+ }
+
+ // If the message is still empty, take the entire node content
+ if ($message == '') {
+ $message = $node->textContent;
+ }
+ // Add the IDs to the message
+ if ($scheduleId != '') {
+ $message .= ' scheduleId: ' . $scheduleId;
+ }
+ if ($layoutId != '') {
+ $message .= ' layoutId: ' . $layoutId;
+ }
+ if ($mediaId != '') {
+ $message .= ' mediaId: ' . $mediaId;
+ }
+ // Trim the page if it is over 50 characters.
+ $page = $thread . $method . $type;
+
+ if (strlen($page) >= 50) {
+ $page = substr($page, 0, 49);
+ }
+
+ $logs[] = [
+ $this->logProcessor->getUid(),
+ $date,
+ 'PLAYER',
+ $levelName,
+ $page,
+ 'POST',
+ $message,
+ 0,
+ $this->display->displayId
+ ];
+ }
+
+ if (count($logs) > 0) {
+ // Insert
+ $sql = '
+ INSERT INTO `log` (
+ `runNo`,
+ `logdate`,
+ `channel`,
+ `type`,
+ `page`,
+ `function`,
+ `message`,
+ `userid`,
+ `displayid`
+ ) VALUES
+ ';
+
+ // Build our query
+ $params = [];
+
+ // We're going to make params for each row/column
+ $i = 0;
+ $row = 0;
+ foreach ($logs as $log) {
+ $row++;
+ $sql .= '(';
+ foreach ($log as $field) {
+ $i++;
+ $key = $row . '_' . $i;
+ $sql .= ':' . $key . ',';
+ $params[$key] = $field;
+ }
+ $sql = rtrim($sql, ',');
+ $sql .= '),';
+ }
+ $sql = rtrim($sql, ',');
+
+ // Insert
+ $this->getStore()->update($sql, $params);
+ } else {
+ $this->getLog()->info('0 logs resolved from log package');
+ }
+
+ if ($discardedLogs > 0) {
+ $this->getLog()->info(
+ 'Discarded ' . $discardedLogs . ' logs.
+ Consider adjusting your display profile log level. Resolved level is ' . $logLevel
+ );
+ }
+
+ $this->logBandwidth($this->display->displayId, Bandwidth::$SUBMITLOG, strlen($logXml));
+
+ return true;
+ }
+
+ /**
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param $statXml
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ protected function doSubmitStats($serverKey, $hardwareKey, $statXml)
+ {
+ $this->logProcessor->setRoute('SubmitStats');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ $this->getLog()->debug('Received XML. ' . $statXml);
+
+ if ($statXml == '') {
+ throw new \SoapFault('Receiver', 'Stat XML is empty.');
+ }
+
+ // Store an array of parsed stat data for insert
+ $now = Carbon::now();
+
+ // Get the display timezone to use when adjusting log dates.
+ $defaultTimeZone = $this->getConfig()->getSetting('defaultTimezone');
+
+ // Count stats processed from XML
+ $statCount = 0;
+
+ // Load the XML into a DOMDocument
+ $document = new \DOMDocument('1.0');
+ $document->loadXML($statXml);
+
+ $splashScreenErrorLogged = false;
+ $backgroundWidgetErrorLogged = false;
+ $widgetIdsNotFound = [];
+ $memoryCache = [];
+
+ // Cache of scheduleIds, counts and deleted entities
+ $schedules = [];
+ $campaigns = [];
+ $deletedScheduleIds = [];
+ $deletedCampaignIds = [];
+
+ foreach ($document->documentElement->childNodes as $node) {
+ /* @var \DOMElement $node */
+ // Make sure we don't consider any text nodes
+ if ($node->nodeType == XML_TEXT_NODE) {
+ continue;
+ }
+
+ // Each element should have these attributes
+ $fromDt = $node->getAttribute('fromdt');
+ $toDt = $node->getAttribute('todt');
+ $type = strtolower($node->getAttribute('type'));
+ $duration = $node->getAttribute('duration');
+ $count = $node->getAttribute('count');
+ $count = ($count != '') ? (int) $count : 1;
+
+ // Pull out engagements
+ $engagements = [];
+ foreach ($node->childNodes as $nodeElements) {
+ /* @var \DOMElement $nodeElements */
+ if ($nodeElements->nodeName == 'engagements') {
+ $i = 0;
+ foreach ($nodeElements->childNodes as $child) {
+ /* @var \DOMElement $child */
+ if ($child->nodeName == 'engagement') {
+ $engagements[$i]['tag'] = $child->getAttribute('tag');
+ $engagements[$i]['duration'] = (int) $child->getAttribute('duration');
+ $engagements[$i]['count'] = (int) $child->getAttribute('count');
+ $i++;
+ }
+ }
+ }
+ }
+
+ // Validate
+ // --------
+ // Check we have the minimum required data
+ if ($fromDt == '' || $toDt == '' || $type == '') {
+ $this->getLog()->info('Stat submitted without the fromdt, todt or type attributes.');
+ continue;
+ }
+
+ // Exactly the same dates are not supported
+ if ($fromDt == $toDt) {
+ $this->getLog()->debug('Ignoring a Stat record because the fromDt ('
+ . $fromDt. ') and toDt (' . $toDt. ') are the same');
+ continue;
+ }
+
+ // Adjust the date according to the display timezone
+ // stats are returned in player local date/time
+ // the CMS will have been configured with that Player's timezone, so we can convert accordingly.
+ try {
+ // From date
+ $fromDt = ($this->display->timeZone != null)
+ ? Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $fromDt, $this->display->timeZone)
+ ->tz($defaultTimeZone)
+ : Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $fromDt);
+
+ // To date
+ $toDt = ($this->display->timeZone != null)
+ ? Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $toDt, $this->display->timeZone)
+ ->tz($defaultTimeZone)
+ : Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $toDt);
+
+ // Do we need to set the duration of this record (we will do for older individually collected stats)
+ if ($duration == '') {
+ $duration = $toDt->diffInSeconds($fromDt);
+ }
+ } catch (\Exception $e) {
+ // Protect against the date format being unreadable
+ $this->getLog()->error('Stat with a from or to date that cannot be understood. fromDt: '
+ . $fromDt . ', toDt: ' . $toDt . '. E = ' . $e->getMessage());
+ continue;
+ }
+
+ // From date cannot be ahead of to date
+ if ($fromDt > $toDt) {
+ $this->getLog()->debug('Ignoring a Stat record because the fromDt ('
+ . $fromDt . ') is greater than toDt (' . $toDt . ')');
+ continue;
+ }
+
+ // check maximum retention period against stat date, do not record if it's older than max stat age
+ $maxAge = intval($this->getConfig()->getSetting('MAINTENANCE_STAT_MAXAGE'));
+ if ($maxAge != 0) {
+ $maxAgeDate = Carbon::now()->subDays($maxAge);
+
+ if ($toDt->isBefore($maxAgeDate)) {
+ $this->getLog()->debug('Stat older than max retention period, skipping.');
+ continue;
+ }
+ }
+
+ // If the duration is enormous, then we have an erroneous message from the player
+ if ($duration > (86400 * 365)) {
+ $this->getLog()->debug('Dates are too far apart');
+ continue;
+ }
+
+ // Simple validation end
+ // ---------------------
+ // from here on we need to look things up
+
+ // ScheduleId is supplied to all layout stats, but not event stats.
+ $scheduleId = $node->getAttribute('scheduleid');
+ if (empty($scheduleId)) {
+ $scheduleId = 0;
+ }
+
+ $layoutId = $node->getAttribute('layoutid');
+
+ // Ignore the splash screen
+ if ($layoutId == 'splash') {
+ // only logging this message one time
+ if (!$splashScreenErrorLogged) {
+ $splashScreenErrorLogged = true;
+ $this->getLog()->info('Splash Screen Statistic Ignored');
+ }
+ continue;
+ }
+
+ // Slightly confusing behaviour here to support old players without introduction a different call in
+ // XMDS v=5.
+ // MediaId is actually the widgetId (since 1.8) and the mediaId is looked up by this service
+ $widgetId = $node->getAttribute('mediaid');
+ $mediaId = null;
+
+ // Ignore old "background" stat records.
+ if ($widgetId === 'background') {
+ if (!$backgroundWidgetErrorLogged) {
+ $backgroundWidgetErrorLogged = true;
+ $this->getLog()->info('Ignoring old "background" stat record.');
+ }
+ continue;
+ }
+
+ // The mediaId (really widgetId) might well be null
+ if ($widgetId == 'null' || $widgetId == '') {
+ $widgetId = 0;
+ } else {
+ // Try to get details for this widget
+ try {
+ if (in_array($widgetId, $widgetIdsNotFound)) {
+ continue;
+ }
+
+ // Do we have it in cache?
+ if (!array_key_exists('w_' . $widgetId, $memoryCache)) {
+ $memoryCache['w_' . $widgetId] = $this->widgetFactory->getMediaByWidgetId($widgetId);
+ }
+ $mediaId = $memoryCache['w_' . $widgetId];
+
+ // If the mediaId is empty, then we can assume we're a stat for a region specific widget
+ if ($mediaId === null) {
+ $type = 'widget';
+ }
+ } catch (NotFoundException $notFoundException) {
+ // Widget isn't found
+ // we can only log this and move on
+ // only logging this message one time
+ if (!in_array($widgetId, $widgetIdsNotFound)) {
+ $widgetIdsNotFound[] = $widgetId;
+ $this->getLog()->error('Stat for a widgetId that doesnt exist: ' . $widgetId);
+ }
+ continue;
+ }
+ }
+
+ $tag = $node->getAttribute('tag');
+ if ($tag == 'null') {
+ $tag = null;
+ }
+
+ // Cache a count for this scheduleId
+ $parentCampaignId = 0;
+ $parentCampaign = null;
+
+ if ($scheduleId > 0 && !in_array($scheduleId, $deletedScheduleIds)) {
+ try {
+ // Lookup this schedule
+ if (!array_key_exists($scheduleId, $schedules)) {
+ // Look up the campaign.
+ $schedules[$scheduleId] = $this->scheduleFactory->getById($scheduleId);
+ }
+ $parentCampaignId = $schedules[$scheduleId]->parentCampaignId ?? 0;
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error('Schedule with ID ' . $scheduleId . ' no-longer exists');
+ $deletedScheduleIds[] = $scheduleId;
+ }
+
+ // Does this event have a parent campaign?
+ if (!empty($parentCampaignId) && !in_array($parentCampaignId, $deletedCampaignIds)) {
+ try {
+ // Look it up
+ if (!array_key_exists($parentCampaignId, $campaigns)) {
+ $campaigns[$parentCampaignId] = $this->campaignFactory->getById($parentCampaignId);
+ }
+
+ // Set the parent campaign so that it is recorded with the stat record
+ $parentCampaign = $campaigns[$parentCampaignId];
+
+ // For a layout stat we should increment the number of plays on the Campaign
+ if ($type === 'layout' && $campaigns[$parentCampaignId]->type === 'ad') {
+ // spend/impressions multiplier for this display
+ $spend = empty($this->display->costPerPlay)
+ ? 0
+ : ($count * $this->display->costPerPlay);
+ $impressions = empty($this->display->impressionsPerPlay)
+ ? 0
+ : ($count * $this->display->impressionsPerPlay);
+
+ // record
+ $parentCampaign->incrementPlays($count, $spend, $impressions);
+ }
+ } catch (NotFoundException $notFoundException) {
+ $deletedCampaignIds[] = $parentCampaignId;
+ $this->getLog()->error('Campaign with ID ' . $parentCampaignId . ' no-longer exists');
+ }
+ }
+ }
+
+ // Important - stats will now send display entity instead of displayId
+ $stats = [
+ 'type' => $type,
+ 'statDate' => $now,
+ 'fromDt' => $fromDt,
+ 'toDt' => $toDt,
+ 'scheduleId' => $scheduleId,
+ 'display' => $this->display,
+ 'layoutId' => (int) $layoutId,
+ 'mediaId' => $mediaId,
+ 'tag' => $tag,
+ 'widgetId' => (int) $widgetId,
+ 'duration' => (int) $duration,
+ 'count' => $count,
+ 'engagements' => (count($engagements) > 0) ? $engagements : [],
+ 'parentCampaignId' => $parentCampaignId,
+ 'parentCampaign' => $parentCampaign,
+ ];
+
+ $this->getTimeSeriesStore()->addStat($stats);
+
+ $statCount++;
+ }
+
+ // Insert stats
+ if ($statCount > 0) {
+ $this->getTimeSeriesStore()->addStatFinalize();
+ } else {
+ $this->getLog()->info('0 stats resolved from data package');
+ }
+
+ // Save ad campaign changes.
+ foreach ($campaigns as $campaign) {
+ if ($campaign->type === 'ad') {
+ $campaign->saveIncrementPlays();
+ }
+ }
+
+ $this->logBandwidth($this->display->displayId, Bandwidth::$SUBMITSTATS, strlen($statXml));
+
+ return true;
+ }
+
+ /**
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param $inventory
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ protected function doMediaInventory($serverKey, $hardwareKey, $inventory)
+ {
+ $this->logProcessor->setRoute('MediaInventory');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ $this->getLog()->debug($inventory);
+
+ // Check that the $inventory contains something
+ if ($inventory == '') {
+ throw new \SoapFault('Receiver', 'Inventory Cannot be Empty');
+ }
+
+ // Load the XML into a DOMDocument
+ $document = new \DOMDocument('1.0');
+ $document->loadXML($inventory);
+
+ // Assume we are complete (but we are getting some)
+ $mediaInventoryComplete = 1;
+
+ $xpath = new \DOMXPath($document);
+ $fileNodes = $xpath->query('//file');
+
+ foreach ($fileNodes as $node) {
+ /* @var \DOMElement $node */
+
+ // What type of file?
+ try {
+ $requiredFile = null;
+ switch ($node->getAttribute('type')) {
+ case 'media':
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndMedia(
+ $this->display->displayId,
+ $node->getAttribute('id'),
+ $node->getAttribute('id') < 0 ? 'P' : 'M'
+ );
+ break;
+
+ case 'layout':
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndLayout(
+ $this->display->displayId,
+ $node->getAttribute('id')
+ );
+ break;
+
+ case 'resource':
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndWidget(
+ $this->display->displayId,
+ $node->getAttribute('id')
+ );
+ break;
+
+ case 'dependency':
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndDependency(
+ $this->display->displayId,
+ $node->getAttribute('fileType'),
+ $node->getAttribute('id')
+ );
+ break;
+
+ default:
+ $this->getLog()->debug(sprintf(
+ 'Skipping unknown node in media inventory: %s - %s.',
+ $node->getAttribute('type'),
+ $node->getAttribute('id')
+ ));
+
+ // continue drops out the switch, continue again goes to the top of the foreach
+ continue 2;
+ }
+
+ // File complete?
+ $complete = $node->getAttribute('complete');
+ $requiredFile->complete = $complete;
+ $requiredFile->save();
+
+ // If this item is a 0 then set not complete
+ if ($complete == 0) {
+ $mediaInventoryComplete = 2;
+ }
+ } catch (NotFoundException $e) {
+ $this->getLog()->error('Unable to find file in media inventory: '
+ . $node->getAttribute('type') . '. ' . $node->getAttribute('id'));
+ }
+ }
+
+ $this->display->mediaInventoryStatus = $mediaInventoryComplete;
+
+ // Only call save if this property has actually changed.
+ if ($this->display->hasPropertyChanged('mediaInventoryStatus')) {
+ $this->getLog()->debug('Media Inventory status changed to ' . $this->display->mediaInventoryStatus);
+
+ // If we are complete, then drop the player nonce cache
+ if ($this->display->mediaInventoryStatus == 1) {
+ $this->getLog()->debug('Media Inventory tells us that all downloads are complete');
+ }
+
+ $this->display->saveMediaInventoryStatus();
+ }
+
+ $this->logBandwidth($this->display->displayId, Bandwidth::$MEDIAINVENTORY, strlen($inventory));
+
+ return true;
+ }
+
+ /**
+ * Get Resource for XMDS v6 and lower
+ * outputs the HTML with data included
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param integer $layoutId
+ * @param string $regionId
+ * @param string $mediaId
+ * @param bool $isSupportsDataUrl Does the callee support data URLs in widgets?
+ * @return mixed
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ protected function doGetResource(
+ $serverKey,
+ $hardwareKey,
+ $layoutId,
+ $regionId,
+ $mediaId,
+ bool $isSupportsDataUrl = false
+ ) {
+ $this->logProcessor->setRoute('GetResource');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ 'layoutId' => $layoutId,
+ 'regionId' => $regionId,
+ 'mediaId' => $mediaId
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+ $layoutId = $sanitizer->getInt('layoutId');
+ $regionId = $sanitizer->getString('regionId');
+ $mediaId = $sanitizer->getString('mediaId');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ // The MediaId is actually the widgetId
+ try {
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndWidget(
+ $this->display->displayId,
+ $mediaId
+ );
+
+ $region = $this->regionFactory->getById($regionId);
+ $widget = $this->widgetFactory->loadByWidgetId($mediaId);
+
+ // If this is a canvas region we add all our widgets to this.
+ if ($region->type === 'canvas') {
+ // Render a canvas
+ // ---------------
+ // A canvas plays all widgets in the region at once.
+ // none of them will be anything other than elements
+ $widgets = $region->getPlaylist()->widgets;
+ } else {
+ // Render a widget in a region
+ // ---------------------------
+ // We have a widget
+ $widgets = [$widget];
+ }
+
+ // Module is always the first widget
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // Get all templates
+ $templates = $this->widgetFactory->getTemplatesForWidgets($module, $widgets);
+
+ $renderer = $this->moduleFactory->createWidgetHtmlRenderer();
+ $resource = $renderer->renderOrCache(
+ $region,
+ $widgets,
+ $templates
+ );
+
+ // An array of media we have access to.
+ // Get all linked media for this player and this widget.
+ $media = [];
+ $widgetIds = implode(',', array_map(function ($el) {
+ return $el->widgetId;
+ }, $widgets));
+
+ $sql = '
+ SELECT `media`.mediaId, `media`.storedAs
+ FROM `media`
+ INNER JOIN `lkwidgetmedia`
+ ON `lkwidgetmedia`.mediaId = `media`.mediaId
+ WHERE `lkwidgetmedia`.widgetId IN (' . $widgetIds . ')
+ UNION ALL
+ SELECT `media`.mediaId, `media`.storedAs
+ FROM `media`
+ INNER JOIN `display_media`
+ ON `display_media`.mediaId = `media`.mediaId
+ WHERE `display_media`.displayId = :displayId
+ ';
+
+ // There isn't any point using a prepared statement because the widgetIds are substituted at runtime
+ foreach ($this->getStore()->select($sql, [
+ 'displayId' => $this->display->displayId
+ ]) as $row) {
+ $media[$row['mediaId']] = $row['storedAs'];
+ };
+
+ // If this player doesn't support data URLs, then add the data to this response.
+ $data = [];
+
+ if (!$isSupportsDataUrl) {
+ foreach ($widgets as $widget) {
+ $dataModule = $this->moduleFactory->getByType($widget->type);
+ if ($dataModule->isDataProviderExpected()) {
+ // We only ever return cache.
+ $dataProvider = $dataModule->createDataProvider($widget);
+ $dataProvider->setDisplayProperties(
+ $this->display->latitude ?: $this->getConfig()->getSetting('DEFAULT_LAT'),
+ $this->display->longitude ?: $this->getConfig()->getSetting('DEFAULT_LONG'),
+ $this->display->displayId
+ );
+
+ // Use the cache if we can.
+ try {
+ $widgetDataProviderCache = $this->moduleFactory->createWidgetDataProviderCache();
+ $cacheKey = $this->moduleFactory->determineCacheKey(
+ $dataModule,
+ $widget,
+ $this->display->displayId,
+ $dataProvider,
+ $dataModule->getWidgetProviderOrNull()
+ );
+
+ // We do not pass a modifiedDt in here because we always expect to be cached.
+ if (!$widgetDataProviderCache->decorateWithCache($dataProvider, $cacheKey, null, false)) {
+ throw new NotFoundException('Cache not ready');
+ }
+
+ $widgetData = $widgetDataProviderCache->decorateForPlayer(
+ $this->configService,
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $dataProvider->getData(),
+ $media,
+ );
+ } catch (GeneralException $exception) {
+ // No data cached yet, exception
+ $this->getLog()->error('getResource: Failed to get data cache for widgetId '
+ . $widget->widgetId . ', e: ' . $exception->getMessage());
+ throw new \SoapFault('Receiver', 'Cache not ready');
+ }
+
+ $data[$widget->widgetId] = [
+ 'data' => $widgetData,
+ 'meta' => $dataProvider->getMeta()
+ ];
+ }
+ }
+ }
+
+ // Decorate for the player
+ $resource = $renderer->decorateForPlayer(
+ $this->display,
+ $resource,
+ $media,
+ $isSupportsDataUrl,
+ $data,
+ $this->moduleFactory->getAssetsFromTemplates($templates)
+ );
+
+ if ($resource == '') {
+ throw new ControllerNotImplemented();
+ }
+
+ // Log bandwidth
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + strlen($resource);
+ $requiredFile->save();
+ } catch (NotFoundException) {
+ throw new \SoapFault('Receiver', 'Requested an invalid file.');
+ } catch (\Exception $e) {
+ // Pass soap faults straight through.
+ if ($e instanceof \SoapFault) {
+ throw $e;
+ }
+
+ $this->getLog()->error('Unknown error during getResource. E = ' . $e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+ throw new \SoapFault('Receiver', 'Unable to get the media resource');
+ }
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$GETRESOURCE, strlen($resource));
+
+ return $resource;
+ }
+
+ /**
+ * Authenticates the display
+ * @param string $hardwareKey
+ * @return bool
+ */
+ protected function authDisplay($hardwareKey)
+ {
+ try {
+ $this->display = $this->displayFactory->getByLicence($hardwareKey);
+
+ if ($this->display->licensed != 1) {
+ return false;
+ }
+
+ // Configure our log processor
+ $this->logProcessor->setDisplay($this->display->displayId, $this->display->isAuditing());
+
+ return true;
+
+ } catch (NotFoundException $e) {
+ $this->getLog()->error($e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Alert Display Up
+ * assesses whether a notification is required to be sent for this display, and only does something if the
+ * display is currently marked as offline (i.e. it is coming back online again)
+ * this is only called in Register
+ * @throws NotFoundException
+ */
+ protected function alertDisplayUp(): void
+ {
+ $maintenanceEnabled = $this->getConfig()->getSetting('MAINTENANCE_ENABLED');
+
+ if ($this->display->loggedIn == 0 && !empty($this->display->displayId)) {
+ $this->getLog()->info(sprintf('Display %s was down, now its up.', $this->display->display));
+
+ // Log display up
+ $this->displayEventFactory->createEmpty()->eventEnd($this->display->displayId);
+
+ // Do we need to email?
+ if ($this->display->emailAlert == 1
+ && ($maintenanceEnabled == 'On' || $maintenanceEnabled == 'Protected')
+ && $this->getConfig()->getSetting('MAINTENANCE_EMAIL_ALERTS') == 1
+ ) {
+ // Only send alerts during operating hours.
+ if ($this->isInsideOperatingHours()) {
+ $subject = sprintf(__('Recovery for Display %s'), $this->display->display);
+ $body = sprintf(
+ __('Display ID %d is now back online %s'),
+ $this->display->displayId,
+ Carbon::now()->format(DateFormatHelper::getSystemFormat())
+ );
+
+ // Create a notification assigned to system-wide user groups
+ try {
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'display',
+ );
+
+ // Get groups which have been configured to receive notifications
+ foreach ($this->userGroupFactory
+ ->getDisplayNotificationGroups($this->display->displayGroupId) as $group) {
+ $notification->assignUserGroup($group);
+ }
+
+ // Save the notification and insert the links, etc.
+ $notification->save();
+ } catch (\Exception) {
+ $this->getLog()->error(sprintf(
+ 'Unable to send email alert for display %s with subject %s and body %s',
+ $this->display->display,
+ $subject,
+ $body
+ ));
+ }
+ } else {
+ $this->getLog()->info('Not sending recovery email for Display - '
+ . $this->display->display . ' we are outside of its operating hours');
+ }
+ } else {
+ $this->getLog()->debug(sprintf(
+ 'No email required. Email Alert: %d, Enabled: %s, Email Enabled: %s.',
+ $this->display->emailAlert,
+ $maintenanceEnabled,
+ $this->getConfig()->getSetting('MAINTENANCE_EMAIL_ALERTS')
+ ));
+ }
+ }
+ }
+
+ /**
+ * Is the display currently inside operating hours?
+ * @return bool
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ private function isInsideOperatingHours(): bool
+ {
+ $dayPartId = $this->display->getSetting('dayPartId', null, ['displayOverride' => true]);
+
+ // If dayPart is configured, check operating hours
+ if ($dayPartId !== null) {
+ try {
+ $dayPart = $this->dayPartFactory->getById($dayPartId);
+
+ $startTimeArray = explode(':', $dayPart->startTime);
+ $startTime = Carbon::now()->setTime(intval($startTimeArray[0]), intval($startTimeArray[1]));
+
+ $endTimeArray = explode(':', $dayPart->endTime);
+ $endTime = Carbon::now()->setTime(intval($endTimeArray[0]), intval($endTimeArray[1]));
+
+ $now = Carbon::now();
+
+ // handle exceptions
+ foreach ($dayPart->exceptions as $exception) {
+ // check if we are on exception day and if so override the startTime and endTime accordingly
+ if ($exception['day'] == Carbon::now()->format('D')) {
+ // Parse the start/end times into the current day.
+ $exceptionsStartTime = explode(':', $exception['start']);
+ $startTime = Carbon::now()->setTime(
+ intval($exceptionsStartTime[0]),
+ intval($exceptionsStartTime[1])
+ );
+
+ $exceptionsEndTime = explode(':', $exception['end']);
+ $endTime = Carbon::now()->setTime(
+ intval($exceptionsEndTime[0]),
+ intval($exceptionsEndTime[1])
+ );
+ }
+ }
+
+ // check if we are inside the operating hours for this display - we use that flag to decide
+ // if we need to create a notification and send an email.
+ return ($now >= $startTime && $now <= $endTime);
+ } catch (NotFoundException) {
+ $this->getLog()->debug('Unknown dayPartId set on Display Profile for displayId '
+ . $this->display->displayId);
+ }
+ }
+
+ // Otherwise, assume CMS is within operating hours.
+ return true;
+ }
+
+ /**
+ * Get the Client IP Address
+ * @return string
+ */
+ protected function getIp()
+ {
+ $clientIp = '';
+
+ $keys = array('X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP', 'REMOTE_ADDR');
+ foreach ($keys as $key) {
+ if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
+ $clientIp = $_SERVER[$key];
+ break;
+ }
+ }
+
+ return $clientIp;
+ }
+
+ /**
+ * Check we haven't exceeded the bandwidth limits
+ * - Note, display logging doesn't work in here, this is CMS level logging
+ *
+ * @param int $displayId The Display ID
+ * @return bool true if the check passes, false if it fails
+ * @throws NotFoundException
+ */
+ protected function checkBandwidth($displayId)
+ {
+ // Uncomment to enable auditing.
+ //$this->logProcessor->setDisplay(0, 'debug');
+
+ $this->display = $this->displayFactory->getById($displayId);
+
+ $xmdsLimit = intval($this->getConfig()->getSetting('MONTHLY_XMDS_TRANSFER_LIMIT_KB'));
+ $displayBandwidthLimit = $this->display->bandwidthLimit;
+
+ try {
+ $bandwidthUsage = 0;
+
+ if ($this->bandwidthFactory->isBandwidthExceeded($xmdsLimit, $bandwidthUsage)) {
+ // Bandwidth Exceeded
+ // Create a notification if we don't already have one today for this display.
+ $subject = __('Bandwidth allowance exceeded');
+ $date = Carbon::now();
+ $notifications = $this->notificationFactory->getBySubjectAndDate(
+ $subject,
+ $date->startOfDay()->format('U'),
+ $date->addDay()->startOfDay()->format('U')
+ );
+
+ if (count($notifications) <= 0) {
+ $body = __(
+ sprintf(
+ 'Bandwidth allowance of %s exceeded. Used %s',
+ ByteFormatter::format($xmdsLimit * 1024),
+ ByteFormatter::format($bandwidthUsage)
+ )
+ );
+
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'library'
+ );
+
+ $notification->save();
+
+ $this->getLog()->critical($subject);
+ }
+
+ return false;
+
+ } elseif ($this->bandwidthFactory->isBandwidthExceeded(
+ $displayBandwidthLimit,
+ $bandwidthUsage,
+ $displayId
+ )
+ ) {
+ // Bandwidth Exceeded
+ // Create a notification if we don't already have one today for this display.
+ $subject = __(sprintf('Display ID %d exceeded the bandwidth limit', $this->display->displayId));
+ $date = Carbon::now();
+
+ $notifications = $this->notificationFactory->getBySubjectAndDate(
+ $subject,
+ $date->startOfDay()->format('U'),
+ $date->addDay()->startOfDay()->format('U')
+ );
+ if (count($notifications) <= 0) {
+ $body = __(
+ sprintf(
+ 'Display bandwidth limit %s exceeded. Used %s for Display Id %d',
+ ByteFormatter::format($displayBandwidthLimit * 1024),
+ ByteFormatter::format($bandwidthUsage),
+ $this->display->displayId
+ )
+ );
+
+ $notification = $this->notificationFactory->createSystemNotification(
+ $subject,
+ $body,
+ Carbon::now(),
+ 'display'
+ );
+
+ // Add in any displayNotificationGroups, with permissions
+ foreach ($this->userGroupFactory->getDisplayNotificationGroups(
+ $this->display->displayGroupId
+ ) as $group) {
+ $notification->assignUserGroup($group);
+ }
+
+ $notification->save();
+
+ $this->getLog()->critical($subject);
+ }
+
+ return false;
+ } else {
+ // Bandwidth not exceeded.
+ return true;
+ }
+ } catch (\Exception $e) {
+ $this->getLog()->error($e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Log Bandwidth Usage
+ * @param int $displayId
+ * @param string $type
+ * @param int $sizeInBytes
+ */
+ protected function logBandwidth($displayId, $type, $sizeInBytes)
+ {
+ $this->bandwidthFactory->createAndSave($type, $displayId, $sizeInBytes);
+ }
+
+ /**
+ * Add a dependency to the provided DOM element
+ * @param array $rfIds
+ * @param \DOMDocument $requiredFilesXml
+ * @param \DOMElement $fileElements
+ * @param bool $httpDownloads
+ * @param bool $isSupportsDependency
+ * @param Dependency $dependency
+ * @throws NotFoundException
+ * @throws \DOMException
+ */
+ private function addDependency(
+ array &$rfIds,
+ \DOMDocument $requiredFilesXml,
+ \DOMElement $fileElements,
+ bool $httpDownloads,
+ bool $isSupportsDependency,
+ Dependency $dependency
+ ): void {
+ // Create a required file for this dependency
+ $dependencyBasePath = basename($dependency->path);
+ $rfId = $this->requiredFileFactory
+ ->createForGetDependency(
+ $this->display->displayId,
+ $dependency->fileType,
+ $dependency->legacyId,
+ $dependency->id,
+ $dependencyBasePath,
+ $dependency->size,
+ $isSupportsDependency
+ )
+ ->save()->rfId;
+
+ // Make sure we do not already have it.
+ if (in_array($rfId, $rfIds)) {
+ $this->getLog()->debug('addDependency: ' . $dependency->id . ' already added to XML');
+ return;
+ }
+
+ // Record this required file's ID as a new one.
+ $rfIds[] = $rfId;
+
+ // Add to RF XML
+ $file = $requiredFilesXml->createElement('file');
+
+ // HTTP downloads?
+ // 3) some dependencies don't support HTTP downloads because they aren't in the library
+ $httpFilePath = null;
+ if ($httpDownloads && $dependency->isAvailableOverHttp) {
+ $httpFilePath = LinkSigner::generateSignedLink(
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $this->configService->getSetting('CDN_URL'),
+ RequiredFile::$TYPE_DEPENDENCY,
+ $dependency->id,
+ $dependencyBasePath,
+ $dependency->fileType,
+ );
+
+ $file->setAttribute('download', 'http');
+ } else {
+ $file->setAttribute('download', 'xmds');
+ }
+
+ $file->setAttribute('size', $dependency->size);
+ $file->setAttribute('md5', $dependency->md5);
+ $file->setAttribute('saveAs', $dependencyBasePath);
+
+ // 4) earlier versions of XMDS do not support GetDependency, and will therefore need to have their
+ // dependencies added as media nodes.
+ if ($isSupportsDependency) {
+ // Soap7+: GetDependency supported
+ $file->setAttribute('type', 'dependency');
+ $file->setAttribute('fileType', $dependency->fileType);
+ if ($httpFilePath !== null) {
+ $file->setAttribute('path', $httpFilePath);
+ } else {
+ $file->setAttribute('path', $dependencyBasePath);
+ }
+ $file->setAttribute('saveAs', $dependencyBasePath);
+ $file->setAttribute('id', $dependency->id);
+ } else {
+ // We have no choice but to pretend we're a media download.
+ // Soap3/4 are modified to cater for this.
+ // Soap3: players read, send and save using the `path`. Expects `id.ext`.
+ // Soap4: we only have the ID, but we can use HTTP downloads.
+ // Soap5,6,7: use Soap4
+ $file->setAttribute('type', 'media');
+ if ($httpFilePath !== null) {
+ $file->setAttribute('path', $httpFilePath);
+ } else {
+ $file->setAttribute('path', $dependencyBasePath);
+ }
+ $file->setAttribute('id', $dependency->legacyId);
+ $file->setAttribute('fileType', 'media');
+
+ // We need an extra attribute so that we can retrieve the original asset type from cache.
+ $file->setAttribute('realId', $dependency->id);
+ $file->setAttribute('assetType', $dependency->fileType);
+ }
+
+ // Add our node
+ $fileElements->appendChild($file);
+ }
+
+ /**
+ * Set Date Filters
+ */
+ protected function setDateFilters()
+ {
+ // Hour to hour time bands for the query
+ // Rf lookahead is the number of seconds ahead we should consider.
+ // it may well be less than 1 hour, and if so we cannot do hour to hour time bands, we need to do
+ // now, forwards.
+ // Start with now:
+ $fromFilter = Carbon::now();
+
+ // If this Display is in a different timezone, then we need to set that here for these filter criteria
+ if (!empty($this->display->timeZone)) {
+ $fromFilter->setTimezone($this->display->timeZone);
+ }
+
+ // TODO use new sanitizer here
+ //$rfLookAhead = $this->getSanitizer()->int($this->getConfig()->getSetting('REQUIRED_FILES_LOOKAHEAD'));
+ $rfLookAhead = $this->getConfig()->getSetting('REQUIRED_FILES_LOOKAHEAD');
+ if ($rfLookAhead >= 3600) {
+ // Go from the top of this hour
+ $fromFilter
+ ->minute(0)
+ ->second(0);
+ }
+
+ // If we're set to look ahead, then do so - otherwise grab only a 1 hour slice
+ if ($this->getConfig()->getSetting('SCHEDULE_LOOKAHEAD') == 1) {
+ $toFilter = $fromFilter->copy()->addSeconds($rfLookAhead);
+ } else {
+ $toFilter = $fromFilter->copy()->addHour();
+ }
+
+ // Make sure our filters are expressed in CMS time, so that when we run the query we don't lose the timezone
+ $this->localFromFilter = $fromFilter;
+ $this->localToFilter = $toFilter;
+ $this->fromFilter = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $fromFilter);
+ $this->toFilter = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $toFilter);
+
+ $this->getLog()->debug(
+ sprintf(
+ 'FromDT = %s [%d]. ToDt = %s [%d]',
+ $fromFilter->toRssString(),
+ $fromFilter->format('U'),
+ $toFilter->toRssString(),
+ $toFilter->format('U')
+ )
+ );
+ }
+
+ /**
+ * Adjust the log date according to the Display timezone.
+ * Return current date if we fail.
+ * @param string $date
+ * @param string $format
+ * @return string
+ */
+ protected function adjustDisplayLogDate(string $date, string $format): string
+ {
+ // Get the display timezone to use when adjusting log dates.
+ $defaultTimeZone = $this->getConfig()->getSetting('defaultTimezone');
+
+ // Adjust the date according to the display timezone
+ try {
+ $date = ($this->display->timeZone != null)
+ ? Carbon::createFromFormat(
+ DateFormatHelper::getSystemFormat(),
+ $date,
+ $this->display->timeZone
+ )->tz($defaultTimeZone)
+ : Carbon::createFromFormat(
+ DateFormatHelper::getSystemFormat(),
+ $date
+ );
+ $date = $date->format($format);
+ } catch (\Exception $e) {
+ // Protect against the date format being unreadable
+ $this->getLog()->debug('Date format unreadable on log message: ' . $date);
+
+ // Use now instead
+ $date = Carbon::now()->format($format);
+ }
+
+ return $date;
+ }
+
+ private function createDisplayAlert(\DomElement $alertNode)
+ {
+ $date = $this->adjustDisplayLogDate($alertNode->getAttribute('date'), 'U');
+ $eventType = '';
+ $refId = '';
+ $detail = '';
+ $alertType = '';
+
+ // Get the nodes we are expecting
+ foreach ($alertNode->childNodes as $nodeElements) {
+ if ($nodeElements->nodeName == 'eventType') {
+ $eventType = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'refId') {
+ $refId = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'message') {
+ $detail = $nodeElements->textContent;
+ } else if ($nodeElements->nodeName == 'alertType') {
+ $alertType = $nodeElements->textContent;
+ }
+ }
+
+ // if alerts should provide both start and end or just start
+ if ($alertType == 'both' || $alertType == 'start') {
+ $displayEvent = $this->displayEventFactory->createEmpty();
+
+ // new record populated from the submitLog xml.
+ $displayEvent->displayId = $this->display->displayId;
+ $displayEvent->eventTypeId = $displayEvent->getEventIdFromString($eventType);
+ $displayEvent->eventDate = $date;
+ $displayEvent->start = $date;
+ $displayEvent->end = $alertType == 'both' ? $date : null;
+ $displayEvent->refId = empty($refId) ? null : $refId;
+ $displayEvent->detail = $detail;
+
+ $displayEvent->save();
+ } else if ($alertType == 'end') {
+ // if this event pertain only to end date for an existing event record,
+ // then set the end date for this display and the specified eventType
+ $displayEvent = $this->displayEventFactory->createEmpty();
+ $eventTypeId = $displayEvent->getEventIdFromString($eventType);
+ empty($refId)
+ ? $displayEvent->eventEnd($this->display->displayId, $eventTypeId, $detail, $date)
+ : $displayEvent->eventEndByReference($this->display->displayId, $eventTypeId, $refId, $detail);
+ }
+ }
+
+ /**
+ * Collection Interval with offset
+ * calculates an offset for the collection interval based on the displayId and returns it
+ * the offset is plus or minus 10 seconds and will always be the same when given the same displayId
+ * @param int $collectionInterval
+ * @return int
+ */
+ protected function collectionIntervalWithOffset(int $collectionInterval): int
+ {
+ if ($collectionInterval <= 60) {
+ $offset = $this->display->displayId % 10;
+ return $collectionInterval + ($offset <= 5 ? $offset * -1 : $offset - 5);
+ } else {
+ $offset = $this->display->displayId % 20;
+ return $collectionInterval + ($offset <= 10 ? $offset * -1 : $offset - 10);
+ }
+ }
+}
diff --git a/lib/Xmds/Soap3.php b/lib/Xmds/Soap3.php
new file mode 100644
index 0000000..2434082
--- /dev/null
+++ b/lib/Xmds/Soap3.php
@@ -0,0 +1,364 @@
+.
+ */
+namespace Xibo\Xmds;
+
+use Carbon\Carbon;
+use Xibo\Entity\Bandwidth;
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Soap3
+ * @package Xibo\Xmds
+ * @phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ */
+class Soap3 extends Soap
+{
+ /**
+ * Registers a new display
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $displayName
+ * @param string $version
+ * @return string
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function RegisterDisplay($serverKey, $hardwareKey, $displayName, $version)
+ {
+ $this->logProcessor->setRoute('RegisterDisplay');
+
+ // Sanitize
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches the one we have
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY'))
+ throw new \SoapFault('Sender', 'The Server key you entered does not match with the server key at this address');
+
+ // Check the Length of the hardwareKey
+ if (strlen($hardwareKey) > 40)
+ throw new \SoapFault('Sender', 'The Hardware Key you sent was too long. Only 40 characters are allowed (SHA1).');
+
+ // Check in the database for this hardwareKey
+ try {
+ $display = $this->displayFactory->getByLicence($hardwareKey);
+
+ if (!$display->isDisplaySlotAvailable()) {
+ $display->licensed = 0;
+ }
+
+ $this->logProcessor->setDisplay($display->displayId, $display->isAuditing());
+
+ if ($display->licensed == 0) {
+ $active = 'Display is awaiting licensing approval from an Administrator.';
+ } else {
+ $active = 'Display is active and ready to start.';
+ }
+
+ // Touch
+ $display->lastAccessed = Carbon::now()->format('U');
+ $display->loggedIn = 1;
+ $display->save(['validate' => false, 'audit' => false]);
+
+ // Log Bandwidth
+ $this->logBandwidth($display->displayId, Bandwidth::$REGISTER, strlen($active));
+
+ $this->getLog()->debug($active, $display->displayId);
+
+ return $active;
+ } catch (NotFoundException $e) {
+ $this->getLog()->error('Attempt to register a Version 3 Display with key %s.', $hardwareKey);
+
+ throw new \SoapFault('Sender', 'You cannot register an old display against this CMS.');
+ }
+ }
+
+ /**
+ * Returns a string containing the required files xml for the requesting display
+ * @param string $serverKey
+ * @param string $hardwareKey Display Hardware Key
+ * @param string $version
+ * @return string $requiredXml Xml Formatted
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ function RequiredFiles($serverKey, $hardwareKey, $version)
+ {
+ return $this->doRequiredFiles($serverKey, $hardwareKey, false);
+ }
+
+ /**
+ * Get File
+ * @param string $serverKey The ServerKey for this CMS
+ * @param string $hardwareKey The HardwareKey for this Display
+ * @param string $filePath
+ * @param string $fileType The File Type
+ * @param int $chunkOffset The Offset of the Chunk Requested
+ * @param string $chunkSize The Size of the Chunk Requested
+ * @param string $version
+ * @return string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ */
+ public function GetFile($serverKey, $hardwareKey, $filePath, $fileType, $chunkOffset, $chunkSize, $version)
+ {
+ $this->logProcessor->setRoute('GetFile');
+
+ // Sanitize
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ 'filePath' => $filePath,
+ 'fileType' => $fileType,
+ 'chunkOffset' => $chunkOffset,
+ 'chunkSize' => $chunkSize
+ ]);
+
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+ $filePath = $sanitizer->getString('filePath');
+ $fileType = $sanitizer->getString('fileType');
+ $chunkOffset = $sanitizer->getDouble('chunkOffset');
+ $chunkSize = $sanitizer->getDouble('chunkSize');
+
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Authenticate this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ $this->getLog()->debug(
+ '[IN] Params: ['. $hardwareKey .'] ['. $filePath . ']
+ ['. $fileType.'] ['.$chunkOffset.'] ['.$chunkSize.']'
+ );
+
+ $file = null;
+
+ if (empty($filePath)) {
+ $this->getLog()->error('Soap3 GetFile request without a file path. Maybe a player missing ?v= parameter');
+ throw new \SoapFault(
+ 'Receiver',
+ 'GetFile request is missing file path - is this version compatible with this CMS?'
+ );
+ }
+
+ try {
+ // Handle fetching the file
+ if ($fileType == 'layout') {
+ $fileId = (int) $filePath;
+
+ // Validate the nonce
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndLayout($this->display->displayId, $fileId);
+
+ // Load the layout
+ $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($fileId));
+ try {
+ $path = $layout->xlfToDisk();
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+
+ $file = file_get_contents($path);
+ $chunkSize = filesize($path);
+
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + $chunkSize;
+ $requiredFile->save();
+ } else if ($fileType == 'media') {
+ // Get the ID
+ if (strstr($filePath, '/') || strstr($filePath, '\\')) {
+ throw new NotFoundException('Invalid file path.');
+ }
+
+ $fileId = explode('.', $filePath);
+
+ if (is_numeric($fileId)) {
+ // Validate the nonce
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndMedia(
+ $this->display->displayId,
+ $fileId[0]
+ );
+
+ // Return the Chunk size specified
+ $f = fopen($libraryLocation . $filePath, 'r');
+ } else {
+ // Non-numeric, so assume we're a dependency
+ $this->getLog()->debug('Assumed dependency with path: ' . $filePath);
+
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndDependencyPath(
+ $this->display->displayId,
+ $filePath
+ );
+
+ $event = new XmdsDependencyRequestEvent($requiredFile);
+ $this->getDispatcher()->dispatch($event, 'xmds.dependency.request');
+
+ // Get the path
+ $path = $event->getRelativePath();
+ if (empty($path)) {
+ throw new NotFoundException(__('File not found'));
+ }
+
+ $path = $libraryLocation . $path;
+
+ $f = fopen($path, 'r');
+ }
+
+ fseek($f, $chunkOffset);
+
+ $file = fread($f, $chunkSize);
+
+ // Store file size for bandwidth log
+ $chunkSize = strlen($file);
+
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + $chunkSize;
+ $requiredFile->save();
+
+ } else {
+ throw new NotFoundException(__('Unknown FileType Requested.'));
+ }
+ }
+ catch (NotFoundException $e) {
+ $this->getLog()->error($e->getMessage());
+ throw new \SoapFault('Receiver', 'Requested an invalid file.');
+ }
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$GETFILE, $chunkSize);
+
+ return $file;
+ }
+
+ /**
+ * Returns the schedule for the hardware key specified
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $version
+ * @return string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ function Schedule($serverKey, $hardwareKey, $version)
+ {
+ return $this->doSchedule($serverKey, $hardwareKey);
+ }
+
+ /**
+ * BlackList
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $mediaId
+ * @param string $type
+ * @param string $reason
+ * @param string $version
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ function BlackList($serverKey, $hardwareKey, $mediaId, $type, $reason, $version)
+ {
+ return $this->doBlackList($serverKey, $hardwareKey, $mediaId, $type, $reason);
+ }
+
+ /**
+ * Submit client logging
+ * @param string $version
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $logXml
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ function SubmitLog($version, $serverKey, $hardwareKey, $logXml)
+ {
+ return $this->doSubmitLog($serverKey, $hardwareKey, $logXml);
+ }
+
+ /**
+ * Submit display statistics to the server
+ * @param string $version
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $statXml
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ function SubmitStats($version, $serverKey, $hardwareKey, $statXml)
+ {
+ return $this->doSubmitStats($serverKey, $hardwareKey, $statXml);
+ }
+
+ /**
+ * Store the media inventory for a client
+ * @param string $version
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $inventory
+ * @return bool
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ public function MediaInventory($version, $serverKey, $hardwareKey, $inventory)
+ {
+ return $this->doMediaInventory($serverKey, $hardwareKey, $inventory);
+ }
+
+ /**
+ * Gets additional resources for assigned media
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param int $layoutId
+ * @param string $regionId
+ * @param string $mediaId
+ * @param string $version
+ * @return string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ function GetResource($serverKey, $hardwareKey, $layoutId, $regionId, $mediaId, $version)
+ {
+ return $this->doGetResource($serverKey, $hardwareKey, $layoutId, $regionId, $mediaId);
+ }
+}
diff --git a/lib/Xmds/Soap4.php b/lib/Xmds/Soap4.php
new file mode 100644
index 0000000..22be558
--- /dev/null
+++ b/lib/Xmds/Soap4.php
@@ -0,0 +1,828 @@
+.
+ */
+namespace Xibo\Xmds;
+
+use Carbon\Carbon;
+use Intervention\Image\ImageManagerStatic as Img;
+use Xibo\Entity\Bandwidth;
+use Xibo\Entity\Display;
+use Xibo\Event\XmdsDependencyRequestEvent;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Soap4
+ * @package Xibo\Xmds
+ * @phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ */
+class Soap4 extends Soap
+{
+ /**
+ * Registers a new display
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $displayName
+ * @param string $clientType
+ * @param string $clientVersion
+ * @param int $clientCode
+ * @param string $operatingSystem
+ * @param string $macAddress
+ * @param null $xmrChannel
+ * @param null $xmrPubKey
+ * @return string
+ * @throws GeneralException
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ public function RegisterDisplay($serverKey, $hardwareKey, $displayName, $clientType, $clientVersion, $clientCode, $operatingSystem, $macAddress, $xmrChannel = null, $xmrPubKey = null)
+ {
+ $this->logProcessor->setRoute('RegisterDisplay');
+
+ $sanitized = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ 'displayName' => $displayName,
+ 'clientType' => $clientType,
+ 'clientVersion' => $clientVersion,
+ 'clientCode' => $clientCode,
+ 'operatingSystem' => $operatingSystem,
+ 'macAddress' => $macAddress,
+ 'xmrChannel' => $xmrChannel,
+ 'xmrPubKey' => $xmrPubKey,
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitized->getString('serverKey');
+ $hardwareKey = $sanitized->getString('hardwareKey');
+ $displayName = $sanitized->getString('displayName');
+ $clientType = $sanitized->getString('clientType');
+ $clientVersion = $sanitized->getString('clientVersion');
+ $clientCode = $sanitized->getInt('clientCode');
+ $macAddress = $sanitized->getString('macAddress');
+ $clientAddress = $this->getIp();
+ $operatingSystem = $sanitized->getString('operatingSystem');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault('Sender', 'The Server key you entered does not match with the server key at this address');
+ }
+
+ // Check the Length of the hardwareKey
+ if (strlen($hardwareKey) > 40) {
+ throw new \SoapFault('Sender', 'The Hardware Key you sent was too long. Only 40 characters are allowed (SHA1).');
+ }
+
+ // Return an XML formatted string
+ $return = new \DOMDocument('1.0');
+ $displayElement = $return->createElement('display');
+ $return->appendChild($displayElement);
+
+ // Check in the database for this hardwareKey
+ try {
+ $display = $this->displayFactory->getByLicence($hardwareKey);
+ $this->display = $display;
+
+ $this->logProcessor->setDisplay($display->displayId, $display->isAuditing());
+
+ // Audit in
+ $this->getLog()->debug(
+ 'serverKey: ' . $serverKey . ', hardwareKey: ' . $hardwareKey .
+ ', displayName: ' . $displayName . ', macAddress: ' . $macAddress
+ );
+
+ // Now
+ $dateNow = Carbon::now();
+
+ // Append the time
+ $displayElement->setAttribute('date', $dateNow->format(DateFormatHelper::getSystemFormat()));
+ $displayElement->setAttribute('timezone', $this->getConfig()->getSetting('defaultTimezone'));
+
+ // Determine if we are licensed or not
+ if ($display->licensed == 0) {
+ // It is not authorised
+ $displayElement->setAttribute('status', 2);
+ $displayElement->setAttribute('code', 'WAITING');
+ $displayElement->setAttribute('message', 'Display is awaiting licensing approval from an Administrator.');
+ } else {
+ // It is licensed
+ $displayElement->setAttribute('status', 0);
+ $displayElement->setAttribute('code', 'READY');
+ $displayElement->setAttribute('message', 'Display is active and ready to start.');
+
+ // Display Settings
+ $settings = $this->display->getSettings(['displayOverride' => true]);
+
+ // Create the XML nodes
+ foreach ($settings as $arrayItem) {
+ // Upper case the setting name for windows
+ $settingName = ($clientType == 'windows') ? ucfirst($arrayItem['name']) : $arrayItem['name'];
+
+ // Patch download and update windows to make sure they are unix time stamps
+ // XMDS schema 4 sent down unix time
+ // https://github.com/xibosignage/xibo/issues/1791
+ if (strtolower($arrayItem['name']) == 'downloadstartwindow'
+ || strtolower($arrayItem['name']) == 'downloadendwindow'
+ || strtolower($arrayItem['name']) == 'updatestartwindow'
+ || strtolower($arrayItem['name']) == 'updateendwindow'
+ ) {
+ // Split by :
+ $timeParts = explode(':', $arrayItem['value']);
+ if ($timeParts[0] == '00' && $timeParts[1] == '00') {
+ $arrayItem['value'] = 0;
+ } else {
+ $arrayItem['value'] = Carbon::now()->setTime(intval($timeParts[0]), intval($timeParts[1]));
+ }
+ }
+
+ // Apply an offset to the collectInterval
+ // https://github.com/xibosignage/xibo/issues/3530
+ if (strtolower($arrayItem['name']) == 'collectinterval') {
+ $arrayItem['value'] = $this->collectionIntervalWithOffset($arrayItem['value']);
+ }
+
+ $node = $return->createElement($arrayItem['name'], $arrayItem['value'] ?? $arrayItem['default']);
+ $node->setAttribute('type', $arrayItem['type']);
+ $displayElement->appendChild($node);
+ }
+
+ // Player upgrades
+ $version = '';
+ try {
+ $versionId = $this->display->getSetting('versionMediaId', null, ['displayOverride' => true]);
+
+ if ($clientType != 'windows' && $versionId != null) {
+ $version = $this->playerVersionFactory->getById($versionId);
+
+ if ($clientType == 'android') {
+ $version = json_encode([
+ 'id' => $versionId,
+ 'file' => $version->fileName,
+ 'code' => $version->code
+ ]);
+ } elseif ($clientType == 'lg') {
+ $version = json_encode([
+ 'id' => $versionId,
+ 'file' => $version->fileName,
+ 'code' => $version->code
+ ]);
+ } elseif ($clientType == 'sssp') {
+ // Create a nonce and store it in the cache for this display.
+ $nonce = Random::generateString();
+ $cache = $this->getPool()->getItem('/playerVersion/' . $nonce);
+ $cache->set($this->display->displayId);
+ $cache->expiresAfter(86400);
+ $this->getPool()->saveDeferred($cache);
+
+ $version = json_encode([
+ 'id' => $versionId,
+ 'file' => $version->fileName,
+ 'code' => $version->code,
+ 'url' => str_replace('/xmds.php', '', Wsdl::getRoot()) . '/playersoftware/' . $nonce
+ ]);
+ }
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error('Non-existing version set on displayId ' . $this->display->displayId);
+ }
+
+ $displayElement->setAttribute('version_instructions', $version);
+
+ // Add some special settings
+ $nodeName = ($clientType == 'windows') ? 'DisplayName' : 'displayName';
+ $node = $return->createElement($nodeName);
+ $node->appendChild($return->createTextNode($display->display));
+ $node->setAttribute('type', 'string');
+ $displayElement->appendChild($node);
+
+ $nodeName = ($clientType == 'windows') ? 'ScreenShotRequested' : 'screenShotRequested';
+ $node = $return->createElement($nodeName, $display->screenShotRequested);
+ $node->setAttribute('type', 'checkbox');
+ $displayElement->appendChild($node);
+
+ $nodeName = ($clientType == 'windows') ? 'DisplayTimeZone' : 'displayTimeZone';
+ $node = $return->createElement($nodeName, (!empty($display->timeZone)) ? $display->timeZone : '');
+ $node->setAttribute('type', 'string');
+ $displayElement->appendChild($node);
+
+ if (!empty($display->timeZone)) {
+ // Calculate local time
+ $dateNow->timezone($display->timeZone);
+
+ // Append Local Time
+ $displayElement->setAttribute('localDate', $dateNow->format(DateFormatHelper::getSystemFormat()));
+ }
+ }
+ } catch (NotFoundException $e) {
+ // Add a new display
+ try {
+ $display = $this->displayFactory->createEmpty();
+ $this->display = $display;
+ $display->display = $displayName;
+ $display->auditingUntil = 0;
+ $display->defaultLayoutId = $this->getConfig()->getSetting('DEFAULT_LAYOUT');
+ $display->license = $hardwareKey;
+ $display->licensed = $this->getConfig()->getSetting('DISPLAY_AUTO_AUTH', 0);
+ $display->incSchedule = 0;
+ $display->clientAddress = $this->getIp();
+
+ if (!$display->isDisplaySlotAvailable()) {
+ $display->licensed = 0;
+ }
+ } catch (\InvalidArgumentException $e) {
+ throw new \SoapFault('Sender', $e->getMessage());
+ }
+
+ $displayElement->setAttribute('status', 1);
+ $displayElement->setAttribute('code', 'ADDED');
+ if ($display->licensed == 0) {
+ $displayElement->setAttribute('message', 'Display added and is awaiting licensing approval from an Administrator.');
+ } else {
+ $displayElement->setAttribute('message', 'Display is active and ready to start.');
+ }
+ }
+
+ // Send Notification if required
+ $this->alertDisplayUp();
+
+ $display->lastAccessed = Carbon::now()->format('U');
+ $display->loggedIn = 1;
+ $display->clientAddress = $clientAddress;
+ $display->macAddress = $macAddress;
+ $display->clientType = $clientType;
+ $display->clientVersion = $clientVersion;
+ $display->clientCode = $clientCode;
+
+ // Parse operatingSystem JSON data
+ $operatingSystemJson = json_decode($operatingSystem, false);
+
+ // Newer version of players will return a JSON value, but for older version, it will return a string.
+ // In case the json decode fails, use the operatingSystem string value as the default value for the osVersion.
+ $display->osVersion = $operatingSystemJson->version ?? $operatingSystem;
+ $display->osSdk = $operatingSystemJson->sdk ?? null;
+ $display->manufacturer = $operatingSystemJson->manufacturer ?? null;
+ $display->brand = $operatingSystemJson->brand ?? null;
+ $display->model = $operatingSystemJson->model ?? null;
+
+ $display->save(['validate' => false, 'audit' => false]);
+
+ // Log Bandwidth
+ $returnXml = $return->saveXML();
+ $this->logBandwidth($display->displayId, Bandwidth::$REGISTER, strlen($returnXml));
+
+ // Audit our return
+ $this->getLog()->debug($returnXml);
+
+ return $returnXml;
+ }
+
+ /**
+ * Returns a string containing the required files xml for the requesting display
+ * @param string $serverKey The Server Key
+ * @param string $hardwareKey Display Hardware Key
+ * @return string $requiredXml Xml Formatted String
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function RequiredFiles($serverKey, $hardwareKey)
+ {
+ $httpDownloads = ($this->getConfig()->getSetting('SENDFILE_MODE') != 'Off');
+ return $this->doRequiredFiles($serverKey, $hardwareKey, $httpDownloads);
+ }
+
+ /**
+ * Get File
+ * @param string $serverKey The ServerKey for this CMS
+ * @param string $hardwareKey The HardwareKey for this Display
+ * @param int $fileId The ID
+ * @param string $fileType The File Type
+ * @param int $chunkOffset The Offset of the Chunk Requested
+ * @param string $chunkSize The Size of the Chunk Requested
+ * @return mixed
+ * @throws \SoapFault
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\InvalidArgumentException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function GetFile($serverKey, $hardwareKey, $fileId, $fileType, $chunkOffset, $chunkSize, $isDependency = false)
+ {
+ $this->logProcessor->setRoute('GetFile');
+
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ 'fileId' => $fileId,
+ 'fileType' => $fileType,
+ 'chunkOffset' => $chunkOffset,
+ 'chunkSize' => $chunkSize
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+ if ($isDependency) {
+ $fileId = $sanitizer->getString('fileId');
+ } else {
+ $fileId = $sanitizer->getInt('fileId');
+ }
+ $fileType = $sanitizer->getString('fileType');
+ $chunkOffset = $sanitizer->getDouble('chunkOffset');
+ $chunkSize = $sanitizer->getDouble('chunkSize');
+
+ $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Authenticate this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+
+ $this->getLog()->debug(
+ 'hardwareKey: ' . $hardwareKey . ', fileId: ' . $fileId . ', fileType: ' . $fileType .
+ ', chunkOffset: ' . $chunkOffset . ', chunkSize: ' . $chunkSize
+ );
+
+
+ try {
+ if ($isDependency || ($fileType == 'media' && $fileId < 0)) {
+ // Validate the nonce
+ // If we are an older player downloading as media using a faux fileId, then this lookup
+ // should be performed against the `itemId`
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndDependency(
+ $this->display->displayId,
+ $fileType,
+ $fileId,
+ !($fileType == 'media' && $fileId < 0)
+ );
+
+ // File is valid, see if we can return it.
+ $event = new XmdsDependencyRequestEvent($requiredFile);
+ $this->getDispatcher()->dispatch($event, 'xmds.dependency.request');
+
+ // Get the path
+ $path = $event->getRelativePath();
+ if (empty($path)) {
+ throw new NotFoundException(__('File not found'));
+ }
+
+ $path = $libraryLocation . $path;
+
+ $f = fopen($path, 'r');
+ if (!$f) {
+ throw new NotFoundException(__('Unable to get file pointer'));
+ }
+
+ fseek($f, $chunkOffset);
+ $file = fread($f, $chunkSize);
+
+ // Store file size for bandwidth log
+ $chunkSize = strlen($file);
+
+ if ($chunkSize === 0) {
+ throw new NotFoundException(__('Empty file'));
+ }
+
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + $chunkSize;
+ $requiredFile->save();
+ } else if ($fileType == 'layout') {
+ // Validate the nonce
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndLayout($this->display->displayId, $fileId);
+
+ // Load the layout
+ $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($fileId));
+ try {
+ $path = $layout->xlfToDisk();
+ } finally {
+ $this->layoutFactory->concurrentRequestRelease($layout);
+ }
+
+ $file = file_get_contents($path);
+ $chunkSize = filesize($path);
+
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + $chunkSize;
+ $requiredFile->save();
+ } else if ($fileType == 'media') {
+ // A normal media file.
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndMedia(
+ $this->display->displayId,
+ $fileId
+ );
+
+ $media = $this->mediaFactory->getById($fileId);
+ $this->getLog()->debug(json_encode($media));
+
+ if (!file_exists($libraryLocation . $media->storedAs)) {
+ throw new NotFoundException(__('Media exists but file missing from library.'));
+ }
+
+ // Return the Chunk size specified
+ $f = fopen($libraryLocation . $media->storedAs, 'r');
+
+ if (!$f) {
+ throw new NotFoundException(__('Unable to get file pointer'));
+ }
+
+ fseek($f, $chunkOffset);
+
+ $file = fread($f, $chunkSize);
+
+ // Store file size for bandwidth log
+ $chunkSize = strlen($file);
+
+ if ($chunkSize === 0) {
+ throw new NotFoundException(__('Empty file'));
+ }
+
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + $chunkSize;
+ $requiredFile->save();
+ } else {
+ throw new NotFoundException(__('Unknown FileType Requested.'));
+ }
+ } catch (NotFoundException $e) {
+ $this->getLog()->error('Not found FileId: ' . $fileId . '. FileType: '
+ . $fileType . '. ' . $e->getMessage());
+ throw new \SoapFault('Receiver', 'Requested an invalid file.');
+ }
+
+ // Log Bandwidth
+ if ($isDependency) {
+ $this->logBandwidth($this->display->displayId, Bandwidth::$GET_DEPENDENCY, $chunkSize);
+ } else {
+ $this->logBandwidth($this->display->displayId, Bandwidth::$GETFILE, $chunkSize);
+ }
+
+ return $file;
+ }
+
+ /**
+ * Returns the schedule for the hardware key specified
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @return string
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function Schedule($serverKey, $hardwareKey)
+ {
+ return $this->doSchedule($serverKey, $hardwareKey);
+ }
+
+ /**
+ * Black List
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $mediaId
+ * @param string $type
+ * @param string $reason
+ * @return bool
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function BlackList($serverKey, $hardwareKey, $mediaId, $type, $reason)
+ {
+ return $this->doBlackList($serverKey, $hardwareKey, $mediaId, $type, $reason);
+ }
+
+ /**
+ * Submit client logging
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $logXml
+ * @return bool
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function SubmitLog($serverKey, $hardwareKey, $logXml)
+ {
+ return $this->doSubmitLog($serverKey, $hardwareKey, $logXml);
+ }
+
+ /**
+ * Submit display statistics to the server
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $statXml
+ * @return bool
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function SubmitStats($serverKey, $hardwareKey, $statXml)
+ {
+ return $this->doSubmitStats($serverKey, $hardwareKey, $statXml);
+ }
+
+ /**
+ * Store the media inventory for a client
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $inventory
+ * @return bool
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function MediaInventory($serverKey, $hardwareKey, $inventory)
+ {
+ return $this->doMediaInventory($serverKey, $hardwareKey, $inventory);
+ }
+
+ /**
+ * Gets additional resources for assigned media
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param int $layoutId
+ * @param string $regionId
+ * @param string $mediaId
+ * @return mixed
+ * @throws \SoapFault
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ function GetResource($serverKey, $hardwareKey, $layoutId, $regionId, $mediaId)
+ {
+ return $this->doGetResource($serverKey, $hardwareKey, $layoutId, $regionId, $mediaId);
+ }
+
+ /**
+ * Notify Status
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $status
+ * @return bool
+ * @throws \SoapFault
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function NotifyStatus($serverKey, $hardwareKey, $status)
+ {
+ $this->logProcessor->setRoute('NotifyStatus');
+
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault('Sender', 'The Server key you entered does not match with the server key at this address');
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', "Bandwidth Limit exceeded");
+ }
+
+ // Important to keep this logging in place (status screen notification gets logged)
+ $this->getLog()->debug($status);
+
+ $this->logBandwidth($this->display->displayId, Bandwidth::$NOTIFYSTATUS, strlen($status));
+
+ $status = json_decode($status, true);
+ $sanitizedStatus = $this->getSanitizer($status);
+
+ $this->display->storageAvailableSpace = $sanitizedStatus->getInt('availableSpace', ['default' => $this->display->storageAvailableSpace]);
+ $this->display->storageTotalSpace = $sanitizedStatus->getInt('totalSpace', ['default' => $this->display->storageTotalSpace]);
+ $this->display->lastCommandSuccess = $sanitizedStatus->getCheckbox('lastCommandSuccess');
+ $this->display->deviceName = $sanitizedStatus->getString('deviceName', ['default' => $this->display->deviceName]);
+ $this->display->lanIpAddress = $sanitizedStatus->getString('lanIpAddress', ['default' => $this->display->lanIpAddress]);
+ $commercialLicenceString = $sanitizedStatus->getString('licenceResult', ['default' => null]);
+
+ // Commercial Licence Check, 0 - Not licensed, 1 - licensed, 2 - trial licence, 3 - not applicable
+ if (!empty($commercialLicenceString)) {
+ if ($commercialLicenceString === 'Licensed fully'
+ || $commercialLicenceString === 'licensed'
+ || $commercialLicenceString === 'full'
+ ) {
+ $commercialLicence = 1;
+ } elseif ($commercialLicenceString === 'Trial' || $commercialLicenceString === 'trial') {
+ $commercialLicence = 2;
+ } else {
+ $commercialLicence = 0;
+ }
+
+ $this->display->commercialLicence = $commercialLicence;
+ }
+
+ // commercial licence not applicable for Windows and Linux players.
+ if (in_array($this->display->clientType, ['windows', 'linux'])) {
+ $this->display->commercialLicence = 3;
+ }
+
+ if ($this->getConfig()->getSetting('DISPLAY_LOCK_NAME_TO_DEVICENAME') == 1 && $this->display->hasPropertyChanged('deviceName')) {
+ $this->display->display = $this->display->deviceName;
+ }
+
+ // Timezone
+ $timeZone = $sanitizedStatus->getString('timeZone');
+
+ if (!empty($timeZone)) {
+ // Validate the provided data and log/ignore if not well formatted
+ if (array_key_exists($timeZone, DateFormatHelper::timezoneList())) {
+ $this->display->timeZone = $timeZone;
+ } else {
+ $this->getLog()->info('Ignoring Incorrect timezone string: ' . $timeZone);
+ }
+ }
+
+ // Current Layout
+ // don't fail: xibosignage/xibo#2517
+ try {
+ $currentLayoutId = $sanitizedStatus->getInt('currentLayoutId');
+
+ if ($currentLayoutId !== null) {
+ $this->display->setCurrentLayoutId($this->getPool(), $currentLayoutId);
+ }
+ } catch (\Exception $exception) {
+ $this->getLog()->debug('Ignoring currentLayout due to a validation error.');
+ }
+
+ // Status Dialog
+ $statusDialog = $sanitizedStatus->getString('statusDialog', ['default' => null]);
+
+ if ($statusDialog !== null) {
+ // special handling for Android Players (Other Players send status as json already)
+ if ($this->display->clientType == 'android') {
+ $statusDialog = json_encode($statusDialog);
+ }
+
+ // Log in as an alert
+ $this->getLog()->alert($statusDialog);
+
+ // Cache on the display as transient data
+ try {
+ $this->display->setStatusWindow($this->getPool(), json_decode($statusDialog, true));
+ } catch (\Exception $exception) {
+ $this->getLog()->error('Unable to cache display status. e = ' . $exception->getMessage());
+ }
+ }
+
+ // Resolution
+ $width = $sanitizedStatus->getInt('width');
+ $height = $sanitizedStatus->getInt('height');
+
+ if ($width != null && $height != null) {
+ // Determine the orientation
+ $this->display->orientation = ($width >= $height) ? 'landscape' : 'portrait';
+ $this->display->resolution = $width . 'x' . $height;
+ }
+
+ // Lat/Long
+ $latitude = $sanitizedStatus->getDouble('latitude', ['default' => null]);
+ $longitude = $sanitizedStatus->getDouble('longitude', ['default' => null]);
+
+ if ($latitude != null && $longitude != null) {
+ $this->display->latitude = $latitude;
+ $this->display->longitude = $longitude;
+ }
+
+ // Touch the display record
+ try {
+ if (count($this->display->getChangedProperties()) > 0) {
+ $this->display->save(Display::$saveOptionsMinimum);
+ }
+ } catch (GeneralException $xiboException) {
+ $this->getLog()->error($xiboException->getMessage());
+ throw new \SoapFault('Receiver', 'Unable to save status update');
+ }
+
+ return true;
+ }
+
+ /**
+ * Submit ScreenShot
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $screenShot
+ * @return bool
+ * @throws \SoapFault
+ * @throws GeneralException
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function SubmitScreenShot($serverKey, $hardwareKey, $screenShot)
+ {
+ $this->logProcessor->setRoute('SubmitScreenShot');
+
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ ]);
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ $screenShotFmt = "jpg";
+ $screenShotMime = "image/jpeg";
+ $screenShotImg = false;
+
+ $converted = false;
+ $needConversion = false;
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault('Sender', 'The Server key you entered does not match with the server key at this address');
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', "Bandwidth Limit exceeded");
+ }
+
+ $this->getLog()->debug('Received Screen shot');
+
+ // Open this displays screen shot file and save this.
+ $location = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'screenshots/' . $this->display->displayId . '_screenshot.' . $screenShotFmt;
+
+ foreach (array('imagick', 'gd') as $imgDriver) {
+ Img::configure(array('driver' => $imgDriver));
+ try {
+ $screenShotImg = Img::make($screenShot);
+ } catch (\Exception $e) {
+ $this->getLog()->debug($imgDriver . ' - ' . $e->getMessage());
+ }
+ if ($screenShotImg !== false) {
+ $this->getLog()->debug('Use ' . $imgDriver);
+ break;
+ }
+ }
+
+ if ($screenShotImg !== false) {
+ $imgMime = $screenShotImg->mime();
+
+ if ($imgMime != $screenShotMime) {
+ $needConversion = true;
+ try {
+ $this->getLog()->debug("converting: '" . $imgMime . "' to '" . $screenShotMime . "'");
+ $screenShot = (string) $screenShotImg->encode($screenShotFmt);
+ $converted = true;
+ } catch (\Exception $e) {
+ $this->getLog()->debug($e->getMessage());
+ }
+ }
+ }
+
+ // return early with false, keep screenShotRequested intact, let the Player retry.
+ if ($needConversion && !$converted) {
+ $this->logBandwidth($this->display->displayId, Bandwidth::$SCREENSHOT, filesize($location));
+ throw new \SoapFault('Receiver', __('Incorrect Screen shot Format'));
+ }
+
+ $fp = fopen($location, 'wb');
+ fwrite($fp, $screenShot);
+ fclose($fp);
+
+ // Touch the display record
+ $this->display->screenShotRequested = 0;
+ $this->display->save(Display::$saveOptionsMinimum);
+
+ // Cache the current screen shot time
+ $this->display->setCurrentScreenShotTime($this->getPool(), Carbon::now()->format(DateFormatHelper::getSystemFormat()));
+
+ $this->logBandwidth($this->display->displayId, Bandwidth::$SCREENSHOT, filesize($location));
+
+ return true;
+ }
+}
diff --git a/lib/Xmds/Soap5.php b/lib/Xmds/Soap5.php
new file mode 100755
index 0000000..56571f6
--- /dev/null
+++ b/lib/Xmds/Soap5.php
@@ -0,0 +1,571 @@
+.
+ */
+
+
+namespace Xibo\Xmds;
+
+use Carbon\Carbon;
+use Illuminate\Support\Str;
+use Stash\Invalidation;
+use Xibo\Entity\Bandwidth;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Soap5
+ * @package Xibo\Xmds
+ *
+ * @phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ */
+class Soap5 extends Soap4
+{
+ /**
+ * Registers a new display
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $displayName
+ * @param string $clientType
+ * @param string $clientVersion
+ * @param int $clientCode
+ * @param string $operatingSystem
+ * @param string $macAddress
+ * @param string $xmrChannel
+ * @param string $xmrPubKey
+ * @param string $licenceCheck
+ * @return string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ * @throws GeneralException
+ */
+ public function RegisterDisplay(
+ $serverKey,
+ $hardwareKey,
+ $displayName,
+ $clientType,
+ $clientVersion,
+ $clientCode,
+ $operatingSystem,
+ $macAddress,
+ $xmrChannel = null,
+ $xmrPubKey = null,
+ $licenceResult = null
+ ) {
+ $this->logProcessor->setRoute('RegisterDisplay');
+
+ $sanitized = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ 'displayName' => $displayName,
+ 'clientType' => $clientType,
+ 'clientVersion' => $clientVersion,
+ 'clientCode' => $clientCode,
+ 'operatingSystem' => $operatingSystem,
+ 'macAddress' => $macAddress,
+ 'xmrChannel' => $xmrChannel,
+ 'xmrPubKey' => $xmrPubKey,
+ 'licenceResult' => $licenceResult
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitized->getString('serverKey');
+ $hardwareKey = $sanitized->getString('hardwareKey');
+ $displayName = $sanitized->getString('displayName');
+ $clientType = $sanitized->getString('clientType');
+ $clientVersion = $sanitized->getString('clientVersion');
+ $clientCode = $sanitized->getInt('clientCode');
+ $macAddress = $sanitized->getString('macAddress');
+ $clientAddress = $this->getIp();
+ $xmrChannel = $sanitized->getString('xmrChannel');
+ $xmrPubKey = trim($sanitized->getString('xmrPubKey'));
+ $operatingSystem = $sanitized->getString('operatingSystem');
+
+ // this is only sent from xmds v7
+ $commercialLicenceString = $sanitized->getString('licenceResult');
+
+ if ($xmrPubKey != '' && !Str::contains($xmrPubKey, 'BEGIN PUBLIC KEY')) {
+ $xmrPubKey = "-----BEGIN PUBLIC KEY-----\n" . $xmrPubKey . "\n-----END PUBLIC KEY-----\n";
+ }
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Check the Length of the hardwareKey
+ if (strlen($hardwareKey) > 40) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Hardware Key you sent was too long. Only 40 characters are allowed (SHA1).'
+ );
+ }
+
+ // Return an XML formatted string
+ $return = new \DOMDocument('1.0');
+ $displayElement = $return->createElement('display');
+ $return->appendChild($displayElement);
+
+ // Uncomment this if we want additional logging in register.
+ //$this->logProcessor->setDisplay(0, 'debug');
+
+ // Check in the database for this hardwareKey
+ try {
+ $display = $this->displayFactory->getByLicence($hardwareKey);
+ $this->display = $display;
+
+ $this->logProcessor->setDisplay($display->displayId, $display->isAuditing());
+
+ // Audit in
+ $this->getLog()->debug(
+ 'serverKey: ' . $serverKey . ', hardwareKey: ' . $hardwareKey .
+ ', displayName: ' . $displayName . ', macAddress: ' . $macAddress
+ );
+
+ // Now
+ $dateNow = Carbon::now();
+
+ // Append the time
+ $displayElement->setAttribute('date', $dateNow->format(DateFormatHelper::getSystemFormat()));
+ $displayElement->setAttribute('timezone', $this->getConfig()->getSetting('defaultTimezone'));
+
+ // Determine if we are licensed or not
+ if ($display->licensed == 0) {
+ // It is not authorised
+ $displayElement->setAttribute('status', 2);
+ $displayElement->setAttribute('code', 'WAITING');
+ $displayElement->setAttribute(
+ 'message',
+ 'Display is Registered and awaiting Authorisation from an Administrator in the CMS'
+ );
+ } else {
+ // It is licensed
+ $displayElement->setAttribute('status', 0);
+ $displayElement->setAttribute('code', 'READY');
+ $displayElement->setAttribute('message', 'Display is active and ready to start.');
+
+ // Display Settings
+ $settings = $this->display->getSettings(['displayOverride' => true]);
+
+ // Create the XML nodes
+ foreach ($settings as $arrayItem) {
+ // Upper case the setting name for windows
+ $settingName = ($clientType == 'windows') ? ucfirst($arrayItem['name']) : $arrayItem['name'];
+
+ // Disable the CEF browser option on Windows players
+ if (strtolower($settingName) == 'usecefwebbrowser' && ($clientType == 'windows')) {
+ $arrayItem['value'] = 0;
+ }
+
+ // Override the XMR address if empty
+ if (strtolower($settingName) == 'xmrnetworkaddress' &&
+ (!isset($arrayItem['value']) || $arrayItem['value'] == '')
+ ) {
+ $arrayItem['value'] = $this->getConfig()->getSetting('XMR_PUB_ADDRESS');
+ }
+
+ // Override the XMR address if empty
+ if (strtolower($settingName) == 'xmrwebsocketaddress' &&
+ (!isset($arrayItem['value']) || $arrayItem['value'] == '')
+ ) {
+ $arrayItem['value'] = $this->getConfig()->getSetting('XMR_WS_ADDRESS');
+ }
+
+ // logLevels
+ if (strtolower($settingName) == 'loglevel') {
+ // return resting log level
+ // unless it is currently elevated, in which case return debug
+ $arrayItem['value'] = $this->display->getLogLevel();
+ }
+
+ $value = ($arrayItem['value'] ?? $arrayItem['default']);
+
+ // Patch download and update windows to make sure they are only 00:00
+ // https://github.com/xibosignage/xibo/issues/1791
+ if (strtolower($arrayItem['name']) == 'downloadstartwindow'
+ || strtolower($arrayItem['name']) == 'downloadendwindow'
+ || strtolower($arrayItem['name']) == 'updatestartwindow'
+ || strtolower($arrayItem['name']) == 'updateendwindow'
+ ) {
+ // Split by :
+ $timeParts = explode(':', $value);
+ $value = $timeParts[0] . ':' . $timeParts[1];
+ }
+
+ // Apply an offset to the collectInterval
+ // https://github.com/xibosignage/xibo/issues/3530
+ if (strtolower($arrayItem['name']) == 'collectinterval') {
+ $value = $this->collectionIntervalWithOffset($value);
+ }
+
+ $node = $return->createElement($settingName, $value);
+
+ if (isset($arrayItem['type'])) {
+ $node->setAttribute('type', $arrayItem['type']);
+ }
+
+ $displayElement->appendChild($node);
+ }
+
+ // Player upgrades
+ $version = '';
+ try {
+ $versionId = $this->display->getSetting('versionMediaId', null, ['displayOverride' => true]);
+
+ if ($clientType != 'windows' && $versionId != null) {
+ $version = $this->playerVersionFactory->getById($versionId);
+
+ if ($clientType == 'android') {
+ $version = json_encode([
+ 'id' => $versionId,
+ 'file' => $version->fileName,
+ 'code' => $version->code
+ ]);
+ } elseif ($clientType == 'lg') {
+ $version = json_encode([
+ 'id' => $versionId,
+ 'file' => $version->fileName,
+ 'code' => $version->code
+ ]);
+ } elseif ($clientType == 'sssp') {
+ // Create a nonce and store it in the cache for this display.
+ $nonce = Random::generateString();
+ $cache = $this->getPool()->getItem('/playerVersion/' . $nonce);
+ $cache->set($this->display->displayId);
+ $cache->expiresAfter(86400);
+ $this->getPool()->saveDeferred($cache);
+
+ $version = json_encode([
+ 'id' => $versionId,
+ 'file' => $version->fileName,
+ 'code' => $version->code,
+ 'url' => str_replace('/xmds.php', '', Wsdl::getRoot()) . '/playersoftware/' . $nonce
+ ]);
+ }
+ }
+ } catch (NotFoundException $notFoundException) {
+ $this->getLog()->error('Non-existing version set on displayId ' . $this->display->displayId);
+ }
+
+ $displayElement->setAttribute('version_instructions', $version);
+
+ // cms move
+ $displayMoveAddress = ($clientType == 'windows') ? 'NewCmsAddress' : 'newCmsAddress';
+ $node = $return->createElement($displayMoveAddress, $display->newCmsAddress);
+
+ if ($clientType == 'windows') {
+ $node->setAttribute('type', 'string');
+ }
+
+ $displayElement->appendChild($node);
+
+ $displayMoveKey = ($clientType == 'windows') ? 'NewCmsKey' : 'newCmsKey';
+ $node = $return->createElement($displayMoveKey, $display->newCmsKey);
+
+ if ($clientType == 'windows') {
+ $node->setAttribute('type', 'string');
+ }
+
+ $displayElement->appendChild($node);
+
+ // Add some special settings
+ $nodeName = ($clientType == 'windows') ? 'DisplayName' : 'displayName';
+ $node = $return->createElement($nodeName);
+ $node->appendChild($return->createTextNode($display->display));
+
+ if ($clientType == 'windows') {
+ $node->setAttribute('type', 'string');
+ }
+ $displayElement->appendChild($node);
+
+ $nodeName = ($clientType == 'windows') ? 'ScreenShotRequested' : 'screenShotRequested';
+ $node = $return->createElement($nodeName, $display->screenShotRequested);
+ $node->setAttribute('type', 'checkbox');
+ $displayElement->appendChild($node);
+
+ $nodeName = ($clientType == 'windows') ? 'DisplayTimeZone' : 'displayTimeZone';
+ $node = $return->createElement($nodeName, (!empty($display->timeZone)) ? $display->timeZone : '');
+ if ($clientType == 'windows') {
+ $node->setAttribute('type', 'string');
+ }
+ $displayElement->appendChild($node);
+
+ // Adspace Enabled CMS?
+ $isAdspaceEnabled = intval($this->getConfig()->getSetting('isAdspaceEnabled', 0));
+ $node = $return->createElement('isAdspaceEnabled', $isAdspaceEnabled);
+ $node->setAttribute('type', 'checkbox');
+ $displayElement->appendChild($node);
+
+ if (!empty($display->timeZone)) {
+ // Calculate local time
+ $dateNow->timezone($display->timeZone);
+
+ // Append Local Time
+ $displayElement->setAttribute('localTimezone', $display->timeZone);
+ $displayElement->setAttribute('localDate', $dateNow->format(DateFormatHelper::getSystemFormat()));
+ }
+
+ // XMR
+ // Type of XMR connection
+ $node = $return->createElement('xmrType', $this->display->isWebSocketXmrSupported() ? 'ws' : 'zmq');
+ $node->setAttribute('type', 'string');
+ $displayElement->appendChild($node);
+
+ // XMR key (this is the key a player should use the intialise a connection to XMR
+ $node = $return->createElement('xmrCmsKey', $this->getConfig()->getSetting('XMR_CMS_KEY'));
+ $node->setAttribute('type', 'string');
+ $displayElement->appendChild($node);
+
+ // Commands
+ $commands = $display->getCommands();
+
+ if (count($commands) > 0) {
+ // Append a command element
+ $commandElement = $return->createElement('commands');
+ $displayElement->appendChild($commandElement);
+
+ // Append each individual command
+ foreach ($display->getCommands() as $command) {
+ try {
+ if (!$command->isReady()) {
+ continue;
+ }
+
+ $node = $return->createElement($command->code);
+ $commandString = $return->createElement('commandString');
+ $commandStringCData = $return->createCDATASection($command->getCommandString());
+ $commandString->appendChild($commandStringCData);
+ $validationString = $return->createElement('validationString');
+ $validationStringCData = $return->createCDATASection($command->getValidationString());
+ $validationString->appendChild($validationStringCData);
+ $alertOnString = $return->createElement('createAlertOn');
+ $alertOnStringCData = $return->createCDATASection($command->getCreateAlertOn());
+ $alertOnString->appendChild($alertOnStringCData);
+
+ $node->appendChild($commandString);
+ $node->appendChild($validationString);
+ $node->appendChild($alertOnString);
+
+ $commandElement->appendChild($node);
+ } catch (\DOMException $DOMException) {
+ $this->getLog()->error(
+ 'Cannot add command to settings for displayId ' .
+ $this->display->displayId . ', ' . $DOMException->getMessage()
+ );
+ }
+ }
+ }
+
+ // Tags
+ if (count($display->tags) > 0) {
+ $tagsElement = $return->createElement('tags');
+ $displayElement->appendChild($tagsElement);
+
+ foreach ($display->tags as $tagLink) {
+ $tagNode = $return->createElement('tag');
+
+ $tagNameNode = $return->createElement('tagName');
+ $tag = $return->createTextNode($tagLink->tag);
+ $tagNameNode->appendChild($tag);
+
+ $tagNode->appendChild($tagNameNode);
+
+ if ($tagLink->value !== null) {
+ $valueNode = $return->createElement('tagValue');
+ $value = $return->createTextNode($tagLink->value);
+ $valueNode->appendChild($value);
+
+ $tagNode->appendChild($valueNode);
+ }
+
+ $tagsElement->appendChild($tagNode);
+ }
+ }
+
+ // Check to see if the channel/pubKey are already entered
+ $this->getLog()->debug('xmrChannel: [' . $xmrChannel . ']. xmrPublicKey: [' . $xmrPubKey . ']');
+
+ // Update the Channel
+ $display->xmrChannel = $xmrChannel;
+ // Update the PUB Key only if it has been cleared
+ if ($display->xmrPubKey == '') {
+ $display->xmrPubKey = $xmrPubKey;
+ }
+ }
+ } catch (NotFoundException $e) {
+ // Add a new display
+ try {
+ $display = $this->displayFactory->createEmpty();
+ $this->display = $display;
+ $display->display = $displayName;
+ $display->auditingUntil = 0;
+ // defaultLayoutId column cannot be null or empty string
+ // if we do not have global default layout, set it here to 0 to allow register to proceed
+ $display->defaultLayoutId = intval($this->getConfig()->getSetting('DEFAULT_LAYOUT', 0));
+ $display->license = $hardwareKey;
+ $display->licensed = $this->getConfig()->getSetting('DISPLAY_AUTO_AUTH', 0);
+ $display->incSchedule = 0;
+ $display->clientAddress = $this->getIp();
+ $display->xmrChannel = $xmrChannel;
+ $display->xmrPubKey = $xmrPubKey;
+ $display->folderId = $this->getConfig()->getSetting('DISPLAY_DEFAULT_FOLDER', 1);
+
+ if (!$display->isDisplaySlotAvailable()) {
+ $display->licensed = 0;
+ }
+ } catch (\InvalidArgumentException $e) {
+ throw new \SoapFault('Sender', $e->getMessage());
+ }
+
+ $displayElement->setAttribute('status', 1);
+ $displayElement->setAttribute('code', 'ADDED');
+ if ($display->licensed == 0) {
+ $displayElement->setAttribute(
+ 'message',
+ 'Display is now Registered and awaiting Authorisation from an Administrator in the CMS'
+ );
+ } else {
+ $displayElement->setAttribute('message', 'Display is active and ready to start.');
+ }
+ }
+
+ // Send Notification if required
+ $this->alertDisplayUp();
+
+ $display->lastAccessed = Carbon::now()->format('U');
+ $display->loggedIn = 1;
+ $display->clientAddress = $clientAddress;
+ $display->macAddress = $macAddress;
+ $display->clientType = $clientType;
+ $display->clientVersion = $clientVersion;
+ $display->clientCode = $clientCode;
+
+ // Parse operatingSystem data
+ $operatingSystemJson = json_decode($operatingSystem, false);
+
+ // Newer version of players will return a JSON value, but for older version, it will return a string.
+ // In case the json decode fails, use the operatingSystem string value as the default value for the osVersion.
+ $display->osVersion = $operatingSystemJson->version ?? $operatingSystem;
+ $display->osSdk = $operatingSystemJson->sdk ?? null;
+ $display->manufacturer = $operatingSystemJson->manufacturer ?? null;
+ $display->brand = $operatingSystemJson->brand ?? null;
+ $display->model = $operatingSystemJson->model ?? null;
+
+ // Commercial Licence Check, 0 - Not licensed, 1 - licensed, 2 - trial licence, 3 - not applicable
+ // only sent by xmds v7
+ if (!empty($commercialLicenceString) && !in_array($display->clientType, ['windows', 'linux'])) {
+ $commercialLicenceString = strtolower($commercialLicenceString);
+ if ($commercialLicenceString === 'licensed' || $commercialLicenceString === 'full') {
+ $commercialLicence = 1;
+ } elseif ($commercialLicenceString === 'trial') {
+ $commercialLicence = 2;
+ } else {
+ $commercialLicence = 0;
+ }
+
+ $display->commercialLicence = $commercialLicence;
+ $node = $return->createElement('commercialLicence', $commercialLicenceString);
+ $displayElement->appendChild($node);
+ }
+
+ // commercial licence not applicable for Windows and Linux players.
+ if (in_array($display->clientType, ['windows', 'linux'])) {
+ $display->commercialLicence = 3;
+ }
+
+ if (!empty($display->syncGroupId)) {
+ try {
+ $syncGroup = $this->syncGroupFactory->getById($display->syncGroupId);
+
+ if ($syncGroup->leadDisplayId === $display->displayId) {
+ $syncNodeValue = 'lead';
+ } else {
+ $leadDisplay = $this->syncGroupFactory->getLeadDisplay($syncGroup->leadDisplayId);
+ $syncNodeValue = $leadDisplay->lanIpAddress;
+ }
+
+ $syncNode = $return->createElement('syncGroup');
+ $value = $return->createTextNode($syncNodeValue ?? '');
+ $syncNode->appendChild($value);
+ $displayElement->appendChild($syncNode);
+
+ $syncPublisherPortNode = $return->createElement('syncPublisherPort');
+ $value = $return->createTextNode($syncGroup->syncPublisherPort ?? 9590);
+ $syncPublisherPortNode->appendChild($value);
+ $displayElement->appendChild($syncPublisherPortNode);
+
+ $syncSwitchDelayNode = $return->createElement('syncSwitchDelay');
+ $value = $return->createTextNode($syncGroup->syncSwitchDelay ?? 750);
+ $syncSwitchDelayNode->appendChild($value);
+ $displayElement->appendChild($syncSwitchDelayNode);
+
+ $syncVideoPauseDelayNode = $return->createElement('syncVideoPauseDelay');
+ $value = $return->createTextNode($syncGroup->syncVideoPauseDelay ?? 100);
+ $syncVideoPauseDelayNode->appendChild($value);
+ $displayElement->appendChild($syncVideoPauseDelayNode);
+ } catch (GeneralException $exception) {
+ $this->getLog()->error('registerDisplay: cannot get sync group information, e = '
+ . $exception->getMessage());
+ }
+ }
+
+ $display->save(Display::$saveOptionsMinimum);
+
+ // cache checks
+ $cacheSchedule = $this->getPool()->getItem($this->display->getCacheKey() . '/schedule');
+ $cacheSchedule->setInvalidationMethod(Invalidation::OLD);
+ $displayElement->setAttribute(
+ 'checkSchedule',
+ ($cacheSchedule->isHit() ? crc32($cacheSchedule->get()) : '')
+ );
+
+ $cacheRF = $this->getPool()->getItem($this->display->getCacheKey() . '/requiredFiles');
+ $cacheRF->setInvalidationMethod(Invalidation::OLD);
+ $displayElement->setAttribute('checkRf', ($cacheRF->isHit() ? crc32($cacheRF->get()) : ''));
+
+ // Log Bandwidth
+ $returnXml = $return->saveXML();
+ $this->logBandwidth($display->displayId, Bandwidth::$REGISTER, strlen($returnXml));
+
+ // Audit our return
+ $this->getLog()->debug($returnXml);
+
+ return $returnXml;
+ }
+
+ /**
+ * Returns the schedule for the hardware key specified
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @return string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ public function Schedule($serverKey, $hardwareKey)
+ {
+ return $this->doSchedule($serverKey, $hardwareKey, ['dependentsAsNodes' => true, 'includeOverlays' => true]);
+ }
+}
diff --git a/lib/Xmds/Soap6.php b/lib/Xmds/Soap6.php
new file mode 100644
index 0000000..e053332
--- /dev/null
+++ b/lib/Xmds/Soap6.php
@@ -0,0 +1,233 @@
+.
+ */
+
+namespace Xibo\Xmds;
+
+use Carbon\Carbon;
+use Xibo\Entity\Bandwidth;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Support\Sanitizer\SanitizerInterface;
+
+/**
+ * Class Soap6
+ * @package Xibo\Xmds
+ */
+class Soap6 extends Soap5
+{
+ /**
+ * Report Player Fault to the CMS
+ *
+ * @param string $serverKey
+ * @param string $hardwareKey
+ * @param string $fault
+ *
+ * @return bool
+ */
+ public function reportFaults(string $serverKey, string $hardwareKey, string $fault): bool
+ {
+ $this->logProcessor->setRoute('ReportFault');
+ //$this->logProcessor->setDisplay(0, 'debug');
+
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ $faultDecoded = json_decode($fault, true);
+
+ // check if we should record or update any display events.
+ $this->manageDisplayAlerts($faultDecoded);
+
+ // clear existing records from player_faults table
+ $this->getStore()->update('DELETE FROM `player_faults` WHERE displayId = :displayId', [
+ 'displayId' => $this->display->displayId
+ ]);
+
+ foreach ($faultDecoded as $faultAlert) {
+ $sanitizedFaultAlert = $this->getSanitizer($faultAlert);
+
+ $incidentDt = $sanitizedFaultAlert->getDate(
+ 'date',
+ ['default' => Carbon::now()]
+ )->format(DateFormatHelper::getSystemFormat());
+ $expires = $sanitizedFaultAlert->getDate('expires', ['default' => null]);
+ $code = $sanitizedFaultAlert->getInt('code');
+ $reason = $sanitizedFaultAlert->getString('reason');
+ $scheduleId = $sanitizedFaultAlert->getInt('scheduleId');
+ $layoutId = $sanitizedFaultAlert->getInt('layoutId');
+ $regionId = $sanitizedFaultAlert->getInt('regionId');
+ $mediaId = $sanitizedFaultAlert->getInt('mediaId');
+ $widgetId = $sanitizedFaultAlert->getInt('widgetId');
+
+ // Trim the reason if it is too long
+ if (strlen($reason) >= 255) {
+ $reason = substr($reason, 0, 255);
+ }
+
+ try {
+ $dbh = $this->getStore()->getConnection();
+
+ $insertSth = $dbh->prepare('
+ INSERT INTO player_faults (displayId, incidentDt, expires, code, reason, scheduleId, layoutId, regionId, mediaId, widgetId)
+ VALUES (:displayId, :incidentDt, :expires, :code, :reason, :scheduleId, :layoutId, :regionId, :mediaId, :widgetId)
+ ');
+
+ $insertSth->execute([
+ 'displayId' => $this->display->displayId,
+ 'incidentDt' => $incidentDt,
+ 'expires' => $expires,
+ 'code' => $code,
+ 'reason' => $reason,
+ 'scheduleId' => $scheduleId,
+ 'layoutId' => $layoutId,
+ 'regionId' => $regionId,
+ 'mediaId' => $mediaId,
+ 'widgetId' => $widgetId
+ ]);
+ } catch (\Exception $e) {
+ $this->getLog()->error('Unable to insert Player Faults records. ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ $this->logBandwidth($this->display->displayId, Bandwidth::$REPORTFAULT, strlen($fault));
+
+ return true;
+ }
+
+ private function manageDisplayAlerts(array $newPlayerFaults)
+ {
+ // check current faults for player
+ $currentFaults = $this->playerFaultFactory->getByDisplayId($this->display->displayId);
+
+ // if we had faults and now we no longer have any to add
+ // set end date for any existing fault events
+ // add display event for cleared all faults
+ if (!empty($currentFaults) && empty($newPlayerFaults)) {
+ $displayEvent = $this->displayEventFactory->createEmpty();
+ $displayEvent->eventTypeId = $displayEvent->getEventIdFromString('Player Fault');
+ $displayEvent->displayId = $this->display->displayId;
+ // clear any open player fault events for this display
+ $displayEvent->eventEnd($displayEvent->displayId, $displayEvent->eventTypeId);
+
+ // log new event for all faults cleared.
+ $displayEvent->start = Carbon::now()->format('U');
+ $displayEvent->end = Carbon::now()->format('U');
+ $displayEvent->detail = __('All Player faults cleared');
+ $displayEvent->save();
+ } else if (empty($currentFaults) && !empty($newPlayerFaults)) {
+ $codesAdded = [];
+ // we do not have any faults currently, but new ones will be added
+ foreach ($newPlayerFaults as $newPlayerFault) {
+ $sanitizedFaultAlert = $this->getSanitizer($newPlayerFault);
+ // if we do not have an alert for the specific code yet, add it
+ if (!in_array($sanitizedFaultAlert->getInt('code'), $codesAdded)) {
+ $this->addDisplayEvent($sanitizedFaultAlert);
+ // keep track of added codes, we want a single alert per code
+ $codesAdded[] = $sanitizedFaultAlert->getInt('code');
+ }
+ }
+ } else if (!empty($newPlayerFaults) && !empty($currentFaults)) {
+ // we have both existing faults and new faults
+ $existingFaultsCodes = [];
+ $newFaultCodes = [];
+ $codesAdded = [];
+
+ // keep track of existing fault codes.
+ foreach ($currentFaults as $currentFault) {
+ $existingFaultsCodes[] = $currentFault->code;
+ }
+
+ // go through new faults
+ foreach ($newPlayerFaults as $newPlayerFault) {
+ $sanitizedFaultAlert = $this->getSanitizer($newPlayerFault);
+ $newFaultCodes[] = $sanitizedFaultAlert->getInt('code');
+ // if it already exists, we do not do anything with alerts
+ // if it is a new code and was not added already
+ // add it now
+ if (!in_array($sanitizedFaultAlert->getInt('code'), $existingFaultsCodes)
+ && !in_array($sanitizedFaultAlert->getInt('code'), $codesAdded)
+ ) {
+ $this->addDisplayEvent($sanitizedFaultAlert);
+ // keep track of added codes, we want a single alert per code
+ $codesAdded[] = $sanitizedFaultAlert->getInt('code');
+ }
+ }
+
+ // go through any existing codes that are no longer reported
+ // update the end date on all of them.
+ foreach (array_diff($existingFaultsCodes, $newFaultCodes) as $code) {
+ $displayEvent = $this->displayEventFactory->createEmpty();
+ $displayEvent->eventEndByReference(
+ $this->display->displayId,
+ $displayEvent->getEventIdFromString('Player Fault'),
+ $code
+ );
+ }
+ }
+ }
+
+ private function addDisplayEvent(SanitizerInterface $sanitizedFaultAlert)
+ {
+ $this->getLog()->debug(
+ sprintf(
+ 'displayEvent : add new display alert for player fault code %d and displayId %d',
+ $sanitizedFaultAlert->getInt('code'),
+ $this->display->displayId
+ )
+ );
+
+ $displayEvent = $this->displayEventFactory->createEmpty();
+ $displayEvent->eventTypeId = $displayEvent->getEventIdFromString('Player Fault');
+ $displayEvent->displayId = $this->display->displayId;
+ $displayEvent->start = $sanitizedFaultAlert->getDate(
+ 'date',
+ ['default' => Carbon::now()]
+ )->format('U');
+ $displayEvent->end = null;
+ $displayEvent->detail = $sanitizedFaultAlert->getString('reason');
+ $displayEvent->refId = $sanitizedFaultAlert->getInt('code');
+ $displayEvent->save();
+ }
+}
diff --git a/lib/Xmds/Soap7.php b/lib/Xmds/Soap7.php
new file mode 100644
index 0000000..9686499
--- /dev/null
+++ b/lib/Xmds/Soap7.php
@@ -0,0 +1,324 @@
+.
+ */
+
+namespace Xibo\Xmds;
+
+use Xibo\Entity\Bandwidth;
+use Xibo\Event\XmdsWeatherRequestEvent;
+use Xibo\Helper\LinkSigner;
+use Xibo\Support\Exception\GeneralException;
+use Xibo\Support\Exception\NotFoundException;
+
+/**
+ * Class Soap7
+ * @package Xibo\Xmds
+ *
+ * @phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ */
+class Soap7 extends Soap6
+{
+ /**
+ * @inheritDoc
+ * @noinspection PhpMissingParentCallCommonInspection
+ */
+ public function RequiredFiles($serverKey, $hardwareKey)
+ {
+ $httpDownloads = ($this->getConfig()->getSetting('SENDFILE_MODE') != 'Off');
+ return $this->doRequiredFiles($serverKey, $hardwareKey, $httpDownloads, true, true);
+ }
+
+ /**
+ * @inheritDoc
+ * @noinspection PhpMissingParentCallCommonInspection
+ */
+ public function GetResource($serverKey, $hardwareKey, $layoutId, $regionId, $mediaId)
+ {
+ return $this->doGetResource($serverKey, $hardwareKey, $layoutId, $regionId, $mediaId, true);
+ }
+
+ /**
+ * Get player dependencies.
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param $fileType
+ * @param $id
+ * @param $chunkOffset
+ * @param $chunkSize
+ * @return string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ public function GetDependency($serverKey, $hardwareKey, $fileType, $id, $chunkOffset, $chunkSize)
+ {
+ return $this->GetFile($serverKey, $hardwareKey, $id, $fileType, $chunkOffset, $chunkSize, true);
+ }
+
+ /**
+ * Get Data for a widget
+ * @param $serverKey
+ * @param $hardwareKey
+ * @param $widgetId
+ * @return false|string
+ * @throws NotFoundException
+ * @throws \SoapFault
+ */
+ public function GetData($serverKey, $hardwareKey, $widgetId)
+ {
+ $this->logProcessor->setRoute('GetData');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ 'widgetId' => $widgetId,
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+ $widgetId = $sanitizer->getInt('widgetId');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ // Now that we authenticated the Display, make sure we are sticking to our bandwidth limit
+ if (!$this->checkBandwidth($this->display->displayId)) {
+ throw new \SoapFault('Receiver', 'Bandwidth Limit exceeded');
+ }
+
+ // The MediaId is actually the widgetId
+ try {
+ $requiredFile = $this->requiredFileFactory->getByDisplayAndWidget(
+ $this->display->displayId,
+ $widgetId,
+ 'D',
+ );
+
+ $widget = $this->widgetFactory->loadByWidgetId($widgetId);
+
+ $module = $this->moduleFactory->getByType($widget->type);
+
+ // We just want the data.
+ $dataModule = $this->moduleFactory->getByType($widget->type);
+ if ($dataModule->isDataProviderExpected()) {
+ // We only ever return cache.
+ $dataProvider = $module->createDataProvider($widget);
+ $dataProvider->setDisplayProperties(
+ $this->display->latitude ?: $this->getConfig()->getSetting('DEFAULT_LAT'),
+ $this->display->longitude ?: $this->getConfig()->getSetting('DEFAULT_LONG'),
+ $this->display->displayId
+ );
+
+ // We only __ever__ return cache from XMDS.
+ try {
+ $cacheKey = $this->moduleFactory->determineCacheKey(
+ $module,
+ $widget,
+ $this->display->displayId,
+ $dataProvider,
+ $module->getWidgetProviderOrNull()
+ );
+
+ $widgetDataProviderCache = $this->moduleFactory->createWidgetDataProviderCache();
+
+ // We do not pass a modifiedDt in here because we always expect to be cached.
+ if (!$widgetDataProviderCache->decorateWithCache($dataProvider, $cacheKey, null, false)) {
+ throw new NotFoundException('Cache not ready');
+ }
+
+ $this->getLog()->debug('Cache ready and populated');
+
+ // Get media references
+ $media = [];
+ $requiredFiles = [];
+ $mediaIds = $widgetDataProviderCache->getCachedMediaIds();
+ $cdnUrl = $this->configService->getSetting('CDN_URL');
+
+ if (count($mediaIds) > 0) {
+ $this->getLog()->debug('Processing media links');
+
+ $sql = '
+ SELECT `media`.`mediaId`,
+ `media`.`storedAs`,
+ `media`.`fileSize`,
+ `media`.`released`,
+ `media`.`md5`,
+ `display_media`.`mediaId` AS displayMediaId
+ FROM `media`
+ LEFT OUTER JOIN `display_media`
+ ON `display_media`.`mediaId` = `media`.`mediaId`
+ AND `display_media`.`displayId` = :displayId
+ WHERE `media`.`mediaId` IN ( ' . implode(',', $mediaIds) . ')
+ ';
+
+ // There isn't any point using a prepared statement because the widgetIds are substituted
+ // at runtime
+ foreach ($this->getStore()->select($sql, [
+ 'displayId' => $this->display->displayId
+ ]) as $row) {
+ // Media to use for decorating the JSON file.
+ $media[$row['mediaId']] = $row['storedAs'];
+
+ // Only media we're interested in.
+ if (!in_array($row['displayMediaId'], $mediaIds)) {
+ continue;
+ }
+
+ // Output required file nodes for any media used in get data.
+ // these will appear in required files as well, and may already be downloaded.
+ $released = intval($row['released']);
+ $this->requiredFileFactory
+ ->createForMedia(
+ $this->display->displayId,
+ $row['mediaId'],
+ $row['fileSize'],
+ $row['storedAs'],
+ $released
+ )
+ ->save();
+
+ // skip media which has released == 0 or 2
+ if ($released == 0 || $released == 2) {
+ continue;
+ }
+
+ // Add the file node
+ $requiredFiles[] = [
+ 'id' => intval($row['mediaId']),
+ 'size' => intval($row['fileSize']),
+ 'md5' => $row['md5'],
+ 'saveAs' => $row['storedAs'],
+ 'path' => LinkSigner::generateSignedLink(
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $cdnUrl,
+ 'M',
+ intval($row['mediaId']),
+ $row['storedAs'],
+ ),
+ ];
+ }
+ } else {
+ $this->getLog()->debug('No media links');
+ }
+
+ $resource = json_encode([
+ 'data' => $widgetDataProviderCache->decorateForPlayer(
+ $this->configService,
+ $this->display,
+ $this->configService->getApiKeyDetails()['encryptionKey'],
+ $dataProvider->getData(),
+ $media,
+ ),
+ 'meta' => $dataProvider->getMeta(),
+ 'files' => $requiredFiles,
+ ]);
+ } catch (GeneralException $exception) {
+ $this->getLog()->error('getData: Failed to get data cache for widgetId '
+ . $widget->widgetId . ', e = ' . $exception->getMessage());
+ throw new \SoapFault('Receiver', 'Cache not ready');
+ }
+ } else {
+ // No data cached yet, exception
+ throw new \SoapFault('Receiver', 'Cache not ready');
+ }
+
+ // Log bandwidth
+ $requiredFile->bytesRequested = $requiredFile->bytesRequested + strlen($resource);
+ $requiredFile->save();
+ } catch (NotFoundException) {
+ throw new \SoapFault('Receiver', 'Requested an invalid file.');
+ } catch (\Exception $e) {
+ if ($e instanceof \SoapFault) {
+ return $e;
+ }
+
+ $this->getLog()->error('Unknown error during getData. E = ' . $e->getMessage());
+ $this->getLog()->debug($e->getTraceAsString());
+ throw new \SoapFault('Receiver', 'Unable to get the media resource');
+ }
+
+ // Log Bandwidth
+ $this->logBandwidth($this->display->displayId, Bandwidth::$GET_DATA, strlen($resource));
+
+ return $resource;
+ }
+
+ /**
+ * Get Weather data for Display
+ * @param $serverKey
+ * @param $hardwareKey
+ * @return string
+ * @throws \SoapFault
+ */
+ public function GetWeather($serverKey, $hardwareKey): string
+ {
+ $this->logProcessor->setRoute('GetWeather');
+ $sanitizer = $this->getSanitizer([
+ 'serverKey' => $serverKey,
+ 'hardwareKey' => $hardwareKey,
+ ]);
+
+ // Sanitize
+ $serverKey = $sanitizer->getString('serverKey');
+ $hardwareKey = $sanitizer->getString('hardwareKey');
+
+ // Check the serverKey matches
+ if ($serverKey != $this->getConfig()->getSetting('SERVER_KEY')) {
+ throw new \SoapFault(
+ 'Sender',
+ 'The Server key you entered does not match with the server key at this address'
+ );
+ }
+
+ // Auth this request...
+ if (!$this->authDisplay($hardwareKey)) {
+ throw new \SoapFault('Receiver', 'This Display is not authorised.');
+ }
+
+ $latitude = $this->display->latitude;
+ $longitude = $this->display->longitude;
+
+ // check for coordinates if present
+ if ($latitude && $longitude) {
+ // Dispatch an event to initialize weather data.
+ $event = new XmdsWeatherRequestEvent($latitude, $longitude);
+ $this->getDispatcher()->dispatch($event, XmdsWeatherRequestEvent::$NAME);
+ } else {
+ throw new \SoapFault(
+ 'Receiver',
+ 'Display coordinates is not configured'
+ );
+ }
+
+ // return weather data
+ return $event->getWeatherData();
+ }
+}
diff --git a/lib/Xmds/Wsdl.php b/lib/Xmds/Wsdl.php
new file mode 100644
index 0000000..07f4179
--- /dev/null
+++ b/lib/Xmds/Wsdl.php
@@ -0,0 +1,73 @@
+.
+ */
+
+
+namespace Xibo\Xmds;
+
+
+use Xibo\Helper\HttpsDetect;
+
+class Wsdl
+{
+ private $path;
+ private $version;
+
+ /**
+ * Wsdl
+ * @param $path
+ * @param $version
+ */
+ public function __construct($path, $version)
+ {
+ $this->path = $path;
+ $this->version = $version;
+ }
+
+ public function output()
+ {
+ // We need to buffer the output so that we can send a Content-Length header with the WSDL
+ ob_start();
+ $wsdl = file_get_contents($this->path);
+ $wsdl = str_replace('{{XMDS_LOCATION}}', $this->getRoot() . '?v=' . $this->version, $wsdl);
+ echo $wsdl;
+
+ // Get the contents of the buffer and work out its length
+ $buffer = ob_get_contents();
+ $length = strlen($buffer);
+
+ // Output the headers
+ header('Content-Type: text/xml; charset=ISO-8859-1\r\n');
+ header('Content-Length: ' . $length);
+
+ // Flush the buffer
+ ob_end_flush();
+ }
+
+ /**
+ * get Root url
+ * @return string
+ */
+ public static function getRoot(): string
+ {
+ return (new HttpsDetect())->getBaseUrl();
+ }
+}
diff --git a/lib/Xmds/service_v3.wsdl b/lib/Xmds/service_v3.wsdl
new file mode 100644
index 0000000..ecd142d
--- /dev/null
+++ b/lib/Xmds/service_v3.wsdl
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Register the Display with the CMS
+
+
+
+
+ The files required by the requesting display
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Gets the schedule
+
+
+
+
+ Set media to be blacklisted
+
+
+
+
+ Submit Logging from the Client
+
+
+
+
+ Submit Display statistics from the Client
+
+
+
+
+ Report back the clients MediaInventory
+
+
+
+
+ Gets the file requested
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/Xmds/service_v4.wsdl b/lib/Xmds/service_v4.wsdl
new file mode 100644
index 0000000..3789af9
--- /dev/null
+++ b/lib/Xmds/service_v4.wsdl
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Register the Display with the CMS
+
+
+
+
+ The files required by the requesting display
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Gets the schedule
+
+
+
+
+ Set media to be blacklisted
+
+
+
+
+ Submit Logging from the Client
+
+
+
+
+ Submit Display statistics from the Client
+
+
+
+
+ Report back the clients MediaInventory
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Report back the current status
+
+
+
+
+ Submit a screen shot for a display
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/Xmds/service_v5.wsdl b/lib/Xmds/service_v5.wsdl
new file mode 100644
index 0000000..2eb51a6
--- /dev/null
+++ b/lib/Xmds/service_v5.wsdl
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Register the Display with the CMS
+
+
+
+
+ The files required by the requesting display
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Gets the schedule
+
+
+
+
+ Set media to be blacklisted
+
+
+
+
+ Submit Logging from the Client
+
+
+
+
+ Submit Display statistics from the Client
+
+
+
+
+ Report back the clients MediaInventory
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Report back the current status
+
+
+
+
+ Submit a screen shot for a display
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/Xmds/service_v6.wsdl b/lib/Xmds/service_v6.wsdl
new file mode 100644
index 0000000..f119c6a
--- /dev/null
+++ b/lib/Xmds/service_v6.wsdl
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Register the Display with the CMS
+
+
+
+
+ The files required by the requesting display
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Gets the schedule
+
+
+
+
+ Report Player faults
+
+
+
+
+ Submit Logging from the Client
+
+
+
+
+ Submit Display statistics from the Client
+
+
+
+
+ Report back the clients MediaInventory
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Report back the current status
+
+
+
+
+ Submit a screen shot for a display
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/Xmds/service_v7.wsdl b/lib/Xmds/service_v7.wsdl
new file mode 100644
index 0000000..8a07661
--- /dev/null
+++ b/lib/Xmds/service_v7.wsdl
@@ -0,0 +1,361 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Register the Display with the CMS
+
+
+
+
+ The files required by the requesting display
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Gets the schedule
+
+
+
+
+ Report Player faults
+
+
+
+
+ Submit Logging from the Client
+
+
+
+
+ Submit Display statistics from the Client
+
+
+
+
+ Report back the clients MediaInventory
+
+
+
+
+ Gets the file requested
+
+
+
+
+ Report back the current status
+
+
+
+
+ Submit a screen shot for a display
+
+
+
+
+ Get data for a widget
+
+
+
+
+ Get a player dependency (player bundle/font/etc)
+
+
+
+
+ Get Weather conditions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/routes-cypress.php b/lib/routes-cypress.php
new file mode 100644
index 0000000..13cbfd9
--- /dev/null
+++ b/lib/routes-cypress.php
@@ -0,0 +1,32 @@
+.
+ */
+
+defined('XIBO') or die('Sorry, you are not allowed to directly access this page.');
+
+
+// Cypress endpoints
+// these are removed during the docker build process and are not in the final release files.
+$app->post('/createCommand', ['\Xibo\Controller\CypressTest','createCommand']);
+$app->post('/createCampaign', ['\Xibo\Controller\CypressTest','createCampaign']);
+$app->post('/scheduleCampaign', ['\Xibo\Controller\CypressTest','scheduleCampaign']);
+$app->post('/displaySetStatus', ['\Xibo\Controller\CypressTest','displaySetStatus']);
+$app->get('/displayStatusEquals', ['\Xibo\Controller\CypressTest','displayStatusEquals']);
diff --git a/lib/routes-install.php b/lib/routes-install.php
new file mode 100644
index 0000000..faf40c3
--- /dev/null
+++ b/lib/routes-install.php
@@ -0,0 +1,174 @@
+.
+ */
+
+use Psr\Container\ContainerInterface;
+use Slim\Http\Response as Response;
+use Slim\Http\ServerRequest as Request;
+use Slim\Views\Twig;
+use Xibo\Storage\PdoStorageService;
+use Xibo\Support\Exception\InstallationError;
+
+$app->get('/login', function(Request $request, Response $response) use ($app) {
+ // Just a helper to get correct login route url
+ return $response->withStatus(404, 'This function should not be called from install/.');
+})->setName('login');
+
+$app->map(['GET', 'POST'],'/{step}', function(Request $request, Response $response, $step = 1) use ($app) {
+ session_start();
+
+ $container = $app->getContainer();
+ $routeParser = $app->getRouteCollector()->getRouteParser();
+
+ $container->set('store', function(ContainerInterface $container) {
+ return (new PdoStorageService($container->get('logService')));
+ });
+
+ $container->get('configService')->setDependencies($container->get('store'), $container->get('rootUri'));
+
+ /** @var Twig $view */
+ $view = $container->get('view');
+
+ $twigEnvironment = $view->getEnvironment();
+ $twigEnvironment->enableAutoReload();
+
+ $container->get('logService')->info('Installer Step %s', $step);
+
+ $install = new \Xibo\Helper\Install($container);
+ $settingsExists = file_exists(PROJECT_ROOT . '/web/settings.php');
+ $template = '';
+ $data = [];
+
+ switch ($step) {
+ case 1:
+ if ($settingsExists) {
+ throw new InstallationError(__('The CMS has already been installed. Please contact your system administrator.'));
+ }
+ unset($_SESSION['error']);
+ // Welcome to the installer (this should only show once)
+ // Checks environment
+ $template = 'install-step1';
+ $data = $install->step1();
+ break;
+
+ case 2:
+ if ($settingsExists) {
+ throw new InstallationError(__('The CMS has already been installed. Please contact your system administrator.'));
+ }
+
+ unset($_SESSION['error']);
+ // Collect details about the database
+ $template = 'install-step2';
+ $data = $install->step2();
+ break;
+
+ case 3:
+ if ($settingsExists) {
+ throw new InstallationError(__('The CMS has already been installed. Please contact your system administrator.'));
+ }
+
+ // Check and validate DB details
+ if (defined('MAX_EXECUTION') && MAX_EXECUTION) {
+ $app->getContainer()->get('logService')->info('Setting unlimited max execution time.');
+ set_time_limit(0);
+ }
+ unset($_SESSION['error']);
+ try {
+ $install->step3($request, $response);
+ // Redirect to step 4
+ return $response->withRedirect($routeParser->urlFor('install', ['step' => 4]));
+ } catch (InstallationError $e) {
+ $container->get('logService')->error('Installation Exception on Step %d: %s', $step, $e->getMessage());
+
+ $_SESSION['error'] = $e->getMessage();
+
+ // Add our object properties to the flash vars, so we render the form with them set
+ foreach (\Xibo\Helper\ObjectVars::getObjectVars($install) as $key => $value) {
+ $_SESSION[$key] = $value;
+ }
+
+ // Reload step 2
+ $template = 'install-step2';
+ $data = $install->step2();
+ }
+ break;
+
+ case 4:
+ // DB installed and we are ready to collect some more details
+ // We should get the admin username and password
+ $data = $install->step4();
+ $template = 'install-step4';
+ break;
+
+ case 5:
+ unset($_SESSION['error']);
+ // Create a user account
+ try {
+ $install->step5($request, $response);
+ return $response->withRedirect($routeParser->urlFor('install', ['step' => 6]));
+ } catch (InstallationError $e) {
+ $container->get('logService')->error('Installation Exception on Step %d: %s', $step, $e->getMessage());
+
+ $_SESSION['error'] = $e->getMessage();
+
+ // Reload step 4
+ $template = 'install-step4';
+ $data = $install->step4();
+ }
+ break;
+
+ case 6:
+ $template = 'install-step6';
+ $data = $install->step6();
+ break;
+
+ case 7:
+ unset($_SESSION['error']);
+ // Create a user account
+ try {
+ $install->step7($request, $response);
+
+ // Redirect to login
+ // This will always be one folder down
+ $login = str_replace('/install', '', $routeParser->urlFor('login'));
+
+ $container->get('logService')->info('Installation Complete. Redirecting to %s', $login);
+ session_destroy();
+ return $response->withRedirect($login);
+ } catch (InstallationError $e) {
+ $container->get('logService')->error('Installation Exception on Step %d: %s', $step, $e->getMessage());
+
+ $_SESSION['error'] = $e->getMessage();
+
+ // Reload step 6
+ $template = 'install-step6';
+ $data = $install->step6();
+ }
+ break;
+ }
+
+ // Add in our session object
+ $data['session'] = $_SESSION;
+
+ // Render
+ return $view->render($response, $template . '.twig', $data);
+
+})->setName('install');
diff --git a/lib/routes-web.php b/lib/routes-web.php
new file mode 100644
index 0000000..55769dc
--- /dev/null
+++ b/lib/routes-web.php
@@ -0,0 +1,800 @@
+.
+ */
+
+use Slim\Routing\RouteCollectorProxy;
+use Xibo\Middleware\FeatureAuth;
+use Xibo\Middleware\SuperAdminAuth;
+
+// Special "root" route
+$app->get('/', ['\Xibo\Controller\User', 'home'])->setName('home');
+$app->get('/welcome', ['\Xibo\Controller\User', 'welcome'])->setName('welcome.view');
+
+//
+// Dashboards
+//
+$app->group('', function(RouteCollectorProxy $group) {
+ $group->get('/statusdashboard', ['\Xibo\Controller\StatusDashboard', 'displayPage'])
+ ->setName('statusdashboard.view');
+ $group->get('/statusdashboard/displays', ['\Xibo\Controller\StatusDashboard', 'displays'])
+ ->setName('statusdashboard.displays');
+ $group->get('/statusdashboard/displayGroups', ['\Xibo\Controller\StatusDashboard', 'displayGroups'])
+ ->setName('statusdashboard.displayGroups');
+})->add(new FeatureAuth($app->getContainer(), ['dashboard.status']));
+
+// Everyone has access to this dashboard.
+$app->get('/icondashboard', ['\Xibo\Controller\IconDashboard', 'displayPage'])
+ ->setName('icondashboard.view');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/mediamanager', ['\Xibo\Controller\MediaManager', 'displayPage'])
+ ->setName('mediamanager.view');
+ $group->get('/mediamanager/data', ['\Xibo\Controller\MediaManager', 'grid'])
+ ->setName('mediamanager.search');
+})->add(new FeatureAuth($app->getContainer(), ['dashboard.media.manager']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/playlistdashboard', ['\Xibo\Controller\PlaylistDashboard', 'displayPage'])
+ ->setName('playlistdashboard.view');
+ $group->get('/playlistdashboard/data', ['\Xibo\Controller\PlaylistDashboard', 'grid'])
+ ->setName('playlistdashboard.search');
+ $group->get('/playlistdashboard/{id}', ['\Xibo\Controller\PlaylistDashboard', 'show'])
+ ->setName('playlistdashboard.show');
+ $group->get('/playlistdashboard/widget/form/delete/{id}', [
+ '\Xibo\Controller\PlaylistDashboard',
+ 'deletePlaylistWidgetForm',
+ ])->setName('playlist.module.widget.delete.form');
+})->add(new FeatureAuth($app->getContainer(), ['dashboard.playlist']));
+
+// Login Form
+$app->get('/login', ['\Xibo\Controller\Login', 'loginForm'])->setName('login');
+
+// Login Requests
+$app->post('/login', ['\Xibo\Controller\Login','login']);
+$app->post('/login/forgotten', ['\Xibo\Controller\Login','forgottenPassword'])->setName('login.forgotten');
+$app->get('/tfa', ['\Xibo\Controller\Login','twoFactorAuthForm'])->setName('tfa');
+
+// Logout Request
+$app->get('/logout', ['\Xibo\Controller\Login','logout'])->setName('logout');
+
+// Ping pong route
+$app->get('/login/ping', ['\Xibo\Controller\Login','PingPong'])->setName('ping');
+
+//
+// schedule
+//
+$app->get('/schedule/view', ['\Xibo\Controller\Schedule','displayPage'])
+ ->add(new FeatureAuth($app->getContainer(), ['schedule.view']))
+ ->setName('schedule.view');
+
+$app->get('/schedule/grid/view', ['\Xibo\Controller\Schedule','gridPage'])
+ ->add(new FeatureAuth($app->getContainer(), ['schedule.view']))
+ ->setName('schedule.grid.view');
+
+$app->get('/schedule/form/add[/{from}/{id}]', ['\Xibo\Controller\Schedule','addForm'])
+ ->add(new FeatureAuth($app->getContainer(), ['schedule.add']))
+ ->setName('schedule.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/schedule/form/edit/{id}', ['\Xibo\Controller\Schedule', 'editForm'])
+ ->setName('schedule.edit.form');
+
+ $group->get('/schedule/form/delete/{id}', ['\Xibo\Controller\Schedule', 'deleteForm'])
+ ->setName('schedule.delete.form');
+
+ $group->get('/schedulerecurrence/form/delete/{id}', ['\Xibo\Controller\Schedule', 'deleteRecurrenceForm'])
+ ->setName('schedule.recurrence.delete.form');
+})->add(new FeatureAuth($app->getContainer(), ['schedule.modify']));
+
+//
+// notification
+//
+$app->get('/drawer/notification/show/{id}', ['\Xibo\Controller\Notification','show'])->setName('notification.show');
+$app->get('/drawer/notification/interrupt/{id}', ['\Xibo\Controller\Notification','interrupt'])->setName('notification.interrupt');
+$app->get('/notification/export/{id}', ['\Xibo\Controller\Notification','exportAttachment'])->setName('notification.exportattachment');
+
+$app->get('/notification/view', ['\Xibo\Controller\Notification','displayPage'])
+ ->add(new FeatureAuth($app->getContainer(), ['notification.centre']))
+ ->setName('notification.view');
+
+$app->get('/notification/form/add', ['\Xibo\Controller\Notification', 'addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['notification.add']))
+ ->setName('notification.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/notification/form/edit/{id}', ['\Xibo\Controller\Notification', 'editForm'])
+ ->setName('notification.edit.form');
+ $group->get('/notification/form/delete/{id}', ['\Xibo\Controller\Notification', 'deleteForm'])
+ ->setName('notification.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['notification.modify']));
+
+//
+// layouts
+//
+$app->get('/layout/view', ['\Xibo\Controller\Layout', 'displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.view']))
+ ->setName('layout.view');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/layout/xlf/{id}', ['\Xibo\Controller\Preview', 'getXlf'])->setName('layout.getXlf');
+ $group->get('/layout/background/{id}', ['\Xibo\Controller\Layout', 'downloadBackground'])->setName('layout.download.background');
+ $group->get('/layout/thumbnail/{id}', ['\Xibo\Controller\Layout', 'downloadThumbnail'])->setName('layout.download.thumbnail');
+ $group->get('/layout/playerBundle', ['\Xibo\Controller\Preview', 'playerBundle'])->setName('layout.preview.bundle');
+ $group->get('/connector/widget/preview', ['\Xibo\Controller\Connector', 'connectorPreview'])->setName('layout.preview.connector');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.view', 'template.view']));
+
+$app->get('/layout/preview/{id}', ['\Xibo\Controller\Preview', 'show'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.view', 'template.view', 'campaign.view']))
+ ->setName('layout.preview');
+
+// forms
+$app->get('/layout/form/add', ['\Xibo\Controller\Layout','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.add']))
+ ->setName('layout.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/layout/designer[/{id}]', ['\Xibo\Controller\Layout','displayDesigner'])->setName('layout.designer');
+ $group->get('/layout/form/edit/{id}', ['\Xibo\Controller\Layout', 'editForm'])->setName('layout.edit.form');
+ $group->get('/layout/form/background/{id}', ['\Xibo\Controller\Layout', 'editBackgroundForm'])->setName('layout.background.form');
+ $group->get('/layout/form/copy/{id}', ['\Xibo\Controller\Layout', 'copyForm'])->setName('layout.copy.form');
+ $group->get('/layout/form/delete/{id}', ['\Xibo\Controller\Layout', 'deleteForm'])->setName('layout.delete.form');
+ $group->get('/layout/form/clear/{id}', ['\Xibo\Controller\Layout', 'clearForm'])->setName('layout.clear.form');
+ $group->get('/layout/form/checkout/{id}', ['\Xibo\Controller\Layout', 'checkoutForm'])->setName('layout.checkout.form');
+ $group->get('/layout/form/publish/{id}', ['\Xibo\Controller\Layout', 'publishForm'])->setName('layout.publish.form');
+ $group->get('/layout/form/discard/{id}', ['\Xibo\Controller\Layout', 'discardForm'])->setName('layout.discard.form');
+ $group->get('/layout/form/retire/{id}', ['\Xibo\Controller\Layout', 'retireForm'])->setName('layout.retire.form');
+ $group->get('/layout/form/unretire/{id}', ['\Xibo\Controller\Layout', 'unretireForm'])->setName('layout.unretire.form');
+ $group->get('/layout/form/setenablestat/{id}', ['\Xibo\Controller\Layout', 'setEnableStatForm'])->setName('layout.setenablestat.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify', 'template.modify']));
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/layout/form/export/{id}', ['\Xibo\Controller\Layout', 'exportForm'])->setName('layout.export.form');
+ $group->get('/layout/export/{id}', ['\Xibo\Controller\Layout', 'export'])->setName('layout.export');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.export']));
+
+$app->get('/layout/form/campaign/assign/{id}', ['\Xibo\Controller\Layout','assignToCampaignForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.modify']))
+ ->setName('layout.assignTo.campaign.form');
+
+// Layout with Codes
+$app->get('/layout/codes', ['\Xibo\Controller\Layout', 'getLayoutCodes'])->setName('layout.code.search');
+
+//
+// regions
+//
+$app->get('/region/preview/{id}', ['\Xibo\Controller\Region','preview'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.view']))
+ ->setName('region.preview');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/region/{id}', ['\Xibo\Controller\Region', 'get'])->setName('region.get');
+
+ // Designer
+ $group->get('/playlist/form/library/assign/{id}', ['\Xibo\Controller\Playlist','libraryAssignForm'])->setName('playlist.library.assign.form');
+
+ // Outputs
+ $group->get('/playlist/widget/resource/{regionId}[/{id}]', [
+ '\Xibo\Controller\Widget', 'getResource'
+ ])->setName('module.getResource');
+
+ $group->get('/playlist/widget/data/{regionId}/{id}', [
+ '\Xibo\Controller\Widget', 'getData'
+ ])->setName('module.getData');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify']));
+
+$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
+ // Widget functions
+ $group->get('/playlist/widget/{id}', ['\Xibo\Controller\Widget','getWidget'])->setName('module.widget.get');
+ $group->get('/playlist/widget/form/transition/edit/{type}/{id}', ['\Xibo\Controller\Widget','editWidgetTransitionForm'])->setName('module.widget.transition.edit.form');
+ $group->get('/playlist/widget/form/audio/{id}', ['\Xibo\Controller\Widget','widgetAudioForm'])->setName('module.widget.audio.form');
+ $group->get('/playlist/widget/form/expiry/{id}', ['\Xibo\Controller\Widget','widgetExpiryForm'])->setName('module.widget.expiry.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify', 'playlist.modify']));
+
+//
+// playlists
+//
+$app->get('/playlist/view', ['\Xibo\Controller\Playlist','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['playlist.view']))
+ ->setName('playlist.view');
+
+$app->get('/playlist/form/add', ['\Xibo\Controller\Playlist','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['playlist.add']))
+ ->setName('playlist.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/playlist/form/edit/{id}', ['\Xibo\Controller\Playlist', 'editForm'])->setName('playlist.edit.form');
+ $group->get('/playlist/form/copy/{id}', ['\Xibo\Controller\Playlist', 'copyForm'])->setName('playlist.copy.form');
+ $group->get('/playlist/form/delete/{id}', ['\Xibo\Controller\Playlist', 'deleteForm'])->setName('playlist.delete.form');
+ $group->get('/playlist/form/setenablestat/{id}', ['\Xibo\Controller\Playlist','setEnableStatForm'])->setName('playlist.setenablestat.form');
+ $group->get('/playlist/form/{id}/selectfolder', ['\Xibo\Controller\Playlist','selectFolderForm'])->setName('playlist.selectfolder.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['playlist.modify']));
+
+$app->get('/playlist/form/timeline/{id}', ['\Xibo\Controller\Playlist','timelineForm'])->setName('playlist.timeline.form');
+
+$app->get('/playlist/form/usage/{id}', ['\Xibo\Controller\Playlist','usageForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['schedule.view', 'layout.view']))
+ ->setName('playlist.usage.form');
+
+//
+// library
+//
+$app->get('/library/search', ['\Xibo\Controller\Library','search'])
+ ->setName('library.search.all');
+
+$app->get('/library/connector/list', ['\Xibo\Controller\Library','providersList'])
+ ->setName('library.search.providers');
+
+$app->get('/library/view', ['\Xibo\Controller\Library','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['library.view']))
+ ->setName('library.view');
+
+$app->get('/library/form/uploadUrl', ['\Xibo\Controller\Library','uploadFromUrlForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['library.add']))
+ ->setName('library.uploadUrl.form');
+
+$app->post('/library/connector/import', ['\Xibo\Controller\Library', 'connectorImport'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['library.add']))
+ ->setName('library.connector.import');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/library/form/edit/{id}', ['\Xibo\Controller\Library', 'editForm'])->setName('library.edit.form');
+ $group->get('/library/form/delete/{id}', ['\Xibo\Controller\Library', 'deleteForm'])->setName('library.delete.form');
+ $group->get('/library/form/tidy', ['\Xibo\Controller\Library', 'tidyForm'])->setName('library.tidy.form');
+ $group->get('/library/form/copy/{id}', ['\Xibo\Controller\Library','copyForm'])->setName('library.copy.form');
+ $group->get('/library/form/setenablestat/{id}', ['\Xibo\Controller\Library','setEnableStatForm'])->setName('library.setenablestat.form');
+ $group->get('/library/form/{id}/selectfolder', ['\Xibo\Controller\Library','selectFolderForm'])->setName('library.selectfolder.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['library.modify']));
+
+$app->get('/library/form/usage/{id}', ['\Xibo\Controller\Library','usageForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['schedule.view', 'layout.view']))
+ ->setName('library.usage.form');
+
+//
+// display
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/display/map', ['\Xibo\Controller\Display', 'displayMap'])->setName('display.map');
+ $group->get('/display/view', ['\Xibo\Controller\Display', 'displayPage'])->setName('display.view');
+ $group->get('/display/manage/{id}', ['\Xibo\Controller\Display', 'displayManage'])->setName('display.manage');
+ $group->get('/display/form/screenshot/{id}', ['\Xibo\Controller\Display','requestScreenShotForm'])->setName('display.screenshot.form');
+ $group->get('/display/form/wol/{id}', ['\Xibo\Controller\Display','wakeOnLanForm'])->setName('display.wol.form');
+ $group->get('/display/form/licenceCheck/{id}', ['\Xibo\Controller\Display','checkLicenceForm'])->setName('display.licencecheck.form');
+ $group->get('/display/form/purgeAll/{id}', ['\Xibo\Controller\Display','purgeAllForm'])->setName('display.purge.all.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['displays.view']));
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/display/form/addViaCode', ['\Xibo\Controller\Display','addViaCodeForm'])->setName('display.addViaCode.form');
+ $group->get('/display/form/authorise/{id}', ['\Xibo\Controller\Display','authoriseForm'])->setName('display.authorise.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['displays.add']));
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/display/form/edit/{id}', ['\Xibo\Controller\Display', 'editForm'])->setName('display.edit.form');
+ $group->get('/display/form/delete/{id}', ['\Xibo\Controller\Display', 'deleteForm'])->setName('display.delete.form');
+ $group->get('/display/form/defaultlayout/{id}', ['\Xibo\Controller\Display','defaultLayoutForm'])->setName('display.defaultlayout.form');
+ $group->get('/display/form/moveCms/{id}', ['\Xibo\Controller\Display','moveCmsForm'])->setName('display.moveCms.form');
+ $group->get('/display/form/moveCmsCancel/{id}', ['\Xibo\Controller\Display','moveCmsCancelForm'])->setName('display.moveCmsCancel.form');
+ $group->get('/display/form/membership/{id}', ['\Xibo\Controller\Display','membershipForm'])->setName('display.membership.form');
+ $group->get('/display/form/setBandwidthLimit', ['\Xibo\Controller\Display','setBandwidthLimitMultipleForm'])->setName('display.setBandwidthLimitMultiple.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['displays.modify']));
+
+//
+// user
+//
+$app->get('/user/view', ['\Xibo\Controller\User', 'displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['users.view']))
+ ->setName('user.view');
+
+$app->post('/user/welcome', ['\Xibo\Controller\User','userWelcomeSetUnseen'])->setName('welcome.wizard.unseen');
+$app->put('/user/welcome', ['\Xibo\Controller\User','userWelcomeSetSeen'])->setName('welcome.wizard.seen');
+
+$app->get('/user/apps', ['\Xibo\Controller\User','myApplications'])->setName('user.applications');
+
+$app->get('/user/form/profile', ['\Xibo\Controller\User','editProfileForm'])->setName('user.edit.profile.form');
+$app->get('/user/form/preferences', ['\Xibo\Controller\User', 'preferencesForm'])->setName('user.preferences.form');
+$app->get('/user/permissions/form/{entity}/{id}', ['\Xibo\Controller\User','permissionsForm'])->setName('user.permissions.form');
+$app->get('/user/permissions/multiple/form/{entity}', ['\Xibo\Controller\User','permissionsMultiForm'])->setName('user.permissions.multi.form');
+$app->get('/user/page/password', ['\Xibo\Controller\User','forceChangePasswordPage'])->setName('user.force.change.password.page');
+
+$app->get('/user/form/add', ['\Xibo\Controller\User','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['users.add']))
+ ->setName('user.add.form');
+
+$app->get('/user/form/onboarding', ['\Xibo\Controller\User','onboardingForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['users.add']))
+ ->setName('user.onboarding.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/user/form/edit/{id}', ['\Xibo\Controller\User', 'editForm'])->setName('user.edit.form');
+ $group->get('/user/form/delete/{id}', ['\Xibo\Controller\User', 'deleteForm'])->setName('user.delete.form');
+ $group->get('/user/form/membership/{id}', ['\Xibo\Controller\User', 'membershipForm'])->setName('user.membership.form');
+ $group->get('/user/form/setHomeFolder/{id}', ['\Xibo\Controller\User', 'setHomeFolderForm'])
+ ->addMiddleware(new FeatureAuth($group->getContainer(), ['folder.userHome']))
+ ->setName('user.homeFolder.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['users.modify']));
+
+$app->get('/user/form/homepages', ['\Xibo\Controller\User', 'homepages'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['users.add', 'users.modify']))
+ ->setName('user.homepages.search');
+
+//
+// log
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/log/view', ['\Xibo\Controller\Logging', 'displayPage'])->setName('log.view');
+ $group->get('/log/delete', ['\Xibo\Controller\Logging', 'truncateForm'])->setName('log.truncate.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['log.view']));
+
+//
+// campaign
+//
+$app->get('/campaign/view', ['\Xibo\Controller\Campaign','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.view']))
+ ->setName('campaign.view');
+
+$app->get('/campaign/form/add', ['\Xibo\Controller\Campaign','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.add']))
+ ->setName('campaign.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/campaign/form/edit/{id}', ['\Xibo\Controller\Campaign', 'editForm'])->setName('campaign.edit.form');
+ $group->get('/campaign/form/copy/{id}', ['\Xibo\Controller\Campaign', 'copyForm'])->setName('campaign.copy.form');
+ $group->get('/campaign/form/delete/{id}', ['\Xibo\Controller\Campaign', 'deleteForm'])->setName('campaign.delete.form');
+ $group->get('/campaign/form/retire/{id}', ['\Xibo\Controller\Campaign', 'retireForm'])->setName('campaign.retire.form');
+ $group->get('/campaign/form/layout/remove/{id}', ['\Xibo\Controller\Campaign', 'removeLayoutForm'])
+ ->setName('campaign.layout.remove.form');
+
+ $group->get('/campaign-builder/{id}', ['\Xibo\Controller\Campaign', 'displayCampaignBuilder'])
+ ->addMiddleware(new FeatureAuth($group->getContainer(), ['ad.campaign']))
+ ->setName('campaign.builder');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.modify']));
+
+$app->get('/campaign/form/{id}/selectfolder', ['\Xibo\Controller\Campaign','selectFolderForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.modify', 'layout.modify']))
+ ->setName('campaign.selectfolder.form');
+
+$app->get('/campaign/{id}/preview', ['\Xibo\Controller\Campaign','preview'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.view', 'layout.view']))
+ ->setName('campaign.preview');
+
+//
+// template
+//
+$app->get('/template/connector/list', ['\Xibo\Controller\Template','providersList'])
+ ->setName('template.search.providers');
+$app->get('/template/search', ['\Xibo\Controller\Template', 'search'])->setName('template.search.all');
+$app->get('/template/view', ['\Xibo\Controller\Template','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['template.view']))
+ ->setName('template.view');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/template/form/layout/{id}', ['\Xibo\Controller\Template', 'addTemplateForm'])->setName('template.from.layout.form');
+ $group->get('/template/form/add', ['\Xibo\Controller\Template', 'addForm'])->setName('template.add.form');
+ $group->get('/template/form/edit/{id}', ['\Xibo\Controller\Template', 'editForm'])->setName('template.edit.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['template.add']));
+
+//
+// resolution
+//
+$app->get('/resolution/view', ['\Xibo\Controller\Resolution','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['resolution.view']))
+ ->setName('resolution.view');
+
+$app->get('/resolution/form/add', ['\Xibo\Controller\Resolution','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['resolution.add']))
+ ->setName('resolution.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/resolution/form/edit/{id}', ['\Xibo\Controller\Resolution', 'editForm'])->setName('resolution.edit.form');
+ $group->get('/resolution/form/delete/{id}', ['\Xibo\Controller\Resolution', 'deleteForm'])->setName('resolution.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['resolution.modify']));
+
+//
+// dataset
+//
+$app->get('/dataset/view', ['\Xibo\Controller\DataSet','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['dataset.view']))
+ ->setName('dataset.view');
+
+$app->get('/dataset/form/add', ['\Xibo\Controller\DataSet','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['dataset.add']))
+ ->setName('dataSet.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/dataset/form/edit/{id}', ['\Xibo\Controller\DataSet', 'editForm'])->setName('dataSet.edit.form');
+ $group->get('/dataset/form/copy/{id}', ['\Xibo\Controller\DataSet', 'copyForm'])->setName('dataSet.copy.form');
+ $group->get('/dataset/form/delete/{id}', ['\Xibo\Controller\DataSet', 'deleteForm'])->setName('dataSet.delete.form');
+ $group->get('/dataset/form/import/{id}', ['\Xibo\Controller\DataSet', 'importForm'])->setName('dataSet.import.form');
+ $group->get('/dataset/form/cache/clear/{id}', ['\Xibo\Controller\DataSet', 'clearCacheForm'])->setName('dataSet.clear.cache.form');
+ $group->post('/dataset/cache/clear/{id}', ['\Xibo\Controller\DataSet', 'clearCache'])->setName('dataSet.clear.cache');
+ $group->get('/dataset/form/{id}/selectfolder', ['\Xibo\Controller\DataSet', 'selectFolderForm'])->setName('dataSet.selectfolder.form');
+
+ $group->get('/dataset/dataConnector/{id}', ['\Xibo\Controller\DataSet', 'dataConnectorView'])->setName('dataSet.dataConnector.view');
+ $group->get('/dataset/dataConnector/request/{id}', ['\Xibo\Controller\DataSet', 'dataConnectorRequest'])->setName('dataSet.dataConnector.request');
+ $group->get('/dataset/dataConnector/test/{id}', ['\Xibo\Controller\DataSet', 'dataConnectorTest'])->setName('dataSet.dataConnector.test');
+
+ // columns
+ $group->get('/dataset/{id}/column/view', ['\Xibo\Controller\DataSetColumn','displayPage'])->setName('dataSet.column.view');
+ $group->get('/dataset/{id}/column/form/add', ['\Xibo\Controller\DataSetColumn','addForm'])->setName('dataSet.column.add.form');
+ $group->get('/dataset/{id}/column/form/edit/{colId}', ['\Xibo\Controller\DataSetColumn','editForm'])->setName('dataSet.column.edit.form');
+ $group->get('/dataset/{id}/column/form/delete/{colId}', ['\Xibo\Controller\DataSetColumn','deleteForm'])->setName('dataSet.column.delete.form');
+
+ // RSS
+ $group->get('/dataset/{id}/rss/view', ['\Xibo\Controller\DataSetRss','displayPage'])->setName('dataSet.rss.view');
+ $group->get('/dataset/{id}/rss/form/add', ['\Xibo\Controller\DataSetRss','addForm'])->setName('dataSet.rss.add.form');
+ $group->get('/dataset/{id}/rss/form/edit/{rssId}', ['\Xibo\Controller\DataSetRss','editForm'])->setName('dataSet.rss.edit.form');
+ $group->get('/dataset/{id}/rss/form/delete/{rssId}', ['\Xibo\Controller\DataSetRss','deleteForm'])->setName('dataSet.rss.delete.form');
+
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['dataset.modify']));
+
+// data
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/dataset/data/view/{id}', ['\Xibo\Controller\DataSetData','displayPage'])->setName('dataSet.view.data');
+ $group->get('/dataset/data/form/add/{id}', ['\Xibo\Controller\DataSetData','addForm'])->setName('dataSet.data.add.form');
+ $group->get('/dataset/data/form/edit/{id}/{rowId}', ['\Xibo\Controller\DataSetData','editForm'])->setName('dataSet.data.edit.form');
+ $group->get('/dataset/data/form/delete/{id}/{rowId}', ['\Xibo\Controller\DataSetData','deleteForm'])->setName('dataSet.data.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['dataset.data']));
+
+//
+// displaygroup
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/displaygroup/view', ['\Xibo\Controller\DisplayGroup','displayPage'])->setName('displaygroup.view');
+ $group->get('/displaygroup/form/command/{id}', ['\Xibo\Controller\DisplayGroup','commandForm'])->setName('displayGroup.command.form');
+ $group->get('/displaygroup/form/collect/{id}', ['\Xibo\Controller\DisplayGroup','collectNowForm'])->setName('displayGroup.collectNow.form');
+ $group->get('/displaygroup/form/trigger/webhook/{id}', ['\Xibo\Controller\DisplayGroup','triggerWebhookForm'])->setName('displayGroup.trigger.webhook.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['displaygroup.view']));
+
+$app->get('/displaygroup/form/add', ['\Xibo\Controller\DisplayGroup','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['displaygroup.add']))
+ ->setName('displayGroup.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/displaygroup/form/edit/{id}', ['\Xibo\Controller\DisplayGroup','editForm'])->setName('displayGroup.edit.form');
+ $group->get('/displaygroup/form/delete/{id}', ['\Xibo\Controller\DisplayGroup','deleteForm'])->setName('displayGroup.delete.form');
+ $group->get('/displaygroup/form/members/{id}', ['\Xibo\Controller\DisplayGroup','membersForm'])->setName('displayGroup.members.form');
+ $group->get('/displaygroup/form/media/{id}', ['\Xibo\Controller\DisplayGroup','mediaForm'])->setName('displayGroup.media.form');
+ $group->get('/displaygroup/form/layout/{id}', ['\Xibo\Controller\DisplayGroup','layoutsForm'])->setName('displayGroup.layout.form');
+ $group->get('/displaygroup/form/copy/{id}', ['\Xibo\Controller\DisplayGroup','copyForm'])->setName('displayGroup.copy.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['displaygroup.modify']));
+
+$app->get('/displaygroup/form/{id}/selectfolder', ['\Xibo\Controller\DisplayGroup','selectFolderForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['displaygroup.modify', 'display.modify']))
+ ->setName('displayGroup.selectfolder.form');
+
+//
+// displayprofile
+//
+$app->get('/displayprofile/view', ['\Xibo\Controller\DisplayProfile','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['displayprofile.view']))
+ ->setName('displayprofile.view');
+
+$app->get('/displayprofile/form/add', ['\Xibo\Controller\DisplayProfile','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['displayprofile.add']))
+ ->setName('displayProfile.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/displayprofile/form/edit/{id}', ['\Xibo\Controller\DisplayProfile','editForm'])->setName('displayProfile.edit.form');
+ $group->get('/displayprofile/form/delete/{id}', ['\Xibo\Controller\DisplayProfile','deleteForm'])->setName('displayProfile.delete.form');
+ $group->get('/displayprofile/form/copy/{id}', ['\Xibo\Controller\DisplayProfile','copyForm'])->setName('displayProfile.copy.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['displayprofile.modify']));
+
+//
+// group
+//
+$app->get('/group/view', ['\Xibo\Controller\UserGroup','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['usergroup.view']))
+ ->setName('group.view');
+
+$app->get('/group/form/add', ['\Xibo\Controller\UserGroup','addForm'])->setName('group.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/group/form/edit/{id}', ['\Xibo\Controller\UserGroup','editForm'])->setName('group.edit.form');
+ $group->get('/group/form/delete/{id}', ['\Xibo\Controller\UserGroup','deleteForm'])->setName('group.delete.form');
+ $group->get('/group/form/copy/{id}', ['\Xibo\Controller\UserGroup','copyForm'])->setName('group.copy.form');
+ $group->get('/group/form/acl/{id}/[{userId}]', ['\Xibo\Controller\UserGroup','aclForm'])->setName('group.acl.form');
+ $group->get('/group/form/members/{id}', ['\Xibo\Controller\UserGroup','membersForm'])->setName('group.members.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['usergroup.modify']));
+
+//
+// admin
+//
+$app->get('/admin/view', ['\Xibo\Controller\Settings','displayPage'])
+ ->addMiddleware(new SuperAdminAuth($app->getContainer()))
+ ->setName('admin.view');
+
+//
+// maintenance
+//
+$app->get('/maintenance/form/tidy', ['\Xibo\Controller\Maintenance','tidyLibraryForm'])
+ ->addMiddleware(new SuperAdminAuth($app->getContainer()))
+ ->setName('maintenance.libraryTidy.form');
+
+//
+// Folders
+//
+$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/folders/view', ['\Xibo\Controller\Folder', 'displayPage'])->setName('folders.view');
+ $group->get('/folders/form/add', ['\Xibo\Controller\Folder', 'addForm'])->setName('folders.add.form');
+ $group->get('/folders/form/edit/{id}', ['\Xibo\Controller\Folder', 'editForm'])->setName('folders.edit.form');
+ $group->get('/folders/form/delete/{id}', ['\Xibo\Controller\Folder', 'deleteForm'])->setName('folders.delete.form');
+})->addMiddleware(new SuperAdminAuth($app->getContainer()));
+
+//
+// Applications and connectors
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/application/view', ['\Xibo\Controller\Applications','displayPage'])->setName('application.view');
+ $group->get('/application/data/activity', ['\Xibo\Controller\Applications','viewActivity'])->setName('application.view.activity');
+ $group->get('/application/form/add', ['\Xibo\Controller\Applications','addForm'])->setName('application.add.form');
+ $group->get('/application/form/edit/{id}', ['\Xibo\Controller\Applications','editForm'])->setName('application.edit.form');
+ $group->get('/application/form/delete/{id}', ['\Xibo\Controller\Applications','deleteForm'])->setName('application.delete.form');
+ $group->put('/application/{id}', ['\Xibo\Controller\Applications','edit'])->setName('application.edit');
+ $group->delete('/application/{id}', ['\Xibo\Controller\Applications','delete'])->setName('application.delete');
+
+ // We can only view/edit these through the web app
+ $group->get('/connectors', ['\Xibo\Controller\Connector','grid'])->setName('connector.search');
+ $group->get('/connectors/form/edit/{id}', ['\Xibo\Controller\Connector','editForm'])
+ ->setName('connector.edit.form');
+ $group->map(
+ ['GET', 'POST'],
+ '/connectors/form/{id}/proxy/{method}',
+ ['\Xibo\Controller\Connector', 'editFormProxy']
+ )->setName('connector.edit.form.proxy');
+ $group->put('/connectors/{id}', ['\Xibo\Controller\Connector','edit'])->setName('connector.edit');
+})->addMiddleware(new SuperAdminAuth($app->getContainer()));
+
+$app->get('/application/authorize', ['\Xibo\Controller\Applications','authorizeRequest'])->setName('application.authorize.request');
+$app->post('/application/authorize', ['\Xibo\Controller\Applications','authorize'])->setName('application.authorize');
+
+//
+// module
+//
+$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/module/view', ['\Xibo\Controller\Module','displayPage'])->setName('module.view');
+ $group->get('/module/form/clear-cache/{id}', ['\Xibo\Controller\Module','clearCacheForm'])
+ ->setName('module.clear.cache.form');
+
+ $group->get('/module/form/settings/{id}', ['\Xibo\Controller\Module','settingsForm'])
+ ->setName('module.settings.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['module.view']));
+
+//
+// Developer
+//
+$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/developer/template/datatypes', ['\Xibo\Controller\Developer', 'getAvailableDataTypes'])
+ ->setName('developer.templates.datatypes.search');
+ $group->get('/developer/template/view', ['\Xibo\Controller\Developer', 'displayTemplatePage'])
+ ->setName('developer.templates.view');
+ $group->get('/developer/template', ['\Xibo\Controller\Developer', 'templateGrid'])
+ ->setName('developer.templates.search');
+
+ $group->get('/developer/template/{id}', ['\Xibo\Controller\Developer', 'displayTemplateEditPage'])
+ ->setName('developer.templates.view.edit');
+
+ $group->get('/developer/template/form/add', ['\Xibo\Controller\Developer', 'templateAddForm'])
+ ->setName('developer.templates.form.add');
+
+ $group->get('/developer/template/form/edit/{id}', ['\Xibo\Controller\Developer', 'templateEditForm'])
+ ->setName('developer.templates.form.edit');
+
+ $group->get('/developer/template/form/delete/{id}', ['\Xibo\Controller\Developer', 'templateDeleteForm'])
+ ->setName('developer.templates.form.delete');
+
+ $group->get('/developer/template/form/copy/{id}', ['\Xibo\Controller\Developer', 'templateCopyForm'])
+ ->setName('developer.templates.form.copy');
+
+ $group->post('/developer/template', ['\Xibo\Controller\Developer', 'templateAdd'])
+ ->setName('developer.templates.add');
+ $group->put('/developer/template/{id}', ['\Xibo\Controller\Developer', 'templateEdit'])
+ ->setName('developer.templates.edit');
+ $group->delete('/developer/template/{id}', ['\Xibo\Controller\Developer', 'templateDelete'])
+ ->setName('developer.templates.delete');
+ $group->get('/developer/template/{id}/export', ['\Xibo\Controller\Developer', 'templateExport'])
+ ->setName('developer.templates.export');
+ $group->post('/developer/template/import', ['\Xibo\Controller\Developer', 'templateImport'])
+ ->setName('developer.templates.import');
+ $group->post('/developer/template/{id}/copy', ['\Xibo\Controller\Developer', 'templateCopy'])
+ ->setName('developer.templates.copy');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['developer.edit']));
+
+//
+// transition
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/transition/view', ['\Xibo\Controller\Transition','displayPage'])->setName('transition.view');
+ $group->get('/transition/form/edit/{id}', ['\Xibo\Controller\Transition','editForm'])->setName('transition.edit.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['transition.view']));
+
+//
+// sessions
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/sessions/view', ['\Xibo\Controller\Sessions','displayPage'])->setName('sessions.view');
+ $group->get('/sessions/form/logout/{id}', ['\Xibo\Controller\Sessions','confirmLogoutForm'])->setName('sessions.confirm.logout.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['session.view']));
+
+//
+// fault
+//
+$app->get('/fault/view', ['\Xibo\Controller\Fault','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['fault.view']))
+ ->setName('fault.view');
+
+//
+// license
+//
+$app->get('/license/view', ['\Xibo\Controller\Login','about'])->setName('license.view');
+
+//
+// Reporting
+//
+$app->get('/report/view', ['\Xibo\Controller\Stats','displayReportPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['report.view']))
+ ->setName('report.view');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/stats/form/export', ['\Xibo\Controller\Stats','exportForm'])->setName('stats.export.form');
+ $group->get('/stats/getExportStatsCount', ['\Xibo\Controller\Stats','getExportStatsCount'])->setName('stats.getExportStatsCount');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['proof-of-play']));
+
+// Used in Display Manage
+$app->get('/stats/data/bandwidth', ['\Xibo\Controller\Stats','bandwidthData'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['displays.reporting']))
+ ->setName('stats.bandwidth.data');
+
+//
+// Audit Log
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/audit/view', ['\Xibo\Controller\AuditLog','displayPage'])->setName('auditlog.view');
+ $group->get('/audit/form/export', ['\Xibo\Controller\AuditLog','exportForm'])->setName('auditLog.export.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['auditlog.view']));
+
+//
+// Commands
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/command/view', ['\Xibo\Controller\Command','displayPage'])->setName('command.view');
+ $group->get('/command/form/add', ['\Xibo\Controller\Command','addForm'])->setName('command.add.form');
+ $group->get('/command/form/edit/{id}', ['\Xibo\Controller\Command','editForm'])->setName('command.edit.form');
+ $group->get('/command/form/delete/{id}', ['\Xibo\Controller\Command','deleteForm'])->setName('command.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['command.view']));
+
+//
+// Daypart
+//
+$app->get('/daypart/view', ['\Xibo\Controller\DayPart','displayPage'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['daypart.view']))
+ ->setName('daypart.view');
+
+$app->get('/daypart/form/add', ['\Xibo\Controller\DayPart','addForm'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['daypart.add']))
+ ->setName('daypart.add.form');
+
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/daypart/form/edit/{id}', ['\Xibo\Controller\DayPart','editForm'])->setName('daypart.edit.form');
+ $group->get('/daypart/form/delete/{id}', ['\Xibo\Controller\DayPart','deleteForm'])->setName('daypart.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['daypart.modify']));
+
+//
+// Tasks
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/task/view', ['\Xibo\Controller\Task','displayPage'])->setName('task.view');
+ $group->get('/task/form/add', ['\Xibo\Controller\Task','addForm'])->setName('task.add.form');
+ $group->get('/task/form/edit/{id}', ['\Xibo\Controller\Task','editForm'])->setName('task.edit.form');
+ $group->get('/task/form/delete/{id}', ['\Xibo\Controller\Task','deleteForm'])->setName('task.delete.form');
+ $group->get('/task/form/runNow/{id}', ['\Xibo\Controller\Task','runNowForm'])->setName('task.runNow.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['task.view']));
+
+
+//
+// Report Schedule
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/report/reportschedule/view', ['\Xibo\Controller\ScheduleReport','displayReportSchedulePage'])->setName('reportschedule.view');
+ $group->get('/report/reportschedule/form/add', ['\Xibo\Controller\ScheduleReport','addReportScheduleForm'])->setName('reportschedule.add.form');
+ $group->get('/report/reportschedule/form/edit/{id}', ['\Xibo\Controller\ScheduleReport','editReportScheduleForm'])->setName('reportschedule.edit.form');
+ $group->get('/report/reportschedule/form/delete/{id}', ['\Xibo\Controller\ScheduleReport','deleteReportScheduleForm'])->setName('reportschedule.delete.form');
+ $group->get('/report/reportschedule/form/deleteall/{id}', ['\Xibo\Controller\ScheduleReport','deleteAllSavedReportReportScheduleForm'])->setName('reportschedule.deleteall.form');
+ $group->get('/report/reportschedule/form/toggleactive/{id}', ['\Xibo\Controller\ScheduleReport','toggleActiveReportScheduleForm'])->setName('reportschedule.toggleactive.form');
+ $group->get('/report/reportschedule/form/reset/{id}', ['\Xibo\Controller\ScheduleReport','resetReportScheduleForm'])->setName('reportschedule.reset.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['report.scheduling']));
+
+//
+// Saved reports
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/report/savedreport/view', ['\Xibo\Controller\SavedReport','displaySavedReportPage'])->setName('savedreport.view');
+ $group->get('/report/savedreport/{id}/report/{name}/open', ['\Xibo\Controller\SavedReport','savedReportOpen'])->setName('savedreport.open');
+ $group->get('/report/savedreport/{id}/report/{name}/export', ['\Xibo\Controller\SavedReport','savedReportExport'])->setName('savedreport.export');
+ $group->get('/report/savedreport/form/delete/{id}', ['\Xibo\Controller\SavedReport','deleteSavedReportForm'])->setName('savedreport.delete.form');
+ $group->get('/report/savedreport/{id}/report/{name}/convert', ['\Xibo\Controller\OldReport','savedReportConvert'])->setName('savedreport.convert');
+ $group->get('/report/savedreport/form/convert/{id}', ['\Xibo\Controller\OldReport','convertSavedReportForm'])->setName('savedreport.convert.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['report.saving']));
+
+//
+// Ad hoc report
+//
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/report/form/{name}', ['\Xibo\Controller\Report','getReportForm'])->setName('report.form');
+ $group->get('/report/data/{name}', ['\Xibo\Controller\Report','getReportData'])->setName('report.data');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['report.view']));
+
+// Player Software
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/playersoftware/view', ['\Xibo\Controller\PlayerSoftware','displayPage'])->setName('playersoftware.view');
+ $group->get('/playersoftware/form/edit/{id}', ['\Xibo\Controller\PlayerSoftware','editForm'])->setName('playersoftware.edit.form');
+ $group->get('/playersoftware/form/delete/{id}', ['\Xibo\Controller\PlayerSoftware','deleteForm'])->setName('playersoftware.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['playersoftware.view']));
+
+// Tags
+$app->group('', function(\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/tag/view', ['\Xibo\Controller\Tag','displayPage'])->setName('tag.view');
+ $group->get('/tag/form/add', ['\Xibo\Controller\Tag','addForm'])->setName('tag.add.form');
+ $group->get('/tag/form/edit/{id}', ['\Xibo\Controller\Tag','editForm'])->setName('tag.edit.form');
+ $group->get('/tag/form/delete/{id}', ['\Xibo\Controller\Tag','deleteForm'])->setName('tag.delete.form');
+ $group->get('/tag/form/usage/{id}', ['\Xibo\Controller\Tag', 'usageForm'])->setName('tag.usage.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['tag.view']));
+
+// Menu Boards
+$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
+ $group->get('/menuboard/view', ['\Xibo\Controller\MenuBoard','displayPage'])->setName('menuBoard.view');
+ $group->get('/menuboard/form/add', ['\Xibo\Controller\MenuBoard', 'addForm'])->setName('menuBoard.add.form');
+ $group->get('/menuboard/form/{id}/edit', ['\Xibo\Controller\MenuBoard', 'editForm'])->setName('menuBoard.edit.form');
+ $group->get('/menuboard/form/{id}/delete', ['\Xibo\Controller\MenuBoard', 'deleteForm'])->setName('menuBoard.delete.form');
+ $group->get('/menuboard/form/{id}/selectfolder', ['\Xibo\Controller\MenuBoard', 'selectFolderForm'])->setName('menuBoard.selectfolder.form');
+
+ $group->get('/menuboard/{id}/categories/view', ['\Xibo\Controller\MenuBoardCategory', 'displayPage'])->setName('menuBoard.category.view');
+ $group->get('/menuboard/{id}/category/form/add', ['\Xibo\Controller\MenuBoardCategory', 'addForm'])->setName('menuBoard.category.add.form');
+ $group->get('/menuboard/{id}/category/form/edit', ['\Xibo\Controller\MenuBoardCategory', 'editForm'])->setName('menuBoard.category.edit.form');
+ $group->get('/menuboard/{id}/category/form/delete', ['\Xibo\Controller\MenuBoardCategory', 'deleteForm'])->setName('menuBoard.category.delete.form');
+
+ $group->get('/menuboard/{id}/products/view', ['\Xibo\Controller\MenuBoardProduct', 'displayPage'])->setName('menuBoard.product.view');
+ $group->get('/menuboard/{id}/product/form/add', ['\Xibo\Controller\MenuBoardProduct', 'addForm'])->setName('menuBoard.product.add.form');
+ $group->get('/menuboard/{id}/product/form/edit', ['\Xibo\Controller\MenuBoardProduct', 'editForm'])->setName('menuBoard.product.edit.form');
+ $group->get('/menuboard/{id}/product/form/delete', ['\Xibo\Controller\MenuBoardProduct', 'deleteForm'])->setName('menuBoard.product.delete.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['menuBoard.view']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/folders/form/{folderId}/move', ['\Xibo\Controller\Folder', 'moveForm'])->setName('folders.move.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['folder.modify']));
+
+$app->get('/fonts/fontcss', ['\Xibo\Controller\Font','fontCss'])->setName('library.font.css');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/fonts/view', ['\Xibo\Controller\Font', 'displayPage'])->setName('font.view');
+ $group->get('/fonts/{id}/form/delete', ['\Xibo\Controller\Font', 'deleteForm'])->setName('font.form.delete');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['font.view']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/syncgroup/view', ['\Xibo\Controller\SyncGroup', 'displayPage'])->setName('syncgroup.view');
+ $group->get('/syncgroup/form/add', ['\Xibo\Controller\SyncGroup', 'addForm'])->setName('syncgroup.form.add');
+ $group->get('/syncgroup/form/{id}/members', ['\Xibo\Controller\SyncGroup', 'membersForm'])->setName('syncgroup.form.members');
+ $group->get('/syncgroup/form/{id}/edit', ['\Xibo\Controller\SyncGroup', 'editForm'])->setName('syncgroup.form.edit');
+ $group->get('/syncgroup/form/{id}/delete', ['\Xibo\Controller\SyncGroup', 'deleteForm'])->setName('syncgroup.form.delete');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['display.syncView']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/schedule/form/sync', ['\Xibo\Controller\Schedule', 'syncForm'])->setName('schedule.add.sync.form');
+ $group->get('/schedule/form/{id}/sync', ['\Xibo\Controller\Schedule', 'syncEditForm'])->setName('schedule.edit.sync.form');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['schedule.sync']));
diff --git a/lib/routes.php b/lib/routes.php
new file mode 100644
index 0000000..3c0964a
--- /dev/null
+++ b/lib/routes.php
@@ -0,0 +1,877 @@
+.
+ */
+
+global $app;
+
+use Slim\Routing\RouteCollectorProxy;
+use Xibo\Middleware\FeatureAuth;
+use Xibo\Middleware\LayoutLock;
+use Xibo\Middleware\SuperAdminAuth;
+
+defined('XIBO') or die('Sorry, you are not allowed to directly access this page.');
+
+if (file_exists(PROJECT_ROOT . '/lib/routes-cypress.php')) {
+ include(PROJECT_ROOT . '/lib/routes-cypress.php');
+}
+
+/**
+ * @SWG\Swagger(
+ * basePath="/api",
+ * produces={"application/json"},
+ * schemes={"http"},
+ * security={
+ * {"auth": {"write:all", "read:all"}}
+ * },
+ * @SWG\ExternalDocumentation(
+ * description="Manual",
+ * url="https://xibosignage.com/manual"
+ * )
+ * )
+ *
+ * @SWG\Info(
+ * title="Xibo API",
+ * description="Xibo CMS API.
+ Using HTTP formData requests.
+ All PUT requests require Content-Type:application/x-www-form-urlencoded header.",
+ * version="4.0",
+ * termsOfService="https://xibosignage.com/legal",
+ * @SWG\License(
+ * name="AGPLv3 or later",
+ * url="http://www.gnu.org/licenses/"
+ * ),
+ * @SWG\Contact(
+ * email="info@xibo.org.uk"
+ * )
+ * )
+ *
+ * @SWG\SecurityScheme(
+ * securityDefinition="auth",
+ * type="oauth2",
+ * flow="accessCode",
+ * authorizationUrl="/api/authorize",
+ * tokenUrl="/api/authorize/access_token",
+ * scopes={
+ * "read:all": "read access",
+ * "write:all": "write access"
+ * }
+ * )
+ */
+
+/**
+ * Misc
+ * @SWG\Tag(
+ * name="misc",
+ * description="Miscellaneous"
+ * )
+ */
+$app->get('/about', ['\Xibo\Controller\Login', 'About'])->setName('about');
+$app->get('/clock', ['\Xibo\Controller\Clock', 'clock'])->setName('clock');
+$app->post('/tfa', ['\Xibo\Controller\Login' , 'twoFactorAuthValidate'])->setName('tfa.auth.validate');
+
+/**
+ * Schedule
+ * @SWG\Tag(
+ * name="schedule",
+ * description="Schedule"
+ * )
+ */
+$app->get('/schedule', ['\Xibo\Controller\Schedule','grid'])->setName('schedule.search');
+// ⚠️ Deprecated: This route will be removed in v5.0
+$app->get('/schedule/data/events', ['\Xibo\Controller\Schedule','eventData'])->setName('schedule.calendar.data');
+
+$app->get('/schedule/{id}/events', ['\Xibo\Controller\Schedule','eventList'])->setName('schedule.events');
+
+$app->post('/schedule', ['\Xibo\Controller\Schedule','add'])
+ ->add(new FeatureAuth($app->getContainer(), ['schedule.add']))
+ ->setName('schedule.add');
+
+$app->group('', function(RouteCollectorProxy $group) {
+ $group->put('/schedule/{id}', ['\Xibo\Controller\Schedule','edit'])
+ ->setName('schedule.edit');
+
+ $group->delete('/schedule/{id}', ['\Xibo\Controller\Schedule','delete'])
+ ->setName('schedule.delete');
+
+ $group->delete('/schedulerecurrence/{id}', ['\Xibo\Controller\Schedule','deleteRecurrence'])
+ ->setName('schedule.recurrence.delete');
+})->add(new FeatureAuth($app->getContainer(), ['schedule.modify']));
+
+/**
+ * Notification
+ * @SWG\Tag(
+ * name="notification",
+ * description="Notifications"
+ * )
+ */
+$app->get('/notification', ['\Xibo\Controller\Notification','grid'])->setName('notification.search');
+
+$app->post('/notification', ['\Xibo\Controller\Notification','add'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['notification.add']))
+ ->setName('notification.add');
+
+$app->group('', function(RouteCollectorProxy $group) {
+ //$app->map(['HEAD'], '/notification/attachment', ['\Xibo\Controller\Notification','addAttachment']);
+ $group->post('/notification/attachment', ['\Xibo\Controller\Notification', 'addAttachment'])
+ ->setName('notification.addattachment');
+
+ $group->put('/notification/{id}', ['\Xibo\Controller\Notification', 'edit'])->setName('notification.edit');
+ $group->delete('/notification/{id}', ['\Xibo\Controller\Notification', 'delete'])->setName('notification.delete');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['notification.modify']));
+
+/**
+ * Layouts
+ * @SWG\Tag(
+ * name="layout",
+ * description="Layouts"
+ * )
+ */
+$app->get('/layout', ['\Xibo\Controller\Layout','grid'])->setName('layout.search');
+$app->get('/layout/status/{id}', ['\Xibo\Controller\Layout','status'])->setName('layout.status');
+$app->put('/layout/lock/release/{id}', ['\Xibo\Controller\Layout', 'releaseLock'])->setName('layout.lock.release');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/layout', ['\Xibo\Controller\Layout', 'add'])->setName('layout.add');
+ $group->post('/layout/fullscreen', ['\Xibo\Controller\Layout', 'createFullScreenLayout'])->setName('layout.add.full.screen.schedule');
+ $group->post('/layout/copy/{id}', ['\Xibo\Controller\Layout','copy'])->setName('layout.copy');
+
+ // TODO: why commented out? Layout Import
+ //$group->map(['HEAD'],'/layout/import', ['\Xibo\Controller\Library','add');
+ $group->post('/layout/import', ['\Xibo\Controller\Layout','import'])->setName('layout.import');
+
+})->add(new FeatureAuth($app->getContainer(), ['layout.add']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/layout/{id}', ['\Xibo\Controller\Layout','edit'])->setName('layout.edit');
+ $group->delete('/layout/{id}', ['\Xibo\Controller\Layout','delete'])->setName('layout.delete');
+ $group->put('/layout/applyTemplate/{id}', ['\Xibo\Controller\Layout', 'applyTemplate'])
+ ->setName('layout.apply.template');
+ $group->put('/layout/background/{id}', ['\Xibo\Controller\Layout','editBackground'])->setName('layout.edit.background');
+ $group->put('/layout/publish/{id}', ['\Xibo\Controller\Layout','publish'])->setName('layout.publish');
+ $group->put('/layout/discard/{id}', ['\Xibo\Controller\Layout','discard'])->setName('layout.discard');
+ $group->put('/layout/clear/{id}', ['\Xibo\Controller\Layout','clear'])->setName('layout.clear');
+ $group->put('/layout/retire/{id}', ['\Xibo\Controller\Layout','retire'])->setName('layout.retire');
+ $group->put('/layout/unretire/{id}', ['\Xibo\Controller\Layout','unretire'])->setName('layout.unretire');
+ $group->post('/layout/thumbnail/{id}', ['\Xibo\Controller\Layout','addThumbnail'])->setName('layout.thumbnail.add');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify']))
+ ->addMiddleware(new LayoutLock($app));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/layout/checkout/{id}', ['\Xibo\Controller\Layout', 'checkout'])->setName('layout.checkout');
+ $group->put('/layout/setenablestat/{id}',['\Xibo\Controller\Layout', 'setEnableStat'])->setName('layout.setenablestat');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify']));
+
+// Tagging
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/layout/{id}/tag', ['\Xibo\Controller\Layout', 'tag'])->setName('layout.tag');
+ $group->post('/layout/{id}/untag', ['\Xibo\Controller\Layout', 'untag'])->setName('layout.untag');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['tag.tagging']));
+
+/**
+ * Region
+ */
+$app->group('/region', function (RouteCollectorProxy $group) {
+ $group->post('/{id}', ['\Xibo\Controller\Region','add'])->setName('region.add');
+ $group->put('/{id}', ['\Xibo\Controller\Region','edit'])->setName('region.edit');
+ $group->delete('/{id}', ['\Xibo\Controller\Region','delete'])->setName('region.delete');
+ $group->put('/position/all/{id}', ['\Xibo\Controller\Region','positionAll'])->setName('region.position.all');
+ $group->post('/drawer/{id}', ['\Xibo\Controller\Region','addDrawer'])->setName('region.add.drawer');
+ $group->put('/drawer/{id}', ['\Xibo\Controller\Region','saveDrawer'])->setName('region.save.drawer');
+})
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify']))
+ ->addMiddleware(new LayoutLock($app));
+
+/**
+ * playlist
+ * @SWG\Tag(
+ * name="playlist",
+ * description="Playlists"
+ * )
+ */
+$app->get('/playlist', ['\Xibo\Controller\Playlist','grid'])->setName('playlist.search');
+
+$app->post('/playlist', ['\Xibo\Controller\Playlist','add'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['playlist.add']))
+ ->setName('playlist.add');
+
+$app->group('', function (RouteCollectorProxy $group) use ($app) {
+ $group->put('/playlist/{id}', ['\Xibo\Controller\Playlist','edit'])->setName('playlist.edit');
+ $group->delete('/playlist/{id}', ['\Xibo\Controller\Playlist','delete'])->setName('playlist.delete');
+ $group->post('/playlist/copy/{id}', ['\Xibo\Controller\Playlist','copy'])->setName('playlist.copy');
+ $group->put(
+ '/playlist/setenablestat/{id}',
+ ['\Xibo\Controller\Playlist','setEnableStat']
+ )->setName('playlist.setenablestat');
+ $group->put(
+ '/playlist/{id}/selectfolder',
+ ['\Xibo\Controller\Playlist','selectFolder']
+ )->setName('playlist.selectfolder');
+ $group->post(
+ '/playlist/{id}/convert',
+ ['\Xibo\Controller\Playlist','convert']
+ )->setName('playlist.convert');
+
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['playlist.modify']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/playlist/order/{id}', ['\Xibo\Controller\Playlist','order'])->setName('playlist.order');
+ $group->post('/playlist/library/assign/{id}', ['\Xibo\Controller\Playlist','libraryAssign'])->setName('playlist.library.assign');
+})
+ ->addMiddleware(new LayoutLock($app))
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify', 'playlist.modify']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/playlist/usage/{id}', ['\Xibo\Controller\Playlist','usage'])->setName('playlist.usage');
+ $group->get('/playlist/usage/layouts/{id}', ['\Xibo\Controller\Playlist','usageLayouts'])->setName('playlist.usage.layouts');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['schedule.view', 'layout.view']));
+
+/**
+ * @SWG\Tag(
+ * name="widget",
+ * description="Widgets"
+ * )
+ */
+$app->get('/widget/{id}/edit/options', ['\Xibo\Controller\Widget', 'additionalWidgetEditOptions'])->setName('widget.edit.options');
+$app->group('/playlist/widget', function (RouteCollectorProxy $group) {
+ $group->post('/{type}/{id}', ['\Xibo\Controller\Widget','addWidget'])->setName('module.widget.add');
+ $group->put('/{id}', ['\Xibo\Controller\Widget','editWidget'])->setName('module.widget.edit');
+ $group->delete('/{id}', ['\Xibo\Controller\Widget','deleteWidget'])->setName('module.widget.delete');
+ $group->put('/transition/{type}/{id}', ['\Xibo\Controller\Widget','editWidgetTransition'])
+ ->setName('module.widget.transition.edit');
+ $group->put('/{id}/audio', ['\Xibo\Controller\Widget','widgetAudio'])->setName('module.widget.audio');
+ $group->delete('/{id}/audio', ['\Xibo\Controller\Widget','widgetAudioDelete']);
+ $group->put('/{id}/expiry', ['\Xibo\Controller\Widget','widgetExpiry'])->setName('module.widget.expiry');
+ $group->put('/{id}/elements', ['\Xibo\Controller\Widget','saveElements'])->setName('module.widget.elements');
+ $group->get('/{id}/dataType', ['\Xibo\Controller\Widget','getDataType'])->setName('module.widget.dataType');
+
+ // Drawer widgets Region
+ $group->put('/{id}/target', ['\Xibo\Controller\Widget','widgetSetRegion'])->setName('module.widget.set.region');
+
+ // Widget Fallback Data APIs
+ $group->get('/fallback/data/{id}', ['\Xibo\Controller\WidgetData','get'])
+ ->setName('module.widget.data.get');
+ $group->post('/fallback/data/{id}', ['\Xibo\Controller\WidgetData','add'])
+ ->setName('module.widget.data.add');
+ $group->put('/fallback/data/{id}/{dataId}', ['\Xibo\Controller\WidgetData','edit'])
+ ->setName('module.widget.data.edit');
+ $group->delete('/fallback/data/{id}/{dataId}', ['\Xibo\Controller\WidgetData','delete'])
+ ->setName('module.widget.data.delete');
+ $group->post('/fallback/data/{id}/order', ['\Xibo\Controller\WidgetData','setOrder'])
+ ->setName('module.widget.data.set.order');
+})
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify', 'playlist.modify']))
+ ->addMiddleware(new LayoutLock($app));
+
+/**
+ * Campaign
+ * @SWG\Tag(
+ * name="campaign",
+ * description="Campaigns"
+ * )
+ */
+$app->get('/campaign', ['\Xibo\Controller\Campaign','grid'])->setName('campaign.search');
+$app->post('/campaign', ['\Xibo\Controller\Campaign','add'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.add']))
+ ->setName('campaign.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/campaign/{id}', ['\Xibo\Controller\Campaign','edit'])->setName('campaign.edit');
+ $group->delete('/campaign/{id}', ['\Xibo\Controller\Campaign','delete'])->setName('campaign.delete');
+ $group->post('/campaign/{id}/copy', ['\Xibo\Controller\Campaign','copy'])->setName('campaign.copy');
+ $group->put('/campaign/{id}/selectfolder', ['\Xibo\Controller\Campaign','selectFolder'])->setName('campaign.selectfolder');
+ $group->post('/campaign/layout/assign/{id}', ['\Xibo\Controller\Campaign','assignLayout'])
+ ->setName('campaign.assign.layout');
+ $group->delete('/campaign/layout/remove/{id}', ['\Xibo\Controller\Campaign','removeLayout'])
+ ->setName('campaign.remove.layout');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['campaign.modify']));
+
+/**
+ * Templates
+ * @SWG\Tag(
+ * name="template",
+ * description="Templates"
+ * )
+ */
+$app->get('/template', ['\Xibo\Controller\Template', 'grid'])->setName('template.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/template', ['\Xibo\Controller\Template', 'add'])->setName('template.add');
+ $group->post('/template/{id}', ['\Xibo\Controller\Template', 'addFromLayout'])->setName('template.add.from.layout');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['template.add']));
+
+/**
+ * Resolutions
+ * @SWG\Tag(
+ * name="resolution",
+ * description="Resolutions"
+ * )
+ */
+$app->get('/resolution', ['\Xibo\Controller\Resolution','grid'])->setName('resolution.search');
+$app->post('/resolution', ['\Xibo\Controller\Resolution','add'])
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['resolution.add']))
+ ->setName('resolution.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/resolution/{id}', ['\Xibo\Controller\Resolution','edit'])->setName('resolution.edit');
+ $group->delete('/resolution/{id}', ['\Xibo\Controller\Resolution','delete'])->setName('resolution.delete');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['resolution.modify']));
+
+/**
+ * Library
+ * @SWG\Tag(
+ * name="library",
+ * description="Library"
+ * )
+ */
+$app->get('/library', ['\Xibo\Controller\Library','grid'])->setName('library.search');
+$app->get('/library/{id}/isused', ['\Xibo\Controller\Library','isUsed'])->setName('library.isused');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/library/usage/{id}', ['\Xibo\Controller\Library','usage'])->setName('library.usage');
+ $group->get('/library/usage/layouts/{id}', ['\Xibo\Controller\Library','usageLayouts'])->setName('library.usage.layouts');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['library.view']));
+
+$app->get('/library/download/{id}', ['\Xibo\Controller\Library', 'download'])->setName('library.download');
+$app->get('/library/thumbnail/{id}', ['\Xibo\Controller\Library', 'thumbnail'])->setName('library.thumbnail');
+$app->get('/public/thumbnail/{id}', ['\Xibo\Controller\Library', 'thumbnailPublic'])
+ ->setName('library.public.thumbnail');
+
+$app->post('/library', ['\Xibo\Controller\Library','add'])->setName('library.add')
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['library.add', 'dashboard.playlist']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ //$group->map(['HEAD'],'/library', ['\Xibo\Controller\Library',' addgroup
+ $group->post('/library/uploadUrl', ['\Xibo\Controller\Library','uploadFromUrl'])->setName('library.uploadFromUrl');
+ $group->post('/library/thumbnail', ['\Xibo\Controller\Library','addThumbnail'])->setName('library.thumbnail.add');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['library.add']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/library/{id}', ['\Xibo\Controller\Library','edit'])->setName('library.edit');
+ $group->put('/library/setenablestat/{id}', ['\Xibo\Controller\Library','setEnableStat'])->setName('library.setenablestat');
+ $group->delete('/library/tidy', ['\Xibo\Controller\Library','tidy'])->setName('library.tidy');
+ $group->delete('/library/{id}', ['\Xibo\Controller\Library','delete'])->setName('library.delete');
+ $group->post('/library/copy/{id}', ['\Xibo\Controller\Library','copy'])->setName('library.copy');
+ $group->put('/library/{id}/selectfolder', ['\Xibo\Controller\Library','selectFolder'])->setName('library.selectfolder');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['library.modify']));
+
+// Tagging
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/library/{id}/tag', ['\Xibo\Controller\Library','tag'])->setName('library.tag');
+ $group->post('/library/{id}/untag', ['\Xibo\Controller\Library','untag'])->setName('library.untag');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['tag.tagging']));
+
+/**
+ * Displays
+ * @SWG\Tag(
+ * name="display",
+ * description="Displays"
+ * )
+ */
+$app->get('/display', ['\Xibo\Controller\Display', 'grid'])->setName('display.search');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/display/requestscreenshot/{id}', ['\Xibo\Controller\Display','requestScreenShot'])->setName('display.requestscreenshot');
+ $group->put('/display/licenceCheck/{id}', ['\Xibo\Controller\Display','checkLicence'])->setName('display.licencecheck');
+ $group->put('/display/purgeAll/{id}', ['\Xibo\Controller\Display','purgeAll'])->setName('display.purge.all');
+ $group->get('/display/screenshot/{id}', ['\Xibo\Controller\Display','screenShot'])->setName('display.screenShot');
+ $group->get('/display/status/{id}', ['\Xibo\Controller\Display','statusWindow'])->setName('display.statusWindow');
+ $group->get('/display/faults[/{displayId}]', ['\Xibo\Controller\PlayerFault','grid'])->setName('display.faults.search');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displays.view']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/display/authorise/{id}', ['\Xibo\Controller\Display','toggleAuthorise'])->setName('display.authorise');
+ $group->post('/display/addViaCode', ['\Xibo\Controller\Display','addViaCode'])->setName('display.addViaCode');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displays.add']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/display/{id}', ['\Xibo\Controller\Display','edit'])->setName('display.edit');
+ $group->delete('/display/{id}', ['\Xibo\Controller\Display','delete'])->setName('display.delete');
+ $group->post('/display/wol/{id}', ['\Xibo\Controller\Display','wakeOnLan'])->setName('display.wol');
+ $group->put('/display/setBandwidthLimit/multi', ['\Xibo\Controller\Display','setBandwidthLimitMultiple'])->setName('display.setBandwidthLimitMultiple');
+ $group->put('/display/defaultlayout/{id}', ['\Xibo\Controller\Display','setDefaultLayout'])->setName('display.defaultlayout');
+ $group->post('/display/{id}/displaygroup/assign', ['\Xibo\Controller\Display','assignDisplayGroup'])->setName('display.assign.displayGroup');
+ $group->put('/display/{id}/moveCms', ['\Xibo\Controller\Display','moveCms'])->setName('display.moveCms');
+ $group->delete('/display/{id}/moveCms', ['\Xibo\Controller\Display','moveCmsCancel'])->setName('display.moveCmsCancel');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displays.modify']));
+
+/**
+ * Display Groups
+ * @SWG\Tag(
+ * name="displayGroup",
+ * description="Display Groups"
+ * )
+ */
+$app->get('/displayvenue', ['\Xibo\Controller\Display','displayVenue'])->setName('display.venue.search');
+$app->get('/displaygroup', ['\Xibo\Controller\DisplayGroup','grid'])->setName('displayGroup.search');
+
+$app->post('/displaygroup', ['\Xibo\Controller\DisplayGroup','add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displaygroup.add']))
+ ->setName('displayGroup.add');
+$app->post('/displaygroup/criteria/{displayGroupId}', ['\Xibo\Controller\DisplayGroup','pushCriteriaUpdate'])->setName('displayGroup.criteria.push');
+
+$app->post('/displaygroup/{id}/action/collectNow', ['\Xibo\Controller\DisplayGroup','collectNow'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displaygroup.view']))
+ ->setName('displayGroup.action.collectNow');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/displaygroup/{id}', ['\Xibo\Controller\DisplayGroup','edit'])->setName('displayGroup.edit');
+ $group->delete('/displaygroup/{id}', ['\Xibo\Controller\DisplayGroup','delete'])->setName('displayGroup.delete');
+
+ $group->post('/displaygroup/{id}/display/assign', ['\Xibo\Controller\DisplayGroup','assignDisplay'])->setName('displayGroup.assign.display');
+ $group->post('/displaygroup/{id}/display/unassign', ['\Xibo\Controller\DisplayGroup','unassignDisplay'])->setName('displayGroup.unassign.display');
+ $group->post('/displaygroup/{id}/displayGroup/assign', ['\Xibo\Controller\DisplayGroup','assignDisplayGroup'])->setName('displayGroup.assign.displayGroup');
+ $group->post('/displaygroup/{id}/displayGroup/unassign', ['\Xibo\Controller\DisplayGroup','unassignDisplayGroup'])->setName('displayGroup.unassign.displayGroup');
+ $group->post('/displaygroup/{id}/media/assign', ['\Xibo\Controller\DisplayGroup','assignMedia'])->setName('displayGroup.assign.media');
+ $group->post('/displaygroup/{id}/media/unassign', ['\Xibo\Controller\DisplayGroup','unassignMedia'])->setName('displayGroup.unassign.media');
+ $group->post('/displaygroup/{id}/layout/assign', ['\Xibo\Controller\DisplayGroup','assignLayouts'])->setName('displayGroup.assign.layout');
+ $group->post('/displaygroup/{id}/layout/unassign', ['\Xibo\Controller\DisplayGroup','unassignLayouts'])->setName('displayGroup.unassign.layout');
+ $group->post('/displaygroup/{id}/action/changeLayout', ['\Xibo\Controller\DisplayGroup','changeLayout'])->setName('displayGroup.action.changeLayout');
+ $group->post('/displaygroup/{id}/action/overlayLayout', ['\Xibo\Controller\DisplayGroup','overlayLayout'])->setName('displayGroup.action.overlayLayout');
+ $group->post('/displaygroup/{id}/action/revertToSchedule', ['\Xibo\Controller\DisplayGroup','revertToSchedule'])->setName('displayGroup.action.revertToSchedule');
+ $group->post('/displaygroup/{id}/copy', ['\Xibo\Controller\DisplayGroup','copy'])->setName('displayGroup.copy');
+ $group->post('/displaygroup/{id}/action/clearStatsAndLogs', ['\Xibo\Controller\DisplayGroup','clearStatsAndLogs'])->setName('displayGroup.action.clearStatsAndLogs');
+ $group->post('/displaygroup/{id}/action/triggerWebhook', ['\Xibo\Controller\DisplayGroup','triggerWebhook'])->setName('displayGroup.action.trigger.webhook');
+ $group->put('/displaygroup/{id}/selectfolder', ['\Xibo\Controller\DisplayGroup','selectFolder'])->setName('displayGroup.selectfolder');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displaygroup.modify']));
+
+$app->post('/displaygroup/{id}/action/command', ['\Xibo\Controller\DisplayGroup','command'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displaygroup.modify']))
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['command.view']))
+ ->setName('displayGroup.action.command');
+/**
+ * Display Profile
+ * @SWG\Tag(
+ * name="displayprofile",
+ * description="Display Settings"
+ * )
+ */
+$app->get('/displayprofile', ['\Xibo\Controller\DisplayProfile','grid'])->setName('displayProfile.search');
+
+$app->post('/displayprofile', ['\Xibo\Controller\DisplayProfile','add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displayprofile.add']))
+ ->setName('displayProfile.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/displayprofile/{id}', ['\Xibo\Controller\DisplayProfile','edit'])->setName('displayProfile.edit');
+ $group->delete('/displayprofile/{id}', ['\Xibo\Controller\DisplayProfile','delete'])->setName('displayProfile.delete');
+ $group->post('/displayprofile/{id}/copy', ['\Xibo\Controller\DisplayProfile','copy'])->setName('displayProfile.copy');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['displayprofile.modify']));
+
+/**
+ * DataSet
+ * @SWG\Tag(
+ * name="dataset",
+ * description="DataSets"
+ * )
+ */
+$app->get('/dataset', ['\Xibo\Controller\DataSet','grid'])->setName('dataSet.search');
+$app->post('/dataset', ['\Xibo\Controller\DataSet','add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['dataset.add']))
+ ->setName('dataSet.add');
+$app->get('/rss/{psk}', ['\Xibo\Controller\DataSetRss','feed'])->setName('dataSet.rss.feed');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/dataset/{id}', ['\Xibo\Controller\DataSet','edit'])->setName('dataSet.edit');
+ $group->delete('/dataset/{id}', ['\Xibo\Controller\DataSet','delete'])->setName('dataSet.delete');
+ $group->put('/dataset/{id}/selectfolder', ['\Xibo\Controller\DataSet', 'selectFolder'])->setName('dataSet.selectfolder');
+
+ $group->post('/dataset/copy/{id}', ['\Xibo\Controller\DataSet','copy'])->setName('dataSet.copy');
+ //$group->map(['HEAD'],'/dataset/import/{id}', ['\Xibo\Controller\DataSet','import');
+ $group->post('/dataset/import/{id}', ['\Xibo\Controller\DataSet','import'])->setName('dataSet.import');
+ $group->post('/dataset/importjson/{id}', ['\Xibo\Controller\DataSet','importJson'])->setName('dataSet.import.json');
+ $group->post('/dataset/remote/test', ['\Xibo\Controller\DataSet','testRemoteRequest'])->setName('dataSet.test.remote');
+ $group->put('/dataset/dataConnector/{id}', ['\Xibo\Controller\DataSet','updateDataConnector'])->setName('dataSet.dataConnector.update');
+ $group->get('/dataset/export/csv/{id}', ['\Xibo\Controller\DataSet', 'exportToCsv'])->setName('dataSet.export.csv');
+
+ // Columns
+ $group->get('/dataset/{id}/column', ['\Xibo\Controller\DataSetColumn','grid'])->setName('dataSet.column.search');
+ $group->post('/dataset/{id}/column', ['\Xibo\Controller\DataSetColumn','add'])->setName('dataSet.column.add');
+ $group->put('/dataset/{id}/column/{colId}', ['\Xibo\Controller\DataSetColumn','edit'])->setName('dataSet.column.edit');
+ $group->delete('/dataset/{id}/column/{colId}', ['\Xibo\Controller\DataSetColumn','delete'])->setName('dataSet.column.delete');
+
+ // RSS
+ $group->get('/dataset/{id}/rss', ['\Xibo\Controller\DataSetRss','grid'])->setName('dataSet.rss.search');
+ $group->post('/dataset/{id}/rss', ['\Xibo\Controller\DataSetRss','add'])->setName('dataSet.rss.add');
+ $group->put('/dataset/{id}/rss/{rssId}', ['\Xibo\Controller\DataSetRss','edit'])
+ ->setName('dataSet.rss.edit');
+ $group->delete('/dataset/{id}/rss/{rssId}', ['\Xibo\Controller\DataSetRss','delete'])
+ ->setName('dataSet.rss.delete');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['dataset.modify']));
+
+// Data
+$app->get('/dataset/data/{id}', ['\Xibo\Controller\DataSetData','grid'])->setName('dataSet.data.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/dataset/data/{id}', ['\Xibo\Controller\DataSetData','add'])->setName('dataSet.data.add');
+ $group->put('/dataset/data/{id}/{rowId}', ['\Xibo\Controller\DataSetData','edit'])->setName('dataSet.data.edit');
+ $group->delete('/dataset/data/{id}/{rowId}', ['\Xibo\Controller\DataSetData','delete'])->setName('dataSet.data.delete');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['dataset.data']));
+
+/**
+ * Folders
+ * @SWG\Tag(
+ * name="folder",
+ * description="Folders"
+ * )
+ */
+$app->get('/folders[/{folderId}]', ['\Xibo\Controller\Folder', 'grid'])->setName('folders.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/folders/contextButtons/{folderId}', ['\Xibo\Controller\Folder', 'getContextMenuButtons'])->setName('folders.context.buttons');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['folder.view']));
+
+$app->post('/folders', ['\Xibo\Controller\Folder', 'add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['folder.add']))
+ ->setName('folders.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/folders/{folderId}', ['\Xibo\Controller\Folder', 'edit'])->setName('folders.edit');
+ $group->delete('/folders/{folderId}', ['\Xibo\Controller\Folder', 'delete'])->setName('folders.delete');
+ $group->put('/folders/{folderId}/move', ['\Xibo\Controller\Folder', 'move'])->setName('folders.move');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['folder.modify']));
+
+/**
+ * Statistics
+ * @SWG\Tag(
+ * name="statistics",
+ * description="Statistics"
+ * )
+ */
+$app->get('/stats', ['\Xibo\Controller\Stats','grid'])->setName('stats.search');
+
+$app->get('/stats/timeDisconnected', ['\Xibo\Controller\Stats', 'gridTimeDisconnected'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['display.reporting']))
+ ->setName('stats.timeDisconnected.search');
+
+$app->get('/stats/export', ['\Xibo\Controller\Stats','export'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['proof-of-play']))
+ ->setName('stats.export');
+
+// Log (no APIs)
+// -------------
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/log', ['\Xibo\Controller\Logging', 'grid'])->setName('log.search');
+ $group->delete('/log', ['\Xibo\Controller\Logging', 'truncate'])->setName('log.truncate');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['log.view']));
+
+/**
+ * User
+ * @SWG\Tag(
+ * name="user",
+ * description="Users"
+ * )
+ */
+$app->get('/user/pref', ['\Xibo\Controller\User' , 'pref'])->setName('user.pref');
+$app->post('/user/pref', ['\Xibo\Controller\User' ,'prefEdit']);
+$app->put('/user/pref', ['\Xibo\Controller\User' ,'prefEditFromForm']);
+$app->get('/user/me', ['\Xibo\Controller\User','myDetails'])->setName('user.me');
+$app->get('/user', ['\Xibo\Controller\User','grid'])->setName('user.search');
+$app->put('/user/profile/edit', ['\Xibo\Controller\User','editProfile'])->setName('user.edit.profile');
+$app->get('/user/profile/setup', ['\Xibo\Controller\User','tfaSetup'])->setName('user.setup.profile');
+$app->post('/user/profile/validate', ['\Xibo\Controller\User','tfaValidate'])->setName('user.validate.profile');
+$app->get('/user/profile/recoveryGenerate', ['\Xibo\Controller\User','tfaRecoveryGenerate'])->setName('user.recovery.generate.profile');
+$app->get('/user/profile/recoveryShow', ['\Xibo\Controller\User','tfaRecoveryShow'])->setName('user.recovery.show.profile');
+$app->put('/user/password/forceChange', ['\Xibo\Controller\User','forceChangePassword'])->setName('user.force.change.password');
+
+// permissions
+$app->get('/user/permissions/{entity}/{id}', ['\Xibo\Controller\User','permissionsGrid'])->setName('user.permissions');
+$app->get('/user/permissions/{entity}', ['\Xibo\Controller\User','permissionsMultiGrid'])->setName('user.permissions.multi');
+$app->post('/user/permissions/{entity}/{id}', ['\Xibo\Controller\User','permissions'])->setName('user.set.permissions');
+$app->post('/user/permissions/{entity}', ['\Xibo\Controller\User','permissionsMulti'])->setName('user.set.permissions.multi');
+
+$app->post('/user', ['\Xibo\Controller\User','add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['users.add']))
+ ->setName('user.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/user/{id}', ['\Xibo\Controller\User','edit'])->setName('user.edit');
+ $group->delete('/user/{id}', ['\Xibo\Controller\User','delete'])->setName('user.delete');
+ $group->post('/user/{id}/usergroup/assign', ['\Xibo\Controller\User','assignUserGroup'])->setName('user.assign.userGroup');
+ $group->post('/user/{id}/setHomeFolder', ['\Xibo\Controller\User', 'setHomeFolder'])
+ ->addMiddleware(new FeatureAuth($group->getContainer(), ['folder.userHome']))
+ ->setName('user.homeFolder');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['users.modify']));
+
+/**
+ * User Group
+ * @SWG\Tag(
+ * name="usergroup",
+ * description="User Groups"
+ * )
+ */
+$app->get('/group', ['\Xibo\Controller\UserGroup','grid'])->setName('group.search');
+
+$app->post('/group', ['\Xibo\Controller\UserGroup','add'])->setName('group.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/group/{id}', ['\Xibo\Controller\UserGroup','edit'])->setName('group.edit');
+ $group->delete('/group/{id}', ['\Xibo\Controller\UserGroup','delete'])->setName('group.delete');
+ $group->post('/group/{id}/copy', ['\Xibo\Controller\UserGroup','copy'])->setName('group.copy');
+
+ $group->post('/group/members/assign/{id}', ['\Xibo\Controller\UserGroup','assignUser'])->setName('group.members.assign');
+ $group->post('/group/members/unassign/{id}', ['\Xibo\Controller\UserGroup','unassignUser'])->setName('group.members.unassign');
+
+ $group->post('/group/acl/{id}', ['\Xibo\Controller\UserGroup','acl'])->setName('group.acl');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['usergroup.modify']));
+
+//
+// Applications
+//
+$app->get('/application', ['\Xibo\Controller\Applications','grid'])->setName('application.search');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/application', ['\Xibo\Controller\Applications','add'])->setName('application.add');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['application.add']));
+$app->delete('/application/revoke/{id}/{userId}', ['\Xibo\Controller\Applications', 'revokeAccess'])
+ ->setName('application.revoke');
+
+
+/**
+ * Modules
+ * @SWG\Tag(
+ * name="module",
+ * description="Modules and Widgets"
+ * )
+ */
+$app->get('/module', ['\Xibo\Controller\Module','grid'])->setName('module.search');
+$app->get('/module/templates/{dataType}', [
+ '\Xibo\Controller\Module', 'templateGrid'
+])->setName('module.template.search');
+
+$app->get('/module/asset/{assetId}', [
+ '\Xibo\Controller\Module',
+ 'assetDownload',
+])->setName('module.asset.download');
+
+// Properties
+$app->get('/module/properties/{id}', ['\Xibo\Controller\Module','getProperties'])
+ ->setName('module.get.properties');
+$app->get('/module/template/{dataType}/properties/{id}', ['\Xibo\Controller\Module','getTemplateProperties'])
+ ->setName('module.template.get.properties');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/module/settings/{id}', ['\Xibo\Controller\Module','settings'])->setName('module.settings');
+ $group->put('/module/clear-cache/{id}', ['\Xibo\Controller\Module','clearCache'])->setName('module.clear.cache');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['module.view']));
+
+//
+// Transition
+//
+$app->get('/transition', ['\Xibo\Controller\Transition','grid'])->setName('transition.search');
+$app->put('/transition/{id}', ['\Xibo\Controller\Transition','edit'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['transition.view']))
+ ->setName('transition.edit');
+
+//
+// Sessions
+//
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/sessions', ['\Xibo\Controller\Sessions','grid'])->setName('sessions.search');
+ $group->delete('/sessions/logout/{id}', ['\Xibo\Controller\Sessions','logout'])->setName('sessions.confirm.logout');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['session.view']));
+
+//
+// Settings
+//
+$app->put('/admin', ['\Xibo\Controller\Settings','update'])
+ ->addMiddleware(new SuperAdminAuth($app->getContainer()))
+ ->setName('settings.update');
+
+//
+// Maintenance
+//
+$app->post('/maintenance/tidy', ['\Xibo\Controller\Maintenance','tidyLibrary'])
+ ->addMiddleware(new SuperAdminAuth($app->getContainer()))
+ ->setName('maintenance.tidy');
+
+//
+// Audit Log
+//
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/audit', ['\Xibo\Controller\AuditLog','grid'])->setName('auditLog.search');
+ $group->get('/audit/export', ['\Xibo\Controller\AuditLog','export'])->setName('auditLog.export');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['auditlog.view']));
+
+//
+// Fault
+//
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/fault/debug/on', ['\Xibo\Controller\Fault','debugOn'])->setName('fault.debug.on');
+ $group->put('/fault/debug/off', ['\Xibo\Controller\Fault','debugOff'])->setName('fault.debug.off');
+ $group->get('/fault/collect', ['\Xibo\Controller\Fault','collect'])->setName('fault.collect');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['fault.view']));
+
+/**
+ * Commands
+ * @SWG\Tag(
+ * name="command",
+ * description="Commands"
+ * )
+ */
+$app->get('/command', ['\Xibo\Controller\Command','grid'])->setName('command.search');
+$app->post('/command', ['\Xibo\Controller\Command','add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['command.add']))
+ ->setName('command.add');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/command/{id}', ['\Xibo\Controller\Command','edit'])->setName('command.edit');
+ $group->delete('/command/{id}', ['\Xibo\Controller\Command','delete'])->setName('command.delete');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['command.modify']));
+
+/**
+ * Dayparts
+ * @SWG\Tag(
+ * name="dayPart",
+ * description="Dayparting"
+ * )
+ */
+$app->get('/daypart', ['\Xibo\Controller\DayPart','grid'])->setName('daypart.search');
+$app->post('/daypart', ['\Xibo\Controller\DayPart','add'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['daypart.add']))
+ ->setName('daypart.add');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/daypart/{id}', ['\Xibo\Controller\DayPart','edit'])->setName('daypart.edit');
+ $group->delete('/daypart/{id}', ['\Xibo\Controller\DayPart','delete'])->setName('daypart.delete');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['daypart.modify']));
+
+// Tasks (no APIs)
+// ----
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/task', ['\Xibo\Controller\Task', 'grid'])->setName('task.search');
+ $group->post('/task', ['\Xibo\Controller\Task', 'add'])->setName('task.add');
+ $group->put('/task/{id}', ['\Xibo\Controller\Task', 'edit'])->setName('task.edit');
+ $group->delete('/task/{id}', ['\Xibo\Controller\Task', 'delete'])->setName('task.delete');
+ $group->post('/task/{id}/run', ['\Xibo\Controller\Task', 'runNow'])->setName('task.runNow');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['task.view']));
+
+// Report schedule (no APIs)
+// -------------------------
+$app->get('/report/reportschedule', ['\Xibo\Controller\ScheduleReport','reportScheduleGrid'])->setName('reportschedule.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/report/reportschedule', ['\Xibo\Controller\ScheduleReport','reportScheduleAdd'])->setName('reportschedule.add');
+ $group->put('/report/reportschedule/{id}', ['\Xibo\Controller\ScheduleReport','reportScheduleEdit'])->setName('reportschedule.edit');
+ $group->delete('/report/reportschedule/{id}', ['\Xibo\Controller\ScheduleReport','reportScheduleDelete'])->setName('reportschedule.delete');
+ $group->post('/report/reportschedule/{id}/deletesavedreport', ['\Xibo\Controller\ScheduleReport','reportScheduleDeleteAllSavedReport'])->setName('reportschedule.deleteall');
+ $group->post('/report/reportschedule/{id}/toggleactive', ['\Xibo\Controller\ScheduleReport','reportScheduleToggleActive'])->setName('reportschedule.toggleactive');
+ $group->post('/report/reportschedule/{id}/reset', ['\Xibo\Controller\ScheduleReport','reportScheduleReset'])->setName('reportschedule.reset');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['report.scheduling']));
+
+//
+// Saved reports
+//
+$app->get('/report/savedreport', ['\Xibo\Controller\SavedReport','savedReportGrid'])
+ ->setName('savedreport.search');
+$app->delete('/report/savedreport/{id}', ['\Xibo\Controller\SavedReport','savedReportDelete'])
+ ->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['report.saving']))
+ ->setName('savedreport.delete');
+
+/**
+ * Player Versions
+ * @SWG\Tag(
+ * name="Player Software",
+ * )
+ */
+$app->get('/playersoftware', ['\Xibo\Controller\PlayerSoftware','grid'])->setName('playersoftware.search');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/playersoftware/download/{id}', ['\Xibo\Controller\PlayerSoftware', 'download'])->setName('playersoftware.download');
+ $group->post('/playersoftware', ['\Xibo\Controller\PlayerSoftware','add'])->setName('playersoftware.add');
+ $group->put('/playersoftware/{id}', ['\Xibo\Controller\PlayerSoftware','edit'])->setName('playersoftware.edit');
+ $group->delete('/playersoftware/{id}', ['\Xibo\Controller\PlayerSoftware','delete'])->setName('playersoftware.delete');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['playersoftware.view']));
+
+// Install
+$app->get('/sssp_config.xml', ['\Xibo\Controller\PlayerSoftware','getSsspInstall'])->setName('playersoftware.sssp.install');
+$app->get('/sssp_dl.wgt', ['\Xibo\Controller\PlayerSoftware','getSsspInstallDownload'])->setName('playersoftware.sssp.install.download');
+$app->get('/playersoftware/{nonce}/sssp_config.xml', ['\Xibo\Controller\PlayerSoftware','getSssp'])->setName('playersoftware.sssp');
+$app->get('/playersoftware/{nonce}/sssp_dl.wgt', ['\Xibo\Controller\PlayerSoftware','getVersionFile'])->setName('playersoftware.version.file');
+
+/**
+ * Tags
+ * @SWG\Tag(
+ * name="tags",
+ * description="Tags"
+ * )
+ */
+$app->get('/tag', ['\Xibo\Controller\Tag','grid'])->setName('tag.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/tag', ['\Xibo\Controller\Tag','add'])->setName('tag.add');
+ $group->put('/tag/{id}', ['\Xibo\Controller\Tag','edit'])->setName('tag.edit');
+ $group->delete('/tag/{id}', ['\Xibo\Controller\Tag','delete'])->setName('tag.delete');
+ $group->get('/tag/name', ['\Xibo\Controller\Tag','loadTagOptions'])->setName('tag.getByName');
+ $group->put('/tag/{type}/multi', ['\Xibo\Controller\Tag','editMultiple'])->setName('tag.editMultiple');
+ $group->get('/tag/usage/{id}', ['\Xibo\Controller\Tag', 'usage'])->setName('tag.usage');
+})->addMiddleware(new \Xibo\Middleware\FeatureAuth($app->getContainer(), ['tag.view']));
+
+// Actions (no APIs)
+// -----------------
+$app->get('/action', ['\Xibo\Controller\Action', 'grid'])->setName('action.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/action', ['\Xibo\Controller\Action', 'add'])->setName('action.add');
+ $group->put('/action/{id}', ['\Xibo\Controller\Action', 'edit'])->setName('action.edit');
+ $group->delete('/action/{id}', ['\Xibo\Controller\Action', 'delete'])->setName('action.delete');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['layout.modify', 'playlist.modify']));
+
+/**
+ * Menu Boards
+ * @SWG\Tag(
+ * name="menuBoard",
+ * description="Menu Boards - feature preview, please do not use in production."
+ * )
+ */
+$app->get('/menuboards', ['\Xibo\Controller\MenuBoard', 'grid'])->setName('menuBoard.search');
+$app->post('/menuboard', ['\Xibo\Controller\MenuBoard', 'add'])->addMiddleware(new FeatureAuth($app->getContainer(), ['menuBoard.add']))->setName('menuBoard.add');
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->put('/menuboard/{id}', ['\Xibo\Controller\MenuBoard', 'edit'])->setName('menuBoard.edit');
+ $group->delete('/menuboard/{id}', ['\Xibo\Controller\MenuBoard', 'delete'])->setName('menuBoard.delete');
+ $group->put('/menuboard/{id}/selectfolder', ['\Xibo\Controller\MenuBoard', 'selectFolder'])->setName('menuBoard.selectfolder');
+
+ $group->get('/menuboard/{id}/categories', ['\Xibo\Controller\MenuBoardCategory', 'grid'])->setName('menuBoard.category.search');
+ $group->post('/menuboard/{id}/category', ['\Xibo\Controller\MenuBoardCategory', 'add'])->setName('menuBoard.category.add');
+ $group->put('/menuboard/{id}/category', ['\Xibo\Controller\MenuBoardCategory', 'edit'])->setName('menuBoard.category.edit');
+ $group->delete('/menuboard/{id}/category', ['\Xibo\Controller\MenuBoardCategory', 'delete'])->setName('menuBoard.category.delete');
+
+ $group->get('/menuboard/{id}/products', ['\Xibo\Controller\MenuBoardProduct', 'grid'])->setName('menuBoard.product.search');
+ $group->get('/menuboard/products', ['\Xibo\Controller\MenuBoardProduct', 'productsForWidget'])->setName('menuBoard.product.search.widget');
+ $group->post('/menuboard/{id}/product', ['\Xibo\Controller\MenuBoardProduct', 'add'])->setName('menuBoard.product.add');
+ $group->put('/menuboard/{id}/product', ['\Xibo\Controller\MenuBoardProduct', 'edit'])->setName('menuBoard.product.edit');
+ $group->delete('/menuboard/{id}/product', ['\Xibo\Controller\MenuBoardProduct', 'delete'])->setName('menuBoard.product.delete');
+})
+ ->addMiddleware(new FeatureAuth($app->getContainer(), ['menuBoard.modify']));
+
+$app->get('/fonts', ['\Xibo\Controller\Font', 'grid'])->setName('font.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/fonts/details/{id}', ['\Xibo\Controller\Font', 'getFontLibDetails'])->setName('font.details');
+ $group->get('/fonts/download/{id}', ['\Xibo\Controller\Font', 'download'])->setName('font.download');
+ $group->post('/fonts', ['\Xibo\Controller\Font','add'])->setName('font.add');
+ $group->delete('/fonts/{id}/delete', ['\Xibo\Controller\Font','delete'])->setName('font.delete');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['font.view']));
+
+$app->get('/syncgroups', ['\Xibo\Controller\SyncGroup', 'grid'])->setName('syncgroup.search');
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/syncgroup/{id}/displays', ['\Xibo\Controller\SyncGroup', 'fetchDisplays'])
+ ->setName('syncgroup.fetch.displays');
+ $group->post('/syncgroup/add', ['\Xibo\Controller\SyncGroup', 'add'])->setName('syncgroup.add');
+ $group->post('/syncgroup/{id}/members', ['\Xibo\Controller\SyncGroup', 'members'])->setName('syncgroup.members');
+ $group->put('/syncgroup/{id}/edit', ['\Xibo\Controller\SyncGroup', 'edit'])->setName('syncgroup.edit');
+ $group->delete('/syncgroup/{id}/delete', ['\Xibo\Controller\SyncGroup', 'delete'])->setName('syncgroup.delete');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['display.syncView']));
+
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->post('/schedule/sync/add', ['\Xibo\Controller\Schedule', 'syncAdd'])->setName('schedule.add.sync');
+})->addMiddleware(new FeatureAuth($app->getContainer(), ['schedule.sync']));
diff --git a/locale/README.md b/locale/README.md
new file mode 100644
index 0000000..b58120b
--- /dev/null
+++ b/locale/README.md
@@ -0,0 +1,22 @@
+# Xibo Translations
+Translations are managed in [Launchpad](https://translations.launchpad.net/xibo) and will updated to this folder before each release.
+
+# Translators
+We greatly appreciate any translation contributions. There are two options.
+
+## Translating Online
+Translations can be done using the Launchpad Web User Interface.
+
+## Translating Offline
+ 1. Request a download of the language translation you are interested in doing from Launchpad.
+ 2. Download it when you receive the email
+ 3. Download and Install [PoEdit](http://poedit.net/)
+ 4. Open the PO file you downloaded in POEdit
+ 5. Make the translations
+ 6. Upload the file to Launchpad (making sure you are on the same language translation page)
+
+Alternatively get the PO files from the translation export branch:
+
+```bash
+bzr pull lp:~dangarner/xibo/swift-translations
+```
\ No newline at end of file
diff --git a/locale/af.mo b/locale/af.mo
new file mode 100644
index 0000000..33728ee
Binary files /dev/null and b/locale/af.mo differ
diff --git a/locale/ar.mo b/locale/ar.mo
new file mode 100644
index 0000000..9207dc7
Binary files /dev/null and b/locale/ar.mo differ
diff --git a/locale/bg.mo b/locale/bg.mo
new file mode 100644
index 0000000..079bc08
Binary files /dev/null and b/locale/bg.mo differ
diff --git a/locale/ca.mo b/locale/ca.mo
new file mode 100644
index 0000000..73836e5
Binary files /dev/null and b/locale/ca.mo differ
diff --git a/locale/cs.mo b/locale/cs.mo
new file mode 100644
index 0000000..4a946e2
Binary files /dev/null and b/locale/cs.mo differ
diff --git a/locale/da.mo b/locale/da.mo
new file mode 100644
index 0000000..90160c8
Binary files /dev/null and b/locale/da.mo differ
diff --git a/locale/dbtranslate.php b/locale/dbtranslate.php
new file mode 100644
index 0000000..e585472
--- /dev/null
+++ b/locale/dbtranslate.php
@@ -0,0 +1,170 @@
+.
+ */
+
+
+/* Translations from the Database that need to be registered with Gettext */
+
+// Transitions
+echo __('Fade In');
+echo __('Fade Out');
+echo __('Fly');
+
+// Data Sets
+echo __('String');
+echo __('Number');
+echo __('Date');
+echo __('External Image');
+echo __('Library Image');
+
+echo __('Value');
+echo __('Formula');
+
+// Module names
+echo __('Data Set');
+echo __('DataSet View');
+echo __('A view on a DataSet');
+echo __('DataSet Ticker');
+echo __('Ticker with a DataSet providing the items');
+echo __('Ticker');
+echo __('RSS Ticker.');
+echo __('Text');
+echo __('Text. With Directional Controls.');
+echo __('Embedded');
+echo __('Embedded HTML');
+echo __('Image');
+echo __('Images. PNG, JPG, BMP, GIF');
+echo __('Video');
+echo __('Videos - support varies depending on the client hardware you are using.');
+echo __('Video In');
+echo __('A module for displaying Video and Audio from an external source');
+echo __('Flash');
+echo __('PowerPoint');
+echo __('Powerpoint. PPT, PPS');
+echo __('Webpage');
+echo __('Webpages.');
+echo __('Counter');
+echo __('Shell Command');
+echo __('Execute a shell command on the client');
+echo __('Local Video');
+echo __('Play a video locally stored on the client');
+echo __('Clock');
+echo __('Font');
+echo __('A font to use in other Modules');
+echo __('Generic File');
+echo __('A generic file to be stored in the library');
+echo __('Audio');
+echo __('Audio - support varies depending on the client hardware');
+echo __('PDF');
+echo __('PDF document viewer');
+echo __('Notification');
+echo __('Display Notifications from the Notification Centre');
+
+echo __('Stocks Module');
+echo __('A module for showing Stock quotes');
+echo __('Currencies Module');
+echo __('A module for showing Currency pairs and exchange rates');
+
+echo __('Stocks');
+echo __('Yahoo Stocks');
+echo __('Currencies');
+echo __('Yahoo Currencies');
+echo __('Finance');
+echo __('Yahoo Finance');
+echo __('Google Traffic');
+echo __('Google Traffic Map');
+echo __('HLS');
+echo __('HLS Video Stream');
+echo __('Twitter');
+echo __('Twitter Search Module');
+echo __('Twitter Metro');
+echo __('Twitter Metro Search Module');
+echo __('Weather');
+echo __('Weather module showing Current and Daily forecasts.');
+echo __('Sub-Playlist');
+echo __('Embed a Sub-Playlist');
+echo __('Countdown');
+
+echo __('January');
+echo __('February');
+echo __('March');
+echo __('April');
+echo __('May');
+echo __('June');
+echo __('July');
+echo __('August');
+echo __('September');
+echo __('October');
+echo __('November');
+echo __('December');
+
+// Dashboards
+echo __('Icon Dashboard');
+echo __('Status Dashboard');
+echo __('Media Dashboard');
+
+// Application Scopes
+echo __('Full account access');
+echo __('Access to DataSets');
+echo __('Access to deleting DataSets');
+echo __('Access to Library, Layouts, Playlists, Widgets and Resolutions');
+echo __('Access to deleting content from Library, Layouts, Playlists, Widgets and Resolutions');
+echo __('Access to Displays and Display Groups');
+echo __('Access to deleting Displays and Display Groups');
+echo __('Media Conversion as a Service');
+echo __('Access to Scheduling');
+echo __('Access to deleting Scheduled Events');
+
+// Widget Templates
+echo __('Primary');
+echo __('Secondary');
+echo __('Background');
+echo __('Header color');
+echo __('Header text');
+echo __('Disabled item text');
+echo __('Highlighted item text');
+echo __('Show product images?');
+echo __('Show category images?');
+echo __('Show header?');
+echo __('Product border colour');
+echo __('Title');
+echo __('Description');
+
+// Display Types
+echo __('Billboard');
+echo __('Kiosk');
+echo __('LED Matrix / LED Video Wall');
+echo __('Monitor / Other');
+echo __('Projector');
+echo __('Shelf-edge Display');
+echo __('Smart Mirror');
+echo __('TV / Panel');
+echo __('Tablet');
+echo __('Totem');
+
+// Weather templates
+echo __('Wind');
+echo __('Humidity');
+echo __('Feels Like');
+echo __('Right now');
+echo __('Pressure');
+echo __('Visibility');
+echo __('TODAY');
+echo __('RIGHT NOW');
diff --git a/locale/de.mo b/locale/de.mo
new file mode 100644
index 0000000..2217cd3
Binary files /dev/null and b/locale/de.mo differ
diff --git a/locale/default.pot b/locale/default.pot
new file mode 100755
index 0000000..6c0a57d
--- /dev/null
+++ b/locale/default.pot
@@ -0,0 +1,27494 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR , YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-10-03 10:50+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: locale/moduletranslate.php:3 locale/dbtranslate.php:73
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:556
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1366
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1596
+msgid "Audio"
+msgstr ""
+
+#: locale/moduletranslate.php:4
+msgid "Upload Audio files to assign to Layouts"
+msgstr ""
+
+#: locale/moduletranslate.php:5 locale/moduletranslate.php:463
+#: locale/moduletranslate.php:491 locale/moduletranslate.php:525
+#: locale/moduletranslate.php:531 locale/moduletranslate.php:978
+msgid "Valid Extensions"
+msgstr ""
+
+#: locale/moduletranslate.php:6 locale/moduletranslate.php:464
+#: locale/moduletranslate.php:492 locale/moduletranslate.php:526
+#: locale/moduletranslate.php:532 locale/moduletranslate.php:979
+msgid ""
+"The Extensions allowed on files uploaded using this module. Comma Separated."
+msgstr ""
+
+#: locale/moduletranslate.php:7
+msgid ""
+"This audio will play for %media.duration% seconds. To cut the audio short "
+"set a lower duration in the Advanced tab, to wait on the last frame or "
+"select to Loop set a higher duration."
+msgstr ""
+
+#: locale/moduletranslate.php:8 locale/moduletranslate.php:987
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:935
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:179
+msgid "Loop?"
+msgstr ""
+
+#: locale/moduletranslate.php:9
+msgid "Should the audio loop if it finishes before the provided duration?"
+msgstr ""
+
+#: locale/moduletranslate.php:10
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:479
+msgid "Calendar"
+msgstr ""
+
+#: locale/moduletranslate.php:11
+msgid "A module for displaying a calendar based on an iCal feed"
+msgstr ""
+
+#: locale/moduletranslate.php:12
+msgid "Cache Period (mins)"
+msgstr ""
+
+#: locale/moduletranslate.php:13
+msgid "Please enter the number of minutes you would like to cache ICS feeds."
+msgstr ""
+
+#: locale/moduletranslate.php:14
+msgid "Feed URL"
+msgstr ""
+
+#: locale/moduletranslate.php:15
+msgid "The Link for the iCal Feed."
+msgstr ""
+
+#: locale/moduletranslate.php:16
+msgid "Events to show"
+msgstr ""
+
+#: locale/moduletranslate.php:17
+msgid "Get events using a preset date range?"
+msgstr ""
+
+#: locale/moduletranslate.php:18
+msgid "Use the checkbox to return events within defined start and end dates."
+msgstr ""
+
+#: locale/moduletranslate.php:19 locale/moduletranslate.php:1169
+msgid "Events from the start of the"
+msgstr ""
+
+#: locale/moduletranslate.php:20 locale/moduletranslate.php:1170
+msgid "When should events be returned from?"
+msgstr ""
+
+#: locale/moduletranslate.php:21 locale/moduletranslate.php:1171
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:375
+msgid "Day"
+msgstr ""
+
+#: locale/moduletranslate.php:22 locale/moduletranslate.php:1172
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:379
+msgid "Week"
+msgstr ""
+
+#: locale/moduletranslate.php:23 locale/moduletranslate.php:1173
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:383
+msgid "Month"
+msgstr ""
+
+#: locale/moduletranslate.php:24
+msgid "for an interval of"
+msgstr ""
+
+#: locale/moduletranslate.php:25
+msgid ""
+"Using natural language enter a string representing the period for which "
+"events should be returned, for example 2 days or 1 week."
+msgstr ""
+
+#: locale/moduletranslate.php:26 locale/moduletranslate.php:1719
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:170
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:141
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:610
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:282
+#: lib/Widget/DataType/Event.php:68
+msgid "Start Date"
+msgstr ""
+
+#: locale/moduletranslate.php:27 locale/moduletranslate.php:1720
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:174
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:158
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:627
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:286
+#: lib/Widget/DataType/Event.php:69
+msgid "End Date"
+msgstr ""
+
+#: locale/moduletranslate.php:28
+msgid "Exclude all day events?"
+msgstr ""
+
+#: locale/moduletranslate.php:29
+msgid ""
+"When all day events are excluded they are removed from the list of events in "
+"the feed and wont be shown"
+msgstr ""
+
+#: locale/moduletranslate.php:30
+msgid "Exclude past events?"
+msgstr ""
+
+#: locale/moduletranslate.php:31
+msgid ""
+"When past events are excluded they are removed from the list of events in "
+"the feed and wont be shown."
+msgstr ""
+
+#: locale/moduletranslate.php:32
+msgid "Show only current events?"
+msgstr ""
+
+#: locale/moduletranslate.php:33
+msgid "Show current events and hide all other events from the feed."
+msgstr ""
+
+#: locale/moduletranslate.php:34
+msgid "Exclude current events?"
+msgstr ""
+
+#: locale/moduletranslate.php:35
+msgid ""
+"When current events are excluded they are removed from the list of events in "
+"the feed and wont be shown."
+msgstr ""
+
+#: locale/moduletranslate.php:36
+msgid "Use event timezone?"
+msgstr ""
+
+#: locale/moduletranslate.php:37
+msgid ""
+"If an event specifies a timezone, should it be used. Deselection means the "
+"CMS timezone will be used."
+msgstr ""
+
+#: locale/moduletranslate.php:38
+msgid "Use calendar timezone?"
+msgstr ""
+
+#: locale/moduletranslate.php:39
+msgid ""
+"If your calendar feed specifies its own time zone, should this be used for "
+"events without their own timezone? Deselecting means the CMS timezone will "
+"be used."
+msgstr ""
+
+#: locale/moduletranslate.php:40
+msgid "Windows format Calendar?"
+msgstr ""
+
+#: locale/moduletranslate.php:41
+msgid "Does the calendar feed come from Windows - if unsure leave unselected."
+msgstr ""
+
+#: locale/moduletranslate.php:42 locale/moduletranslate.php:305
+#: locale/moduletranslate.php:344 locale/moduletranslate.php:676
+#: locale/moduletranslate.php:696
+msgid "Duration is per item"
+msgstr ""
+
+#: locale/moduletranslate.php:43 locale/moduletranslate.php:345
+#: locale/moduletranslate.php:677
+msgid "The duration specified is per item otherwise it is per feed."
+msgstr ""
+
+#: locale/moduletranslate.php:44
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:206
+msgid "Number of items"
+msgstr ""
+
+#: locale/moduletranslate.php:45
+msgid "The number of items you want to display."
+msgstr ""
+
+#: locale/moduletranslate.php:46 locale/moduletranslate.php:673
+msgid "When duration is per item then number of items must be 1 or higher"
+msgstr ""
+
+#: locale/moduletranslate.php:47 locale/moduletranslate.php:317
+#: locale/moduletranslate.php:325 locale/moduletranslate.php:360
+#: locale/moduletranslate.php:459
+msgid "Update Interval (mins)"
+msgstr ""
+
+#: locale/moduletranslate.php:48 locale/moduletranslate.php:318
+#: locale/moduletranslate.php:326 locale/moduletranslate.php:361
+#: locale/moduletranslate.php:460
+msgid ""
+"Please enter the update interval in minutes. This should be kept as high as "
+"possible. For example, if the data will only change once per hour this could "
+"be set to 60."
+msgstr ""
+
+#: locale/moduletranslate.php:49
+msgid "Web Hook triggers"
+msgstr ""
+
+#: locale/moduletranslate.php:50
+msgid ""
+"Web Hook triggers can be executed when certain conditions are detected. If "
+"you would like to execute a trigger, enter the trigger code below against "
+"each event."
+msgstr ""
+
+#: locale/moduletranslate.php:51
+msgid "Current Event"
+msgstr ""
+
+#: locale/moduletranslate.php:52
+msgid "Code to be triggered when a event is currently ongoing."
+msgstr ""
+
+#: locale/moduletranslate.php:53
+msgid "No Event"
+msgstr ""
+
+#: locale/moduletranslate.php:54
+msgid "Code to be triggered when no events are ongoing at the moment."
+msgstr ""
+
+#: locale/moduletranslate.php:55
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:101
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1742
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1770
+msgid "Canvas"
+msgstr ""
+
+#: locale/moduletranslate.php:56
+msgid "Canvas module"
+msgstr ""
+
+#: locale/moduletranslate.php:57
+msgid "Clock - Analogue"
+msgstr ""
+
+#: locale/moduletranslate.php:58
+msgid "Analogue Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:59 locale/moduletranslate.php:95
+msgid "Theme"
+msgstr ""
+
+#: locale/moduletranslate.php:60
+msgid "Please select a theme for the clock."
+msgstr ""
+
+#: locale/moduletranslate.php:61
+msgid "Light"
+msgstr ""
+
+#: locale/moduletranslate.php:62
+msgid "Dark"
+msgstr ""
+
+#: locale/moduletranslate.php:63 locale/moduletranslate.php:91
+#: locale/moduletranslate.php:104 locale/moduletranslate.php:2828
+msgid "Offset"
+msgstr ""
+
+#: locale/moduletranslate.php:64 locale/moduletranslate.php:105
+msgid ""
+"The offset in minutes that should be applied to the current time, or if a "
+"counter then date/time to run from in the format Y-m-d H:i:s."
+msgstr ""
+
+#: locale/moduletranslate.php:65 locale/moduletranslate.php:131
+#: locale/moduletranslate.php:179 locale/moduletranslate.php:209
+#: locale/moduletranslate.php:241 locale/moduletranslate.php:280
+#: locale/moduletranslate.php:307 locale/moduletranslate.php:479
+#: locale/moduletranslate.php:543 locale/moduletranslate.php:591
+#: locale/moduletranslate.php:627 locale/moduletranslate.php:1041
+#: locale/moduletranslate.php:1103 locale/moduletranslate.php:1120
+#: locale/moduletranslate.php:1147 locale/moduletranslate.php:2123
+#: locale/moduletranslate.php:2778 locale/moduletranslate.php:2817
+#: locale/moduletranslate.php:2861 locale/moduletranslate.php:2879
+#: locale/moduletranslate.php:3036 locale/moduletranslate.php:3056
+#: locale/moduletranslate.php:3105 locale/moduletranslate.php:3138
+#: locale/moduletranslate.php:3171 locale/moduletranslate.php:3196
+#: locale/moduletranslate.php:3221 locale/moduletranslate.php:3246
+#: locale/moduletranslate.php:3277 locale/moduletranslate.php:3310
+#: locale/moduletranslate.php:3343 locale/moduletranslate.php:3376
+#: locale/moduletranslate.php:3407 locale/moduletranslate.php:3438
+#: locale/moduletranslate.php:3475 locale/moduletranslate.php:3511
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:596
+msgid "Horizontal Align"
+msgstr ""
+
+#: locale/moduletranslate.php:66 locale/moduletranslate.php:132
+#: locale/moduletranslate.php:180 locale/moduletranslate.php:210
+#: locale/moduletranslate.php:242 locale/moduletranslate.php:281
+#: locale/moduletranslate.php:308 locale/moduletranslate.php:480
+#: locale/moduletranslate.php:1042 locale/moduletranslate.php:1104
+#: locale/moduletranslate.php:1121 locale/moduletranslate.php:1148
+msgid "How should this widget be horizontally aligned?"
+msgstr ""
+
+#: locale/moduletranslate.php:67 locale/moduletranslate.php:133
+#: locale/moduletranslate.php:181 locale/moduletranslate.php:211
+#: locale/moduletranslate.php:243 locale/moduletranslate.php:282
+#: locale/moduletranslate.php:309 locale/moduletranslate.php:481
+#: locale/moduletranslate.php:545 locale/moduletranslate.php:592
+#: locale/moduletranslate.php:628 locale/moduletranslate.php:1043
+#: locale/moduletranslate.php:1105 locale/moduletranslate.php:1122
+#: locale/moduletranslate.php:1149 locale/moduletranslate.php:2125
+#: locale/moduletranslate.php:2779 locale/moduletranslate.php:2818
+#: locale/moduletranslate.php:2862 locale/moduletranslate.php:2881
+#: locale/moduletranslate.php:3037 locale/moduletranslate.php:3057
+#: locale/moduletranslate.php:3107 locale/moduletranslate.php:3140
+#: locale/moduletranslate.php:3173 locale/moduletranslate.php:3198
+#: locale/moduletranslate.php:3223 locale/moduletranslate.php:3248
+#: locale/moduletranslate.php:3279 locale/moduletranslate.php:3312
+#: locale/moduletranslate.php:3345 locale/moduletranslate.php:3378
+#: locale/moduletranslate.php:3409 locale/moduletranslate.php:3440
+#: locale/moduletranslate.php:3477 locale/moduletranslate.php:3513
+#: cache/56/5629071fb0a52f06baa7fed9fc5c004d.php:414
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:552
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:613
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:839
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:995
+msgid "Left"
+msgstr ""
+
+#: locale/moduletranslate.php:68 locale/moduletranslate.php:134
+#: locale/moduletranslate.php:182 locale/moduletranslate.php:212
+#: locale/moduletranslate.php:244 locale/moduletranslate.php:283
+#: locale/moduletranslate.php:310 locale/moduletranslate.php:482
+#: locale/moduletranslate.php:535 locale/moduletranslate.php:540
+#: locale/moduletranslate.php:546 locale/moduletranslate.php:1044
+#: locale/moduletranslate.php:1106 locale/moduletranslate.php:1123
+#: locale/moduletranslate.php:1150 locale/moduletranslate.php:2126
+#: locale/moduletranslate.php:2882
+msgid "Centre"
+msgstr ""
+
+#: locale/moduletranslate.php:69 locale/moduletranslate.php:135
+#: locale/moduletranslate.php:183 locale/moduletranslate.php:213
+#: locale/moduletranslate.php:245 locale/moduletranslate.php:284
+#: locale/moduletranslate.php:311 locale/moduletranslate.php:483
+#: locale/moduletranslate.php:547 locale/moduletranslate.php:594
+#: locale/moduletranslate.php:630 locale/moduletranslate.php:1045
+#: locale/moduletranslate.php:1107 locale/moduletranslate.php:1124
+#: locale/moduletranslate.php:1151 locale/moduletranslate.php:2127
+#: locale/moduletranslate.php:2781 locale/moduletranslate.php:2820
+#: locale/moduletranslate.php:2864 locale/moduletranslate.php:2883
+#: locale/moduletranslate.php:3039 locale/moduletranslate.php:3059
+#: locale/moduletranslate.php:3109 locale/moduletranslate.php:3142
+#: locale/moduletranslate.php:3175 locale/moduletranslate.php:3200
+#: locale/moduletranslate.php:3225 locale/moduletranslate.php:3250
+#: locale/moduletranslate.php:3281 locale/moduletranslate.php:3314
+#: locale/moduletranslate.php:3347 locale/moduletranslate.php:3380
+#: locale/moduletranslate.php:3411 locale/moduletranslate.php:3442
+#: locale/moduletranslate.php:3479 locale/moduletranslate.php:3515
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:621
+msgid "Right"
+msgstr ""
+
+#: locale/moduletranslate.php:70 locale/moduletranslate.php:136
+#: locale/moduletranslate.php:184 locale/moduletranslate.php:214
+#: locale/moduletranslate.php:246 locale/moduletranslate.php:285
+#: locale/moduletranslate.php:312 locale/moduletranslate.php:484
+#: locale/moduletranslate.php:548 locale/moduletranslate.php:595
+#: locale/moduletranslate.php:631 locale/moduletranslate.php:1046
+#: locale/moduletranslate.php:1108 locale/moduletranslate.php:1125
+#: locale/moduletranslate.php:1152 locale/moduletranslate.php:2128
+#: locale/moduletranslate.php:2782 locale/moduletranslate.php:2821
+#: locale/moduletranslate.php:2865 locale/moduletranslate.php:2884
+#: locale/moduletranslate.php:3040 locale/moduletranslate.php:3060
+#: locale/moduletranslate.php:3110 locale/moduletranslate.php:3143
+#: locale/moduletranslate.php:3176 locale/moduletranslate.php:3201
+#: locale/moduletranslate.php:3226 locale/moduletranslate.php:3251
+#: locale/moduletranslate.php:3282 locale/moduletranslate.php:3315
+#: locale/moduletranslate.php:3348 locale/moduletranslate.php:3381
+#: locale/moduletranslate.php:3412 locale/moduletranslate.php:3443
+#: locale/moduletranslate.php:3480 locale/moduletranslate.php:3516
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:604
+msgid "Vertical Align"
+msgstr ""
+
+#: locale/moduletranslate.php:71 locale/moduletranslate.php:137
+#: locale/moduletranslate.php:185 locale/moduletranslate.php:215
+#: locale/moduletranslate.php:247 locale/moduletranslate.php:286
+#: locale/moduletranslate.php:313 locale/moduletranslate.php:485
+#: locale/moduletranslate.php:1047 locale/moduletranslate.php:1109
+#: locale/moduletranslate.php:1126 locale/moduletranslate.php:1153
+#: locale/moduletranslate.php:3111 locale/moduletranslate.php:3144
+#: locale/moduletranslate.php:3177 locale/moduletranslate.php:3202
+#: locale/moduletranslate.php:3227 locale/moduletranslate.php:3252
+#: locale/moduletranslate.php:3283 locale/moduletranslate.php:3316
+#: locale/moduletranslate.php:3349 locale/moduletranslate.php:3382
+#: locale/moduletranslate.php:3413 locale/moduletranslate.php:3444
+#: locale/moduletranslate.php:3481 locale/moduletranslate.php:3517
+msgid "How should this widget be vertically aligned?"
+msgstr ""
+
+#: locale/moduletranslate.php:72 locale/moduletranslate.php:138
+#: locale/moduletranslate.php:186 locale/moduletranslate.php:216
+#: locale/moduletranslate.php:248 locale/moduletranslate.php:287
+#: locale/moduletranslate.php:314 locale/moduletranslate.php:486
+#: locale/moduletranslate.php:550 locale/moduletranslate.php:596
+#: locale/moduletranslate.php:632 locale/moduletranslate.php:1048
+#: locale/moduletranslate.php:1110 locale/moduletranslate.php:1127
+#: locale/moduletranslate.php:1154 locale/moduletranslate.php:2130
+#: locale/moduletranslate.php:2783 locale/moduletranslate.php:2822
+#: locale/moduletranslate.php:2866 locale/moduletranslate.php:2886
+#: locale/moduletranslate.php:3041 locale/moduletranslate.php:3061
+#: locale/moduletranslate.php:3112 locale/moduletranslate.php:3145
+#: locale/moduletranslate.php:3178 locale/moduletranslate.php:3203
+#: locale/moduletranslate.php:3228 locale/moduletranslate.php:3253
+#: locale/moduletranslate.php:3284 locale/moduletranslate.php:3317
+#: locale/moduletranslate.php:3350 locale/moduletranslate.php:3383
+#: locale/moduletranslate.php:3414 locale/moduletranslate.php:3445
+#: locale/moduletranslate.php:3482 locale/moduletranslate.php:3518
+#: cache/56/5629071fb0a52f06baa7fed9fc5c004d.php:410
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:548
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:625
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:835
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:987
+msgid "Top"
+msgstr ""
+
+#: locale/moduletranslate.php:73 locale/moduletranslate.php:139
+#: locale/moduletranslate.php:187 locale/moduletranslate.php:217
+#: locale/moduletranslate.php:249 locale/moduletranslate.php:288
+#: locale/moduletranslate.php:315 locale/moduletranslate.php:487
+#: locale/moduletranslate.php:551 locale/moduletranslate.php:597
+#: locale/moduletranslate.php:633 locale/moduletranslate.php:1049
+#: locale/moduletranslate.php:1111 locale/moduletranslate.php:1128
+#: locale/moduletranslate.php:1155 locale/moduletranslate.php:2131
+#: locale/moduletranslate.php:2784 locale/moduletranslate.php:2823
+#: locale/moduletranslate.php:2867 locale/moduletranslate.php:2887
+#: locale/moduletranslate.php:3042 locale/moduletranslate.php:3062
+#: locale/moduletranslate.php:3113 locale/moduletranslate.php:3146
+#: locale/moduletranslate.php:3179 locale/moduletranslate.php:3204
+#: locale/moduletranslate.php:3229 locale/moduletranslate.php:3254
+#: locale/moduletranslate.php:3285 locale/moduletranslate.php:3318
+#: locale/moduletranslate.php:3351 locale/moduletranslate.php:3384
+#: locale/moduletranslate.php:3415 locale/moduletranslate.php:3446
+#: locale/moduletranslate.php:3483 locale/moduletranslate.php:3519
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:629
+msgid "Middle"
+msgstr ""
+
+#: locale/moduletranslate.php:74 locale/moduletranslate.php:140
+#: locale/moduletranslate.php:188 locale/moduletranslate.php:218
+#: locale/moduletranslate.php:250 locale/moduletranslate.php:289
+#: locale/moduletranslate.php:316 locale/moduletranslate.php:488
+#: locale/moduletranslate.php:552 locale/moduletranslate.php:598
+#: locale/moduletranslate.php:634 locale/moduletranslate.php:1050
+#: locale/moduletranslate.php:1112 locale/moduletranslate.php:1129
+#: locale/moduletranslate.php:1156 locale/moduletranslate.php:2132
+#: locale/moduletranslate.php:2785 locale/moduletranslate.php:2824
+#: locale/moduletranslate.php:2868 locale/moduletranslate.php:2888
+#: locale/moduletranslate.php:3043 locale/moduletranslate.php:3063
+#: locale/moduletranslate.php:3114 locale/moduletranslate.php:3147
+#: locale/moduletranslate.php:3180 locale/moduletranslate.php:3205
+#: locale/moduletranslate.php:3230 locale/moduletranslate.php:3255
+#: locale/moduletranslate.php:3286 locale/moduletranslate.php:3319
+#: locale/moduletranslate.php:3352 locale/moduletranslate.php:3385
+#: locale/moduletranslate.php:3416 locale/moduletranslate.php:3447
+#: locale/moduletranslate.php:3484 locale/moduletranslate.php:3520
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:633
+msgid "Bottom"
+msgstr ""
+
+#: locale/moduletranslate.php:75
+msgid "Clock - Digital"
+msgstr ""
+
+#: locale/moduletranslate.php:76
+msgid "Digital Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:77
+msgid ""
+"Enter a format for the Digital Clock e.g. [HH:mm] or [DD/MM/YYYY]. See the "
+"manual for more information."
+msgstr ""
+
+#: locale/moduletranslate.php:78
+msgid "Enter text in the box below."
+msgstr ""
+
+#: locale/moduletranslate.php:79
+msgid "Date Formats"
+msgstr ""
+
+#: locale/moduletranslate.php:80
+msgid "Choose from a preset date format"
+msgstr ""
+
+#: locale/moduletranslate.php:81
+msgid "Time only"
+msgstr ""
+
+#: locale/moduletranslate.php:82
+msgid "Day/Month/Year"
+msgstr ""
+
+#: locale/moduletranslate.php:83
+msgid "Localised - Time"
+msgstr ""
+
+#: locale/moduletranslate.php:84
+msgid "Localised - Time with seconds"
+msgstr ""
+
+#: locale/moduletranslate.php:85
+msgid "Localised - Month numeral, day of month, year"
+msgstr ""
+
+#: locale/moduletranslate.php:86
+msgid "Localised - Month name, day of month, year"
+msgstr ""
+
+#: locale/moduletranslate.php:87
+msgid "Localised - Month name, day of month, year, time"
+msgstr ""
+
+#: locale/moduletranslate.php:88
+msgid "Localised - Month name, day of month, day of week, year, time"
+msgstr ""
+
+#: locale/moduletranslate.php:89 locale/moduletranslate.php:475
+#: locale/moduletranslate.php:1736 locale/moduletranslate.php:1757
+#: locale/moduletranslate.php:1799 locale/moduletranslate.php:1845
+#: locale/moduletranslate.php:1892 locale/moduletranslate.php:1937
+#: locale/moduletranslate.php:1984 locale/moduletranslate.php:2031
+#: locale/moduletranslate.php:2067 locale/moduletranslate.php:2789
+#: locale/moduletranslate.php:2833
+msgid "Language"
+msgstr ""
+
+#: locale/moduletranslate.php:90 locale/moduletranslate.php:476
+#: locale/moduletranslate.php:1737 locale/moduletranslate.php:1758
+#: locale/moduletranslate.php:1800 locale/moduletranslate.php:1846
+#: locale/moduletranslate.php:1893 locale/moduletranslate.php:1938
+#: locale/moduletranslate.php:1985 locale/moduletranslate.php:2032
+#: locale/moduletranslate.php:2068 locale/moduletranslate.php:2790
+#: locale/moduletranslate.php:2834
+msgid "Select the language you would like to use."
+msgstr ""
+
+#: locale/moduletranslate.php:92
+msgid "The offset in minutes that should be applied to the current time."
+msgstr ""
+
+#: locale/moduletranslate.php:93
+msgid "Clock - Flip"
+msgstr ""
+
+#: locale/moduletranslate.php:94
+msgid "Flip Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:96
+msgid "Please select a clock face."
+msgstr ""
+
+#: locale/moduletranslate.php:97
+msgid "12h Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:98
+msgid "24h Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:99
+msgid "Hourly Counter"
+msgstr ""
+
+#: locale/moduletranslate.php:100
+msgid "Minute Counter"
+msgstr ""
+
+#: locale/moduletranslate.php:101
+msgid "Daily Counter"
+msgstr ""
+
+#: locale/moduletranslate.php:102
+msgid "Show Seconds?"
+msgstr ""
+
+#: locale/moduletranslate.php:103
+msgid "Should the clock show seconds or not?"
+msgstr ""
+
+#: locale/moduletranslate.php:106 locale/moduletranslate.php:575
+#: locale/moduletranslate.php:963 locale/moduletranslate.php:1138
+#: locale/moduletranslate.php:1165 locale/moduletranslate.php:1201
+#: locale/moduletranslate.php:1326 locale/moduletranslate.php:1368
+#: locale/moduletranslate.php:1384 locale/moduletranslate.php:1403
+#: locale/moduletranslate.php:1466 locale/moduletranslate.php:1483
+#: locale/moduletranslate.php:1513 locale/moduletranslate.php:1657
+#: locale/moduletranslate.php:1682 locale/moduletranslate.php:1738
+#: locale/moduletranslate.php:1772 locale/moduletranslate.php:1775
+#: locale/moduletranslate.php:1780 locale/moduletranslate.php:1783
+#: locale/moduletranslate.php:1786 locale/moduletranslate.php:1789
+#: locale/moduletranslate.php:1814 locale/moduletranslate.php:1817
+#: locale/moduletranslate.php:1822 locale/moduletranslate.php:1825
+#: locale/moduletranslate.php:1828 locale/moduletranslate.php:1831
+#: locale/moduletranslate.php:1863 locale/moduletranslate.php:1866
+#: locale/moduletranslate.php:1871 locale/moduletranslate.php:1874
+#: locale/moduletranslate.php:1877 locale/moduletranslate.php:1880
+#: locale/moduletranslate.php:1912 locale/moduletranslate.php:1915
+#: locale/moduletranslate.php:1920 locale/moduletranslate.php:1923
+#: locale/moduletranslate.php:1926 locale/moduletranslate.php:1929
+#: locale/moduletranslate.php:1954 locale/moduletranslate.php:1957
+#: locale/moduletranslate.php:1960 locale/moduletranslate.php:1964
+#: locale/moduletranslate.php:1967 locale/moduletranslate.php:1970
+#: locale/moduletranslate.php:1973 locale/moduletranslate.php:1976
+#: locale/moduletranslate.php:2001 locale/moduletranslate.php:2004
+#: locale/moduletranslate.php:2007 locale/moduletranslate.php:2011
+#: locale/moduletranslate.php:2014 locale/moduletranslate.php:2017
+#: locale/moduletranslate.php:2020 locale/moduletranslate.php:2023
+#: locale/moduletranslate.php:2043 locale/moduletranslate.php:2046
+#: locale/moduletranslate.php:2050 locale/moduletranslate.php:2053
+#: locale/moduletranslate.php:2056 locale/moduletranslate.php:2059
+#: locale/moduletranslate.php:2079 locale/moduletranslate.php:2082
+#: locale/moduletranslate.php:2086 locale/moduletranslate.php:2089
+#: locale/moduletranslate.php:2092 locale/moduletranslate.php:2095
+#: locale/moduletranslate.php:2922 locale/moduletranslate.php:2933
+#: locale/moduletranslate.php:2943 locale/moduletranslate.php:2949
+#: locale/moduletranslate.php:2959 locale/moduletranslate.php:2969
+#: locale/moduletranslate.php:3534 locale/moduletranslate.php:3554
+#: locale/moduletranslate.php:3577
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:108
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:351
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:865
+msgid "Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:107
+msgid "Flip Card Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:108
+msgid "Flip Card Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:109 locale/moduletranslate.php:293
+msgid "Divider Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:110
+msgid "AM/PM Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:111
+msgid "Countdown - Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:112
+msgid "A module for displaying a Countdown timer as a clock"
+msgstr ""
+
+#: locale/moduletranslate.php:113 locale/moduletranslate.php:155
+#: locale/moduletranslate.php:191 locale/moduletranslate.php:231
+#: locale/moduletranslate.php:262
+msgid "Countdown Type"
+msgstr ""
+
+#: locale/moduletranslate.php:114 locale/moduletranslate.php:156
+#: locale/moduletranslate.php:192 locale/moduletranslate.php:232
+#: locale/moduletranslate.php:263
+msgid "Please select the type of countdown."
+msgstr ""
+
+#: locale/moduletranslate.php:115 locale/moduletranslate.php:157
+#: locale/moduletranslate.php:193 locale/moduletranslate.php:233
+#: locale/moduletranslate.php:264
+msgid "Widget Duration"
+msgstr ""
+
+#: locale/moduletranslate.php:116 locale/moduletranslate.php:158
+#: locale/moduletranslate.php:194 locale/moduletranslate.php:234
+#: locale/moduletranslate.php:265
+msgid "Custom Duration"
+msgstr ""
+
+#: locale/moduletranslate.php:117 locale/moduletranslate.php:159
+#: locale/moduletranslate.php:195 locale/moduletranslate.php:235
+#: locale/moduletranslate.php:266
+msgid "Use Date"
+msgstr ""
+
+#: locale/moduletranslate.php:118 locale/moduletranslate.php:160
+#: locale/moduletranslate.php:196 locale/moduletranslate.php:236
+#: locale/moduletranslate.php:267
+msgid "Countdown Duration"
+msgstr ""
+
+#: locale/moduletranslate.php:119 locale/moduletranslate.php:161
+#: locale/moduletranslate.php:197 locale/moduletranslate.php:237
+#: locale/moduletranslate.php:268
+msgid "The duration in seconds."
+msgstr ""
+
+#: locale/moduletranslate.php:120 locale/moduletranslate.php:162
+#: locale/moduletranslate.php:198 locale/moduletranslate.php:238
+#: locale/moduletranslate.php:269
+msgid "Please enter a positive countdown duration"
+msgstr ""
+
+#: locale/moduletranslate.php:121 locale/moduletranslate.php:163
+#: locale/moduletranslate.php:199 locale/moduletranslate.php:239
+#: locale/moduletranslate.php:270
+msgid "Countdown Date"
+msgstr ""
+
+#: locale/moduletranslate.php:122 locale/moduletranslate.php:164
+#: locale/moduletranslate.php:200 locale/moduletranslate.php:240
+#: locale/moduletranslate.php:271
+msgid "Select the target date and time."
+msgstr ""
+
+#: locale/moduletranslate.php:123 locale/moduletranslate.php:165
+#: locale/moduletranslate.php:201 locale/moduletranslate.php:272
+msgid "Warning Duration"
+msgstr ""
+
+#: locale/moduletranslate.php:124 locale/moduletranslate.php:166
+#: locale/moduletranslate.php:202 locale/moduletranslate.php:273
+msgid ""
+"The countdown will show in a warning mode from the end duration entered."
+msgstr ""
+
+#: locale/moduletranslate.php:125 locale/moduletranslate.php:167
+#: locale/moduletranslate.php:203 locale/moduletranslate.php:274
+msgid "Warning duration needs to be lower than the countdown main duration."
+msgstr ""
+
+#: locale/moduletranslate.php:126 locale/moduletranslate.php:168
+#: locale/moduletranslate.php:204 locale/moduletranslate.php:275
+msgid "Warning duration needs to be lower than the widget duration."
+msgstr ""
+
+#: locale/moduletranslate.php:127 locale/moduletranslate.php:169
+#: locale/moduletranslate.php:205 locale/moduletranslate.php:276
+msgid "Please enter a positive warning duration"
+msgstr ""
+
+#: locale/moduletranslate.php:128 locale/moduletranslate.php:170
+#: locale/moduletranslate.php:206 locale/moduletranslate.php:277
+msgid "Warning Date"
+msgstr ""
+
+#: locale/moduletranslate.php:129 locale/moduletranslate.php:171
+#: locale/moduletranslate.php:207 locale/moduletranslate.php:278
+msgid ""
+"The countdown will show in a warning mode from the warning date entered."
+msgstr ""
+
+#: locale/moduletranslate.php:130 locale/moduletranslate.php:172
+#: locale/moduletranslate.php:208 locale/moduletranslate.php:279
+msgid "Warning date needs to be before countdown date."
+msgstr ""
+
+#: locale/moduletranslate.php:141
+msgid "Inner Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:142
+msgid "Inner Text Font"
+msgstr ""
+
+#: locale/moduletranslate.php:143 locale/moduletranslate.php:147
+#: locale/moduletranslate.php:220 locale/moduletranslate.php:225
+#: locale/moduletranslate.php:252 locale/moduletranslate.php:291
+#: locale/moduletranslate.php:557 locale/moduletranslate.php:603
+#: locale/moduletranslate.php:1131 locale/moduletranslate.php:1135
+#: locale/moduletranslate.php:1158 locale/moduletranslate.php:1162
+#: locale/moduletranslate.php:1241 locale/moduletranslate.php:1262
+#: locale/moduletranslate.php:1283 locale/moduletranslate.php:1301
+#: locale/moduletranslate.php:1338 locale/moduletranslate.php:1397
+#: locale/moduletranslate.php:1418 locale/moduletranslate.php:1462
+#: locale/moduletranslate.php:1507 locale/moduletranslate.php:1535
+#: locale/moduletranslate.php:1552 locale/moduletranslate.php:1569
+#: locale/moduletranslate.php:1586 locale/moduletranslate.php:1603
+#: locale/moduletranslate.php:1620 locale/moduletranslate.php:1637
+#: locale/moduletranslate.php:1694 locale/moduletranslate.php:2190
+#: locale/moduletranslate.php:2219 locale/moduletranslate.php:2248
+#: locale/moduletranslate.php:2283 locale/moduletranslate.php:2318
+#: locale/moduletranslate.php:2349 locale/moduletranslate.php:2378
+#: locale/moduletranslate.php:2407 locale/moduletranslate.php:2436
+#: locale/moduletranslate.php:2473 locale/moduletranslate.php:2510
+#: locale/moduletranslate.php:2547 locale/moduletranslate.php:2574
+#: locale/moduletranslate.php:2603 locale/moduletranslate.php:2638
+#: locale/moduletranslate.php:2673 locale/moduletranslate.php:2729
+#: locale/moduletranslate.php:2751 locale/moduletranslate.php:2792
+#: locale/moduletranslate.php:2836 locale/moduletranslate.php:2988
+#: locale/moduletranslate.php:3031 locale/moduletranslate.php:3049
+#: locale/moduletranslate.php:3125 locale/moduletranslate.php:3158
+#: locale/moduletranslate.php:3189 locale/moduletranslate.php:3214
+#: locale/moduletranslate.php:3239 locale/moduletranslate.php:3266
+#: locale/moduletranslate.php:3297 locale/moduletranslate.php:3328
+#: locale/moduletranslate.php:3361 locale/moduletranslate.php:3394
+#: locale/moduletranslate.php:3425 locale/moduletranslate.php:3458
+#: locale/moduletranslate.php:3489 locale/moduletranslate.php:3571
+#: locale/moduletranslate.php:3590
+msgid "Select a custom font - leave empty to use the default font."
+msgstr ""
+
+#: locale/moduletranslate.php:144
+msgid "Inner Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:145
+msgid "Outer Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:146
+msgid "Label Text Font"
+msgstr ""
+
+#: locale/moduletranslate.php:148
+msgid "Label Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:149
+msgid "Warning Background Colour 1"
+msgstr ""
+
+#: locale/moduletranslate.php:150
+msgid "Warning Background Colour 2"
+msgstr ""
+
+#: locale/moduletranslate.php:151
+msgid "Finished Background Colour 1"
+msgstr ""
+
+#: locale/moduletranslate.php:152
+msgid "Finished Background Colour 2"
+msgstr ""
+
+#: locale/moduletranslate.php:153
+msgid "Countdown - Custom"
+msgstr ""
+
+#: locale/moduletranslate.php:154
+msgid "A module for displaying a custom Countdown timer"
+msgstr ""
+
+#: locale/moduletranslate.php:173 locale/moduletranslate.php:1095
+#: locale/moduletranslate.php:1372 locale/moduletranslate.php:2152
+#: locale/moduletranslate.php:2153 locale/moduletranslate.php:3077
+#: locale/moduletranslate.php:3078 locale/moduletranslate.php:3540
+msgid "Original Width"
+msgstr ""
+
+#: locale/moduletranslate.php:174 locale/moduletranslate.php:1096
+#: locale/moduletranslate.php:1373 locale/moduletranslate.php:2154
+#: locale/moduletranslate.php:3079 locale/moduletranslate.php:3541
+msgid ""
+"This is the intended width of the template and is used to scale the Widget "
+"within its region when the template is applied."
+msgstr ""
+
+#: locale/moduletranslate.php:175 locale/moduletranslate.php:1097
+#: locale/moduletranslate.php:1374 locale/moduletranslate.php:2155
+#: locale/moduletranslate.php:3080 locale/moduletranslate.php:3542
+msgid "Original Height"
+msgstr ""
+
+#: locale/moduletranslate.php:176 locale/moduletranslate.php:1098
+#: locale/moduletranslate.php:1375 locale/moduletranslate.php:2156
+#: locale/moduletranslate.php:3081 locale/moduletranslate.php:3543
+msgid ""
+"This is the intended height of the template and is used to scale the Widget "
+"within its region when the template is applied."
+msgstr ""
+
+#: locale/moduletranslate.php:177 locale/moduletranslate.php:1376
+msgid "mainTemplate"
+msgstr ""
+
+#: locale/moduletranslate.php:178 locale/moduletranslate.php:1378
+msgid "styleSheet"
+msgstr ""
+
+#: locale/moduletranslate.php:189
+msgid "Countdown - Days"
+msgstr ""
+
+#: locale/moduletranslate.php:190
+msgid "A module for displaying a Countdown timer for days"
+msgstr ""
+
+#: locale/moduletranslate.php:219
+msgid "Text Font"
+msgstr ""
+
+#: locale/moduletranslate.php:221 locale/moduletranslate.php:292
+#: locale/moduletranslate.php:1773 locale/moduletranslate.php:1776
+#: locale/moduletranslate.php:1781 locale/moduletranslate.php:1784
+#: locale/moduletranslate.php:1787 locale/moduletranslate.php:1790
+#: locale/moduletranslate.php:1815 locale/moduletranslate.php:1818
+#: locale/moduletranslate.php:1823 locale/moduletranslate.php:1826
+#: locale/moduletranslate.php:1829 locale/moduletranslate.php:1832
+#: locale/moduletranslate.php:1864 locale/moduletranslate.php:1867
+#: locale/moduletranslate.php:1872 locale/moduletranslate.php:1875
+#: locale/moduletranslate.php:1878 locale/moduletranslate.php:1881
+#: locale/moduletranslate.php:1913 locale/moduletranslate.php:1916
+#: locale/moduletranslate.php:1921 locale/moduletranslate.php:1924
+#: locale/moduletranslate.php:1927 locale/moduletranslate.php:1930
+#: locale/moduletranslate.php:1955 locale/moduletranslate.php:1958
+#: locale/moduletranslate.php:1961 locale/moduletranslate.php:1965
+#: locale/moduletranslate.php:1968 locale/moduletranslate.php:1971
+#: locale/moduletranslate.php:1974 locale/moduletranslate.php:1977
+#: locale/moduletranslate.php:2002 locale/moduletranslate.php:2005
+#: locale/moduletranslate.php:2008 locale/moduletranslate.php:2012
+#: locale/moduletranslate.php:2015 locale/moduletranslate.php:2018
+#: locale/moduletranslate.php:2021 locale/moduletranslate.php:2024
+#: locale/moduletranslate.php:2044 locale/moduletranslate.php:2051
+#: locale/moduletranslate.php:2054 locale/moduletranslate.php:2057
+#: locale/moduletranslate.php:2060 locale/moduletranslate.php:2080
+#: locale/moduletranslate.php:2087 locale/moduletranslate.php:2090
+#: locale/moduletranslate.php:2093 locale/moduletranslate.php:2096
+msgid "Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:222
+msgid "Text Card Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:223
+msgid "Text Card Border Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:224 locale/moduletranslate.php:1130
+#: locale/moduletranslate.php:1157
+msgid "Label Font"
+msgstr ""
+
+#: locale/moduletranslate.php:226 locale/moduletranslate.php:1132
+#: locale/moduletranslate.php:1159
+msgid "Label Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:227
+msgid "Warning Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:228
+msgid "Finished Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:229
+msgid "Countdown - Table"
+msgstr ""
+
+#: locale/moduletranslate.php:230
+msgid "A module for displaying a Countdown timer in a table"
+msgstr ""
+
+#: locale/moduletranslate.php:251 locale/moduletranslate.php:290
+#: locale/moduletranslate.php:1240 locale/moduletranslate.php:1261
+#: locale/moduletranslate.php:1282 locale/moduletranslate.php:1300
+#: locale/moduletranslate.php:1396 locale/moduletranslate.php:1417
+#: locale/moduletranslate.php:1461 locale/moduletranslate.php:1506
+#: locale/moduletranslate.php:1534 locale/moduletranslate.php:1551
+#: locale/moduletranslate.php:1568 locale/moduletranslate.php:1585
+#: locale/moduletranslate.php:1602 locale/moduletranslate.php:1619
+#: locale/moduletranslate.php:1636 locale/moduletranslate.php:2189
+#: locale/moduletranslate.php:2218 locale/moduletranslate.php:2247
+#: locale/moduletranslate.php:2282 locale/moduletranslate.php:2317
+#: locale/moduletranslate.php:2348 locale/moduletranslate.php:2377
+#: locale/moduletranslate.php:2406 locale/moduletranslate.php:2435
+#: locale/moduletranslate.php:2472 locale/moduletranslate.php:2509
+#: locale/moduletranslate.php:2546 locale/moduletranslate.php:2573
+#: locale/moduletranslate.php:2602 locale/moduletranslate.php:2637
+#: locale/moduletranslate.php:2672 locale/moduletranslate.php:2728
+#: locale/moduletranslate.php:3124 locale/moduletranslate.php:3157
+#: locale/moduletranslate.php:3188 locale/moduletranslate.php:3213
+#: locale/moduletranslate.php:3238 locale/moduletranslate.php:3265
+#: locale/moduletranslate.php:3296 locale/moduletranslate.php:3327
+#: locale/moduletranslate.php:3360 locale/moduletranslate.php:3393
+#: locale/moduletranslate.php:3424 locale/moduletranslate.php:3457
+#: locale/moduletranslate.php:3488 locale/moduletranslate.php:3570
+#: locale/moduletranslate.php:3589 locale/dbtranslate.php:69
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:95
+msgid "Font"
+msgstr ""
+
+#: locale/moduletranslate.php:253 locale/moduletranslate.php:3430
+#: locale/moduletranslate.php:3465
+msgid "Header Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:254 locale/moduletranslate.php:1519
+#: locale/moduletranslate.php:3432 locale/moduletranslate.php:3467
+msgid "Header Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:255
+msgid "Even Row Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:256
+msgid "Even Row Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:257
+msgid "Odd Row Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:258
+msgid "Odd Row Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:259 locale/moduletranslate.php:1468
+#: locale/moduletranslate.php:1515 locale/moduletranslate.php:3370
+msgid "Border Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:260
+msgid "Countdown - Simple Text"
+msgstr ""
+
+#: locale/moduletranslate.php:261
+msgid "A module for displaying a Countdown timer with Simple Text"
+msgstr ""
+
+#: locale/moduletranslate.php:294
+msgid "Warning Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:295
+msgid "Finished Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:296 locale/moduletranslate.php:299
+#: locale/dbtranslate.php:87
+msgid "Currencies"
+msgstr ""
+
+#: locale/moduletranslate.php:297 locale/dbtranslate.php:83
+msgid "A module for showing Currency pairs and exchange rates"
+msgstr ""
+
+#: locale/moduletranslate.php:298 locale/moduletranslate.php:331
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:102
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:149
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:642
+msgid "Configuration"
+msgstr ""
+
+#: locale/moduletranslate.php:300
+msgid ""
+"A comma separated list of Currency Acronyms/Abbreviations, e.g. GBP,USD,EUR. "
+"For the best results enter no more than 5 items. Do not include the Base "
+"currency in this list."
+msgstr ""
+
+#: locale/moduletranslate.php:301
+msgid "Base"
+msgstr ""
+
+#: locale/moduletranslate.php:302
+msgid "The base currency."
+msgstr ""
+
+#: locale/moduletranslate.php:303
+msgid "Reverse conversion?"
+msgstr ""
+
+#: locale/moduletranslate.php:304
+msgid ""
+"Tick if you would like your base currency to be used as the comparison "
+"currency for each currency you've entered. For example base/compare becomes "
+"compare/base - USD/GBP becomes GBP/USD."
+msgstr ""
+
+#: locale/moduletranslate.php:306
+msgid ""
+"The duration specified is per page/item otherwise the widget duration is "
+"divided between the number of pages/items."
+msgstr ""
+
+#: locale/moduletranslate.php:319
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:582
+msgid "Dashboards"
+msgstr ""
+
+#: locale/moduletranslate.php:320
+msgid ""
+"Securely connect to services like Microsoft PowerBI and display dashboards"
+msgstr ""
+
+#: locale/moduletranslate.php:321
+#: cache/16/16ddf7e420eb8069c1a5deab6ce65be2.php:162
+#: cache/84/846b4e365858df7d149d45d99237360c.php:224
+#: cache/84/846b4e365858df7d149d45d99237360c.php:380
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:137
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:178
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:464
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:237
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:345
+#: cache/1c/1c65822405d03c36923ecc7399544cf8.php:121
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:131
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:83
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:451
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:122
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:142
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:982
+#: cache/34/340ecc3756674aa339156a3d6b49cc46.php:73
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:589
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:1002
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:108
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:278
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1137
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1246
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1626
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:98
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:144
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:189
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:278
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:208
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:147
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:143
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:176
+msgid "Type"
+msgstr ""
+
+#: locale/moduletranslate.php:322
+msgid "Select the dashboards type below"
+msgstr ""
+
+#: locale/moduletranslate.php:323 locale/moduletranslate.php:599
+#: locale/moduletranslate.php:1011 locale/moduletranslate.php:1188
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:125
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2262
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2414
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:125
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:120
+#: lib/Widget/DataType/Article.php:74
+msgid "Link"
+msgstr ""
+
+#: locale/moduletranslate.php:324
+msgid "The Location (URL) of the dashboard webpage"
+msgstr ""
+
+#: locale/moduletranslate.php:327 locale/moduletranslate.php:329
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:589
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:617
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:611
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:248
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+msgid "DataSet"
+msgstr ""
+
+#: locale/moduletranslate.php:328
+msgid "Display DataSet content"
+msgstr ""
+
+#: locale/moduletranslate.php:330
+msgid "Please select the DataSet to use as a source of data for this template."
+msgstr ""
+
+#: locale/moduletranslate.php:332 locale/moduletranslate.php:708
+msgid "Lower Row Limit"
+msgstr ""
+
+#: locale/moduletranslate.php:333
+msgid ""
+"Please enter the Lower Row Limit for this DataSet (enter 0 for no limit)."
+msgstr ""
+
+#: locale/moduletranslate.php:334 locale/moduletranslate.php:710
+msgid "Lower limit must be 0 or above"
+msgstr ""
+
+#: locale/moduletranslate.php:335 locale/moduletranslate.php:711
+msgid "Lower limit must be lower than the upper limit"
+msgstr ""
+
+#: locale/moduletranslate.php:336 locale/moduletranslate.php:712
+msgid "Upper Row Limit"
+msgstr ""
+
+#: locale/moduletranslate.php:337
+msgid ""
+"Please enter the Upper Row Limit for this DataSet (enter 0 for no limit)."
+msgstr ""
+
+#: locale/moduletranslate.php:338 locale/moduletranslate.php:714
+msgid "Upper limit must be 0 or above"
+msgstr ""
+
+#: locale/moduletranslate.php:339
+msgid "Randomise?"
+msgstr ""
+
+#: locale/moduletranslate.php:340
+msgid ""
+"Should the order of the feed be randomised? When enabled each time the "
+"Widget is shown the items will be randomly shuffled and displayed in a "
+"random order."
+msgstr ""
+
+#: locale/moduletranslate.php:341
+msgid "Number of Items"
+msgstr ""
+
+#: locale/moduletranslate.php:342
+msgid "The Number of items you want to display"
+msgstr ""
+
+#: locale/moduletranslate.php:343
+msgid "When duration is per item the number of items must be greater than 1"
+msgstr ""
+
+#: locale/moduletranslate.php:346 locale/moduletranslate.php:350
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:193
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:345
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:148
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:193
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:362
+msgid "Order"
+msgstr ""
+
+#: locale/moduletranslate.php:347
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:313
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:330
+msgid ""
+"The DataSet results can be ordered by any column and set below. New fields "
+"can be added by selecting the plus icon at the end of the current row. "
+"Should a more complicated order be required the advanced checkbox can be "
+"selected to provide custom SQL syntax."
+msgstr ""
+
+#: locale/moduletranslate.php:348
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:328
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:345
+msgid "Use advanced order clause?"
+msgstr ""
+
+#: locale/moduletranslate.php:349 locale/moduletranslate.php:356
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:334
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:385
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:351
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:402
+msgid "Provide a custom clause instead of using the clause builder above."
+msgstr ""
+
+#: locale/moduletranslate.php:351
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:351
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:368
+msgid "Please enter a SQL clause for how this dataset should be ordered"
+msgstr ""
+
+#: locale/moduletranslate.php:352 locale/moduletranslate.php:357
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:197
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:396
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:146
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:146
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:197
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:413
+msgid "Filter"
+msgstr ""
+
+#: locale/moduletranslate.php:353
+msgid ""
+"The DataSet results can be filtered by any column and set below. New fields "
+"can be added by selecting the plus icon at the end of the current row. "
+"Should a more complicated filter be required the advanced checkbox can be "
+"selected to provide custom SQL syntax. The substitution [DisplayId] can be "
+"used in filter clauses and will be substituted at run time with the Display "
+"ID. When shown in the CMS it will be substituted with 0."
+msgstr ""
+
+#: locale/moduletranslate.php:354
+msgid ""
+"The substitution [Tag:tagName:defaultValue] can also be used in filter "
+"clauses. Replace tagName with the actual display tag name you want to use "
+"and defaultValue with the value to be used if the tag value is not found (e."
+"g., [Tag:region:unknown]). At runtime, it will be substituted with the "
+"Display's tag value or defaultValue if the tag value is not found. When "
+"shown in the CMS, it will be substituted with an empty string if the tag is "
+"not found at all."
+msgstr ""
+
+#: locale/moduletranslate.php:355
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:379
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:396
+msgid "Use advanced filter clause?"
+msgstr ""
+
+#: locale/moduletranslate.php:358
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:402
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:419
+msgid "Please enter a SQL clause to filter this DataSet."
+msgstr ""
+
+#: locale/moduletranslate.php:359
+msgid "Caching"
+msgstr ""
+
+#: locale/moduletranslate.php:362
+msgid "Freshness (mins)"
+msgstr ""
+
+#: locale/moduletranslate.php:363
+msgid ""
+"If the Player is offline it will switch to the No Data Template after this "
+"freshness time. Set this to 0 to never switch."
+msgstr ""
+
+#: locale/moduletranslate.php:364 locale/dbtranslate.php:50
+msgid "Embedded"
+msgstr ""
+
+#: locale/moduletranslate.php:365
+msgid "Embed HTML and JavaScript"
+msgstr ""
+
+#: locale/moduletranslate.php:366 locale/moduletranslate.php:1013
+msgid "Background transparent?"
+msgstr ""
+
+#: locale/moduletranslate.php:367 locale/moduletranslate.php:1014
+msgid ""
+"Should the Widget be shown with a transparent background? Also requires the "
+"embedded content to have a transparent background."
+msgstr ""
+
+#: locale/moduletranslate.php:368
+msgid "Scale Content?"
+msgstr ""
+
+#: locale/moduletranslate.php:369
+msgid "Should the embedded content be scaled along with the layout?"
+msgstr ""
+
+#: locale/moduletranslate.php:370 locale/moduletranslate.php:1015
+msgid "Preload?"
+msgstr ""
+
+#: locale/moduletranslate.php:371
+msgid ""
+"Should this Widget be loaded entirely off-screen so that it is ready when "
+"shown? Dynamic content will start running off screen."
+msgstr ""
+
+#: locale/moduletranslate.php:372 locale/moduletranslate.php:1440
+msgid "HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:373
+msgid "Add HTML to be included between the BODY tag."
+msgstr ""
+
+#: locale/moduletranslate.php:374
+msgid "Style Sheet"
+msgstr ""
+
+#: locale/moduletranslate.php:375
+msgid ""
+"Add CSS to be included immediately before the closing body tag. Please do "
+"not include style tags."
+msgstr ""
+
+#: locale/moduletranslate.php:376
+msgid "JavaScript"
+msgstr ""
+
+#: locale/moduletranslate.php:377 locale/moduletranslate.php:974
+#: locale/moduletranslate.php:1215 locale/moduletranslate.php:1448
+#: locale/moduletranslate.php:1480 locale/moduletranslate.php:1748
+#: locale/moduletranslate.php:2161 locale/moduletranslate.php:3087
+#: locale/moduletranslate.php:3548
+msgid ""
+"Add JavaScript to be included immediately before the closing body tag. Do "
+"not use [] array notation as this is reserved for library references. Do not "
+"include script tags."
+msgstr ""
+
+#: locale/moduletranslate.php:378
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:277
+msgid "HEAD"
+msgstr ""
+
+#: locale/moduletranslate.php:379
+msgid ""
+"Add additional tags to appear immediately before the closing head tag, such "
+"as meta, link, etc. If your JavaScript uses the [] array notation add it "
+"inside script tags here."
+msgstr ""
+
+#: locale/moduletranslate.php:380
+msgid "Emergency Alert"
+msgstr ""
+
+#: locale/moduletranslate.php:381
+msgid "A module for displaying emergency alert elements based on an CAP feed"
+msgstr ""
+
+#: locale/moduletranslate.php:382
+msgid "CAP URL"
+msgstr ""
+
+#: locale/moduletranslate.php:383
+msgid "The Link for the CAP feed"
+msgstr ""
+
+#: locale/moduletranslate.php:384
+msgid "Area-Specific Alert Delivery"
+msgstr ""
+
+#: locale/moduletranslate.php:385
+msgid "Send this alert only to displays matching the alert's area?"
+msgstr ""
+
+#: locale/moduletranslate.php:386
+msgid "Filter by Status"
+msgstr ""
+
+#: locale/moduletranslate.php:387
+msgid ""
+"Only show Emergency Alerts in this layout if the status matches the selected "
+"option."
+msgstr ""
+
+#: locale/moduletranslate.php:388 locale/moduletranslate.php:396
+#: locale/moduletranslate.php:404 locale/moduletranslate.php:410
+#: locale/moduletranslate.php:425 locale/moduletranslate.php:437
+#: locale/moduletranslate.php:445 locale/moduletranslate.php:453
+msgid "Any"
+msgstr ""
+
+#: locale/moduletranslate.php:389
+msgid "Actual"
+msgstr ""
+
+#: locale/moduletranslate.php:390
+msgid "Exercise"
+msgstr ""
+
+#: locale/moduletranslate.php:391
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:533
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:208
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:127
+msgid "System"
+msgstr ""
+
+#: locale/moduletranslate.php:392
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3458
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:640
+msgid "Test"
+msgstr ""
+
+#: locale/moduletranslate.php:393 lib/Factory/LayoutFactory.php:2762
+#: lib/Factory/LayoutFactory.php:2775
+msgid "Draft"
+msgstr ""
+
+#: locale/moduletranslate.php:394
+msgid "Filter by Message Type"
+msgstr ""
+
+#: locale/moduletranslate.php:395
+msgid ""
+"Only show Emergency Alerts in this layout if the message type matches the "
+"selected option."
+msgstr ""
+
+#: locale/moduletranslate.php:397
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:333
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:197
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:268
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3257
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3325
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:361
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:293
+msgid "Alert"
+msgstr ""
+
+#: locale/moduletranslate.php:398
+msgid "Update"
+msgstr ""
+
+#: locale/moduletranslate.php:399
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:67
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:68
+#: cache/16/16ddf7e420eb8069c1a5deab6ce65be2.php:70
+#: cache/39/396f36a5868b8bc37c54b9672b88f669.php:70
+#: cache/c8/c8a44d0ca0af4311b6a8db2c78bb5576.php:69
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:67
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:70
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:76
+#: cache/48/4832df7e87cac4c001ba6814607f0504.php:67
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:175
+#: cache/b8/b827f37e4bcd9aceec71b399ad0e597f.php:67
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:68
+#: cache/b8/b86809ac450d70fe762a12de38b6b84f.php:67
+#: cache/ff/ff53ca9025540b897618be0c098d83cb.php:67
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:67
+#: cache/55/55480053db687228f2af258c58523fef.php:67
+#: cache/5a/5ab03a5b2ec6fe2346c93676aa6a2ece.php:67
+#: cache/46/46a12bada7850bd93ae80ec4ec703020.php:70
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:67
+#: cache/e7/e7d5481c1fc1f99f2eb87ba3724b4798.php:67
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:69
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:69
+#: cache/36/36bf1042971e35f01c528382c4e2ebe9.php:67
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:68
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:68
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:71
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:68
+#: cache/7a/7a8ad14e9394ea474747eba4d5e8355e.php:67
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:68
+#: cache/f4/f4b0861fe3bd8635c8e4460f698cac0e.php:79
+#: cache/84/84b99aef46296bdae8366b9b2cef050f.php:67
+#: cache/09/090cf553152bf65c409fe77487c28249.php:67
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:68
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:68
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:68
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:68
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:70
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:69
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:67
+#: cache/3f/3fff772c4b50b4be1c278efa53e51975.php:70
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:67
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:67
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:69
+#: cache/d4/d4b2abaf26d2534088241d6656d9b1a9.php:73
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:68
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:76
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:69
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:69
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:74
+#: cache/bf/bffb4da6719cf0b10a505165c869e456.php:70
+#: cache/64/64646a640c76027371e2935cda4b433c.php:67
+#: cache/ae/ae9dbc50775e4a2e35f2b3d47ea4c903.php:67
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:70
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:591
+#: cache/d2/d21e8a64f7edc7ce59554632a094e1cc.php:70
+#: cache/30/304a80e4331dc3ca1db9b9f51d833987.php:67
+#: cache/b1/b1dc7b94fc6bf2d2aa104558aeac60a2.php:67
+#: cache/a4/a42e589153f031845c0d3304ed5d9d32.php:67
+#: cache/f6/f670ecee482264686c6d025f5b04b60d.php:67
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:76
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:70
+#: cache/90/90862a7ca7a5cceaa1f138c598b6b16d.php:67
+#: cache/dc/dcfc1ae248c38fe9cf4cfee76751c346.php:70
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:76
+#: cache/96/9633909657b383876962dc7af40f1d9a.php:67
+#: cache/fb/fb7adbdacbb41d667f8ac87d10d7340e.php:67
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:73
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:76
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:73
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:67
+#: cache/25/25d4074e9eb61f4cf38f7925754d7e66.php:67
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:125
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:69
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:67
+#: cache/ee/eef84193bcdd079248f9945fb2d47f22.php:67
+#: cache/ee/eed40b13770b97167f375466867a779e.php:69
+#: cache/2f/2ff5e074540e4fba11b17571f8a8f63e.php:67
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:67
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:70
+#: cache/ad/ad6451318b1bb1af5d4ae026008254d7.php:70
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:76
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:67
+#: cache/9a/9aa73425f7ca637f225c7ea17827792f.php:70
+#: cache/74/74834652bf0eeb1d711de01b8086ce3b.php:67
+#: cache/1b/1bb1add0325cf87ffe8364fea056a443.php:67
+#: cache/23/23525fb78ad0912d772847c7fa4abf9c.php:67
+#: cache/2a/2a7041a7aaa1920fa6e115acab2baed8.php:67
+#: cache/34/3439fa3af454e93f2ed9d709b898b56f.php:72
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:68
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:73
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:80
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:76
+#: cache/1f/1fb3ca31aef0e3fe7b52bbfbb6a4af73.php:70
+#: cache/63/63b15102c208fae253c32c2f77313cc2.php:67
+#: cache/c0/c0be2434c592f085d1c7d2a30346ab1f.php:67
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:76
+#: cache/0b/0bfce4ff89cb4e56420dcb4f282c333f.php:67
+#: cache/0b/0bb72264cd242f2afd564859f6bf11f9.php:69
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:70
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:67
+#: cache/fd/fddc43e726be2bc7dfd3ad2aeb4e2104.php:67
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:67
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:69
+#: cache/2b/2b1a2c99481cb6bf3041ec49215e7147.php:76
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:76
+#: cache/71/7194942bf0bccb84d38e565de0fdafc8.php:68
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:67
+#: cache/02/02c84068a1b9b436fec1c165c50ea5c7.php:67
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:67
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:69
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:67
+#: cache/50/50d14368a7ff5330505ebb92bb2ad8e8.php:67
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:72
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:70
+#: cache/c6/c6d931f51a0aaa053f73e039b1fd4947.php:67
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:67
+#: cache/b0/b0b923280921575816524c84c5dc4bf9.php:67
+#: cache/4e/4e4d410560dee1c82b8fd849dba6db02.php:76
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:71
+#: cache/bc/bc9269a5f1accd4b0930cf946764c3dc.php:67
+#: cache/4a/4ab9918a9e09352e2b177d64bcd99b84.php:67
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:68
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:67
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:70
+#: cache/78/7875975092226a1328f8ec7d90ce4b25.php:70
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:234
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:255
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:709
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:67
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:67
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:68
+#: cache/cd/cd143932270a543fae63974db1f8be19.php:67
+#: cache/ca/ca9b899371870f4435fd70e881698a42.php:67
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:76
+#: cache/42/42493ba813fc77f7caf608638138cbdc.php:70
+#: cache/6b/6b5b58443921708eea82a32bb9f87c4c.php:70
+#: cache/72/7223733b6d46a336184288000193d298.php:67
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:69
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:67
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:70
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:67
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:70
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:67
+#: cache/10/10a3d62a635559ddeaa4c9ab8b676aaf.php:67
+#: cache/b9/b9b5222ce78d1050139467177732ab76.php:70
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:67
+#: cache/d0/d0cbe599603d68ffbcfef9b7b507a1dc.php:67
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:69
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:76
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:76
+#: cache/3e/3e3117c1d042d50f9efa8908d5b286c1.php:70
+#: cache/5b/5b618f049877ed3965ece85d731e1cc7.php:67
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:70
+#: cache/5b/5b72c53542bfbaa4cd431216826e8495.php:67
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:68
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:67
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:69
+#: cache/20/20d56467e17c048a093ad7168955489f.php:71
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:95
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:70
+#: cache/19/199e4757d5dd04d073c54aef2b324867.php:67
+#: cache/ce/cee07744a1d4a288c81e3d8649a8ddc2.php:67
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:69
+msgid "Cancel"
+msgstr ""
+
+#: locale/moduletranslate.php:400
+msgid "Ack"
+msgstr ""
+
+#: locale/moduletranslate.php:401
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:333
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:197
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:268
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3269
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3337
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:361
+#: cache/83/83bb1c5c0884298aa59fc23624c344db.php:51
+#: cache/83/83bb1c5c0884298aa59fc23624c344db.php:62
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:293
+msgid "Error"
+msgstr ""
+
+#: locale/moduletranslate.php:402
+msgid "Filter by Scope"
+msgstr ""
+
+#: locale/moduletranslate.php:403
+msgid ""
+"Only show Emergency Alerts in this layout if the scope matches the selected "
+"option."
+msgstr ""
+
+#: locale/moduletranslate.php:405
+msgid "Public"
+msgstr ""
+
+#: locale/moduletranslate.php:406
+msgid "Restricted"
+msgstr ""
+
+#: locale/moduletranslate.php:407
+msgid "Private"
+msgstr ""
+
+#: locale/moduletranslate.php:408
+msgid "Filter by Event Category"
+msgstr ""
+
+#: locale/moduletranslate.php:409
+msgid ""
+"Only show Emergency Alerts in this layout if the category matches the "
+"selected option."
+msgstr ""
+
+#: locale/moduletranslate.php:411
+msgid "Geo - Geophysical (including landslides)"
+msgstr ""
+
+#: locale/moduletranslate.php:412
+msgid "Met - Meteorological (including floods)"
+msgstr ""
+
+#: locale/moduletranslate.php:413
+msgid "Safety - General emergency and public safety"
+msgstr ""
+
+#: locale/moduletranslate.php:414
+msgid ""
+"Security - Law enforcement, military, homeland and local/private security"
+msgstr ""
+
+#: locale/moduletranslate.php:415
+msgid "Rescue - Rescue and recovery"
+msgstr ""
+
+#: locale/moduletranslate.php:416
+msgid "Fire - Fire suppression and rescue"
+msgstr ""
+
+#: locale/moduletranslate.php:417
+msgid "Health - Medical and public health"
+msgstr ""
+
+#: locale/moduletranslate.php:418
+msgid "Env - Pollution and other environmental"
+msgstr ""
+
+#: locale/moduletranslate.php:419
+msgid "Transport - Public and private transportation"
+msgstr ""
+
+#: locale/moduletranslate.php:420
+msgid "Infra - Utility, telecommunication, other non-transport infrastructure"
+msgstr ""
+
+#: locale/moduletranslate.php:421
+msgid ""
+"CBRNE - Chemical, Biological, Radiological, Nuclear or High-Yield Explosive "
+"threat or attack"
+msgstr ""
+
+#: locale/moduletranslate.php:422
+msgid "Other - Other events"
+msgstr ""
+
+#: locale/moduletranslate.php:423
+msgid "Filter by Response Type"
+msgstr ""
+
+#: locale/moduletranslate.php:424
+msgid ""
+"Only show Emergency Alerts in this layout if the response type matches the "
+"selected option."
+msgstr ""
+
+#: locale/moduletranslate.php:426
+msgid "Shelter"
+msgstr ""
+
+#: locale/moduletranslate.php:427
+msgid "Evacuate"
+msgstr ""
+
+#: locale/moduletranslate.php:428
+msgid "Prepare"
+msgstr ""
+
+#: locale/moduletranslate.php:429
+msgid "Execute"
+msgstr ""
+
+#: locale/moduletranslate.php:430
+msgid "Avoid"
+msgstr ""
+
+#: locale/moduletranslate.php:431
+msgid "Monitor"
+msgstr ""
+
+#: locale/moduletranslate.php:432
+msgid "Assess"
+msgstr ""
+
+#: locale/moduletranslate.php:433
+msgid "AllClear"
+msgstr ""
+
+#: locale/moduletranslate.php:434 locale/moduletranslate.php:2164
+#: locale/moduletranslate.php:2193 locale/moduletranslate.php:2222
+#: locale/moduletranslate.php:2251 locale/moduletranslate.php:2286
+#: locale/moduletranslate.php:2321 locale/moduletranslate.php:2352
+#: locale/moduletranslate.php:2381 locale/moduletranslate.php:2410
+#: locale/moduletranslate.php:2439 locale/moduletranslate.php:2476
+#: locale/moduletranslate.php:2513 locale/moduletranslate.php:2550
+#: locale/moduletranslate.php:2577 locale/moduletranslate.php:2606
+#: locale/moduletranslate.php:2641 locale/moduletranslate.php:2676
+#: locale/moduletranslate.php:2700 locale/moduletranslate.php:2732
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:189
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:357
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:621
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:387
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:434
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:739
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:758
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:241
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2494
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:413
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:677
+msgid "None"
+msgstr ""
+
+#: locale/moduletranslate.php:435
+msgid "Filter by Urgency"
+msgstr ""
+
+#: locale/moduletranslate.php:436
+msgid ""
+"Only show Emergency Alerts in this layout if the urgency matches the "
+"selected option."
+msgstr ""
+
+#: locale/moduletranslate.php:438
+msgid "Immediate"
+msgstr ""
+
+#: locale/moduletranslate.php:439
+msgid "Expected"
+msgstr ""
+
+#: locale/moduletranslate.php:440
+msgid "Future"
+msgstr ""
+
+#: locale/moduletranslate.php:441
+msgid "Past"
+msgstr ""
+
+#: locale/moduletranslate.php:442 locale/moduletranslate.php:450
+#: locale/moduletranslate.php:458 lib/Controller/Display.php:1338
+#: lib/Controller/Display.php:1340
+msgid "Unknown"
+msgstr ""
+
+#: locale/moduletranslate.php:443
+msgid "Filter by Severity"
+msgstr ""
+
+#: locale/moduletranslate.php:444
+msgid ""
+"Only show Emergency Alerts in this layout if the severity matches the "
+"selected option."
+msgstr ""
+
+#: locale/moduletranslate.php:446
+msgid "Extreme"
+msgstr ""
+
+#: locale/moduletranslate.php:447
+msgid "Severe"
+msgstr ""
+
+#: locale/moduletranslate.php:448
+msgid "Moderate"
+msgstr ""
+
+#: locale/moduletranslate.php:449
+msgid "Minor"
+msgstr ""
+
+#: locale/moduletranslate.php:451
+msgid "Filter by certainty"
+msgstr ""
+
+#: locale/moduletranslate.php:452
+msgid ""
+"Only show Emergency Alerts in this layout if the certainty matches the "
+"selected option."
+msgstr ""
+
+#: locale/moduletranslate.php:454
+msgid "Observed"
+msgstr ""
+
+#: locale/moduletranslate.php:455
+msgid "Likely"
+msgstr ""
+
+#: locale/moduletranslate.php:456
+msgid "Possible"
+msgstr ""
+
+#: locale/moduletranslate.php:457
+msgid "Unlikely"
+msgstr ""
+
+#: locale/moduletranslate.php:461 locale/dbtranslate.php:58
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1600
+msgid "Flash"
+msgstr ""
+
+#: locale/moduletranslate.php:462
+msgid "Upload SWF files to assign to Layouts"
+msgstr ""
+
+#: locale/moduletranslate.php:465 locale/dbtranslate.php:99
+#: lib/Connector/OpenWeatherMapConnector.php:667
+msgid "Weather"
+msgstr ""
+
+#: locale/moduletranslate.php:466
+msgid "A module for displaying weather information. Uses the Forecast API"
+msgstr ""
+
+#: locale/moduletranslate.php:467 locale/moduletranslate.php:500
+msgid "Use the Display Location"
+msgstr ""
+
+#: locale/moduletranslate.php:468 locale/moduletranslate.php:501
+msgid "Use the location configured on the display"
+msgstr ""
+
+#: locale/moduletranslate.php:469 locale/moduletranslate.php:502
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:732
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:374
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:555
+msgid "Latitude"
+msgstr ""
+
+#: locale/moduletranslate.php:470 locale/moduletranslate.php:503
+msgid "The Latitude for this widget"
+msgstr ""
+
+#: locale/moduletranslate.php:471 locale/moduletranslate.php:504
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:736
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:391
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:559
+msgid "Longitude"
+msgstr ""
+
+#: locale/moduletranslate.php:472 locale/moduletranslate.php:505
+msgid "The Longitude for this widget"
+msgstr ""
+
+#: locale/moduletranslate.php:473 locale/moduletranslate.php:3065
+msgid "Units"
+msgstr ""
+
+#: locale/moduletranslate.php:474
+msgid "Select the units you would like to use."
+msgstr ""
+
+#: locale/moduletranslate.php:477
+msgid "Only show Daytime weather conditions"
+msgstr ""
+
+#: locale/moduletranslate.php:478
+msgid "Tick if you would like to only show the Daytime weather conditions."
+msgstr ""
+
+#: locale/moduletranslate.php:489 locale/dbtranslate.php:71
+msgid "Generic File"
+msgstr ""
+
+#: locale/moduletranslate.php:490 locale/dbtranslate.php:72
+msgid "A generic file to be stored in the library"
+msgstr ""
+
+#: locale/moduletranslate.php:493 locale/dbtranslate.php:91
+msgid "Google Traffic"
+msgstr ""
+
+#: locale/moduletranslate.php:494
+msgid "A module for displaying traffic information using Google Maps"
+msgstr ""
+
+#: locale/moduletranslate.php:495
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:81
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:71
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:85
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:147
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:287
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:72
+#: cache/d7/d750736be0a77fbe10db020765a473f7.php:73
+msgid "API Key"
+msgstr ""
+
+#: locale/moduletranslate.php:496
+msgid "Enter your API Key from Google Maps."
+msgstr ""
+
+#: locale/moduletranslate.php:497
+msgid "Minimum recommended duration"
+msgstr ""
+
+#: locale/moduletranslate.php:498
+msgid "Please enter a minimum recommended duration in seconds for this Module."
+msgstr ""
+
+#: locale/moduletranslate.php:499
+msgid ""
+"This module uses the Google Traffic JavaScript API which is a paid-for API "
+"from Google. Charges will apply each time the map is loaded in the CMS "
+"preview and on each Player, therefore we recommend setting a high widget "
+"duration."
+msgstr ""
+
+#: locale/moduletranslate.php:506
+msgid "Zoom"
+msgstr ""
+
+#: locale/moduletranslate.php:507
+msgid ""
+"How far should the map be zoomed in? The higher the number the closer, 1 "
+"represents the entire globe."
+msgstr ""
+
+#: locale/moduletranslate.php:508
+msgid ""
+"This module is rendered on the Player which means the Player must have an "
+"internet connection."
+msgstr ""
+
+#: locale/moduletranslate.php:509
+msgid ""
+"The Traffic Widget has not been configured yet, please ask your CMS "
+"Administrator to look at it for you."
+msgstr ""
+
+#: locale/moduletranslate.php:510
+msgid ""
+"You have entered a duration lower than the recommended minimum, this could "
+"cause significant API charges."
+msgstr ""
+
+#: locale/moduletranslate.php:511 locale/dbtranslate.php:93
+msgid "HLS"
+msgstr ""
+
+#: locale/moduletranslate.php:512
+msgid "A module for displaying HLS video streams"
+msgstr ""
+
+#: locale/moduletranslate.php:513 locale/moduletranslate.php:639
+#: locale/moduletranslate.php:980
+msgid "Default Mute?"
+msgstr ""
+
+#: locale/moduletranslate.php:514 locale/moduletranslate.php:640
+msgid "Should new widgets default to Muted?"
+msgstr ""
+
+#: locale/moduletranslate.php:515
+msgid "Default Subtitle?"
+msgstr ""
+
+#: locale/moduletranslate.php:516
+msgid "Should new widgets default to Enabled Subtitles?"
+msgstr ""
+
+#: locale/moduletranslate.php:517 locale/moduletranslate.php:641
+msgid "Video Path"
+msgstr ""
+
+#: locale/moduletranslate.php:518
+msgid ""
+"A URL to the HLS video stream. Requires Player running Windows 8.1 or later, "
+"or Android 6 or later. Earlier Android devices may play HLS via the "
+"LocalVideo widget."
+msgstr ""
+
+#: locale/moduletranslate.php:519 locale/moduletranslate.php:647
+#: locale/moduletranslate.php:993
+msgid "Mute?"
+msgstr ""
+
+#: locale/moduletranslate.php:520 locale/moduletranslate.php:648
+#: locale/moduletranslate.php:994
+msgid "Should the video be muted?"
+msgstr ""
+
+#: locale/moduletranslate.php:521
+msgid "Enable Subtitles?"
+msgstr ""
+
+#: locale/moduletranslate.php:522
+msgid ""
+"Show subtitles if available in the HLS stream. Note that not all streams "
+"include captions, and some may have them permanently embedded in the video."
+msgstr ""
+
+#: locale/moduletranslate.php:523
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1604
+msgid "HTML Package"
+msgstr ""
+
+#: locale/moduletranslate.php:524
+msgid "Upload a complete package to distribute to Players"
+msgstr ""
+
+#: locale/moduletranslate.php:527
+msgid "Nominated File"
+msgstr ""
+
+#: locale/moduletranslate.php:528
+msgid ""
+"Enter a nominated file name that player will attempt to open after "
+"extracting the .htz archive"
+msgstr ""
+
+#: locale/moduletranslate.php:529 locale/moduletranslate.php:1187
+#: locale/moduletranslate.php:1435 locale/moduletranslate.php:2114
+#: locale/moduletranslate.php:2869 locale/moduletranslate.php:2982
+#: locale/dbtranslate.php:52 cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:876
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1358
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1588
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2274
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2426
+#: lib/Widget/DataType/Product.php:52 lib/Widget/DataType/Article.php:77
+#: lib/Widget/DataType/ProductCategory.php:43
+msgid "Image"
+msgstr ""
+
+#: locale/moduletranslate.php:530
+msgid "Upload Image files to assign to Layouts"
+msgstr ""
+
+#: locale/moduletranslate.php:533 locale/moduletranslate.php:982
+msgid "Default Scale type"
+msgstr ""
+
+#: locale/moduletranslate.php:534 locale/moduletranslate.php:1221
+#: locale/moduletranslate.php:1243 locale/moduletranslate.php:1264
+msgid "How should images be scaled by default?"
+msgstr ""
+
+#: locale/moduletranslate.php:536 locale/moduletranslate.php:541
+#: locale/moduletranslate.php:646 locale/moduletranslate.php:985
+#: locale/moduletranslate.php:992 locale/moduletranslate.php:2166
+#: locale/moduletranslate.php:2195 locale/moduletranslate.php:2224
+#: locale/moduletranslate.php:2253 locale/moduletranslate.php:2288
+#: locale/moduletranslate.php:2323 locale/moduletranslate.php:2354
+#: locale/moduletranslate.php:2383 locale/moduletranslate.php:2412
+#: locale/moduletranslate.php:2441 locale/moduletranslate.php:2478
+#: locale/moduletranslate.php:2515 locale/moduletranslate.php:2552
+#: locale/moduletranslate.php:2579 locale/moduletranslate.php:2608
+#: locale/moduletranslate.php:2643 locale/moduletranslate.php:2678
+#: locale/moduletranslate.php:2702 locale/moduletranslate.php:2734
+msgid "Stretch"
+msgstr ""
+
+#: locale/moduletranslate.php:537 locale/moduletranslate.php:542
+#: locale/moduletranslate.php:2165 locale/moduletranslate.php:2194
+#: locale/moduletranslate.php:2223 locale/moduletranslate.php:2252
+#: locale/moduletranslate.php:2287 locale/moduletranslate.php:2322
+#: locale/moduletranslate.php:2353 locale/moduletranslate.php:2382
+#: locale/moduletranslate.php:2411 locale/moduletranslate.php:2440
+#: locale/moduletranslate.php:2477 locale/moduletranslate.php:2514
+#: locale/moduletranslate.php:2551 locale/moduletranslate.php:2578
+#: locale/moduletranslate.php:2607 locale/moduletranslate.php:2642
+#: locale/moduletranslate.php:2677 locale/moduletranslate.php:2701
+#: locale/moduletranslate.php:2733
+msgid "Fit"
+msgstr ""
+
+#: locale/moduletranslate.php:538 locale/moduletranslate.php:643
+#: locale/moduletranslate.php:989 locale/moduletranslate.php:2118
+#: locale/moduletranslate.php:2874
+msgid "Scale type"
+msgstr ""
+
+#: locale/moduletranslate.php:539 locale/moduletranslate.php:2119
+#: locale/moduletranslate.php:2875
+msgid "How should this image be scaled?"
+msgstr ""
+
+#: locale/moduletranslate.php:544 locale/moduletranslate.php:2124
+#: locale/moduletranslate.php:2880
+msgid "How should this image be aligned?"
+msgstr ""
+
+#: locale/moduletranslate.php:549 locale/moduletranslate.php:2129
+#: locale/moduletranslate.php:2885
+msgid "How should this image be vertically aligned?"
+msgstr ""
+
+#: locale/moduletranslate.php:553
+msgid "Button"
+msgstr ""
+
+#: locale/moduletranslate.php:554
+msgid "A module for a button to be used as Trigger for Interactive"
+msgstr ""
+
+#: locale/moduletranslate.php:555 locale/moduletranslate.php:601
+#: locale/moduletranslate.php:2181 locale/moduletranslate.php:2210
+#: locale/moduletranslate.php:2239 locale/moduletranslate.php:2268
+#: locale/moduletranslate.php:2303 locale/moduletranslate.php:2338
+#: locale/moduletranslate.php:2369 locale/moduletranslate.php:2398
+#: locale/moduletranslate.php:2427 locale/moduletranslate.php:2456
+#: locale/moduletranslate.php:2493 locale/moduletranslate.php:2530
+#: locale/moduletranslate.php:2567 locale/moduletranslate.php:2594
+#: locale/moduletranslate.php:2623 locale/moduletranslate.php:2658
+#: locale/moduletranslate.php:2717 locale/moduletranslate.php:2748
+#: locale/moduletranslate.php:2749 locale/dbtranslate.php:48
+#: lib/Widget/DataType/SocialMedia.php:65
+msgid "Text"
+msgstr ""
+
+#: locale/moduletranslate.php:556 locale/moduletranslate.php:602
+#: locale/moduletranslate.php:2750 locale/moduletranslate.php:2791
+#: locale/moduletranslate.php:2835 locale/moduletranslate.php:2987
+#: locale/moduletranslate.php:3030 locale/moduletranslate.php:3048
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2279
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2431
+msgid "Font Family"
+msgstr ""
+
+#: locale/moduletranslate.php:558 locale/moduletranslate.php:604
+#: locale/moduletranslate.php:1354 locale/moduletranslate.php:2106
+#: locale/moduletranslate.php:2753 locale/moduletranslate.php:2794
+#: locale/moduletranslate.php:2838 locale/moduletranslate.php:3028
+#: locale/moduletranslate.php:3046
+msgid "Fit to selection"
+msgstr ""
+
+#: locale/moduletranslate.php:559 locale/moduletranslate.php:605
+#: locale/moduletranslate.php:1355 locale/moduletranslate.php:2107
+#: locale/moduletranslate.php:2754 locale/moduletranslate.php:2795
+#: locale/moduletranslate.php:2839 locale/moduletranslate.php:3029
+#: locale/moduletranslate.php:3047
+msgid "Fit to selected area instead of using the font size?"
+msgstr ""
+
+#: locale/moduletranslate.php:560 locale/moduletranslate.php:606
+#: locale/moduletranslate.php:1239 locale/moduletranslate.php:1260
+#: locale/moduletranslate.php:1281 locale/moduletranslate.php:1299
+#: locale/moduletranslate.php:1353 locale/moduletranslate.php:1463
+#: locale/moduletranslate.php:1508 locale/moduletranslate.php:1536
+#: locale/moduletranslate.php:1553 locale/moduletranslate.php:1570
+#: locale/moduletranslate.php:1587 locale/moduletranslate.php:1604
+#: locale/moduletranslate.php:1621 locale/moduletranslate.php:1638
+#: locale/moduletranslate.php:2105 locale/moduletranslate.php:2697
+#: locale/moduletranslate.php:2727 locale/moduletranslate.php:2758
+#: locale/moduletranslate.php:2799 locale/moduletranslate.php:2843
+#: locale/moduletranslate.php:2989 locale/moduletranslate.php:3032
+#: locale/moduletranslate.php:3050
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2287
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2439
+msgid "Font Size"
+msgstr ""
+
+#: locale/moduletranslate.php:561 locale/moduletranslate.php:607
+msgid "Padding"
+msgstr ""
+
+#: locale/moduletranslate.php:562 locale/moduletranslate.php:608
+#: locale/moduletranslate.php:2759 locale/moduletranslate.php:2800
+#: locale/moduletranslate.php:2844
+msgid "Line Height"
+msgstr ""
+
+#: locale/moduletranslate.php:563 locale/moduletranslate.php:609
+#: locale/moduletranslate.php:1341 locale/moduletranslate.php:1697
+#: locale/moduletranslate.php:2760 locale/moduletranslate.php:2801
+#: locale/moduletranslate.php:2845
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2291
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2443
+msgid "Bold"
+msgstr ""
+
+#: locale/moduletranslate.php:564 locale/moduletranslate.php:610
+#: locale/moduletranslate.php:2761 locale/moduletranslate.php:2802
+#: locale/moduletranslate.php:2846
+msgid "Should the text be bold?"
+msgstr ""
+
+#: locale/moduletranslate.php:565 locale/moduletranslate.php:611
+#: locale/moduletranslate.php:1343 locale/moduletranslate.php:1699
+#: locale/moduletranslate.php:2762 locale/moduletranslate.php:2803
+#: locale/moduletranslate.php:2847
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2295
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2447
+msgid "Italics"
+msgstr ""
+
+#: locale/moduletranslate.php:566 locale/moduletranslate.php:612
+#: locale/moduletranslate.php:2763 locale/moduletranslate.php:2804
+#: locale/moduletranslate.php:2848
+msgid "Should the text be italicised?"
+msgstr ""
+
+#: locale/moduletranslate.php:567 locale/moduletranslate.php:613
+#: locale/moduletranslate.php:1345 locale/moduletranslate.php:1701
+#: locale/moduletranslate.php:2764 locale/moduletranslate.php:2805
+#: locale/moduletranslate.php:2849
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2299
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2451
+msgid "Underline"
+msgstr ""
+
+#: locale/moduletranslate.php:568 locale/moduletranslate.php:614
+#: locale/moduletranslate.php:2765 locale/moduletranslate.php:2806
+#: locale/moduletranslate.php:2850
+msgid "Should the text be underlined?"
+msgstr ""
+
+#: locale/moduletranslate.php:569 locale/moduletranslate.php:615
+#: locale/moduletranslate.php:2766 locale/moduletranslate.php:2807
+#: locale/moduletranslate.php:2851
+msgid "Text Wrap"
+msgstr ""
+
+#: locale/moduletranslate.php:570 locale/moduletranslate.php:616
+#: locale/moduletranslate.php:2767 locale/moduletranslate.php:2808
+#: locale/moduletranslate.php:2852
+msgid "Should the text wrap to the next line?"
+msgstr ""
+
+#: locale/moduletranslate.php:571 locale/moduletranslate.php:617
+#: locale/moduletranslate.php:1470 locale/moduletranslate.php:1511
+#: locale/moduletranslate.php:2104 locale/moduletranslate.php:2752
+#: locale/moduletranslate.php:2793 locale/moduletranslate.php:2837
+#: locale/moduletranslate.php:3033 locale/moduletranslate.php:3051
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2283
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2435
+msgid "Font Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:572 locale/moduletranslate.php:618
+#: locale/moduletranslate.php:2755 locale/moduletranslate.php:2796
+#: locale/moduletranslate.php:2840
+msgid "Use gradient for the text?"
+msgstr ""
+
+#: locale/moduletranslate.php:573 locale/moduletranslate.php:619
+#: locale/moduletranslate.php:2756 locale/moduletranslate.php:2797
+#: locale/moduletranslate.php:2841
+msgid ""
+"Gradients work well with most fonts. If you use a custom font please ensure "
+"you test the Layout on your player."
+msgstr ""
+
+#: locale/moduletranslate.php:574 locale/moduletranslate.php:620
+msgid "Font Gradient"
+msgstr ""
+
+#: locale/moduletranslate.php:576 locale/moduletranslate.php:2923
+#: locale/moduletranslate.php:2934 locale/moduletranslate.php:2950
+#: locale/moduletranslate.php:2960 locale/moduletranslate.php:2970
+msgid "Use gradient as background?"
+msgstr ""
+
+#: locale/moduletranslate.php:577 locale/moduletranslate.php:2757
+#: locale/moduletranslate.php:2798 locale/moduletranslate.php:2842
+#: locale/moduletranslate.php:2924 locale/moduletranslate.php:2935
+#: locale/moduletranslate.php:2951 locale/moduletranslate.php:2961
+#: locale/moduletranslate.php:2971
+msgid "Gradient"
+msgstr ""
+
+#: locale/moduletranslate.php:578 locale/moduletranslate.php:621
+msgid "Use Shadow?"
+msgstr ""
+
+#: locale/moduletranslate.php:579 locale/moduletranslate.php:622
+msgid "Should the background have a shadow?"
+msgstr ""
+
+#: locale/moduletranslate.php:580
+msgid "Shadow Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:581 locale/moduletranslate.php:624
+#: locale/moduletranslate.php:2775 locale/moduletranslate.php:2814
+#: locale/moduletranslate.php:2858 locale/moduletranslate.php:2895
+msgid "Shadow X Offset"
+msgstr ""
+
+#: locale/moduletranslate.php:582 locale/moduletranslate.php:625
+#: locale/moduletranslate.php:2776 locale/moduletranslate.php:2815
+#: locale/moduletranslate.php:2859 locale/moduletranslate.php:2896
+msgid "Shadow Y Offset"
+msgstr ""
+
+#: locale/moduletranslate.php:583 locale/moduletranslate.php:626
+#: locale/moduletranslate.php:2777 locale/moduletranslate.php:2816
+#: locale/moduletranslate.php:2860 locale/moduletranslate.php:2897
+msgid "Shadow Blur"
+msgstr ""
+
+#: locale/moduletranslate.php:584 locale/moduletranslate.php:2115
+#: locale/moduletranslate.php:2889 locale/moduletranslate.php:2925
+msgid "Round Border"
+msgstr ""
+
+#: locale/moduletranslate.php:585 locale/moduletranslate.php:2926
+msgid "Should the rectangle have rounded corners?"
+msgstr ""
+
+#: locale/moduletranslate.php:586 locale/moduletranslate.php:2117
+#: locale/moduletranslate.php:2891 locale/moduletranslate.php:2927
+msgid "Border Radius"
+msgstr ""
+
+#: locale/moduletranslate.php:587 locale/moduletranslate.php:2928
+#: locale/moduletranslate.php:2938 locale/moduletranslate.php:2944
+#: locale/moduletranslate.php:2954 locale/moduletranslate.php:2964
+#: locale/moduletranslate.php:2974
+msgid "Show Outline"
+msgstr ""
+
+#: locale/moduletranslate.php:588 locale/moduletranslate.php:2929
+msgid "Should the rectangle have an outline?"
+msgstr ""
+
+#: locale/moduletranslate.php:589 locale/moduletranslate.php:2930
+#: locale/moduletranslate.php:2940 locale/moduletranslate.php:2946
+#: locale/moduletranslate.php:2956 locale/moduletranslate.php:2966
+#: locale/moduletranslate.php:2976
+msgid "Outline Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:590 locale/moduletranslate.php:2931
+#: locale/moduletranslate.php:2941 locale/moduletranslate.php:2947
+#: locale/moduletranslate.php:2957 locale/moduletranslate.php:2967
+#: locale/moduletranslate.php:2977
+msgid "Outline Width"
+msgstr ""
+
+#: locale/moduletranslate.php:593 locale/moduletranslate.php:629
+#: locale/moduletranslate.php:2167 locale/moduletranslate.php:2196
+#: locale/moduletranslate.php:2225 locale/moduletranslate.php:2254
+#: locale/moduletranslate.php:2289 locale/moduletranslate.php:2324
+#: locale/moduletranslate.php:2355 locale/moduletranslate.php:2384
+#: locale/moduletranslate.php:2413 locale/moduletranslate.php:2442
+#: locale/moduletranslate.php:2479 locale/moduletranslate.php:2516
+#: locale/moduletranslate.php:2553 locale/moduletranslate.php:2580
+#: locale/moduletranslate.php:2609 locale/moduletranslate.php:2644
+#: locale/moduletranslate.php:2679 locale/moduletranslate.php:2703
+#: locale/moduletranslate.php:2735 locale/moduletranslate.php:2780
+#: locale/moduletranslate.php:2819 locale/moduletranslate.php:2863
+#: locale/moduletranslate.php:3038 locale/moduletranslate.php:3058
+#: locale/moduletranslate.php:3108 locale/moduletranslate.php:3141
+#: locale/moduletranslate.php:3174 locale/moduletranslate.php:3199
+#: locale/moduletranslate.php:3224 locale/moduletranslate.php:3249
+#: locale/moduletranslate.php:3280 locale/moduletranslate.php:3313
+#: locale/moduletranslate.php:3346 locale/moduletranslate.php:3379
+#: locale/moduletranslate.php:3410 locale/moduletranslate.php:3441
+#: locale/moduletranslate.php:3478 locale/moduletranslate.php:3514
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:617
+msgid "Center"
+msgstr ""
+
+#: locale/moduletranslate.php:600
+msgid "A module for a link to be used as Trigger for Interactive"
+msgstr ""
+
+#: locale/moduletranslate.php:623 locale/moduletranslate.php:2774
+#: locale/moduletranslate.php:2813 locale/moduletranslate.php:2857
+msgid "Text Shadow Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:635
+msgid "Interactive Zone"
+msgstr ""
+
+#: locale/moduletranslate.php:636
+msgid "A module for a zone to be used as Target or Trigger for Interactive"
+msgstr ""
+
+#: locale/moduletranslate.php:637 locale/dbtranslate.php:66
+msgid "Local Video"
+msgstr ""
+
+#: locale/moduletranslate.php:638
+msgid ""
+"Display Video that only exists on the Display by providing a local file path "
+"or URL"
+msgstr ""
+
+#: locale/moduletranslate.php:642
+msgid "A local file path or URL to the video. This can be a RTSP stream."
+msgstr ""
+
+#: locale/moduletranslate.php:644 locale/moduletranslate.php:990
+msgid "How should this video be scaled?"
+msgstr ""
+
+#: locale/moduletranslate.php:645 locale/moduletranslate.php:984
+#: locale/moduletranslate.php:991
+msgid "Aspect"
+msgstr ""
+
+#: locale/moduletranslate.php:649 locale/moduletranslate.php:995
+#: locale/moduletranslate.php:1006
+msgid "Show Full Screen?"
+msgstr ""
+
+#: locale/moduletranslate.php:650 locale/moduletranslate.php:996
+#: locale/moduletranslate.php:1007
+msgid ""
+"Should the video expand over the top of existing content and show in full "
+"screen?"
+msgstr ""
+
+#: locale/moduletranslate.php:651
+msgid ""
+"Please note that video scaling and video streaming via RTSP is only "
+"supported by Android, webOS and Linux players at the current time. The HLS "
+"streaming Widget can be used to show compatible video streams on Windows."
+msgstr ""
+
+#: locale/moduletranslate.php:652 locale/moduletranslate.php:653
+msgid "Mastodon"
+msgstr ""
+
+#: locale/moduletranslate.php:654
+msgid "Default Server URL"
+msgstr ""
+
+#: locale/moduletranslate.php:655
+msgid "The default URL for the mastodon instance."
+msgstr ""
+
+#: locale/moduletranslate.php:656
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:144
+msgid "Cache Period for Images"
+msgstr ""
+
+#: locale/moduletranslate.php:657
+msgid ""
+"Please enter the number of hours you would like to cache mastodon images."
+msgstr ""
+
+#: locale/moduletranslate.php:658
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:127
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:106
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:127
+msgid "Cache Period"
+msgstr ""
+
+#: locale/moduletranslate.php:659
+msgid ""
+"Please enter the number of seconds you would like to cache mastodon search "
+"results."
+msgstr ""
+
+#: locale/moduletranslate.php:660
+msgid "Hashtag"
+msgstr ""
+
+#: locale/moduletranslate.php:661
+msgid ""
+"Test your search by using a Hashtag to return results from the mastodon URL "
+"provided in the module settings."
+msgstr ""
+
+#: locale/moduletranslate.php:662
+msgid "Search on"
+msgstr ""
+
+#: locale/moduletranslate.php:663
+msgid "Show only local/remote server posts."
+msgstr ""
+
+#: locale/moduletranslate.php:664
+msgid "All known servers"
+msgstr ""
+
+#: locale/moduletranslate.php:665
+msgid "Local server"
+msgstr ""
+
+#: locale/moduletranslate.php:666
+msgid "Remote servers"
+msgstr ""
+
+#: locale/moduletranslate.php:667
+msgid "Server"
+msgstr ""
+
+#: locale/moduletranslate.php:668
+msgid "Leave empty to use the one from settings."
+msgstr ""
+
+#: locale/moduletranslate.php:669 locale/moduletranslate.php:3071
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:136
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:205
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:126
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:189
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:134
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:400
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:456
+msgid "Username"
+msgstr ""
+
+#: locale/moduletranslate.php:670
+msgid "Provide Mastodon username to get public statuses from the account."
+msgstr ""
+
+#: locale/moduletranslate.php:671
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:633
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:689
+#: lib/Report/DistributionReport.php:490
+#: lib/Report/SummaryDistributionCommonTrait.php:89
+#: lib/Report/SummaryReport.php:482
+msgid "Count"
+msgstr ""
+
+#: locale/moduletranslate.php:672
+msgid "The number of posts to return (default = 15)."
+msgstr ""
+
+#: locale/moduletranslate.php:674
+msgid "Only posts with attached media?"
+msgstr ""
+
+#: locale/moduletranslate.php:675
+msgid "Return only posts which included attached media."
+msgstr ""
+
+#: locale/moduletranslate.php:678
+msgid "Remove Mentions?"
+msgstr ""
+
+#: locale/moduletranslate.php:679
+msgid "Should mentions (@someone) be removed from the Mastodon Post?"
+msgstr ""
+
+#: locale/moduletranslate.php:680
+msgid "Remove Hashtags?"
+msgstr ""
+
+#: locale/moduletranslate.php:681
+msgid "Should Hashtags (#something) be removed from the Mastodon Post?"
+msgstr ""
+
+#: locale/moduletranslate.php:682
+msgid "Remove URLs?"
+msgstr ""
+
+#: locale/moduletranslate.php:683
+msgid ""
+"Should URLs be removed from the Mastodon Post? Most URLs do not compliment "
+"digital signage."
+msgstr ""
+
+#: locale/moduletranslate.php:684
+msgid "Menu Board: Category"
+msgstr ""
+
+#: locale/moduletranslate.php:685
+msgid "Display categories from a Menu Board"
+msgstr ""
+
+#: locale/moduletranslate.php:686 locale/moduletranslate.php:692
+msgid "Menu"
+msgstr ""
+
+#: locale/moduletranslate.php:687 locale/moduletranslate.php:693
+msgid "Please select the Menu to use as a source of data for this template."
+msgstr ""
+
+#: locale/moduletranslate.php:688 locale/moduletranslate.php:694
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:108
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:108
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:116
+#: lib/Connector/CapConnector.php:543
+#: lib/Connector/NationalWeatherServiceConnector.php:410
+msgid "Category"
+msgstr ""
+
+#: locale/moduletranslate.php:689 locale/moduletranslate.php:695
+msgid ""
+"Please select the Category to use as a source of data for this template."
+msgstr ""
+
+#: locale/moduletranslate.php:690
+msgid "Menu Board: Products"
+msgstr ""
+
+#: locale/moduletranslate.php:691
+msgid "Display products from a Menu Board"
+msgstr ""
+
+#: locale/moduletranslate.php:697
+msgid "The duration specified is per item otherwise it is per menu."
+msgstr ""
+
+#: locale/moduletranslate.php:698
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1271
+msgid "Sort by"
+msgstr ""
+
+#: locale/moduletranslate.php:699
+msgid "How should we sort the menu items?"
+msgstr ""
+
+#: locale/moduletranslate.php:700
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:161
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:304
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:160
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:328
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:221
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:656
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:470
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:651
+msgid "Display Order"
+msgstr ""
+
+#: locale/moduletranslate.php:701 locale/moduletranslate.php:1278
+#: locale/moduletranslate.php:3012 locale/moduletranslate.php:3015
+#: locale/moduletranslate.php:3521
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:154
+#: cache/16/16ddf7e420eb8069c1a5deab6ce65be2.php:135
+#: cache/16/16ddf7e420eb8069c1a5deab6ce65be2.php:158
+#: cache/39/396f36a5868b8bc37c54b9672b88f669.php:149
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:104
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:103
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:157
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:146
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:448
+#: cache/86/86c186879b5295663da37409020c35ad.php:106
+#: cache/86/86c186879b5295663da37409020c35ad.php:122
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:185
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:127
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:238
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:504
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:116
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:158
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:127
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:142
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:113
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:207
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:152
+#: cache/84/846b4e365858df7d149d45d99237360c.php:220
+#: cache/84/846b4e365858df7d149d45d99237360c.php:296
+#: cache/84/846b4e365858df7d149d45d99237360c.php:376
+#: cache/84/846b4e365858df7d149d45d99237360c.php:495
+#: cache/84/846b4e365858df7d149d45d99237360c.php:576
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:160
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:126
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:150
+#: cache/3f/3fff772c4b50b4be1c278efa53e51975.php:94
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:103
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:128
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:137
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:149
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:194
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:186
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:460
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:146
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:341
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:93
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:126
+#: cache/1c/1c65822405d03c36923ecc7399544cf8.php:125
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:94
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:124
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:409
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:100
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:97
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:113
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:119
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:147
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:127
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:172
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:218
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:121
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:213
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:98
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:102
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:139
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:205
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:71
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:172
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:189
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:195
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:447
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:140
+#: cache/ee/eed40b13770b97167f375466867a779e.php:125
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:110
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:197
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:122
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:189
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:111
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:138
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:94
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:173
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:125
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:163
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:498
+#: cache/34/340ecc3756674aa339156a3d6b49cc46.php:69
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:161
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:393
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:513
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:434
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:577
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:824
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:174
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:121
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:305
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:174
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:258
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:94
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:140
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:108
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:144
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:111
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:91
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:118
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:147
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:103
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:93
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:113
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:119
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:127
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:121
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:94
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:274
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:919
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1238
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1280
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1622
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1654
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1782
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:116
+#: cache/8f/8fe7bf7394647124fbd03d8b9c3afd98.php:53
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:164
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:94
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:122
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:262
+#: cache/42/42493ba813fc77f7caf608638138cbdc.php:94
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:108
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:135
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:110
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:271
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:125
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:204
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:247
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:95
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:116
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:183
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:147
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:146
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:192
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:91
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:142
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:175
+#: lib/Widget/DataType/Product.php:46
+#: lib/Widget/DataType/ProductCategory.php:41
+msgid "Name"
+msgstr ""
+
+#: locale/moduletranslate.php:702 locale/moduletranslate.php:3019
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:144
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:143
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:213
+#: lib/Widget/DataType/Product.php:47
+msgid "Price"
+msgstr ""
+
+#: locale/moduletranslate.php:703
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:141
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:191
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:135
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:444
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:163
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:197
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:364
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:495
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:105
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:154
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:203
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:104
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:141
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:621
+#: cache/84/846b4e365858df7d149d45d99237360c.php:292
+#: cache/84/846b4e365858df7d149d45d99237360c.php:372
+#: cache/84/846b4e365858df7d149d45d99237360c.php:491
+#: cache/84/846b4e365858df7d149d45d99237360c.php:572
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:188
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:190
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:115
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:162
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:456
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:135
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:337
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:120
+#: cache/1c/1c65822405d03c36923ecc7399544cf8.php:117
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:405
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:165
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:115
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:166
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:214
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:110
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:209
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:128
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:201
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:443
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:111
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:185
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:114
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:159
+#: cache/9a/9aa73425f7ca637f225c7ea17827792f.php:94
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:382
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:509
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:430
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:301
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:175
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:208
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:282
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:172
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:206
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:166
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:108
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:135
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:462
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:380
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1618
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:111
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:258
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:200
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:243
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:105
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:171
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:372
+msgid "ID"
+msgstr ""
+
+#: locale/moduletranslate.php:704
+msgid "Sort descending?"
+msgstr ""
+
+#: locale/moduletranslate.php:705
+msgid "Show Unavailable Products?"
+msgstr ""
+
+#: locale/moduletranslate.php:706
+msgid "Should the currently unavailable products appear in the menu?"
+msgstr ""
+
+#: locale/moduletranslate.php:707
+msgid ""
+"Row limits can be used to return a subset of menu items. For example if you "
+"wanted the 10th to the 20th item you could put 10 and 20."
+msgstr ""
+
+#: locale/moduletranslate.php:709
+msgid "Provide a Lower Row Limit."
+msgstr ""
+
+#: locale/moduletranslate.php:713
+msgid "Provide an Upper Row Limit."
+msgstr ""
+
+#: locale/moduletranslate.php:715
+msgid "When duration is per item the upper limit must be greater than 1"
+msgstr ""
+
+#: locale/moduletranslate.php:716
+msgid "National Weather Service"
+msgstr ""
+
+#: locale/moduletranslate.php:717
+msgid ""
+"A module for displaying weather alert elements based on National Weather "
+"Service"
+msgstr ""
+
+#: locale/moduletranslate.php:953
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:256
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:257
+msgid "Play count"
+msgstr ""
+
+#: locale/moduletranslate.php:954
+msgid ""
+"In cycle based playback, how many plays should each Widget have before "
+"moving on?"
+msgstr ""
+
+#: locale/moduletranslate.php:955
+msgid "Random Widget each cycle?"
+msgstr ""
+
+#: locale/moduletranslate.php:956
+msgid ""
+"When enabled the next Widget to play will be chosen at random from the "
+"available Widgets."
+msgstr ""
+
+#: locale/moduletranslate.php:957
+msgid "Rich Text"
+msgstr ""
+
+#: locale/moduletranslate.php:958
+msgid "Add Text directly to a Layout"
+msgstr ""
+
+#: locale/moduletranslate.php:959
+msgid "Enter text or HTML in the box below."
+msgstr ""
+
+#: locale/moduletranslate.php:960
+msgid ""
+"Enter the text to display. The red rectangle reflects the size of the region "
+"you are editing. Shift+Enter will drop a single line. Enter alone starts a "
+"new paragraph."
+msgstr ""
+
+#: locale/moduletranslate.php:961
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:367
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:212
+#: cache/84/846b4e365858df7d149d45d99237360c.php:363
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:211
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:149
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:153
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:168
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:217
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:255
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:442
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:167
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:303
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:430
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:247
+msgid "Media"
+msgstr ""
+
+#: locale/moduletranslate.php:962
+msgid "Choose media"
+msgstr ""
+
+#: locale/moduletranslate.php:964 locale/moduletranslate.php:1139
+#: locale/moduletranslate.php:1166 locale/moduletranslate.php:1202
+#: locale/moduletranslate.php:1327 locale/moduletranslate.php:1369
+#: locale/moduletranslate.php:1385 locale/moduletranslate.php:1404
+#: locale/moduletranslate.php:1484 locale/moduletranslate.php:1658
+#: locale/moduletranslate.php:1683 locale/moduletranslate.php:1739
+#: locale/moduletranslate.php:3535 locale/moduletranslate.php:3555
+#: locale/moduletranslate.php:3578
+msgid ""
+"The selected effect works best with a background colour. Optionally add one "
+"here."
+msgstr ""
+
+#: locale/moduletranslate.php:965 locale/moduletranslate.php:1191
+#: locale/moduletranslate.php:1225 locale/moduletranslate.php:1247
+#: locale/moduletranslate.php:1268 locale/moduletranslate.php:1284
+#: locale/moduletranslate.php:1302 locale/moduletranslate.php:1316
+#: locale/moduletranslate.php:1364 locale/moduletranslate.php:1380
+#: locale/moduletranslate.php:1399 locale/moduletranslate.php:1485
+#: locale/moduletranslate.php:1645 locale/moduletranslate.php:1659
+#: locale/moduletranslate.php:1672 locale/moduletranslate.php:1726
+#: locale/moduletranslate.php:3008 locale/moduletranslate.php:3101
+#: locale/moduletranslate.php:3134 locale/moduletranslate.php:3167
+#: locale/moduletranslate.php:3192 locale/moduletranslate.php:3217
+#: locale/moduletranslate.php:3242 locale/moduletranslate.php:3273
+#: locale/moduletranslate.php:3306 locale/moduletranslate.php:3339
+#: locale/moduletranslate.php:3372 locale/moduletranslate.php:3403
+#: locale/moduletranslate.php:3434 locale/moduletranslate.php:3471
+#: locale/moduletranslate.php:3507 locale/moduletranslate.php:3530
+#: locale/moduletranslate.php:3550 locale/moduletranslate.php:3573
+msgid "Effect"
+msgstr ""
+
+#: locale/moduletranslate.php:966 locale/moduletranslate.php:1192
+#: locale/moduletranslate.php:1226 locale/moduletranslate.php:1248
+#: locale/moduletranslate.php:1269 locale/moduletranslate.php:1285
+#: locale/moduletranslate.php:1303 locale/moduletranslate.php:1317
+#: locale/moduletranslate.php:1365 locale/moduletranslate.php:1381
+#: locale/moduletranslate.php:1400 locale/moduletranslate.php:1486
+#: locale/moduletranslate.php:1646 locale/moduletranslate.php:1660
+#: locale/moduletranslate.php:1673 locale/moduletranslate.php:1727
+#: locale/moduletranslate.php:3009 locale/moduletranslate.php:3102
+#: locale/moduletranslate.php:3135 locale/moduletranslate.php:3168
+#: locale/moduletranslate.php:3193 locale/moduletranslate.php:3218
+#: locale/moduletranslate.php:3243 locale/moduletranslate.php:3274
+#: locale/moduletranslate.php:3307 locale/moduletranslate.php:3340
+#: locale/moduletranslate.php:3373 locale/moduletranslate.php:3404
+#: locale/moduletranslate.php:3435 locale/moduletranslate.php:3472
+#: locale/moduletranslate.php:3508 locale/moduletranslate.php:3531
+#: locale/moduletranslate.php:3551 locale/moduletranslate.php:3574
+msgid "Please select the effect that will be used to transition between items."
+msgstr ""
+
+#: locale/moduletranslate.php:967 locale/moduletranslate.php:1193
+#: locale/moduletranslate.php:1227 locale/moduletranslate.php:1249
+#: locale/moduletranslate.php:1270 locale/moduletranslate.php:1286
+#: locale/moduletranslate.php:1304 locale/moduletranslate.php:1318
+#: locale/moduletranslate.php:1366 locale/moduletranslate.php:1382
+#: locale/moduletranslate.php:1401 locale/moduletranslate.php:1487
+#: locale/moduletranslate.php:1647 locale/moduletranslate.php:1661
+#: locale/moduletranslate.php:1674 locale/moduletranslate.php:1728
+#: locale/moduletranslate.php:3010 locale/moduletranslate.php:3103
+#: locale/moduletranslate.php:3136 locale/moduletranslate.php:3169
+#: locale/moduletranslate.php:3194 locale/moduletranslate.php:3219
+#: locale/moduletranslate.php:3244 locale/moduletranslate.php:3275
+#: locale/moduletranslate.php:3308 locale/moduletranslate.php:3341
+#: locale/moduletranslate.php:3374 locale/moduletranslate.php:3405
+#: locale/moduletranslate.php:3436 locale/moduletranslate.php:3473
+#: locale/moduletranslate.php:3509 locale/moduletranslate.php:3532
+#: locale/moduletranslate.php:3552 locale/moduletranslate.php:3575
+msgid "Speed"
+msgstr ""
+
+#: locale/moduletranslate.php:968 locale/moduletranslate.php:3011
+#: locale/moduletranslate.php:3104 locale/moduletranslate.php:3137
+#: locale/moduletranslate.php:3170 locale/moduletranslate.php:3195
+#: locale/moduletranslate.php:3220 locale/moduletranslate.php:3245
+#: locale/moduletranslate.php:3276 locale/moduletranslate.php:3309
+#: locale/moduletranslate.php:3342 locale/moduletranslate.php:3375
+#: locale/moduletranslate.php:3406 locale/moduletranslate.php:3437
+#: locale/moduletranslate.php:3474 locale/moduletranslate.php:3510
+msgid ""
+"The transition speed of the selected effect in milliseconds (normal = 1000) "
+"or the Marquee Speed in a low to high scale (normal = 1)."
+msgstr ""
+
+#: locale/moduletranslate.php:969
+msgid "Marquee Selector"
+msgstr ""
+
+#: locale/moduletranslate.php:970
+msgid ""
+"The selector to use for stacking marquee items in a line when scrolling Left/"
+"Right."
+msgstr ""
+
+#: locale/moduletranslate.php:971
+msgid "Show advanced controls?"
+msgstr ""
+
+#: locale/moduletranslate.php:972
+msgid "Show Javascript and CSS controls."
+msgstr ""
+
+#: locale/moduletranslate.php:973 locale/moduletranslate.php:1214
+#: locale/moduletranslate.php:1447 locale/moduletranslate.php:1479
+#: locale/moduletranslate.php:1747 locale/moduletranslate.php:2160
+#: locale/moduletranslate.php:3086 locale/moduletranslate.php:3547
+msgid "Optional JavaScript"
+msgstr ""
+
+#: locale/moduletranslate.php:975 locale/moduletranslate.php:3546
+msgid "Optional Stylesheet"
+msgstr ""
+
+#: locale/moduletranslate.php:976 locale/dbtranslate.php:54
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:868
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1374
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1592
+msgid "Video"
+msgstr ""
+
+#: locale/moduletranslate.php:977
+msgid "Upload Video files to assign to Layouts"
+msgstr ""
+
+#: locale/moduletranslate.php:981
+msgid "Should new Video Widgets default to Muted?"
+msgstr ""
+
+#: locale/moduletranslate.php:983
+msgid "How should new Video Widgets be scaled by default?"
+msgstr ""
+
+#: locale/moduletranslate.php:986
+msgid ""
+"This video will play for %media.duration% seconds. Cut the video short by "
+"setting a shorter duration in the Advanced tab. Wait on the last frame or "
+"set to Loop by setting a higher duration in the Advanced tab."
+msgstr ""
+
+#: locale/moduletranslate.php:988
+msgid "Should the video loop if it finishes before the provided duration?"
+msgstr ""
+
+#: locale/moduletranslate.php:997 locale/dbtranslate.php:56
+msgid "Video In"
+msgstr ""
+
+#: locale/moduletranslate.php:998
+msgid "Display input from an external source"
+msgstr ""
+
+#: locale/moduletranslate.php:999
+msgid "Input"
+msgstr ""
+
+#: locale/moduletranslate.php:1000
+msgid "Which device input should be shown"
+msgstr ""
+
+#: locale/moduletranslate.php:1001
+msgid "HDMI"
+msgstr ""
+
+#: locale/moduletranslate.php:1002
+msgid "RGB"
+msgstr ""
+
+#: locale/moduletranslate.php:1003
+msgid "DVI"
+msgstr ""
+
+#: locale/moduletranslate.php:1004
+msgid "DP"
+msgstr ""
+
+#: locale/moduletranslate.php:1005
+msgid "OPS"
+msgstr ""
+
+#: locale/moduletranslate.php:1008
+msgid ""
+"This Module is compatible with webOS, Tizen and Philips SOC Players only"
+msgstr ""
+
+#: locale/moduletranslate.php:1009 locale/dbtranslate.php:61
+msgid "Webpage"
+msgstr ""
+
+#: locale/moduletranslate.php:1010
+msgid "Embed a Webpage"
+msgstr ""
+
+#: locale/moduletranslate.php:1012
+msgid "The Location (URL) of the webpage"
+msgstr ""
+
+#: locale/moduletranslate.php:1016
+msgid ""
+"Should this Widget be loaded entirely off screen so that it is ready when "
+"shown? Dynamic content will start running off screen."
+msgstr ""
+
+#: locale/moduletranslate.php:1017
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:91
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:122
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:122
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:421
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1169
+msgid "Options"
+msgstr ""
+
+#: locale/moduletranslate.php:1018
+msgid "How should this web page be embedded?"
+msgstr ""
+
+#: locale/moduletranslate.php:1019
+msgid "Open Natively"
+msgstr ""
+
+#: locale/moduletranslate.php:1020
+msgid "Manual Position"
+msgstr ""
+
+#: locale/moduletranslate.php:1021
+msgid "Best Fit"
+msgstr ""
+
+#: locale/moduletranslate.php:1022
+msgid "Page Width"
+msgstr ""
+
+#: locale/moduletranslate.php:1023
+msgid "The width of the page. Leave empty to use the region width."
+msgstr ""
+
+#: locale/moduletranslate.php:1024
+msgid "Page Height"
+msgstr ""
+
+#: locale/moduletranslate.php:1025
+msgid "The height of the page. Leave empty to use the region height."
+msgstr ""
+
+#: locale/moduletranslate.php:1026
+msgid "Offset Top"
+msgstr ""
+
+#: locale/moduletranslate.php:1027
+msgid "The starting point from the top in pixels"
+msgstr ""
+
+#: locale/moduletranslate.php:1028
+msgid "Offset Left"
+msgstr ""
+
+#: locale/moduletranslate.php:1029
+msgid "The starting point from the left in pixels"
+msgstr ""
+
+#: locale/moduletranslate.php:1030
+msgid "Scale Percentage"
+msgstr ""
+
+#: locale/moduletranslate.php:1031
+msgid "The Percentage to Scale this Webpage (0 - 100)"
+msgstr ""
+
+#: locale/moduletranslate.php:1032
+msgid "Trigger on page load error"
+msgstr ""
+
+#: locale/moduletranslate.php:1033
+msgid ""
+"Code to be triggered when the page to be loaded returns an error, e.g. a 404 "
+"not found."
+msgstr ""
+
+#: locale/moduletranslate.php:1034
+msgid "World Clock - Analogue"
+msgstr ""
+
+#: locale/moduletranslate.php:1035
+msgid "Analogue World Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:1036 locale/moduletranslate.php:1090
+#: locale/moduletranslate.php:1115 locale/moduletranslate.php:1142
+msgid "Clocks"
+msgstr ""
+
+#: locale/moduletranslate.php:1037 locale/moduletranslate.php:1099
+#: locale/moduletranslate.php:1116 locale/moduletranslate.php:1143
+msgid "Clock Columns"
+msgstr ""
+
+#: locale/moduletranslate.php:1038 locale/moduletranslate.php:1100
+#: locale/moduletranslate.php:1117 locale/moduletranslate.php:1144
+msgid "Number of columns to display"
+msgstr ""
+
+#: locale/moduletranslate.php:1039 locale/moduletranslate.php:1101
+#: locale/moduletranslate.php:1118 locale/moduletranslate.php:1145
+msgid "Clock Rows"
+msgstr ""
+
+#: locale/moduletranslate.php:1040 locale/moduletranslate.php:1102
+#: locale/moduletranslate.php:1119 locale/moduletranslate.php:1146
+msgid "Number of rows to display"
+msgstr ""
+
+#: locale/moduletranslate.php:1051
+msgid "Analogue Clock Settings"
+msgstr ""
+
+#: locale/moduletranslate.php:1052
+msgid "Background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1053 locale/moduletranslate.php:1467
+#: locale/moduletranslate.php:1514
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:114
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:869
+msgid "Use the colour picker to select the background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1054
+msgid "Face colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1055
+msgid "Use the colour picker to select the face colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1056
+msgid "Case colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1057
+msgid "Use the colour picker to select the case colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1058
+msgid "Hour hand colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1059
+msgid "Use the colour picker to select the hour hand colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1060
+msgid "Minute hand colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1061
+msgid "Use the colour picker to select the minute hand colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1062
+msgid "Show seconds hand?"
+msgstr ""
+
+#: locale/moduletranslate.php:1063
+msgid "Tick if you would like to show the seconds hand"
+msgstr ""
+
+#: locale/moduletranslate.php:1064
+msgid "Seconds hand colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1065
+msgid "Use the colour picker to select the seconds hand colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1066
+msgid "Dial centre colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1067
+msgid "Use the colour picker to select the dial centre colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1068
+msgid "Show steps?"
+msgstr ""
+
+#: locale/moduletranslate.php:1069
+msgid "Tick if you would like to show the clock steps"
+msgstr ""
+
+#: locale/moduletranslate.php:1070
+msgid "Steps colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1071
+msgid "Use the colour picker to select the steps colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1072
+msgid "Secondary steps colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1073
+msgid "Use the colour picker to select the secondary steps colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1074
+msgid "Detailed look?"
+msgstr ""
+
+#: locale/moduletranslate.php:1075
+msgid ""
+"Tick if you would like to show a more detailed look for the clock ( using "
+"shadows and 3D effects )"
+msgstr ""
+
+#: locale/moduletranslate.php:1076
+msgid "Show inner digital clock?"
+msgstr ""
+
+#: locale/moduletranslate.php:1077
+msgid "Tick if you would like to show a small inner digital clock"
+msgstr ""
+
+#: locale/moduletranslate.php:1078
+msgid "Digital clock text colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1079
+msgid "Use the colour picker to select the digital clock text colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1080
+msgid "Digital clock background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1081
+msgid "Use the colour picker to select the digital clock background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1082
+msgid "Show label?"
+msgstr ""
+
+#: locale/moduletranslate.php:1083
+msgid "Tick if you would like to show the timezone label"
+msgstr ""
+
+#: locale/moduletranslate.php:1084
+msgid "Label text colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1085
+msgid "Use the colour picker to select the label text colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1086
+msgid "Label background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1087
+msgid "Use the colour picker to select the label background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1088
+msgid "World Clock - Custom"
+msgstr ""
+
+#: locale/moduletranslate.php:1089
+msgid "Custom World Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:1091
+msgid "Template - HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:1092
+msgid ""
+"Enter text or HTML in the box below. Use squared brackets for elements to be "
+"replaced (e.g. [HH:mm] for time, or [label] to show the timezone name"
+msgstr ""
+
+#: locale/moduletranslate.php:1093
+msgid "Template - Stylesheet"
+msgstr ""
+
+#: locale/moduletranslate.php:1094
+msgid "Enter CSS styling in the box below."
+msgstr ""
+
+#: locale/moduletranslate.php:1113
+msgid "World Clock - Time and Date"
+msgstr ""
+
+#: locale/moduletranslate.php:1114
+msgid "Time and Date World Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:1133 locale/moduletranslate.php:1160
+msgid "The colour of the label"
+msgstr ""
+
+#: locale/moduletranslate.php:1134 locale/moduletranslate.php:1161
+msgid "Date/Time Font"
+msgstr ""
+
+#: locale/moduletranslate.php:1136 locale/moduletranslate.php:1163
+msgid "Date/Time Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1137 locale/moduletranslate.php:1164
+#: locale/moduletranslate.php:2182 locale/moduletranslate.php:2211
+#: locale/moduletranslate.php:2240 locale/moduletranslate.php:2269
+#: locale/moduletranslate.php:2304 locale/moduletranslate.php:2339
+#: locale/moduletranslate.php:2370 locale/moduletranslate.php:2399
+#: locale/moduletranslate.php:2428 locale/moduletranslate.php:2457
+#: locale/moduletranslate.php:2494 locale/moduletranslate.php:2531
+#: locale/moduletranslate.php:2568 locale/moduletranslate.php:2595
+#: locale/moduletranslate.php:2624 locale/moduletranslate.php:2659
+#: locale/moduletranslate.php:2718
+msgid "The colour of the text"
+msgstr ""
+
+#: locale/moduletranslate.php:1140
+msgid "World Clock - Text"
+msgstr ""
+
+#: locale/moduletranslate.php:1141
+msgid "Text World Clock"
+msgstr ""
+
+#: locale/moduletranslate.php:1167
+msgid "Muslim Prayer Times"
+msgstr ""
+
+#: locale/moduletranslate.php:1168
+msgid "A module for displaying a calendar based on Muslim Prayer Times"
+msgstr ""
+
+#: locale/moduletranslate.php:1174
+msgid "Future only?"
+msgstr ""
+
+#: locale/moduletranslate.php:1175
+msgid "Only show future events?"
+msgstr ""
+
+#: locale/moduletranslate.php:1176
+msgid "Hello World - with Data"
+msgstr ""
+
+#: locale/moduletranslate.php:1177
+msgid "Here it is, the obligatory Hello World example, with data"
+msgstr ""
+
+#: locale/moduletranslate.php:1179 locale/moduletranslate.php:1237
+#: locale/moduletranslate.php:1259 locale/moduletranslate.php:1279
+#: locale/moduletranslate.php:1298 locale/dbtranslate.php:147
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:121
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:209
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:126
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:174
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:124
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:57
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:192
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2242
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2394
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:209
+#: lib/Widget/DataType/Article.php:69
+msgid "Title"
+msgstr ""
+
+#: locale/moduletranslate.php:1180 locale/moduletranslate.php:1717
+#: locale/moduletranslate.php:2097
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2246
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2398
+#: lib/Widget/DataType/Article.php:70 lib/Widget/DataType/Event.php:65
+msgid "Summary"
+msgstr ""
+
+#: locale/moduletranslate.php:1181
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:278
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2250
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2402
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:111
+#: lib/Widget/DataType/Article.php:71
+msgid "Content"
+msgstr ""
+
+#: locale/moduletranslate.php:1182
+msgid "Remove new lines?"
+msgstr ""
+
+#: locale/moduletranslate.php:1183
+msgid "Should new lines (\\n) be removed from content?"
+msgstr ""
+
+#: locale/moduletranslate.php:1184
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:226
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:128
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2254
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2406
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:226
+#: lib/Widget/DataType/Article.php:72
+msgid "Author"
+msgstr ""
+
+#: locale/moduletranslate.php:1185 locale/moduletranslate.php:1430
+#: locale/moduletranslate.php:2145 locale/moduletranslate.php:2786
+#: locale/moduletranslate.php:2787 locale/moduletranslate.php:2993
+#: locale/moduletranslate.php:3004 locale/moduletranslate.php:3072
+#: locale/dbtranslate.php:33 cache/84/846b4e365858df7d149d45d99237360c.php:170
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:192
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:500
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:126
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:388
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2266
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2418
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:156
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:180
+#: lib/Widget/DataType/SocialMedia.php:72
+msgid "Date"
+msgstr ""
+
+#: locale/moduletranslate.php:1186
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2270
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2422
+#: lib/Widget/DataType/Article.php:76
+msgid "Published Date"
+msgstr ""
+
+#: locale/moduletranslate.php:1189
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2258
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2410
+#: lib/Widget/DataType/Article.php:73
+msgid "Permalink"
+msgstr ""
+
+#: locale/moduletranslate.php:1190
+msgid "Articles shown with custom HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:1194 locale/moduletranslate.php:1287
+#: locale/moduletranslate.php:1305 locale/moduletranslate.php:1488
+#: locale/moduletranslate.php:1662 locale/moduletranslate.php:1729
+msgid ""
+"The transition speed of the selected effect in milliseconds (normal = 1000) "
+"or the Marquee Speed in a low to high scale (normal = 1)"
+msgstr ""
+
+#: locale/moduletranslate.php:1195 locale/moduletranslate.php:1289
+#: locale/moduletranslate.php:1481 locale/moduletranslate.php:1655
+#: locale/moduletranslate.php:1730
+msgid "Show items side by side?"
+msgstr ""
+
+#: locale/moduletranslate.php:1196 locale/moduletranslate.php:1482
+#: locale/moduletranslate.php:1656 locale/moduletranslate.php:1731
+msgid "Should items be shown side by side?"
+msgstr ""
+
+#: locale/moduletranslate.php:1197 locale/moduletranslate.php:1489
+#: locale/moduletranslate.php:1663 locale/moduletranslate.php:1732
+msgid "Items per page"
+msgstr ""
+
+#: locale/moduletranslate.php:1198
+msgid ""
+"If an effect has been selected from the General tab, how many pages should "
+"we split the items across? If you don't enter anything here 1 item will be "
+"put on each page."
+msgstr ""
+
+#: locale/moduletranslate.php:1199 locale/moduletranslate.php:1320
+#: locale/moduletranslate.php:1455 locale/moduletranslate.php:1500
+#: locale/moduletranslate.php:1528 locale/moduletranslate.php:1545
+#: locale/moduletranslate.php:1562 locale/moduletranslate.php:1579
+#: locale/moduletranslate.php:1596 locale/moduletranslate.php:1613
+#: locale/moduletranslate.php:1630 locale/moduletranslate.php:1676
+#: locale/moduletranslate.php:1734 locale/moduletranslate.php:2146
+#: locale/moduletranslate.php:2788 locale/moduletranslate.php:2832
+#: locale/moduletranslate.php:2995 locale/moduletranslate.php:2996
+#: locale/moduletranslate.php:3093 locale/moduletranslate.php:3116
+#: locale/moduletranslate.php:3149 locale/moduletranslate.php:3257
+#: locale/moduletranslate.php:3288 locale/moduletranslate.php:3449
+#: locale/moduletranslate.php:3486 locale/moduletranslate.php:3536
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3109
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2303
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2455
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:306
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:306
+msgid "Date Format"
+msgstr ""
+
+#: locale/moduletranslate.php:1200 locale/moduletranslate.php:1321
+#: locale/moduletranslate.php:1456 locale/moduletranslate.php:1501
+#: locale/moduletranslate.php:1529 locale/moduletranslate.php:1546
+#: locale/moduletranslate.php:1563 locale/moduletranslate.php:1580
+#: locale/moduletranslate.php:1597 locale/moduletranslate.php:1614
+#: locale/moduletranslate.php:1631 locale/moduletranslate.php:1677
+#: locale/moduletranslate.php:1735 locale/moduletranslate.php:2997
+#: locale/moduletranslate.php:3094 locale/moduletranslate.php:3117
+#: locale/moduletranslate.php:3150 locale/moduletranslate.php:3258
+#: locale/moduletranslate.php:3289 locale/moduletranslate.php:3450
+#: locale/moduletranslate.php:3487 locale/moduletranslate.php:3537
+msgid "The format to apply to all dates returned by the Widget."
+msgstr ""
+
+#: locale/moduletranslate.php:1203 locale/moduletranslate.php:1328
+#: locale/moduletranslate.php:1684
+msgid "Text direction"
+msgstr ""
+
+#: locale/moduletranslate.php:1204 locale/moduletranslate.php:1329
+#: locale/moduletranslate.php:1685
+msgid "Which direction does the text in the feed use?"
+msgstr ""
+
+#: locale/moduletranslate.php:1205 locale/moduletranslate.php:1330
+#: locale/moduletranslate.php:1686
+msgid "Left to Right (LTR)"
+msgstr ""
+
+#: locale/moduletranslate.php:1206 locale/moduletranslate.php:1331
+#: locale/moduletranslate.php:1687
+msgid "Right to Left (RTL)"
+msgstr ""
+
+#: locale/moduletranslate.php:1207 locale/moduletranslate.php:1451
+#: locale/moduletranslate.php:1474 locale/moduletranslate.php:1496
+#: locale/moduletranslate.php:1524 locale/moduletranslate.php:1541
+#: locale/moduletranslate.php:1558 locale/moduletranslate.php:1575
+#: locale/moduletranslate.php:1592 locale/moduletranslate.php:1609
+#: locale/moduletranslate.php:1626 locale/moduletranslate.php:1651
+#: locale/moduletranslate.php:1670 locale/moduletranslate.php:1740
+#: locale/moduletranslate.php:3545
+msgid "Item Template"
+msgstr ""
+
+#: locale/moduletranslate.php:1208 locale/moduletranslate.php:1452
+#: locale/moduletranslate.php:1475 locale/moduletranslate.php:1497
+#: locale/moduletranslate.php:1525 locale/moduletranslate.php:1542
+#: locale/moduletranslate.php:1559 locale/moduletranslate.php:1576
+#: locale/moduletranslate.php:1593 locale/moduletranslate.php:1610
+#: locale/moduletranslate.php:1627 locale/moduletranslate.php:1652
+#: locale/moduletranslate.php:1671 locale/moduletranslate.php:1741
+msgid "Enter text in the box below, used to display each article."
+msgstr ""
+
+#: locale/moduletranslate.php:1209 locale/moduletranslate.php:1476
+#: locale/moduletranslate.php:1653 locale/moduletranslate.php:1742
+#: locale/moduletranslate.php:3000
+msgid "Snippets"
+msgstr ""
+
+#: locale/moduletranslate.php:1210 locale/moduletranslate.php:3001
+msgid "Choose element to add to template"
+msgstr ""
+
+#: locale/moduletranslate.php:1211 locale/moduletranslate.php:1229
+#: locale/moduletranslate.php:1251 locale/moduletranslate.php:1272
+#: locale/moduletranslate.php:1292 locale/moduletranslate.php:1309
+#: locale/moduletranslate.php:1347 locale/moduletranslate.php:1453
+#: locale/moduletranslate.php:1491 locale/moduletranslate.php:1498
+#: locale/moduletranslate.php:1526 locale/moduletranslate.php:1543
+#: locale/moduletranslate.php:1560 locale/moduletranslate.php:1577
+#: locale/moduletranslate.php:1594 locale/moduletranslate.php:1611
+#: locale/moduletranslate.php:1628 locale/moduletranslate.php:1665
+#: locale/moduletranslate.php:1703 locale/moduletranslate.php:1744
+#: locale/moduletranslate.php:3006
+msgid "No data message"
+msgstr ""
+
+#: locale/moduletranslate.php:1212 locale/moduletranslate.php:1230
+#: locale/moduletranslate.php:1252 locale/moduletranslate.php:1273
+#: locale/moduletranslate.php:1293 locale/moduletranslate.php:1310
+#: locale/moduletranslate.php:1348 locale/moduletranslate.php:1454
+#: locale/moduletranslate.php:1492 locale/moduletranslate.php:1499
+#: locale/moduletranslate.php:1527 locale/moduletranslate.php:1544
+#: locale/moduletranslate.php:1561 locale/moduletranslate.php:1578
+#: locale/moduletranslate.php:1595 locale/moduletranslate.php:1612
+#: locale/moduletranslate.php:1629 locale/moduletranslate.php:1666
+#: locale/moduletranslate.php:1704 locale/moduletranslate.php:1745
+msgid "A message to display when no data is returned from the source"
+msgstr ""
+
+#: locale/moduletranslate.php:1213 locale/moduletranslate.php:1445
+#: locale/moduletranslate.php:1446 locale/moduletranslate.php:1478
+#: locale/moduletranslate.php:1746 locale/moduletranslate.php:3085
+msgid "Optional Stylesheet Template"
+msgstr ""
+
+#: locale/moduletranslate.php:1216 locale/moduletranslate.php:1231
+#: locale/moduletranslate.php:1253 locale/moduletranslate.php:1274
+#: locale/moduletranslate.php:1294 locale/moduletranslate.php:1311
+#: locale/moduletranslate.php:1335 locale/moduletranslate.php:1691
+msgid "Copyright"
+msgstr ""
+
+#: locale/moduletranslate.php:1217 locale/moduletranslate.php:1232
+#: locale/moduletranslate.php:1254 locale/moduletranslate.php:1275
+#: locale/moduletranslate.php:1295 locale/moduletranslate.php:1312
+#: locale/moduletranslate.php:1336 locale/moduletranslate.php:1692
+msgid "Copyright information to display as the last item in this feed."
+msgstr ""
+
+#: locale/moduletranslate.php:1218
+msgid "Image only"
+msgstr ""
+
+#: locale/moduletranslate.php:1219 locale/moduletranslate.php:1234
+#: locale/moduletranslate.php:1256 locale/moduletranslate.php:1277
+#: locale/moduletranslate.php:1297 locale/moduletranslate.php:2185
+#: locale/moduletranslate.php:2214 locale/moduletranslate.php:2243
+#: locale/moduletranslate.php:2272 locale/moduletranslate.php:2307
+#: locale/moduletranslate.php:2346 locale/moduletranslate.php:2375
+#: locale/moduletranslate.php:2402 locale/moduletranslate.php:2433
+#: locale/moduletranslate.php:2460 locale/moduletranslate.php:2497
+#: locale/moduletranslate.php:2538 locale/moduletranslate.php:2571
+#: locale/moduletranslate.php:2598 locale/moduletranslate.php:2629
+#: locale/moduletranslate.php:2664 locale/dbtranslate.php:138
+msgid "Background"
+msgstr ""
+
+#: locale/moduletranslate.php:1220 locale/moduletranslate.php:1242
+#: locale/moduletranslate.php:1263
+msgid "Image Fit"
+msgstr ""
+
+#: locale/moduletranslate.php:1222 locale/moduletranslate.php:1244
+#: locale/moduletranslate.php:1265 locale/moduletranslate.php:2121
+#: locale/moduletranslate.php:2877
+msgid "Contain"
+msgstr ""
+
+#: locale/moduletranslate.php:1223 locale/moduletranslate.php:1245
+#: locale/moduletranslate.php:1266 locale/moduletranslate.php:2122
+#: locale/moduletranslate.php:2878
+msgid "Cover"
+msgstr ""
+
+#: locale/moduletranslate.php:1224 locale/moduletranslate.php:1246
+#: locale/moduletranslate.php:1267 locale/moduletranslate.php:2120
+#: locale/moduletranslate.php:2876
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2152
+msgid "Fill"
+msgstr ""
+
+#: locale/moduletranslate.php:1228 locale/moduletranslate.php:1250
+#: locale/moduletranslate.php:1271 locale/moduletranslate.php:1367
+#: locale/moduletranslate.php:1383 locale/moduletranslate.php:1402
+#: locale/moduletranslate.php:3533 locale/moduletranslate.php:3553
+#: locale/moduletranslate.php:3576
+msgid ""
+"The transition speed of the selected effect in milliseconds (normal = 1000)."
+msgstr ""
+
+#: locale/moduletranslate.php:1233
+msgid "Image overlaid with the Feed Content on the Left"
+msgstr ""
+
+#: locale/moduletranslate.php:1235 locale/moduletranslate.php:1257
+msgid "Background (content)"
+msgstr ""
+
+#: locale/moduletranslate.php:1236 locale/moduletranslate.php:1258
+msgid "Background opacity (content)"
+msgstr ""
+
+#: locale/moduletranslate.php:1238 locale/moduletranslate.php:1280
+#: locale/moduletranslate.php:1713 locale/moduletranslate.php:1718
+#: locale/moduletranslate.php:3013 locale/moduletranslate.php:3018
+#: locale/moduletranslate.php:3069 locale/dbtranslate.php:148
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:341
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:456
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:171
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:205
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:202
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:161
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:234
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:100
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:244
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:211
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:661
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:100
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:324
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:167
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:91
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:223
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:215
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:240
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:233
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:117
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:94
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:184
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:217
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:115
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:119
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:209
+#: cache/ee/eed40b13770b97167f375466867a779e.php:109
+#: cache/ee/eed40b13770b97167f375466867a779e.php:205
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:209
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:118
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:175
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:180
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:214
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:118
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:91
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:237
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:148
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:131
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:152
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:91
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:225
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:494
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:100
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:375
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:181
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:266
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:147
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:109
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:195
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:91
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:277
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:209
+#: lib/Widget/DataType/Product.php:48 lib/Widget/DataType/Event.php:66
+#: lib/Widget/DataType/ProductCategory.php:42
+msgid "Description"
+msgstr ""
+
+#: locale/moduletranslate.php:1255
+msgid "Image overlaid with the Title"
+msgstr ""
+
+#: locale/moduletranslate.php:1276
+msgid "Prominent title with description and name separator"
+msgstr ""
+
+#: locale/moduletranslate.php:1288 locale/moduletranslate.php:1306
+#: locale/moduletranslate.php:1332 locale/moduletranslate.php:1688
+msgid "Show a separator between items?"
+msgstr ""
+
+#: locale/moduletranslate.php:1290 locale/moduletranslate.php:1307
+#: locale/moduletranslate.php:1333 locale/moduletranslate.php:1689
+msgid "Separator"
+msgstr ""
+
+#: locale/moduletranslate.php:1291 locale/moduletranslate.php:1308
+#: locale/moduletranslate.php:1334 locale/moduletranslate.php:1690
+msgid "A separator to show between marquee items"
+msgstr ""
+
+#: locale/moduletranslate.php:1296
+msgid "Title Only"
+msgstr ""
+
+#: locale/moduletranslate.php:1313
+msgid "Articles shown in a marquee"
+msgstr ""
+
+#: locale/moduletranslate.php:1314
+msgid "Selected Tags"
+msgstr ""
+
+#: locale/moduletranslate.php:1315
+msgid "Select tags to be displayed."
+msgstr ""
+
+#: locale/moduletranslate.php:1319 locale/moduletranslate.php:1675
+msgid "Marquee Speed in a low to high scale (normal = 1)"
+msgstr ""
+
+#: locale/moduletranslate.php:1322 locale/moduletranslate.php:1678
+msgid "Gap between tags"
+msgstr ""
+
+#: locale/moduletranslate.php:1323 locale/moduletranslate.php:1679
+msgid "Value (in pixels) to set a gap between each item's tags."
+msgstr ""
+
+#: locale/moduletranslate.php:1324 locale/moduletranslate.php:1680
+msgid "Gap between items"
+msgstr ""
+
+#: locale/moduletranslate.php:1325 locale/moduletranslate.php:1681
+msgid "Value (in pixels) to set a gap between each item."
+msgstr ""
+
+#: locale/moduletranslate.php:1337 locale/moduletranslate.php:1693
+msgid "Copyright Font Family"
+msgstr ""
+
+#: locale/moduletranslate.php:1339 locale/moduletranslate.php:1695
+msgid "Copyright Font Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1340 locale/moduletranslate.php:1696
+msgid "Copyright Font Size"
+msgstr ""
+
+#: locale/moduletranslate.php:1342 locale/moduletranslate.php:1698
+msgid "Should the copyright text be bold?"
+msgstr ""
+
+#: locale/moduletranslate.php:1344 locale/moduletranslate.php:1700
+msgid "Should the copyright text be italicised?"
+msgstr ""
+
+#: locale/moduletranslate.php:1346 locale/moduletranslate.php:1702
+msgid "Should the copyright text be underlined?"
+msgstr ""
+
+#: locale/moduletranslate.php:1349
+msgid "Currency Name"
+msgstr ""
+
+#: locale/moduletranslate.php:1350 locale/moduletranslate.php:3523
+msgid "Last Trade Price"
+msgstr ""
+
+#: locale/moduletranslate.php:1351 locale/moduletranslate.php:3524
+msgid "Change Percentage"
+msgstr ""
+
+#: locale/moduletranslate.php:1352
+msgid "Change Icon"
+msgstr ""
+
+#: locale/moduletranslate.php:1356
+msgid "Currency Logo"
+msgstr ""
+
+#: locale/moduletranslate.php:1357
+msgid "Currency - Single 1"
+msgstr ""
+
+#: locale/moduletranslate.php:1358
+msgid "Currency - Single 2"
+msgstr ""
+
+#: locale/moduletranslate.php:1359
+msgid "Currency - Single 3"
+msgstr ""
+
+#: locale/moduletranslate.php:1360
+msgid "Currency - Single 4"
+msgstr ""
+
+#: locale/moduletranslate.php:1361
+msgid "Currency - Group 1"
+msgstr ""
+
+#: locale/moduletranslate.php:1362
+msgid "Currency - Group 2"
+msgstr ""
+
+#: locale/moduletranslate.php:1363
+msgid "Currencies Custom HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:1370 locale/moduletranslate.php:3538
+msgid "Items per Page"
+msgstr ""
+
+#: locale/moduletranslate.php:1371 locale/moduletranslate.php:3539
+msgid "This is the intended number of items on each page."
+msgstr ""
+
+#: locale/moduletranslate.php:1377
+msgid "itemTemplate"
+msgstr ""
+
+#: locale/moduletranslate.php:1379
+msgid "Currencies 1"
+msgstr ""
+
+#: locale/moduletranslate.php:1386 locale/moduletranslate.php:1405
+#: locale/moduletranslate.php:3556
+msgid "Item Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1387 locale/moduletranslate.php:1406
+msgid "Background colour for each currency item."
+msgstr ""
+
+#: locale/moduletranslate.php:1388 locale/moduletranslate.php:1407
+#: locale/moduletranslate.php:3558 locale/moduletranslate.php:3579
+msgid "Item Font Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1389 locale/moduletranslate.php:1408
+msgid "Font colour for each currency item."
+msgstr ""
+
+#: locale/moduletranslate.php:1390 locale/moduletranslate.php:1517
+msgid "Header Font Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1391
+msgid "Font colour for the header."
+msgstr ""
+
+#: locale/moduletranslate.php:1392 locale/moduletranslate.php:1411
+#: locale/moduletranslate.php:3564 locale/moduletranslate.php:3583
+msgid "Up Arrow Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1393 locale/moduletranslate.php:1412
+#: locale/moduletranslate.php:3565 locale/moduletranslate.php:3584
+msgid "Colour for the up change arrow."
+msgstr ""
+
+#: locale/moduletranslate.php:1394 locale/moduletranslate.php:1413
+#: locale/moduletranslate.php:3566 locale/moduletranslate.php:3585
+msgid "Down Arrow Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1395 locale/moduletranslate.php:1414
+#: locale/moduletranslate.php:3567 locale/moduletranslate.php:3586
+msgid "Colour for the down change arrow."
+msgstr ""
+
+#: locale/moduletranslate.php:1398
+msgid "Currencies 2"
+msgstr ""
+
+#: locale/moduletranslate.php:1409 locale/moduletranslate.php:3562
+msgid "Item Border Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1410
+msgid "Border colour for each currency item."
+msgstr ""
+
+#: locale/moduletranslate.php:1415 locale/moduletranslate.php:3568
+#: locale/moduletranslate.php:3587
+msgid "Equal Arrow Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1416 locale/moduletranslate.php:3569
+#: locale/moduletranslate.php:3588
+msgid "Colour for the equal change arrow."
+msgstr ""
+
+#: locale/moduletranslate.php:1419
+msgid "Dashboard Image"
+msgstr ""
+
+#: locale/moduletranslate.php:1420 locale/dbtranslate.php:31
+msgid "String"
+msgstr ""
+
+#: locale/moduletranslate.php:1421 locale/moduletranslate.php:1426
+#: locale/moduletranslate.php:1431 locale/moduletranslate.php:1436
+#: locale/moduletranslate.php:1441
+msgid ""
+"Please choose a Dataset from the Configure tab to be able to customise this "
+"element."
+msgstr ""
+
+#: locale/moduletranslate.php:1422 locale/moduletranslate.php:1427
+#: locale/moduletranslate.php:1432 locale/moduletranslate.php:1437
+#: locale/moduletranslate.php:1442
+msgid "No field is available for that type of DataSet element."
+msgstr ""
+
+#: locale/moduletranslate.php:1423 locale/moduletranslate.php:1428
+#: locale/moduletranslate.php:1433 locale/moduletranslate.php:1438
+#: locale/moduletranslate.php:1443 locale/moduletranslate.php:1643
+msgid "Select DataSet Field"
+msgstr ""
+
+#: locale/moduletranslate.php:1424 locale/moduletranslate.php:1429
+#: locale/moduletranslate.php:1434 locale/moduletranslate.php:1439
+#: locale/moduletranslate.php:1444 locale/moduletranslate.php:1644
+msgid "Please choose a DataSet field for this element."
+msgstr ""
+
+#: locale/moduletranslate.php:1425 locale/dbtranslate.php:32
+msgid "Number"
+msgstr ""
+
+#: locale/moduletranslate.php:1449 locale/moduletranslate.php:1473
+#: locale/moduletranslate.php:1494 locale/moduletranslate.php:1522
+#: locale/moduletranslate.php:1539 locale/moduletranslate.php:1556
+#: locale/moduletranslate.php:1573 locale/moduletranslate.php:1590
+#: locale/moduletranslate.php:1607 locale/moduletranslate.php:1624
+#: locale/moduletranslate.php:1641 locale/moduletranslate.php:1650
+#: locale/moduletranslate.php:1668 locale/moduletranslate.php:3592
+msgid "Select a dataset to display appearance options."
+msgstr ""
+
+#: locale/moduletranslate.php:1450 locale/moduletranslate.php:1495
+#: locale/moduletranslate.php:1523 locale/moduletranslate.php:1540
+#: locale/moduletranslate.php:1557 locale/moduletranslate.php:1574
+#: locale/moduletranslate.php:1591 locale/moduletranslate.php:1608
+#: locale/moduletranslate.php:1625 locale/moduletranslate.php:1669
+msgid ""
+"Below you can select the columns to be shown in the table - drag and drop to "
+"reorder and to move between lists."
+msgstr ""
+
+#: locale/moduletranslate.php:1457 locale/moduletranslate.php:1502
+#: locale/moduletranslate.php:1530 locale/moduletranslate.php:1547
+#: locale/moduletranslate.php:1564 locale/moduletranslate.php:1581
+#: locale/moduletranslate.php:1598 locale/moduletranslate.php:1615
+#: locale/moduletranslate.php:1632
+msgid "Show the table headings?"
+msgstr ""
+
+#: locale/moduletranslate.php:1458 locale/moduletranslate.php:1503
+#: locale/moduletranslate.php:1531 locale/moduletranslate.php:1548
+#: locale/moduletranslate.php:1565 locale/moduletranslate.php:1582
+#: locale/moduletranslate.php:1599 locale/moduletranslate.php:1616
+#: locale/moduletranslate.php:1633
+msgid "Should the Table headings be shown?"
+msgstr ""
+
+#: locale/moduletranslate.php:1459 locale/moduletranslate.php:1504
+#: locale/moduletranslate.php:1532 locale/moduletranslate.php:1549
+#: locale/moduletranslate.php:1566 locale/moduletranslate.php:1583
+#: locale/moduletranslate.php:1600 locale/moduletranslate.php:1617
+#: locale/moduletranslate.php:1634
+msgid "Rows per page"
+msgstr ""
+
+#: locale/moduletranslate.php:1460 locale/moduletranslate.php:1505
+#: locale/moduletranslate.php:1533 locale/moduletranslate.php:1550
+#: locale/moduletranslate.php:1567 locale/moduletranslate.php:1584
+#: locale/moduletranslate.php:1601 locale/moduletranslate.php:1618
+#: locale/moduletranslate.php:1635
+msgid "Please enter the number of rows per page. 0 for no pages."
+msgstr ""
+
+#: locale/moduletranslate.php:1464 locale/moduletranslate.php:1509
+#: locale/moduletranslate.php:1537 locale/moduletranslate.php:1554
+#: locale/moduletranslate.php:1571 locale/moduletranslate.php:1588
+#: locale/moduletranslate.php:1605 locale/moduletranslate.php:1622
+#: locale/moduletranslate.php:1639
+msgid "Set the font size"
+msgstr ""
+
+#: locale/moduletranslate.php:1465 locale/moduletranslate.php:1510
+#: locale/moduletranslate.php:1767 locale/moduletranslate.php:1809
+#: locale/moduletranslate.php:1858 locale/moduletranslate.php:1907
+#: locale/moduletranslate.php:1950 locale/moduletranslate.php:1997
+#: locale/moduletranslate.php:2039 locale/moduletranslate.php:2075
+msgid "Colours"
+msgstr ""
+
+#: locale/moduletranslate.php:1469 locale/moduletranslate.php:1516
+msgid "Use the colour picker to select the border colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1471 locale/moduletranslate.php:1512
+msgid "Use the colour picker to select the font colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1472
+msgid "Dataset Custom HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:1477 locale/moduletranslate.php:1654
+msgid "Choose data set snippet"
+msgstr ""
+
+#: locale/moduletranslate.php:1490 locale/moduletranslate.php:1664
+#: locale/moduletranslate.php:1733
+msgid ""
+"If an effect has been selected, how many pages should we split the items "
+"across? If you don't enter anything here 1 item will be put on each page."
+msgstr ""
+
+#: locale/moduletranslate.php:1493
+msgid "Plain Table (Customisable)"
+msgstr ""
+
+#: locale/moduletranslate.php:1518
+msgid "Use the colour picker to select the header font colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1520
+msgid "Use the colour picker to select the header background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1521
+msgid "A light green background with darker green borders. White heading text."
+msgstr ""
+
+#: locale/moduletranslate.php:1538
+msgid "Simple white table with rounded rows."
+msgstr ""
+
+#: locale/moduletranslate.php:1555
+msgid "Striped blue table with darker blue header."
+msgstr ""
+
+#: locale/moduletranslate.php:1572
+msgid "White striped table with orange header."
+msgstr ""
+
+#: locale/moduletranslate.php:1589
+msgid "White and grey table with split rows."
+msgstr ""
+
+#: locale/moduletranslate.php:1606
+msgid "A dark table with round borders and yellow heading text."
+msgstr ""
+
+#: locale/moduletranslate.php:1623
+msgid "Round cells with multi colours and a full coloured header."
+msgstr ""
+
+#: locale/moduletranslate.php:1640
+msgid "Image Slideshow"
+msgstr ""
+
+#: locale/moduletranslate.php:1642
+msgid "No image field is available for the selected DataSet."
+msgstr ""
+
+#: locale/moduletranslate.php:1648
+msgid ""
+"The transition speed of the selected effect in milliseconds (normal = 1000)"
+msgstr ""
+
+#: locale/moduletranslate.php:1649
+msgid "String template with placeholders"
+msgstr ""
+
+#: locale/moduletranslate.php:1667
+msgid "Dataset shown in a marquee"
+msgstr ""
+
+#: locale/moduletranslate.php:1705
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:470
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:323
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:526
+msgid "Source"
+msgstr ""
+
+#: locale/moduletranslate.php:1706
+msgid "Note"
+msgstr ""
+
+#: locale/moduletranslate.php:1707
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:516
+#: lib/Widget/DataType/Event.php:63
+msgid "Event"
+msgstr ""
+
+#: locale/moduletranslate.php:1708
+msgid "Date & Time Effective"
+msgstr ""
+
+#: locale/moduletranslate.php:1709
+msgid "Date & Time Onset"
+msgstr ""
+
+#: locale/moduletranslate.php:1710
+msgid "Date & Time Expires"
+msgstr ""
+
+#: locale/moduletranslate.php:1711
+msgid "Sender Name"
+msgstr ""
+
+#: locale/moduletranslate.php:1712
+msgid "Headline"
+msgstr ""
+
+#: locale/moduletranslate.php:1714
+msgid "Instruction"
+msgstr ""
+
+#: locale/moduletranslate.php:1715
+msgid "Contact"
+msgstr ""
+
+#: locale/moduletranslate.php:1716
+msgid "Area Description"
+msgstr ""
+
+#: locale/moduletranslate.php:1721 locale/moduletranslate.php:2098
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:65
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:65
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:65
+#: lib/Widget/DataType/Event.php:67 lib/Widget/DataType/SocialMedia.php:70
+msgid "Location"
+msgstr ""
+
+#: locale/moduletranslate.php:1722
+msgid "Calendar Detailed Event"
+msgstr ""
+
+#: locale/moduletranslate.php:1723
+msgid "Calendar Simple Event"
+msgstr ""
+
+#: locale/moduletranslate.php:1724
+msgid "Calendar Event Row"
+msgstr ""
+
+#: locale/moduletranslate.php:1725
+msgid "Events shown with custom HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:1743
+msgid "Choose data type snippet"
+msgstr ""
+
+#: locale/moduletranslate.php:1749
+msgid "Daily Calendar - Light"
+msgstr ""
+
+#: locale/moduletranslate.php:1750 locale/moduletranslate.php:1792
+#: locale/moduletranslate.php:1834 locale/moduletranslate.php:1883
+#: locale/moduletranslate.php:1932 locale/moduletranslate.php:1979
+#: locale/moduletranslate.php:2026 locale/moduletranslate.php:2062
+msgid ""
+"This template uses features which will not work on devices with a browser "
+"older than Chrome 57, including webOS older than 6 and Tizen older than 5."
+msgstr ""
+
+#: locale/moduletranslate.php:1751 locale/moduletranslate.php:1793
+#: locale/moduletranslate.php:1835 locale/moduletranslate.php:1884
+msgid "First hour slot"
+msgstr ""
+
+#: locale/moduletranslate.php:1752 locale/moduletranslate.php:1794
+#: locale/moduletranslate.php:1836 locale/moduletranslate.php:1885
+msgid ""
+"This view features a grid running from midnight to midnight. Use the first "
+"slot to shorten the time window shown."
+msgstr ""
+
+#: locale/moduletranslate.php:1753 locale/moduletranslate.php:1795
+#: locale/moduletranslate.php:1837 locale/moduletranslate.php:1886
+msgid "Last hour slot"
+msgstr ""
+
+#: locale/moduletranslate.php:1754 locale/moduletranslate.php:1796
+#: locale/moduletranslate.php:1838 locale/moduletranslate.php:1887
+msgid ""
+"This view features a grid running from midnight to midnight. Use the last "
+"slot to shorten the time window shown."
+msgstr ""
+
+#: locale/moduletranslate.php:1755 locale/moduletranslate.php:1797
+#: locale/moduletranslate.php:1843 locale/moduletranslate.php:1890
+#: locale/moduletranslate.php:1935 locale/moduletranslate.php:1982
+#: locale/moduletranslate.php:2029 locale/moduletranslate.php:2065
+msgid "Time Format"
+msgstr ""
+
+#: locale/moduletranslate.php:1756 locale/moduletranslate.php:1798
+#: locale/moduletranslate.php:1844 locale/moduletranslate.php:1891
+#: locale/moduletranslate.php:1936 locale/moduletranslate.php:1983
+#: locale/moduletranslate.php:2030 locale/moduletranslate.php:2066
+msgid "The format to apply to event time (default HH:mm)."
+msgstr ""
+
+#: locale/moduletranslate.php:1759 locale/moduletranslate.php:1801
+#: locale/moduletranslate.php:1839 locale/moduletranslate.php:1894
+#: locale/moduletranslate.php:1939 locale/moduletranslate.php:1986
+msgid "Start at the current time?"
+msgstr ""
+
+#: locale/moduletranslate.php:1760 locale/moduletranslate.php:1802
+#: locale/moduletranslate.php:1840 locale/moduletranslate.php:1895
+#: locale/moduletranslate.php:1940 locale/moduletranslate.php:1987
+msgid ""
+"Should the calendar start at the current time, or at the time of the first "
+"event?"
+msgstr ""
+
+#: locale/moduletranslate.php:1761 locale/moduletranslate.php:1803
+#: locale/moduletranslate.php:1847 locale/moduletranslate.php:1896
+#: locale/moduletranslate.php:2033 locale/moduletranslate.php:2069
+msgid "Show now marker?"
+msgstr ""
+
+#: locale/moduletranslate.php:1762 locale/moduletranslate.php:1804
+#: locale/moduletranslate.php:1848 locale/moduletranslate.php:1897
+#: locale/moduletranslate.php:2034 locale/moduletranslate.php:2070
+msgid "Should the calendar show a marker for the current time?"
+msgstr ""
+
+#: locale/moduletranslate.php:1763 locale/moduletranslate.php:1805
+#: locale/moduletranslate.php:1849 locale/moduletranslate.php:1898
+#: locale/moduletranslate.php:1943 locale/moduletranslate.php:1990
+#: locale/moduletranslate.php:2037 locale/moduletranslate.php:2073
+msgid "Text scale"
+msgstr ""
+
+#: locale/moduletranslate.php:1764 locale/moduletranslate.php:1806
+#: locale/moduletranslate.php:1850 locale/moduletranslate.php:1899
+#: locale/moduletranslate.php:1944 locale/moduletranslate.php:1991
+#: locale/moduletranslate.php:2038 locale/moduletranslate.php:2074
+msgid "Set the scale for the text element on the calendar."
+msgstr ""
+
+#: locale/moduletranslate.php:1765 locale/moduletranslate.php:1807
+#: locale/moduletranslate.php:1856 locale/moduletranslate.php:1905
+msgid "Grid step"
+msgstr ""
+
+#: locale/moduletranslate.php:1766 locale/moduletranslate.php:1808
+#: locale/moduletranslate.php:1857 locale/moduletranslate.php:1906
+msgid "Duration, in minutes, for each row in the grid."
+msgstr ""
+
+#: locale/moduletranslate.php:1768 locale/moduletranslate.php:1810
+#: locale/moduletranslate.php:1859 locale/moduletranslate.php:1908
+#: locale/moduletranslate.php:1951 locale/moduletranslate.php:1998
+#: locale/moduletranslate.php:2040 locale/moduletranslate.php:2076
+msgid "Use the colour pickers to override the element colours."
+msgstr ""
+
+#: locale/moduletranslate.php:1769 locale/moduletranslate.php:1811
+#: locale/moduletranslate.php:1860 locale/moduletranslate.php:1909
+#: locale/moduletranslate.php:1952 locale/moduletranslate.php:1999
+#: locale/moduletranslate.php:2041 locale/moduletranslate.php:2077
+msgid "Grid Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1770 locale/moduletranslate.php:1812
+#: locale/moduletranslate.php:1861 locale/moduletranslate.php:1910
+msgid "Grid Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1771 locale/moduletranslate.php:1813
+#: locale/moduletranslate.php:1862 locale/moduletranslate.php:1911
+#: locale/moduletranslate.php:1956 locale/moduletranslate.php:2003
+#: locale/moduletranslate.php:2042 locale/moduletranslate.php:2078
+msgid "Header (Weekdays)"
+msgstr ""
+
+#: locale/moduletranslate.php:1774 locale/moduletranslate.php:1816
+#: locale/moduletranslate.php:1865 locale/moduletranslate.php:1914
+#: locale/moduletranslate.php:1959 locale/moduletranslate.php:2006
+#: locale/moduletranslate.php:2045 locale/moduletranslate.php:2081
+msgid "Calendar Days"
+msgstr ""
+
+#: locale/moduletranslate.php:1777 locale/moduletranslate.php:1819
+#: locale/moduletranslate.php:1868 locale/moduletranslate.php:1917
+#: locale/moduletranslate.php:1962 locale/moduletranslate.php:2009
+#: locale/moduletranslate.php:2047 locale/moduletranslate.php:2083
+msgid "Current day text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1778 locale/moduletranslate.php:1820
+#: locale/moduletranslate.php:1869 locale/moduletranslate.php:1918
+#: locale/moduletranslate.php:2048 locale/moduletranslate.php:2084
+msgid "Now marker Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:1779 locale/moduletranslate.php:1821
+#: locale/moduletranslate.php:1870 locale/moduletranslate.php:1919
+#: locale/moduletranslate.php:1966 locale/moduletranslate.php:2013
+#: locale/moduletranslate.php:2049 locale/moduletranslate.php:2085
+msgid "Events"
+msgstr ""
+
+#: locale/moduletranslate.php:1782 locale/moduletranslate.php:1824
+#: locale/moduletranslate.php:1873 locale/moduletranslate.php:1922
+#: locale/moduletranslate.php:1969 locale/moduletranslate.php:2016
+#: locale/moduletranslate.php:2052 locale/moduletranslate.php:2088
+msgid "All day events"
+msgstr ""
+
+#: locale/moduletranslate.php:1785 locale/moduletranslate.php:1827
+#: locale/moduletranslate.php:1876 locale/moduletranslate.php:1925
+#: locale/moduletranslate.php:1972 locale/moduletranslate.php:2019
+#: locale/moduletranslate.php:2055 locale/moduletranslate.php:2091
+msgid "Multiple days events"
+msgstr ""
+
+#: locale/moduletranslate.php:1788 locale/moduletranslate.php:1830
+#: locale/moduletranslate.php:1879 locale/moduletranslate.php:1928
+#: locale/moduletranslate.php:1975 locale/moduletranslate.php:2022
+msgid "Aditional days container"
+msgstr ""
+
+#: locale/moduletranslate.php:1791
+msgid "Daily Calendar - Dark"
+msgstr ""
+
+#: locale/moduletranslate.php:1833
+msgid "Weekly Calendar - Light"
+msgstr ""
+
+#: locale/moduletranslate.php:1841 locale/moduletranslate.php:1888
+#: locale/moduletranslate.php:1933 locale/moduletranslate.php:1980
+msgid "Exclude weekend days?"
+msgstr ""
+
+#: locale/moduletranslate.php:1842 locale/moduletranslate.php:1889
+#: locale/moduletranslate.php:1934 locale/moduletranslate.php:1981
+msgid "Saturdays and Sundays wont be shown."
+msgstr ""
+
+#: locale/moduletranslate.php:1851 locale/moduletranslate.php:1900
+#: locale/moduletranslate.php:1945 locale/moduletranslate.php:1992
+msgid "Week name length"
+msgstr ""
+
+#: locale/moduletranslate.php:1852 locale/moduletranslate.php:1901
+#: locale/moduletranslate.php:1946 locale/moduletranslate.php:1993
+msgid "Please select the length for the week names."
+msgstr ""
+
+#: locale/moduletranslate.php:1853 locale/moduletranslate.php:1902
+#: locale/moduletranslate.php:1947 locale/moduletranslate.php:1994
+msgid "Short"
+msgstr ""
+
+#: locale/moduletranslate.php:1854 locale/moduletranslate.php:1903
+#: locale/moduletranslate.php:1948 locale/moduletranslate.php:1995
+msgid "Medium"
+msgstr ""
+
+#: locale/moduletranslate.php:1855 locale/moduletranslate.php:1904
+#: locale/moduletranslate.php:1949 locale/moduletranslate.php:1996
+msgid "Long"
+msgstr ""
+
+#: locale/moduletranslate.php:1882
+msgid "Weekly Calendar - Dark"
+msgstr ""
+
+#: locale/moduletranslate.php:1931
+msgid "Monthly Calendar - Light"
+msgstr ""
+
+#: locale/moduletranslate.php:1941 locale/moduletranslate.php:1988
+#: locale/dbtranslate.php:145
+msgid "Show header?"
+msgstr ""
+
+#: locale/moduletranslate.php:1942 locale/moduletranslate.php:1989
+msgid "Should the selected template have a header?"
+msgstr ""
+
+#: locale/moduletranslate.php:1953 locale/moduletranslate.php:2000
+msgid "Header (Month)"
+msgstr ""
+
+#: locale/moduletranslate.php:1963 locale/moduletranslate.php:2010
+msgid "Other Month Days"
+msgstr ""
+
+#: locale/moduletranslate.php:1978
+msgid "Monthly Calendar - Dark"
+msgstr ""
+
+#: locale/moduletranslate.php:2025
+msgid "Schedule Calendar - Light"
+msgstr ""
+
+#: locale/moduletranslate.php:2027 locale/moduletranslate.php:2058
+#: locale/moduletranslate.php:2063 locale/moduletranslate.php:2094
+msgid "No events message"
+msgstr ""
+
+#: locale/moduletranslate.php:2028 locale/moduletranslate.php:2064
+msgid "Message to be shown if no events are returned."
+msgstr ""
+
+#: locale/moduletranslate.php:2035 locale/moduletranslate.php:2071
+msgid "Show event description?"
+msgstr ""
+
+#: locale/moduletranslate.php:2036 locale/moduletranslate.php:2072
+msgid "Should events with descriptions display them?"
+msgstr ""
+
+#: locale/moduletranslate.php:2061
+msgid "Schedule Calendar - Dark"
+msgstr ""
+
+#: locale/moduletranslate.php:2099
+msgid "Temperature"
+msgstr ""
+
+#: locale/moduletranslate.php:2100
+msgid "Min. Temperature"
+msgstr ""
+
+#: locale/moduletranslate.php:2101
+msgid "Max. Temperature"
+msgstr ""
+
+#: locale/moduletranslate.php:2102
+msgid "Humidity Percent"
+msgstr ""
+
+#: locale/moduletranslate.php:2103
+msgid "Icon"
+msgstr ""
+
+#: locale/moduletranslate.php:2108
+#: lib/Connector/OpenWeatherMapConnector.php:687
+msgid "Wind Direction"
+msgstr ""
+
+#: locale/moduletranslate.php:2109
+#: lib/Connector/OpenWeatherMapConnector.php:685
+msgid "Wind Speed"
+msgstr ""
+
+#: locale/moduletranslate.php:2110
+msgid "Wind Speed Unit"
+msgstr ""
+
+#: locale/moduletranslate.php:2111
+msgid "Use Slash for Units"
+msgstr ""
+
+#: locale/moduletranslate.php:2112
+msgid ""
+"Use '/' instead of 'p' to represent units of measure (e.g., m/s instead of "
+"mps)."
+msgstr ""
+
+#: locale/moduletranslate.php:2113
+msgid "Attribution"
+msgstr ""
+
+#: locale/moduletranslate.php:2116
+msgid "Should the square have rounded corners?"
+msgstr ""
+
+#: locale/moduletranslate.php:2133
+msgid "Images"
+msgstr ""
+
+#: locale/moduletranslate.php:2134
+msgid ""
+"Select images from the media library to replace the default weather images."
+msgstr ""
+
+#: locale/moduletranslate.php:2135 locale/moduletranslate.php:2170
+#: locale/moduletranslate.php:2199 locale/moduletranslate.php:2228
+#: locale/moduletranslate.php:2257 locale/moduletranslate.php:2292
+#: locale/moduletranslate.php:2327 locale/moduletranslate.php:2358
+#: locale/moduletranslate.php:2387 locale/moduletranslate.php:2416
+#: locale/moduletranslate.php:2445 locale/moduletranslate.php:2482
+#: locale/moduletranslate.php:2519 locale/moduletranslate.php:2556
+#: locale/moduletranslate.php:2583 locale/moduletranslate.php:2612
+#: locale/moduletranslate.php:2647 locale/moduletranslate.php:2682
+#: locale/moduletranslate.php:2706 locale/moduletranslate.php:2738
+msgid "Cloudy"
+msgstr ""
+
+#: locale/moduletranslate.php:2136 locale/moduletranslate.php:2171
+#: locale/moduletranslate.php:2200 locale/moduletranslate.php:2229
+#: locale/moduletranslate.php:2258 locale/moduletranslate.php:2293
+#: locale/moduletranslate.php:2328 locale/moduletranslate.php:2359
+#: locale/moduletranslate.php:2388 locale/moduletranslate.php:2417
+#: locale/moduletranslate.php:2446 locale/moduletranslate.php:2483
+#: locale/moduletranslate.php:2520 locale/moduletranslate.php:2557
+#: locale/moduletranslate.php:2584 locale/moduletranslate.php:2613
+#: locale/moduletranslate.php:2648 locale/moduletranslate.php:2683
+#: locale/moduletranslate.php:2707 locale/moduletranslate.php:2739
+msgid "Cloudy day"
+msgstr ""
+
+#: locale/moduletranslate.php:2137 locale/moduletranslate.php:2172
+#: locale/moduletranslate.php:2201 locale/moduletranslate.php:2230
+#: locale/moduletranslate.php:2259 locale/moduletranslate.php:2294
+#: locale/moduletranslate.php:2329 locale/moduletranslate.php:2360
+#: locale/moduletranslate.php:2389 locale/moduletranslate.php:2418
+#: locale/moduletranslate.php:2447 locale/moduletranslate.php:2484
+#: locale/moduletranslate.php:2521 locale/moduletranslate.php:2558
+#: locale/moduletranslate.php:2585 locale/moduletranslate.php:2614
+#: locale/moduletranslate.php:2649 locale/moduletranslate.php:2684
+#: locale/moduletranslate.php:2708 locale/moduletranslate.php:2740
+#: cache/ae/ae9dbc50775e4a2e35f2b3d47ea4c903.php:71
+#: lib/Connector/OpenWeatherMapConnector.php:674
+msgid "Clear"
+msgstr ""
+
+#: locale/moduletranslate.php:2138 locale/moduletranslate.php:2173
+#: locale/moduletranslate.php:2202 locale/moduletranslate.php:2231
+#: locale/moduletranslate.php:2260 locale/moduletranslate.php:2295
+#: locale/moduletranslate.php:2330 locale/moduletranslate.php:2361
+#: locale/moduletranslate.php:2390 locale/moduletranslate.php:2419
+#: locale/moduletranslate.php:2448 locale/moduletranslate.php:2485
+#: locale/moduletranslate.php:2522 locale/moduletranslate.php:2559
+#: locale/moduletranslate.php:2586 locale/moduletranslate.php:2615
+#: locale/moduletranslate.php:2650 locale/moduletranslate.php:2685
+#: locale/moduletranslate.php:2709 locale/moduletranslate.php:2741
+msgid "Fog"
+msgstr ""
+
+#: locale/moduletranslate.php:2139 locale/moduletranslate.php:2174
+#: locale/moduletranslate.php:2203 locale/moduletranslate.php:2232
+#: locale/moduletranslate.php:2261 locale/moduletranslate.php:2296
+#: locale/moduletranslate.php:2331 locale/moduletranslate.php:2362
+#: locale/moduletranslate.php:2391 locale/moduletranslate.php:2420
+#: locale/moduletranslate.php:2449 locale/moduletranslate.php:2486
+#: locale/moduletranslate.php:2523 locale/moduletranslate.php:2560
+#: locale/moduletranslate.php:2587 locale/moduletranslate.php:2616
+#: locale/moduletranslate.php:2651 locale/moduletranslate.php:2686
+#: locale/moduletranslate.php:2710 locale/moduletranslate.php:2742
+msgid "Hail"
+msgstr ""
+
+#: locale/moduletranslate.php:2140 locale/moduletranslate.php:2175
+#: locale/moduletranslate.php:2204 locale/moduletranslate.php:2233
+#: locale/moduletranslate.php:2262 locale/moduletranslate.php:2297
+#: locale/moduletranslate.php:2332 locale/moduletranslate.php:2363
+#: locale/moduletranslate.php:2392 locale/moduletranslate.php:2421
+#: locale/moduletranslate.php:2450 locale/moduletranslate.php:2487
+#: locale/moduletranslate.php:2524 locale/moduletranslate.php:2561
+#: locale/moduletranslate.php:2588 locale/moduletranslate.php:2617
+#: locale/moduletranslate.php:2652 locale/moduletranslate.php:2687
+#: locale/moduletranslate.php:2711 locale/moduletranslate.php:2743
+msgid "Clear night"
+msgstr ""
+
+#: locale/moduletranslate.php:2141 locale/moduletranslate.php:2176
+#: locale/moduletranslate.php:2205 locale/moduletranslate.php:2234
+#: locale/moduletranslate.php:2263 locale/moduletranslate.php:2298
+#: locale/moduletranslate.php:2333 locale/moduletranslate.php:2364
+#: locale/moduletranslate.php:2393 locale/moduletranslate.php:2422
+#: locale/moduletranslate.php:2451 locale/moduletranslate.php:2488
+#: locale/moduletranslate.php:2525 locale/moduletranslate.php:2562
+#: locale/moduletranslate.php:2589 locale/moduletranslate.php:2618
+#: locale/moduletranslate.php:2653 locale/moduletranslate.php:2688
+#: locale/moduletranslate.php:2712 locale/moduletranslate.php:2744
+msgid "Cloudy night"
+msgstr ""
+
+#: locale/moduletranslate.php:2142 locale/moduletranslate.php:2177
+#: locale/moduletranslate.php:2206 locale/moduletranslate.php:2235
+#: locale/moduletranslate.php:2264 locale/moduletranslate.php:2299
+#: locale/moduletranslate.php:2334 locale/moduletranslate.php:2365
+#: locale/moduletranslate.php:2394 locale/moduletranslate.php:2423
+#: locale/moduletranslate.php:2452 locale/moduletranslate.php:2489
+#: locale/moduletranslate.php:2526 locale/moduletranslate.php:2563
+#: locale/moduletranslate.php:2590 locale/moduletranslate.php:2619
+#: locale/moduletranslate.php:2654 locale/moduletranslate.php:2689
+#: locale/moduletranslate.php:2713 locale/moduletranslate.php:2745
+msgid "Raining"
+msgstr ""
+
+#: locale/moduletranslate.php:2143 locale/moduletranslate.php:2178
+#: locale/moduletranslate.php:2207 locale/moduletranslate.php:2236
+#: locale/moduletranslate.php:2265 locale/moduletranslate.php:2300
+#: locale/moduletranslate.php:2335 locale/moduletranslate.php:2366
+#: locale/moduletranslate.php:2395 locale/moduletranslate.php:2424
+#: locale/moduletranslate.php:2453 locale/moduletranslate.php:2490
+#: locale/moduletranslate.php:2527 locale/moduletranslate.php:2564
+#: locale/moduletranslate.php:2591 locale/moduletranslate.php:2620
+#: locale/moduletranslate.php:2655 locale/moduletranslate.php:2690
+#: locale/moduletranslate.php:2714 locale/moduletranslate.php:2746
+msgid "Snowing"
+msgstr ""
+
+#: locale/moduletranslate.php:2144 locale/moduletranslate.php:2179
+#: locale/moduletranslate.php:2208 locale/moduletranslate.php:2237
+#: locale/moduletranslate.php:2266 locale/moduletranslate.php:2301
+#: locale/moduletranslate.php:2336 locale/moduletranslate.php:2367
+#: locale/moduletranslate.php:2396 locale/moduletranslate.php:2425
+#: locale/moduletranslate.php:2454 locale/moduletranslate.php:2491
+#: locale/moduletranslate.php:2528 locale/moduletranslate.php:2565
+#: locale/moduletranslate.php:2592 locale/moduletranslate.php:2621
+#: locale/moduletranslate.php:2656 locale/moduletranslate.php:2691
+#: locale/moduletranslate.php:2715 locale/moduletranslate.php:2747
+msgid "Windy"
+msgstr ""
+
+#: locale/moduletranslate.php:2147
+msgid "Forecast 1"
+msgstr ""
+
+#: locale/moduletranslate.php:2148
+msgid "Daily 1"
+msgstr ""
+
+#: locale/moduletranslate.php:2149
+msgid "Daily 2"
+msgstr ""
+
+#: locale/moduletranslate.php:2150
+msgid "Daily 3"
+msgstr ""
+
+#: locale/moduletranslate.php:2151
+msgid "Daily 4"
+msgstr ""
+
+#: locale/moduletranslate.php:2157
+msgid "Current Forecast Template"
+msgstr ""
+
+#: locale/moduletranslate.php:2158
+msgid "Daily Forecast Template"
+msgstr ""
+
+#: locale/moduletranslate.php:2159
+msgid "CSS Style Sheet"
+msgstr ""
+
+#: locale/moduletranslate.php:2162 locale/moduletranslate.php:2191
+#: locale/moduletranslate.php:2220 locale/moduletranslate.php:2249
+#: locale/moduletranslate.php:2284 locale/moduletranslate.php:2319
+#: locale/moduletranslate.php:2350 locale/moduletranslate.php:2379
+#: locale/moduletranslate.php:2408 locale/moduletranslate.php:2437
+#: locale/moduletranslate.php:2474 locale/moduletranslate.php:2511
+#: locale/moduletranslate.php:2548 locale/moduletranslate.php:2575
+#: locale/moduletranslate.php:2604 locale/moduletranslate.php:2639
+#: locale/moduletranslate.php:2674 locale/moduletranslate.php:2698
+#: locale/moduletranslate.php:2730
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:127
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:873
+msgid "Background Image"
+msgstr ""
+
+#: locale/moduletranslate.php:2163 locale/moduletranslate.php:2192
+#: locale/moduletranslate.php:2221 locale/moduletranslate.php:2250
+#: locale/moduletranslate.php:2285 locale/moduletranslate.php:2320
+#: locale/moduletranslate.php:2351 locale/moduletranslate.php:2380
+#: locale/moduletranslate.php:2409 locale/moduletranslate.php:2438
+#: locale/moduletranslate.php:2475 locale/moduletranslate.php:2512
+#: locale/moduletranslate.php:2549 locale/moduletranslate.php:2576
+#: locale/moduletranslate.php:2605 locale/moduletranslate.php:2640
+#: locale/moduletranslate.php:2675 locale/moduletranslate.php:2699
+#: locale/moduletranslate.php:2731
+msgid "The background image to use"
+msgstr ""
+
+#: locale/moduletranslate.php:2168 locale/moduletranslate.php:2197
+#: locale/moduletranslate.php:2226 locale/moduletranslate.php:2255
+#: locale/moduletranslate.php:2290 locale/moduletranslate.php:2325
+#: locale/moduletranslate.php:2356 locale/moduletranslate.php:2385
+#: locale/moduletranslate.php:2414 locale/moduletranslate.php:2443
+#: locale/moduletranslate.php:2480 locale/moduletranslate.php:2517
+#: locale/moduletranslate.php:2554 locale/moduletranslate.php:2581
+#: locale/moduletranslate.php:2610 locale/moduletranslate.php:2645
+#: locale/moduletranslate.php:2680 locale/moduletranslate.php:2704
+#: locale/moduletranslate.php:2736
+msgid "Backgrounds"
+msgstr ""
+
+#: locale/moduletranslate.php:2169 locale/moduletranslate.php:2198
+#: locale/moduletranslate.php:2227 locale/moduletranslate.php:2256
+#: locale/moduletranslate.php:2291 locale/moduletranslate.php:2326
+#: locale/moduletranslate.php:2357 locale/moduletranslate.php:2386
+#: locale/moduletranslate.php:2415 locale/moduletranslate.php:2444
+#: locale/moduletranslate.php:2481 locale/moduletranslate.php:2518
+#: locale/moduletranslate.php:2555 locale/moduletranslate.php:2582
+#: locale/moduletranslate.php:2611 locale/moduletranslate.php:2646
+#: locale/moduletranslate.php:2681 locale/moduletranslate.php:2705
+#: locale/moduletranslate.php:2737
+msgid ""
+"Select images from the media library to replace the default weather "
+"backgrounds."
+msgstr ""
+
+#: locale/moduletranslate.php:2180 locale/moduletranslate.php:2397
+msgid "Landscape - Current day, 4 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2183 locale/moduletranslate.php:2212
+#: locale/moduletranslate.php:2241 locale/moduletranslate.php:2270
+#: locale/moduletranslate.php:2305 locale/moduletranslate.php:2340
+#: locale/moduletranslate.php:2371 locale/moduletranslate.php:2400
+#: locale/moduletranslate.php:2429 locale/moduletranslate.php:2458
+#: locale/moduletranslate.php:2495 locale/moduletranslate.php:2532
+#: locale/moduletranslate.php:2569 locale/moduletranslate.php:2596
+#: locale/moduletranslate.php:2627 locale/moduletranslate.php:2662
+#: locale/moduletranslate.php:2719
+msgid "Icons"
+msgstr ""
+
+#: locale/moduletranslate.php:2184 locale/moduletranslate.php:2213
+#: locale/moduletranslate.php:2242 locale/moduletranslate.php:2271
+#: locale/moduletranslate.php:2306 locale/moduletranslate.php:2341
+#: locale/moduletranslate.php:2372 locale/moduletranslate.php:2401
+#: locale/moduletranslate.php:2430 locale/moduletranslate.php:2459
+#: locale/moduletranslate.php:2496 locale/moduletranslate.php:2533
+#: locale/moduletranslate.php:2570 locale/moduletranslate.php:2597
+#: locale/moduletranslate.php:2628 locale/moduletranslate.php:2663
+#: locale/moduletranslate.php:2720
+msgid "The colour of the icons"
+msgstr ""
+
+#: locale/moduletranslate.php:2186 locale/moduletranslate.php:2215
+#: locale/moduletranslate.php:2244 locale/moduletranslate.php:2273
+#: locale/moduletranslate.php:2308 locale/moduletranslate.php:2347
+#: locale/moduletranslate.php:2376 locale/moduletranslate.php:2403
+#: locale/moduletranslate.php:2434 locale/moduletranslate.php:2461
+#: locale/moduletranslate.php:2498 locale/moduletranslate.php:2539
+#: locale/moduletranslate.php:2572 locale/moduletranslate.php:2599
+#: locale/moduletranslate.php:2630 locale/moduletranslate.php:2665
+msgid "The colour of the background"
+msgstr ""
+
+#: locale/moduletranslate.php:2187 locale/moduletranslate.php:2216
+#: locale/moduletranslate.php:2245 locale/moduletranslate.php:2274
+#: locale/moduletranslate.php:2309 locale/moduletranslate.php:2373
+#: locale/moduletranslate.php:2404 locale/moduletranslate.php:2431
+#: locale/moduletranslate.php:2462 locale/moduletranslate.php:2503
+#: locale/moduletranslate.php:2625 locale/moduletranslate.php:2660
+msgid "Shadow"
+msgstr ""
+
+#: locale/moduletranslate.php:2188 locale/moduletranslate.php:2217
+#: locale/moduletranslate.php:2246 locale/moduletranslate.php:2275
+#: locale/moduletranslate.php:2310 locale/moduletranslate.php:2374
+#: locale/moduletranslate.php:2405 locale/moduletranslate.php:2432
+#: locale/moduletranslate.php:2463 locale/moduletranslate.php:2504
+#: locale/moduletranslate.php:2626 locale/moduletranslate.php:2661
+msgid "The colour of the shadow"
+msgstr ""
+
+#: locale/moduletranslate.php:2209
+msgid "Landscape - Current day, summary"
+msgstr ""
+
+#: locale/moduletranslate.php:2238
+msgid "Landscape - Current day"
+msgstr ""
+
+#: locale/moduletranslate.php:2267 locale/moduletranslate.php:2455
+msgid "Landscape - Current day detailed, 4 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2276 locale/moduletranslate.php:2311
+#: locale/moduletranslate.php:2501 locale/moduletranslate.php:2544
+#: locale/moduletranslate.php:2631 locale/moduletranslate.php:2666
+#: locale/moduletranslate.php:2693 locale/moduletranslate.php:2723
+msgid "Footer Background"
+msgstr ""
+
+#: locale/moduletranslate.php:2277 locale/moduletranslate.php:2312
+#: locale/moduletranslate.php:2502 locale/moduletranslate.php:2545
+#: locale/moduletranslate.php:2632 locale/moduletranslate.php:2667
+#: locale/moduletranslate.php:2694 locale/moduletranslate.php:2724
+#: locale/moduletranslate.php:3369 locale/moduletranslate.php:3402
+msgid "The colour of the footer background"
+msgstr ""
+
+#: locale/moduletranslate.php:2278 locale/moduletranslate.php:2313
+#: locale/moduletranslate.php:2468 locale/moduletranslate.php:2505
+#: locale/moduletranslate.php:2540 locale/moduletranslate.php:2633
+#: locale/moduletranslate.php:2668 locale/moduletranslate.php:2695
+#: locale/moduletranslate.php:2725
+msgid "Footer Text"
+msgstr ""
+
+#: locale/moduletranslate.php:2279 locale/moduletranslate.php:2314
+#: locale/moduletranslate.php:2469 locale/moduletranslate.php:2506
+#: locale/moduletranslate.php:2541 locale/moduletranslate.php:2634
+#: locale/moduletranslate.php:2669 locale/moduletranslate.php:2696
+#: locale/moduletranslate.php:2726 locale/moduletranslate.php:3367
+#: locale/moduletranslate.php:3400
+msgid "The colour of the footer text"
+msgstr ""
+
+#: locale/moduletranslate.php:2280 locale/moduletranslate.php:2315
+#: locale/moduletranslate.php:2470 locale/moduletranslate.php:2507
+#: locale/moduletranslate.php:2542 locale/moduletranslate.php:2635
+#: locale/moduletranslate.php:2670
+msgid "Footer Icons"
+msgstr ""
+
+#: locale/moduletranslate.php:2281 locale/moduletranslate.php:2316
+#: locale/moduletranslate.php:2471 locale/moduletranslate.php:2508
+#: locale/moduletranslate.php:2543 locale/moduletranslate.php:2636
+#: locale/moduletranslate.php:2671
+msgid "The colour of the footer icons"
+msgstr ""
+
+#: locale/moduletranslate.php:2302
+msgid "Portrait - Current day, 2 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2337
+msgid "Landscape - Current day detailed table, 4 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2342
+msgid "Cards Background"
+msgstr ""
+
+#: locale/moduletranslate.php:2343
+msgid "The colour of the content cards"
+msgstr ""
+
+#: locale/moduletranslate.php:2344 locale/moduletranslate.php:2600
+msgid "Dividers Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:2345 locale/moduletranslate.php:2601
+msgid "The colour of the divider elements"
+msgstr ""
+
+#: locale/moduletranslate.php:2368
+msgid "Square - Current day"
+msgstr ""
+
+#: locale/moduletranslate.php:2426
+msgid "Portrait - Current day, 4 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2464
+msgid "Footer Background 1"
+msgstr ""
+
+#: locale/moduletranslate.php:2465
+msgid "The colour of the footer background 1"
+msgstr ""
+
+#: locale/moduletranslate.php:2466
+msgid "Footer Background 2"
+msgstr ""
+
+#: locale/moduletranslate.php:2467
+msgid "The colour of the footer background 2"
+msgstr ""
+
+#: locale/moduletranslate.php:2492
+msgid "Portrait - Current day, 3 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2499
+msgid "Circle Background"
+msgstr ""
+
+#: locale/moduletranslate.php:2500
+msgid "The colour of the circle background"
+msgstr ""
+
+#: locale/moduletranslate.php:2529
+msgid "Landscape - Current day detailed, 3 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2534
+msgid "Current Container Background"
+msgstr ""
+
+#: locale/moduletranslate.php:2535
+msgid "The colour of the current container background"
+msgstr ""
+
+#: locale/moduletranslate.php:2536
+msgid "Forecast Container Background"
+msgstr ""
+
+#: locale/moduletranslate.php:2537
+msgid "The colour of the forecast container background"
+msgstr ""
+
+#: locale/moduletranslate.php:2566
+msgid "Landscape - Current day details, 4 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2593
+msgid "Portrait - Current day details, 4 day forecast"
+msgstr ""
+
+#: locale/moduletranslate.php:2622
+msgid "Square - Forecast squared with background"
+msgstr ""
+
+#: locale/moduletranslate.php:2657
+msgid "Square - Detailed weather"
+msgstr ""
+
+#: locale/moduletranslate.php:2692
+msgid "Scale - Weather background only"
+msgstr ""
+
+#: locale/moduletranslate.php:2716
+msgid "Landscape - Weather fullscreen"
+msgstr ""
+
+#: locale/moduletranslate.php:2721
+msgid "Container Background"
+msgstr ""
+
+#: locale/moduletranslate.php:2722
+msgid "The colour of the container background"
+msgstr ""
+
+#: locale/moduletranslate.php:2768
+msgid "Justify"
+msgstr ""
+
+#: locale/moduletranslate.php:2769
+msgid "Should the text be justified?"
+msgstr ""
+
+#: locale/moduletranslate.php:2770 locale/moduletranslate.php:2809
+#: locale/moduletranslate.php:2853
+msgid "Show Overflow"
+msgstr ""
+
+#: locale/moduletranslate.php:2771 locale/moduletranslate.php:2810
+#: locale/moduletranslate.php:2854
+msgid "Should the widget overflow the region?"
+msgstr ""
+
+#: locale/moduletranslate.php:2772 locale/moduletranslate.php:2811
+#: locale/moduletranslate.php:2855
+msgid "Text Shadow"
+msgstr ""
+
+#: locale/moduletranslate.php:2773 locale/moduletranslate.php:2812
+#: locale/moduletranslate.php:2856
+msgid "Should the text have a shadow?"
+msgstr ""
+
+#: locale/moduletranslate.php:2825
+msgid "Date / Time"
+msgstr ""
+
+#: locale/moduletranslate.php:2826
+msgid "Current date?"
+msgstr ""
+
+#: locale/moduletranslate.php:2827
+msgid "Use the current date to be displayed."
+msgstr ""
+
+#: locale/moduletranslate.php:2829
+msgid "The offset in minutes that should be applied to the current date."
+msgstr ""
+
+#: locale/moduletranslate.php:2830
+msgid "Custom Date"
+msgstr ""
+
+#: locale/moduletranslate.php:2831
+msgid "Insert date to be displayed."
+msgstr ""
+
+#: locale/moduletranslate.php:2870
+msgid "Image URL"
+msgstr ""
+
+#: locale/moduletranslate.php:2871
+msgid "Enter the URL of the image you want to use."
+msgstr ""
+
+#: locale/moduletranslate.php:2872
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2307
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2459
+msgid "Opacity"
+msgstr ""
+
+#: locale/moduletranslate.php:2873
+msgid "Should the image have some transparency? Choose from 0 to 100."
+msgstr ""
+
+#: locale/moduletranslate.php:2890
+msgid "Should the image have rounded corners?"
+msgstr ""
+
+#: locale/moduletranslate.php:2892
+msgid "Image Shadow"
+msgstr ""
+
+#: locale/moduletranslate.php:2893
+msgid "Should the image have a shadow?"
+msgstr ""
+
+#: locale/moduletranslate.php:2894
+msgid "Image Shadow Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:2898 locale/dbtranslate.php:35
+msgid "Library Image"
+msgstr ""
+
+#: locale/moduletranslate.php:2899
+msgid "Replace Image"
+msgstr ""
+
+#: locale/moduletranslate.php:2900
+msgid "Select an image from the Toolbox and drop here to replace this element."
+msgstr ""
+
+#: locale/moduletranslate.php:2901
+msgid "Line"
+msgstr ""
+
+#: locale/moduletranslate.php:2902
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:149
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:258
+#: cache/56/5629071fb0a52f06baa7fed9fc5c004d.php:418
+#: cache/64/64646a640c76027371e2935cda4b433c.php:108
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:108
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:214
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:536
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:843
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1003
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1288
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2311
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2463
+msgid "Width"
+msgstr ""
+
+#: locale/moduletranslate.php:2903
+msgid "Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:2904
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:136
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:354
+msgid "Style"
+msgstr ""
+
+#: locale/moduletranslate.php:2905
+msgid "Solid"
+msgstr ""
+
+#: locale/moduletranslate.php:2906
+msgid "Dotted"
+msgstr ""
+
+#: locale/moduletranslate.php:2907
+msgid "Dashed"
+msgstr ""
+
+#: locale/moduletranslate.php:2908
+msgid "Double"
+msgstr ""
+
+#: locale/moduletranslate.php:2909
+msgid "Tip1 Type"
+msgstr ""
+
+#: locale/moduletranslate.php:2910 locale/moduletranslate.php:2916
+msgid "Squared"
+msgstr ""
+
+#: locale/moduletranslate.php:2911 locale/moduletranslate.php:2917
+msgid "Diamond"
+msgstr ""
+
+#: locale/moduletranslate.php:2912 locale/moduletranslate.php:2918
+msgid "Line Arrow"
+msgstr ""
+
+#: locale/moduletranslate.php:2913 locale/moduletranslate.php:2919
+msgid "Solid Arrow"
+msgstr ""
+
+#: locale/moduletranslate.php:2914 locale/moduletranslate.php:2920
+#: locale/moduletranslate.php:2932
+msgid "Circle"
+msgstr ""
+
+#: locale/moduletranslate.php:2915
+msgid "Tip2 Type"
+msgstr ""
+
+#: locale/moduletranslate.php:2921
+msgid "Rectangle"
+msgstr ""
+
+#: locale/moduletranslate.php:2936 locale/moduletranslate.php:2952
+#: locale/moduletranslate.php:2962 locale/moduletranslate.php:2972
+msgid "Fit to area"
+msgstr ""
+
+#: locale/moduletranslate.php:2937 locale/moduletranslate.php:2953
+#: locale/moduletranslate.php:2963 locale/moduletranslate.php:2973
+msgid "Should the shape scale to fit the element area?"
+msgstr ""
+
+#: locale/moduletranslate.php:2939 locale/moduletranslate.php:2945
+msgid "Should the circle have an outline?"
+msgstr ""
+
+#: locale/moduletranslate.php:2942
+msgid "Ellipse"
+msgstr ""
+
+#: locale/moduletranslate.php:2948
+msgid "Triangle"
+msgstr ""
+
+#: locale/moduletranslate.php:2955
+msgid "Should the triangle have an outline?"
+msgstr ""
+
+#: locale/moduletranslate.php:2958
+msgid "Pentagon"
+msgstr ""
+
+#: locale/moduletranslate.php:2965
+msgid "Should the pentagon have an outline?"
+msgstr ""
+
+#: locale/moduletranslate.php:2968
+msgid "Hexagon"
+msgstr ""
+
+#: locale/moduletranslate.php:2975
+msgid "Should the hexagon have an outline?"
+msgstr ""
+
+#: locale/moduletranslate.php:2978
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1641
+msgid "Placeholder"
+msgstr ""
+
+#: locale/moduletranslate.php:2979
+msgid "Placeholder type"
+msgstr ""
+
+#: locale/moduletranslate.php:2980
+msgid "Please select the type of placeholder to use as target."
+msgstr ""
+
+#: locale/moduletranslate.php:2981
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:274
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:323
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:346
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:283
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:484
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1314
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1328
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2482
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:119
+msgid "All"
+msgstr ""
+
+#: locale/moduletranslate.php:2983
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1350
+msgid "Global"
+msgstr ""
+
+#: locale/moduletranslate.php:2984
+msgid "Image Placeholder"
+msgstr ""
+
+#: locale/moduletranslate.php:2985
+msgid "Placeholder message"
+msgstr ""
+
+#: locale/moduletranslate.php:2986
+msgid "Placeholder message colour"
+msgstr ""
+
+#: locale/moduletranslate.php:2990
+msgid "Placeholder background colour"
+msgstr ""
+
+#: locale/moduletranslate.php:2991 locale/moduletranslate.php:3002
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:120
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:120
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:172
+msgid "Subject"
+msgstr ""
+
+#: locale/moduletranslate.php:2992 locale/moduletranslate.php:3003
+msgid "Body"
+msgstr ""
+
+#: locale/moduletranslate.php:2994
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:820
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:193
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:595
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1304
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:315
+#: lib/Widget/DataType/Article.php:75
+msgid "Created Date"
+msgstr ""
+
+#: locale/moduletranslate.php:2998 locale/moduletranslate.php:3084
+#: locale/moduletranslate.php:3544
+msgid "Main Template"
+msgstr ""
+
+#: locale/moduletranslate.php:2999
+msgid ""
+"The template for formatting your notifications. Enter [Subject] and [Body] "
+"with your desired formatting. Enter text or HTML in the box below."
+msgstr ""
+
+#: locale/moduletranslate.php:3005
+msgid "Custom Style Sheets"
+msgstr ""
+
+#: locale/moduletranslate.php:3007
+msgid ""
+"A message to display when there are no notifications to show. Enter text or "
+"HTML in the box below."
+msgstr ""
+
+#: locale/moduletranslate.php:3014
+msgid "Category Photo"
+msgstr ""
+
+#: locale/moduletranslate.php:3016
+msgid "Dim when unavailable?"
+msgstr ""
+
+#: locale/moduletranslate.php:3017
+msgid "Dim Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3020 locale/moduletranslate.php:3052
+#: locale/moduletranslate.php:3594
+msgid "Currency Code"
+msgstr ""
+
+#: locale/moduletranslate.php:3021 locale/moduletranslate.php:3053
+#: locale/moduletranslate.php:3595
+msgid "The 3 digit currency code to apply to the price, e.g. USD/GBP/EUR"
+msgstr ""
+
+#: locale/moduletranslate.php:3022 locale/moduletranslate.php:3034
+#: locale/moduletranslate.php:3054
+msgid "Prefix"
+msgstr ""
+
+#: locale/moduletranslate.php:3023 locale/moduletranslate.php:3035
+#: locale/moduletranslate.php:3055
+msgid "Suffix"
+msgstr ""
+
+#: locale/moduletranslate.php:3024
+msgid "Allergy info"
+msgstr ""
+
+#: locale/moduletranslate.php:3025 lib/Widget/DataType/SocialMedia.php:73
+msgid "Photo"
+msgstr ""
+
+#: locale/moduletranslate.php:3026
+msgid "Options: Name"
+msgstr ""
+
+#: locale/moduletranslate.php:3027 locale/moduletranslate.php:3045
+msgid "Option slot"
+msgstr ""
+
+#: locale/moduletranslate.php:3044
+msgid "Options: Value"
+msgstr ""
+
+#: locale/moduletranslate.php:3064
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:268
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:267
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:233
+#: lib/Widget/DataType/Product.php:51
+msgid "Calories"
+msgstr ""
+
+#: locale/moduletranslate.php:3066
+msgid "Units margin"
+msgstr ""
+
+#: locale/moduletranslate.php:3067
+msgid "Units color"
+msgstr ""
+
+#: locale/moduletranslate.php:3068
+msgid "Profile Photo"
+msgstr ""
+
+#: locale/moduletranslate.php:3070
+msgid "Screen name"
+msgstr ""
+
+#: locale/moduletranslate.php:3073
+msgid "Post Photo"
+msgstr ""
+
+#: locale/moduletranslate.php:3074
+msgid "Post"
+msgstr ""
+
+#: locale/moduletranslate.php:3075
+msgid "Vintage Photo"
+msgstr ""
+
+#: locale/moduletranslate.php:3076
+msgid "Post - Dark"
+msgstr ""
+
+#: locale/moduletranslate.php:3082
+msgid "Original Padding"
+msgstr ""
+
+#: locale/moduletranslate.php:3083
+msgid ""
+"This is the intended padding of the template and is used to position the "
+"Widget within its region when the template is applied."
+msgstr ""
+
+#: locale/moduletranslate.php:3088
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:661
+msgid "Content Type"
+msgstr ""
+
+#: locale/moduletranslate.php:3089
+msgid "This is the intended tweet content type."
+msgstr ""
+
+#: locale/moduletranslate.php:3090
+msgid "All Posts"
+msgstr ""
+
+#: locale/moduletranslate.php:3091
+msgid "Posts with text only content"
+msgstr ""
+
+#: locale/moduletranslate.php:3092
+msgid "Posts with text and image content"
+msgstr ""
+
+#: locale/moduletranslate.php:3095 locale/moduletranslate.php:3118
+#: locale/moduletranslate.php:3151 locale/moduletranslate.php:3182
+#: locale/moduletranslate.php:3207 locale/moduletranslate.php:3232
+#: locale/moduletranslate.php:3259 locale/moduletranslate.php:3290
+#: locale/moduletranslate.php:3321 locale/moduletranslate.php:3354
+#: locale/moduletranslate.php:3387 locale/moduletranslate.php:3418
+#: locale/moduletranslate.php:3451
+msgid "Items Per Page"
+msgstr ""
+
+#: locale/moduletranslate.php:3096 locale/moduletranslate.php:3119
+#: locale/moduletranslate.php:3152 locale/moduletranslate.php:3183
+#: locale/moduletranslate.php:3208 locale/moduletranslate.php:3233
+#: locale/moduletranslate.php:3260 locale/moduletranslate.php:3291
+#: locale/moduletranslate.php:3322 locale/moduletranslate.php:3355
+#: locale/moduletranslate.php:3388 locale/moduletranslate.php:3419
+#: locale/moduletranslate.php:3452
+msgid "The number of items to show per page (default = 5)."
+msgstr ""
+
+#: locale/moduletranslate.php:3097 locale/moduletranslate.php:3120
+#: locale/moduletranslate.php:3153 locale/moduletranslate.php:3184
+#: locale/moduletranslate.php:3209 locale/moduletranslate.php:3234
+#: locale/moduletranslate.php:3261 locale/moduletranslate.php:3292
+#: locale/moduletranslate.php:3323 locale/moduletranslate.php:3356
+#: locale/moduletranslate.php:3389 locale/moduletranslate.php:3420
+#: locale/moduletranslate.php:3453
+msgid "Items direction"
+msgstr ""
+
+#: locale/moduletranslate.php:3098 locale/moduletranslate.php:3121
+#: locale/moduletranslate.php:3154 locale/moduletranslate.php:3185
+#: locale/moduletranslate.php:3210 locale/moduletranslate.php:3235
+#: locale/moduletranslate.php:3262 locale/moduletranslate.php:3293
+#: locale/moduletranslate.php:3324 locale/moduletranslate.php:3357
+#: locale/moduletranslate.php:3390 locale/moduletranslate.php:3421
+#: locale/moduletranslate.php:3454
+msgid "The display order if there's more than one item."
+msgstr ""
+
+#: locale/moduletranslate.php:3099 locale/moduletranslate.php:3122
+#: locale/moduletranslate.php:3155 locale/moduletranslate.php:3186
+#: locale/moduletranslate.php:3211 locale/moduletranslate.php:3236
+#: locale/moduletranslate.php:3263 locale/moduletranslate.php:3294
+#: locale/moduletranslate.php:3325 locale/moduletranslate.php:3358
+#: locale/moduletranslate.php:3391 locale/moduletranslate.php:3422
+#: locale/moduletranslate.php:3455
+msgid "Horizontal"
+msgstr ""
+
+#: locale/moduletranslate.php:3100 locale/moduletranslate.php:3123
+#: locale/moduletranslate.php:3156 locale/moduletranslate.php:3187
+#: locale/moduletranslate.php:3212 locale/moduletranslate.php:3237
+#: locale/moduletranslate.php:3264 locale/moduletranslate.php:3295
+#: locale/moduletranslate.php:3326 locale/moduletranslate.php:3359
+#: locale/moduletranslate.php:3392 locale/moduletranslate.php:3423
+#: locale/moduletranslate.php:3456
+msgid "Vertical"
+msgstr ""
+
+#: locale/moduletranslate.php:3106 locale/moduletranslate.php:3139
+#: locale/moduletranslate.php:3172 locale/moduletranslate.php:3197
+#: locale/moduletranslate.php:3222 locale/moduletranslate.php:3247
+#: locale/moduletranslate.php:3278 locale/moduletranslate.php:3311
+#: locale/moduletranslate.php:3344 locale/moduletranslate.php:3377
+#: locale/moduletranslate.php:3408 locale/moduletranslate.php:3439
+#: locale/moduletranslate.php:3476 locale/moduletranslate.php:3512
+msgid "How should this widget be aligned?"
+msgstr ""
+
+#: locale/moduletranslate.php:3115
+msgid "Template 1 - text, profile image"
+msgstr ""
+
+#: locale/moduletranslate.php:3126 locale/moduletranslate.php:3159
+#: locale/moduletranslate.php:3267 locale/moduletranslate.php:3298
+#: locale/moduletranslate.php:3329 locale/moduletranslate.php:3364
+#: locale/moduletranslate.php:3397 locale/moduletranslate.php:3428
+#: locale/moduletranslate.php:3463
+msgid "Post Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3127 locale/moduletranslate.php:3160
+#: locale/moduletranslate.php:3268 locale/moduletranslate.php:3299
+#: locale/moduletranslate.php:3330 locale/moduletranslate.php:3365
+#: locale/moduletranslate.php:3398 locale/moduletranslate.php:3429
+#: locale/moduletranslate.php:3464
+msgid "The colour of the post background"
+msgstr ""
+
+#: locale/moduletranslate.php:3128 locale/moduletranslate.php:3161
+#: locale/moduletranslate.php:3190 locale/moduletranslate.php:3215
+#: locale/moduletranslate.php:3240 locale/moduletranslate.php:3269
+#: locale/moduletranslate.php:3300 locale/moduletranslate.php:3335
+#: locale/moduletranslate.php:3362 locale/moduletranslate.php:3395
+#: locale/moduletranslate.php:3426 locale/moduletranslate.php:3459
+msgid "Post Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3129 locale/moduletranslate.php:3162
+#: locale/moduletranslate.php:3191 locale/moduletranslate.php:3216
+#: locale/moduletranslate.php:3241 locale/moduletranslate.php:3270
+#: locale/moduletranslate.php:3301 locale/moduletranslate.php:3336
+#: locale/moduletranslate.php:3363 locale/moduletranslate.php:3396
+#: locale/moduletranslate.php:3427 locale/moduletranslate.php:3460
+msgid "The colour of the post text"
+msgstr ""
+
+#: locale/moduletranslate.php:3130 locale/moduletranslate.php:3163
+msgid "Post Header Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3131 locale/moduletranslate.php:3164
+msgid "The colour of the post header text"
+msgstr ""
+
+#: locale/moduletranslate.php:3132 locale/moduletranslate.php:3165
+#: locale/moduletranslate.php:3469
+msgid "Profile Border Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3133 locale/moduletranslate.php:3166
+#: locale/moduletranslate.php:3470
+msgid "The colour of the profile border"
+msgstr ""
+
+#: locale/moduletranslate.php:3148
+msgid "Template 2 - text, profile image, photo"
+msgstr ""
+
+#: locale/moduletranslate.php:3181
+msgid "Template 3 - text"
+msgstr ""
+
+#: locale/moduletranslate.php:3206
+msgid "Template 4 - text, profile image"
+msgstr ""
+
+#: locale/moduletranslate.php:3231
+msgid "Template 5 - text, profile image"
+msgstr ""
+
+#: locale/moduletranslate.php:3256
+msgid "Template 6 - text, profile image"
+msgstr ""
+
+#: locale/moduletranslate.php:3271 locale/moduletranslate.php:3304
+#: locale/moduletranslate.php:3461
+msgid "Date Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3272 locale/moduletranslate.php:3305
+#: locale/moduletranslate.php:3462
+msgid "The colour of the date text"
+msgstr ""
+
+#: locale/moduletranslate.php:3287
+msgid "Template 7 - text, profile image"
+msgstr ""
+
+#: locale/moduletranslate.php:3302 locale/moduletranslate.php:3337
+msgid "User Name Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3303 locale/moduletranslate.php:3338
+msgid "The colour of the username text"
+msgstr ""
+
+#: locale/moduletranslate.php:3320
+msgid "Template 8 - text, profile image"
+msgstr ""
+
+#: locale/moduletranslate.php:3331
+msgid "Inner Post Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3332
+msgid "The colour of the inner post background"
+msgstr ""
+
+#: locale/moduletranslate.php:3333
+msgid "Inner Post Border Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3334
+msgid "The colour of the inner post border"
+msgstr ""
+
+#: locale/moduletranslate.php:3353
+msgid "Template 9 - text, logo"
+msgstr ""
+
+#: locale/moduletranslate.php:3366 locale/moduletranslate.php:3399
+msgid "Footer Text Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3368 locale/moduletranslate.php:3401
+msgid "Footer Background Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3371
+msgid "The colour of the border"
+msgstr ""
+
+#: locale/moduletranslate.php:3386
+msgid "Template 10 - text, photo, logo"
+msgstr ""
+
+#: locale/moduletranslate.php:3417
+msgid "Template 11 - text, logo"
+msgstr ""
+
+#: locale/moduletranslate.php:3431 locale/moduletranslate.php:3466
+msgid "The colour of the header text"
+msgstr ""
+
+#: locale/moduletranslate.php:3433 locale/moduletranslate.php:3468
+msgid "The colour of the header background"
+msgstr ""
+
+#: locale/moduletranslate.php:3448
+msgid "Template 12 - text, profile image, logo"
+msgstr ""
+
+#: locale/moduletranslate.php:3485
+msgid "Metro Social"
+msgstr ""
+
+#: locale/moduletranslate.php:3490
+msgid "Colours Template"
+msgstr ""
+
+#: locale/moduletranslate.php:3491
+msgid ""
+"Select the template colours you would like to apply values to the colours "
+"below."
+msgstr ""
+
+#: locale/moduletranslate.php:3492
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:729
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:250
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:348
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:131
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:724
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+msgid "Custom"
+msgstr ""
+
+#: locale/moduletranslate.php:3493
+msgid "Colours 1 - Default"
+msgstr ""
+
+#: locale/moduletranslate.php:3494
+msgid "Colours 2 - Full"
+msgstr ""
+
+#: locale/moduletranslate.php:3495
+msgid "Colours 3 - Gray Scale"
+msgstr ""
+
+#: locale/moduletranslate.php:3496
+msgid "Colours 4 - Light"
+msgstr ""
+
+#: locale/moduletranslate.php:3497
+msgid "Colours 5 - Soft"
+msgstr ""
+
+#: locale/moduletranslate.php:3498
+msgid "Colours 6 - Vivid"
+msgstr ""
+
+#: locale/moduletranslate.php:3499
+msgid "Colour 1"
+msgstr ""
+
+#: locale/moduletranslate.php:3500
+msgid "Colour 2"
+msgstr ""
+
+#: locale/moduletranslate.php:3501
+msgid "Colour 3"
+msgstr ""
+
+#: locale/moduletranslate.php:3502
+msgid "Colour 4"
+msgstr ""
+
+#: locale/moduletranslate.php:3503
+msgid "Colour 5"
+msgstr ""
+
+#: locale/moduletranslate.php:3504
+msgid "Colour 6"
+msgstr ""
+
+#: locale/moduletranslate.php:3505
+msgid "Colour 7"
+msgstr ""
+
+#: locale/moduletranslate.php:3506
+msgid "Colour 8"
+msgstr ""
+
+#: locale/moduletranslate.php:3522
+msgid "Symbol"
+msgstr ""
+
+#: locale/moduletranslate.php:3525
+msgid "Stock Icon"
+msgstr ""
+
+#: locale/moduletranslate.php:3526
+msgid "Stocks - Single 1"
+msgstr ""
+
+#: locale/moduletranslate.php:3527
+msgid "Stocks - Single 2"
+msgstr ""
+
+#: locale/moduletranslate.php:3528
+msgid "Stocks - Group 1"
+msgstr ""
+
+#: locale/moduletranslate.php:3529
+msgid "Stocks Custom HTML"
+msgstr ""
+
+#: locale/moduletranslate.php:3549
+msgid "Stocks 1"
+msgstr ""
+
+#: locale/moduletranslate.php:3557
+msgid "Background colour for each stock item."
+msgstr ""
+
+#: locale/moduletranslate.php:3559 locale/moduletranslate.php:3580
+msgid "Font colour for each stock item."
+msgstr ""
+
+#: locale/moduletranslate.php:3560 locale/moduletranslate.php:3581
+msgid "Item Label Font Colour"
+msgstr ""
+
+#: locale/moduletranslate.php:3561 locale/moduletranslate.php:3582
+msgid "Font colour for each stock item label."
+msgstr ""
+
+#: locale/moduletranslate.php:3563
+msgid "Border colour for each stock item."
+msgstr ""
+
+#: locale/moduletranslate.php:3572
+msgid "Stocks 2"
+msgstr ""
+
+#: locale/moduletranslate.php:3591
+msgid "Bulleted list with preset style"
+msgstr ""
+
+#: locale/moduletranslate.php:3593
+msgid "Grid Layout"
+msgstr ""
+
+#: locale/dbtranslate.php:26
+msgid "Fade In"
+msgstr ""
+
+#: locale/dbtranslate.php:27 cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2522
+msgid "Fade Out"
+msgstr ""
+
+#: locale/dbtranslate.php:28
+msgid "Fly"
+msgstr ""
+
+#: locale/dbtranslate.php:34
+msgid "External Image"
+msgstr ""
+
+#: locale/dbtranslate.php:37 cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:317
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:994
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:585
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:1014
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:245
+msgid "Value"
+msgstr ""
+
+#: locale/dbtranslate.php:38 cache/15/15895aad4f7efd6a8c70d32de3acade4.php:272
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:272
+msgid "Formula"
+msgstr ""
+
+#: locale/dbtranslate.php:41
+msgid "Data Set"
+msgstr ""
+
+#: locale/dbtranslate.php:42
+msgid "DataSet View"
+msgstr ""
+
+#: locale/dbtranslate.php:43
+msgid "A view on a DataSet"
+msgstr ""
+
+#: locale/dbtranslate.php:44
+msgid "DataSet Ticker"
+msgstr ""
+
+#: locale/dbtranslate.php:45
+msgid "Ticker with a DataSet providing the items"
+msgstr ""
+
+#: locale/dbtranslate.php:46
+msgid "Ticker"
+msgstr ""
+
+#: locale/dbtranslate.php:47
+msgid "RSS Ticker."
+msgstr ""
+
+#: locale/dbtranslate.php:49
+msgid "Text. With Directional Controls."
+msgstr ""
+
+#: locale/dbtranslate.php:51
+msgid "Embedded HTML"
+msgstr ""
+
+#: locale/dbtranslate.php:53
+msgid "Images. PNG, JPG, BMP, GIF"
+msgstr ""
+
+#: locale/dbtranslate.php:55
+msgid "Videos - support varies depending on the client hardware you are using."
+msgstr ""
+
+#: locale/dbtranslate.php:57
+msgid "A module for displaying Video and Audio from an external source"
+msgstr ""
+
+#: locale/dbtranslate.php:59 cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1612
+msgid "PowerPoint"
+msgstr ""
+
+#: locale/dbtranslate.php:60
+msgid "Powerpoint. PPT, PPS"
+msgstr ""
+
+#: locale/dbtranslate.php:62
+msgid "Webpages."
+msgstr ""
+
+#: locale/dbtranslate.php:63
+msgid "Counter"
+msgstr ""
+
+#: locale/dbtranslate.php:64
+msgid "Shell Command"
+msgstr ""
+
+#: locale/dbtranslate.php:65
+msgid "Execute a shell command on the client"
+msgstr ""
+
+#: locale/dbtranslate.php:67
+msgid "Play a video locally stored on the client"
+msgstr ""
+
+#: locale/dbtranslate.php:68
+msgid "Clock"
+msgstr ""
+
+#: locale/dbtranslate.php:70
+msgid "A font to use in other Modules"
+msgstr ""
+
+#: locale/dbtranslate.php:74
+msgid "Audio - support varies depending on the client hardware"
+msgstr ""
+
+#: locale/dbtranslate.php:75 cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:872
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1608
+msgid "PDF"
+msgstr ""
+
+#: locale/dbtranslate.php:76
+msgid "PDF document viewer"
+msgstr ""
+
+#: locale/dbtranslate.php:77
+msgid "Notification"
+msgstr ""
+
+#: locale/dbtranslate.php:78
+msgid "Display Notifications from the Notification Centre"
+msgstr ""
+
+#: locale/dbtranslate.php:80
+msgid "Stocks Module"
+msgstr ""
+
+#: locale/dbtranslate.php:81
+msgid "A module for showing Stock quotes"
+msgstr ""
+
+#: locale/dbtranslate.php:82
+msgid "Currencies Module"
+msgstr ""
+
+#: locale/dbtranslate.php:85
+msgid "Stocks"
+msgstr ""
+
+#: locale/dbtranslate.php:86
+msgid "Yahoo Stocks"
+msgstr ""
+
+#: locale/dbtranslate.php:88
+msgid "Yahoo Currencies"
+msgstr ""
+
+#: locale/dbtranslate.php:89
+msgid "Finance"
+msgstr ""
+
+#: locale/dbtranslate.php:90
+msgid "Yahoo Finance"
+msgstr ""
+
+#: locale/dbtranslate.php:92
+msgid "Google Traffic Map"
+msgstr ""
+
+#: locale/dbtranslate.php:94
+msgid "HLS Video Stream"
+msgstr ""
+
+#: locale/dbtranslate.php:95
+msgid "Twitter"
+msgstr ""
+
+#: locale/dbtranslate.php:96
+msgid "Twitter Search Module"
+msgstr ""
+
+#: locale/dbtranslate.php:97
+msgid "Twitter Metro"
+msgstr ""
+
+#: locale/dbtranslate.php:98
+msgid "Twitter Metro Search Module"
+msgstr ""
+
+#: locale/dbtranslate.php:100
+msgid "Weather module showing Current and Daily forecasts."
+msgstr ""
+
+#: locale/dbtranslate.php:101
+msgid "Sub-Playlist"
+msgstr ""
+
+#: locale/dbtranslate.php:102
+msgid "Embed a Sub-Playlist"
+msgstr ""
+
+#: locale/dbtranslate.php:103
+msgid "Countdown"
+msgstr ""
+
+#: locale/dbtranslate.php:105
+msgid "January"
+msgstr ""
+
+#: locale/dbtranslate.php:106
+msgid "February"
+msgstr ""
+
+#: locale/dbtranslate.php:107
+msgid "March"
+msgstr ""
+
+#: locale/dbtranslate.php:108
+msgid "April"
+msgstr ""
+
+#: locale/dbtranslate.php:109
+msgid "May"
+msgstr ""
+
+#: locale/dbtranslate.php:110
+msgid "June"
+msgstr ""
+
+#: locale/dbtranslate.php:111
+msgid "July"
+msgstr ""
+
+#: locale/dbtranslate.php:112
+msgid "August"
+msgstr ""
+
+#: locale/dbtranslate.php:113
+msgid "September"
+msgstr ""
+
+#: locale/dbtranslate.php:114
+msgid "October"
+msgstr ""
+
+#: locale/dbtranslate.php:115
+msgid "November"
+msgstr ""
+
+#: locale/dbtranslate.php:116
+msgid "December"
+msgstr ""
+
+#: locale/dbtranslate.php:119 lib/Factory/UserGroupFactory.php:994
+msgid "Icon Dashboard"
+msgstr ""
+
+#: locale/dbtranslate.php:120 lib/Factory/UserGroupFactory.php:988
+msgid "Status Dashboard"
+msgstr ""
+
+#: locale/dbtranslate.php:121
+msgid "Media Dashboard"
+msgstr ""
+
+#: locale/dbtranslate.php:124
+msgid "Full account access"
+msgstr ""
+
+#: locale/dbtranslate.php:125
+msgid "Access to DataSets"
+msgstr ""
+
+#: locale/dbtranslate.php:126
+msgid "Access to deleting DataSets"
+msgstr ""
+
+#: locale/dbtranslate.php:127
+msgid "Access to Library, Layouts, Playlists, Widgets and Resolutions"
+msgstr ""
+
+#: locale/dbtranslate.php:128
+msgid ""
+"Access to deleting content from Library, Layouts, Playlists, Widgets and "
+"Resolutions"
+msgstr ""
+
+#: locale/dbtranslate.php:129
+msgid "Access to Displays and Display Groups"
+msgstr ""
+
+#: locale/dbtranslate.php:130
+msgid "Access to deleting Displays and Display Groups"
+msgstr ""
+
+#: locale/dbtranslate.php:131
+msgid "Media Conversion as a Service"
+msgstr ""
+
+#: locale/dbtranslate.php:132
+msgid "Access to Scheduling"
+msgstr ""
+
+#: locale/dbtranslate.php:133
+msgid "Access to deleting Scheduled Events"
+msgstr ""
+
+#: locale/dbtranslate.php:136
+msgid "Primary"
+msgstr ""
+
+#: locale/dbtranslate.php:137
+msgid "Secondary"
+msgstr ""
+
+#: locale/dbtranslate.php:139
+msgid "Header color"
+msgstr ""
+
+#: locale/dbtranslate.php:140
+msgid "Header text"
+msgstr ""
+
+#: locale/dbtranslate.php:141
+msgid "Disabled item text"
+msgstr ""
+
+#: locale/dbtranslate.php:142
+msgid "Highlighted item text"
+msgstr ""
+
+#: locale/dbtranslate.php:143
+msgid "Show product images?"
+msgstr ""
+
+#: locale/dbtranslate.php:144
+msgid "Show category images?"
+msgstr ""
+
+#: locale/dbtranslate.php:146
+msgid "Product border colour"
+msgstr ""
+
+#: locale/dbtranslate.php:151
+msgid "Billboard"
+msgstr ""
+
+#: locale/dbtranslate.php:152
+msgid "Kiosk"
+msgstr ""
+
+#: locale/dbtranslate.php:153
+msgid "LED Matrix / LED Video Wall"
+msgstr ""
+
+#: locale/dbtranslate.php:154
+msgid "Monitor / Other"
+msgstr ""
+
+#: locale/dbtranslate.php:155
+msgid "Projector"
+msgstr ""
+
+#: locale/dbtranslate.php:156
+msgid "Shelf-edge Display"
+msgstr ""
+
+#: locale/dbtranslate.php:157
+msgid "Smart Mirror"
+msgstr ""
+
+#: locale/dbtranslate.php:158
+msgid "TV / Panel"
+msgstr ""
+
+#: locale/dbtranslate.php:159
+msgid "Tablet"
+msgstr ""
+
+#: locale/dbtranslate.php:160
+msgid "Totem"
+msgstr ""
+
+#: locale/dbtranslate.php:163
+msgid "Wind"
+msgstr ""
+
+#: locale/dbtranslate.php:164
+msgid "Humidity"
+msgstr ""
+
+#: locale/dbtranslate.php:165
+msgid "Feels Like"
+msgstr ""
+
+#: locale/dbtranslate.php:166
+msgid "Right now"
+msgstr ""
+
+#: locale/dbtranslate.php:167 lib/Connector/OpenWeatherMapConnector.php:702
+msgid "Pressure"
+msgstr ""
+
+#: locale/dbtranslate.php:168
+msgid "Visibility"
+msgstr ""
+
+#: locale/dbtranslate.php:169
+msgid "TODAY"
+msgstr ""
+
+#: locale/dbtranslate.php:170
+msgid "RIGHT NOW"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:71
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:460
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:130
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:355
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:542
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:56
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:94
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:196
+msgid "Settings"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:75
+msgid ""
+"Your API key allows for secure communication between the CMS and the Xibo "
+"audience service. It is used to analyse your proof of play data for Ad "
+"Campaigns and retrieve reports. It is never possible to retrieve credentials."
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:87
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:91
+msgid "Enter your API Key from Xibo."
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:100
+msgid ""
+"Once enabled additional reporting will be shown for your Ad Campaigns in the "
+"'All Reports' section. To vary your cost/impressions per play by date, time "
+"of day, day of week or geo location, add a DMA (designated market area) "
+"below."
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:120
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:135
+#, no-php-format
+msgid "Your API key is authorised for %numberOfAuthedDisplays% displays."
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:158
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:552
+msgid "Cost per Play"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:162
+msgid "Impressions per Play"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:166
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:570
+msgid "Impression Source"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:178
+msgid "Days of week"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:182
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:270
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:294
+#: cache/ee/eed40b13770b97167f375466867a779e.php:159
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:385
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:664
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:373
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:152
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2052
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:159
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:103
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:95
+msgid "Start Time"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:186
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:287
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:311
+#: cache/ee/eed40b13770b97167f375466867a779e.php:176
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:402
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:681
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:390
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:156
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2056
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:176
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:120
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:112
+msgid "End Time"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:190
+msgid "Is Geo?"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:194
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:749
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:321
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:345
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:673
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:515
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:474
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:668
+msgid "Priority"
+msgstr ""
+
+#: cache/c3/c311afed66d42a9e7c8b7e641cb82f81.php:198
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:99
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:298
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:308
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:322
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:189
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:247
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:57
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:116
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:244
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:211
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:220
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:318
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:336
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:110
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:807
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:141
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:99
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:79
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:415
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:115
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:244
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:306
+msgid "Displays"
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:57
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:57
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:98
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:76
+#: lib/XTR/MaintenanceRegularTask.php:299
+msgid "Tidy Library"
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:71
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:72
+#: cache/16/16ddf7e420eb8069c1a5deab6ce65be2.php:74
+#: cache/39/396f36a5868b8bc37c54b9672b88f669.php:74
+#: cache/c8/c8a44d0ca0af4311b6a8db2c78bb5576.php:73
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:71
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:74
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:80
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:444
+#: cache/b8/b827f37e4bcd9aceec71b399ad0e597f.php:71
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:72
+#: cache/ff/ff53ca9025540b897618be0c098d83cb.php:71
+#: cache/5a/5ab03a5b2ec6fe2346c93676aa6a2ece.php:71
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:77
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:73
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:120
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:122
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:72
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:72
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:75
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:72
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:72
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:497
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:72
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:72
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:72
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:72
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:74
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:73
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:71
+#: cache/3f/3fff772c4b50b4be1c278efa53e51975.php:74
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:71
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:71
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:73
+#: cache/d4/d4b2abaf26d2534088241d6656d9b1a9.php:77
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:72
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:80
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:73
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:73
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:82
+#: cache/64/64646a640c76027371e2935cda4b433c.php:71
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:356
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:587
+#: cache/d2/d21e8a64f7edc7ce59554632a094e1cc.php:74
+#: cache/30/304a80e4331dc3ca1db9b9f51d833987.php:71
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:80
+#: cache/dc/dcfc1ae248c38fe9cf4cfee76751c346.php:74
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:80
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:77
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:80
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:81
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:71
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:129
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:73
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:71
+#: cache/ee/eed40b13770b97167f375466867a779e.php:73
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:71
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:74
+#: cache/ad/ad6451318b1bb1af5d4ae026008254d7.php:74
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:89
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:71
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:265
+#: cache/2a/2a7041a7aaa1920fa6e115acab2baed8.php:71
+#: cache/34/3439fa3af454e93f2ed9d709b898b56f.php:76
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:72
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:77
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:76
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:360
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:86
+#: cache/1f/1fb3ca31aef0e3fe7b52bbfbb6a4af73.php:74
+#: cache/63/63b15102c208fae253c32c2f77313cc2.php:71
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3808
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:430
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:80
+#: cache/0b/0bb72264cd242f2afd564859f6bf11f9.php:73
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:74
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:71
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:71
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:73
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:80
+#: cache/71/7194942bf0bccb84d38e565de0fdafc8.php:72
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:71
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:71
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:73
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:71
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:141
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:74
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:71
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:75
+#: cache/4a/4ab9918a9e09352e2b177d64bcd99b84.php:71
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:72
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:71
+#: cache/78/7875975092226a1328f8ec7d90ce4b25.php:74
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:701
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2331
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:71
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:71
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:72
+#: cache/cd/cd143932270a543fae63974db1f8be19.php:71
+#: cache/ca/ca9b899371870f4435fd70e881698a42.php:75
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:80
+#: cache/42/42493ba813fc77f7caf608638138cbdc.php:74
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:73
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:71
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:74
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:71
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:74
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:71
+#: cache/b9/b9b5222ce78d1050139467177732ab76.php:74
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:71
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:73
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:80
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:80
+#: cache/3e/3e3117c1d042d50f9efa8908d5b286c1.php:74
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:74
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:71
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:73
+#: cache/20/20d56467e17c048a093ad7168955489f.php:75
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:99
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:74
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:73
+msgid "Save"
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:91
+msgid ""
+"Tidying your Library will delete any media that is not currently in use."
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:105
+#, no-php-format
+msgid ""
+"There is %sumExcludingGeneric% of data stored in %countExcludingGeneric% "
+"files . Are you sure you want to proceed?"
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:117
+msgid "You do not have any library files that are not in use."
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:134
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:136
+msgid "Delete Generic Files?"
+msgstr ""
+
+#: cache/c3/c3e6593b031ad5974157ca87a088cb87.php:140
+#, no-php-format
+msgid ""
+"There is %sumGeneric% of data stored in %countGeneric% generic files. To "
+"delete these check here"
+msgstr ""
+
+#: cache/c7/c7619bfe36d59f0d985ea3340e2e0097.php:69
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:436
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:75
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:512
+#: cache/1c/1c65822405d03c36923ecc7399544cf8.php:75
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:84
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:75
+#: cache/7b/7bd5c7627387d102ce14defdbb83a5fa.php:62
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:68
+msgid "Close"
+msgstr ""
+
+#: cache/c7/c7619bfe36d59f0d985ea3340e2e0097.php:92
+#: cache/76/76c39326d719a8082b68eae5f3612d78.php:72
+msgid "Published"
+msgstr ""
+
+#: cache/c7/c7619bfe36d59f0d985ea3340e2e0097.php:96
+#: cache/76/76c39326d719a8082b68eae5f3612d78.php:76
+#, no-php-format
+msgid "you read this %readDt%."
+msgstr ""
+
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:58
+msgid "Export Statistics"
+msgstr ""
+
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:100
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:130
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:216
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2073
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:106
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:389
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:454
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:139
+#: cache/02/02c84068a1b9b436fec1c165c50ea5c7.php:91
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:97
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1520
+msgid "From Date"
+msgstr ""
+
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:111
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:141
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:227
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2111
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:117
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:400
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:458
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:150
+#: cache/02/02c84068a1b9b436fec1c165c50ea5c7.php:102
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1558
+msgid "To Date"
+msgstr ""
+
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:122
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:195
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:561
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:167
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:368
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:625
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:411
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:188
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:175
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:186
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:265
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:77
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:220
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:231
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:208
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:219
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:212
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:176
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:77
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:246
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:466
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:228
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:303
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:404
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:133
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:265
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:376
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:71
+#: lib/Report/DistributionReport.php:253
+msgid "Display"
+msgstr ""
+
+#: cache/c7/c7e14d74542ec111d248e09a3b59b35c.php:136
+msgid "Output dates as UTC? Leave unchecked for local CMS time."
+msgstr ""
+
+#: cache/16/16ddf7e420eb8069c1a5deab6ce65be2.php:60
+msgid "Associate an item from the Library"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:56
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:129
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:189
+msgid "Resolutions"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:72
+msgid "Add a new resolution for use on layouts"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:76
+#: cache/64/64646a640c76027371e2935cda4b433c.php:57
+msgid "Add Resolution"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:82
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:69
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:95
+#: cache/86/86c186879b5295663da37409020c35ad.php:78
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:104
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:77
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:85
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:97
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:83
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:77
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:87
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:107
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:79
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:71
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:97
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:87
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:69
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:80
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:82
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:94
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:82
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:83
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:82
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:84
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:80
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:80
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:82
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:80
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:69
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:70
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:83
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:80
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:82
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:77
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:71
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:80
+msgid "Refresh the Table"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:97
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:145
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:188
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:221
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:669
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:376
+#: cache/64/64646a640c76027371e2935cda4b433c.php:91
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:91
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:323
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:500
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:893
+msgid "Resolution"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:110
+#: cache/86/86c186879b5295663da37409020c35ad.php:425
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:139
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:212
+msgid "Enabled"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:116
+#: cache/48/4832df7e87cac4c001ba6814607f0504.php:71
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:71
+#: cache/55/55480053db687228f2af258c58523fef.php:71
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:306
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:71
+#: cache/36/36bf1042971e35f01c528382c4e2ebe9.php:71
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:442
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:456
+#: cache/7a/7a8ad14e9394ea474747eba4d5e8355e.php:71
+#: cache/f4/f4b0861fe3bd8635c8e4460f698cac0e.php:83
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:204
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:232
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:260
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:548
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:970
+#: cache/84/84b99aef46296bdae8366b9b2cef050f.php:71
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:331
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:676
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:871
+#: cache/64/645346da179f810102af92dd23119517.php:71
+#: cache/b1/b1dc7b94fc6bf2d2aa104558aeac60a2.php:71
+#: cache/a4/a4d917f846495e6caa8f764f3bacf824.php:74
+#: cache/a4/a42e589153f031845c0d3304ed5d9d32.php:71
+#: cache/f6/f670ecee482264686c6d025f5b04b60d.php:71
+#: cache/90/90862a7ca7a5cceaa1f138c598b6b16d.php:71
+#: cache/96/9633909657b383876962dc7af40f1d9a.php:71
+#: cache/0d/0d095ec1390538dc32e5ec788e7c4a04.php:71
+#: cache/fb/fb7adbdacbb41d667f8ac87d10d7340e.php:71
+#: cache/ee/eef84193bcdd079248f9945fb2d47f22.php:71
+#: cache/2f/2ff5e074540e4fba11b17571f8a8f63e.php:71
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:424
+#: cache/74/74834652bf0eeb1d711de01b8086ce3b.php:71
+#: cache/1b/1bb1add0325cf87ffe8364fea056a443.php:71
+#: cache/23/23525fb78ad0912d772847c7fa4abf9c.php:71
+#: cache/38/38337c8b2011d0cb441c15e5f699421a.php:74
+#: cache/c0/c0be2434c592f085d1c7d2a30346ab1f.php:71
+#: cache/0b/0bfce4ff89cb4e56420dcb4f282c333f.php:71
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:124
+#: cache/fd/fddc43e726be2bc7dfd3ad2aeb4e2104.php:71
+#: cache/2b/2b1a2c99481cb6bf3041ec49215e7147.php:80
+#: cache/5c/5c6b86794d0e31f65e8e29e779a5be10.php:74
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:72
+#: cache/50/50d14368a7ff5330505ebb92bb2ad8e8.php:71
+#: cache/e1/e135a48f6c008e1d5927ee93c2d3f474.php:71
+#: cache/c6/c6d931f51a0aaa053f73e039b1fd4947.php:71
+#: cache/b0/b0b923280921575816524c84c5dc4bf9.php:71
+#: cache/fe/fe96f5539f69eac563b59351cac89453.php:80
+#: cache/4e/4e4d410560dee1c82b8fd849dba6db02.php:80
+#: cache/bc/bc9269a5f1accd4b0930cf946764c3dc.php:71
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:75
+#: cache/c9/c9a376b6be90c2a17c251d27704daba1.php:74
+#: cache/72/7223733b6d46a336184288000193d298.php:71
+#: cache/10/10a3d62a635559ddeaa4c9ab8b676aaf.php:71
+#: cache/d0/d0cbe599603d68ffbcfef9b7b507a1dc.php:71
+#: cache/5b/5b618f049877ed3965ece85d731e1cc7.php:71
+#: cache/5b/5b72c53542bfbaa4cd431216826e8495.php:71
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:72
+#: cache/19/199e4757d5dd04d073c54aef2b324867.php:71
+#: cache/ce/ce52d2aeb2c4862f9ff23cfd3d67e2e7.php:80
+#: cache/ce/cee07744a1d4a288c81e3d8649a8ddc2.php:71
+msgid "Yes"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:122
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:303
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:442
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:456
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:210
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:238
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:266
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:554
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:974
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:337
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:682
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:877
+#: cache/64/645346da179f810102af92dd23119517.php:67
+#: cache/a4/a4d917f846495e6caa8f764f3bacf824.php:70
+#: cache/0d/0d095ec1390538dc32e5ec788e7c4a04.php:67
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:431
+#: cache/38/38337c8b2011d0cb441c15e5f699421a.php:70
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:127
+#: cache/5c/5c6b86794d0e31f65e8e29e779a5be10.php:70
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:68
+#: cache/e1/e135a48f6c008e1d5927ee93c2d3f474.php:67
+#: cache/fe/fe96f5539f69eac563b59351cac89453.php:76
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:79
+#: cache/c9/c9a376b6be90c2a17c251d27704daba1.php:70
+#: cache/ce/ce52d2aeb2c4862f9ff23cfd3d67e2e7.php:76
+msgid "No"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:153
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:269
+#: cache/56/5629071fb0a52f06baa7fed9fc5c004d.php:422
+#: cache/64/64646a640c76027371e2935cda4b433c.php:125
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:125
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:225
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:540
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:847
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1011
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1292
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2315
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2467
+msgid "Height"
+msgstr ""
+
+#: cache/39/399aaa1c72b3f5d258b4f1441bc2f8bb.php:157
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:510
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:98
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:127
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:270
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:250
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:194
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:150
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:209
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:406
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:488
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:614
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:696
+msgid "Enabled?"
+msgstr ""
+
+#: cache/39/396f36a5868b8bc37c54b9672b88f669.php:60
+msgid "Assign a Layout"
+msgstr ""
+
+#: cache/39/396f36a5868b8bc37c54b9672b88f669.php:98
+msgid ""
+"Assigning a Layout to a Display/DisplayGroup does NOT schedule that Layout "
+"to be shown. Please use the Schedule to show Layouts."
+msgstr ""
+
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:56
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:84
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:394
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:632
+msgid "Transitions"
+msgstr ""
+
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:108
+msgid "Has Duration"
+msgstr ""
+
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:112
+msgid "Has Direction"
+msgstr ""
+
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:116
+msgid "Enabled for In"
+msgstr ""
+
+#: cache/3d/3dccfdfa3c83139dfce9f86cc52918eb.php:120
+msgid "Enabled for Out"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:63
+#, no-php-format
+msgid "Welcome to the %themeName% Installation"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:72
+#, no-php-format
+msgid ""
+"Thank you for choosing %themeName%. This installation wizard will take you "
+"through\n"
+" setting up %themeName% one step at a time. There are 6 steps "
+"in total, the first one is below."
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:81
+msgid "Installation guide"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:101
+#, no-php-format
+msgid "First we need to check if your server meets %themeName%'s requirements."
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:108
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:187
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:269
+msgid "Item"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:112
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:199
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:452
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:376
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:191
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:273
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:163
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:637
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:127
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:201
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:517
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:450
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:216
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:470
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:108
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:384
+#: lib/Connector/CapConnector.php:534
+#: lib/Connector/NationalWeatherServiceConnector.php:401
+msgid "Status"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:116
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:195
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:277
+msgid "Advice"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:124
+msgid "Settings File System Permissions"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:142
+msgid "Write permissions are required for web/settings.php"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:198
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:216
+msgid "Retest"
+msgstr ""
+
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:230
+#: cache/91/91c89a911ea169e202266272e1c9fced.php:247
+#: cache/48/48656e53670f6713a8f84aa06269d04d.php:114
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:73
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:614
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:338
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:372
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:410
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:500
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:536
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:502
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:520
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:78
+#: cache/54/54d064f129447f51389aeea4a056750e.php:131
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:77
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:83
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:970
+#: cache/ca/ca9b899371870f4435fd70e881698a42.php:71
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:376
+msgid "Next"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:146
+msgid "Please provide your Two Factor Authorisation Code"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:150
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:192
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:507
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:144
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:195
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:124
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:215
+#: cache/84/846b4e365858df7d149d45d99237360c.php:162
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:184
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:125
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:120
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:194
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:132
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:221
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:132
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:136
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:157
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:237
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:135
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:143
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:171
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:116
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:135
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:198
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:120
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:152
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:119
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:139
+msgid "Code"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:166
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:233
+#: cache/b8/b86809ac450d70fe762a12de38b6b84f.php:57
+#: cache/b8/b86809ac450d70fe762a12de38b6b84f.php:71
+msgid "Verify"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:171
+msgid "Use Recovery Code instead?"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:208
+msgid "Please provide your Two Factor Authorisation Recovery Code"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:212
+msgid "Recovery Code"
+msgstr ""
+
+#: cache/c8/c822e8fbc39791428a09dc24068a06fc.php:238
+msgid "Use Two Factor Code instead?"
+msgstr ""
+
+#: cache/c8/c8a44d0ca0af4311b6a8db2c78bb5576.php:59
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:752
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:575
+msgid "Bandwidth Limit"
+msgstr ""
+
+#: cache/c8/c8a44d0ca0af4311b6a8db2c78bb5576.php:95
+msgid "Change Bandwidth Limit to all the selected displays."
+msgstr ""
+
+#: cache/c8/c8a44d0ca0af4311b6a8db2c78bb5576.php:113
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:925
+msgid "Bandwidth limit"
+msgstr ""
+
+#: cache/c8/c8a44d0ca0af4311b6a8db2c78bb5576.php:119
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:931
+msgid "The bandwidth limit that should be applied. Enter 0 for no limit."
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:57
+msgid "Edit Task"
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:87
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:122
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:142
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:95
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:106
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:96
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:57
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:128
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:96
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:96
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:189
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:87
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:74
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:105
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:96
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:142
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:105
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:57
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:90
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:96
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:96
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:142
+#: cache/ee/eed40b13770b97167f375466867a779e.php:105
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:105
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:472
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:96
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:57
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:101
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:102
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:114
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1628
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:107
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:104
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:87
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:57
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:87
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:96
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:108
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:524
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:96
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:105
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:96
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:189
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:87
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:138
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:57
+msgid "General"
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:109
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:119
+msgid "The Name for this Task"
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:120
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:223
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:701
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:58
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:119
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:233
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:357
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:54
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:62
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:80
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:538
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:130
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:328
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:81
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:191
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+#: cache/d9/d9efdfaf56965172b4a6e54931ee7d81.php:47
+#: lib/Controller/Campaign.php:298 lib/Controller/Playlist.php:541
+#: lib/Controller/Library.php:811 lib/Controller/Layout.php:1863
+#: lib/Controller/Display.php:1004
+msgid "Schedule"
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:126
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:136
+msgid "The schedule for this task in CRON syntax"
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:137
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:123
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:114
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:153
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:211
+msgid "Active"
+msgstr ""
+
+#: cache/bd/bd619722c72218fd0eef8618b72a39d0.php:143
+msgid "Is the task active?"
+msgstr ""
+
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:60
+#: cache/b9/b9b5222ce78d1050139467177732ab76.php:60
+#, no-php-format
+msgid "Playlist %name%"
+msgstr ""
+
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:94
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:933
+#: cache/ad/ad6451318b1bb1af5d4ae026008254d7.php:94
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1213
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:94
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1053
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:259
+msgid "Enable Stats Collection?"
+msgstr ""
+
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:100
+msgid "Enable the collection of Proof of Play statistics for this Playlist."
+msgstr ""
+
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:108
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:333
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:281
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:307
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:951
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:197
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:305
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:171
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:268
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:557
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1273
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1334
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1395
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2104
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2444
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3727
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:746
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:361
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:547
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:561
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:630
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:108
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1061
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:293
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:467
+#: lib/Controller/Layout.php:1385
+msgid "Off"
+msgstr ""
+
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:114
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:287
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:313
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:955
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:311
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:177
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1279
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1340
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1401
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2110
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3739
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:750
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:547
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:561
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:630
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:114
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1065
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:467
+#: lib/Controller/Layout.php:1385
+msgid "On"
+msgstr ""
+
+#: cache/bd/bd164b7adfd3a6ce7c50fc3ac845b38d.php:120
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:293
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:319
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:959
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:317
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:183
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1285
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1346
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1407
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:754
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:120
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1069
+msgid "Inherit"
+msgstr ""
+
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:66
+#, no-php-format
+msgid "Manage Membership for %syncGroupName%"
+msgstr ""
+
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:140
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:157
+msgid ""
+"Check or un-check the options against each display to control whether they "
+"are a member or not."
+msgstr ""
+
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:168
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:185
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:254
+msgid "Authorised"
+msgstr ""
+
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:203
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:692
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:220
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:250
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:523
+msgid "Logged In"
+msgstr ""
+
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:207
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:704
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:108
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:224
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:535
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:109
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:148
+msgid "Version"
+msgstr ""
+
+#: cache/21/21d5636bb4b2de418fada2c0b2edcb96.php:211
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:174
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:228
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:290
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:174
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:179
+msgid "Member"
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:67
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:537
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:83
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:459
+#: cache/34/340ecc3756674aa339156a3d6b49cc46.php:65
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:313
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:447
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:492
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:504
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:668
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1551
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1754
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1806
+#: lib/Entity/Schedule.php:2093
+msgid "Playlist"
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:73
+msgid "Please select a Playlist to manage"
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:93
+msgid "Playlist Content"
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:97
+msgid ""
+"Fill empty Spots by clicking on ‘Add’ to select the media file you wish to "
+"use."
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:102
+msgid ""
+"Replace existing media files by clicking on a Spot and select the new media "
+"file you wish to use."
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:359
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:211
+#: cache/84/84b99aef46296bdae8366b9b2cef050f.php:57
+#: cache/d4/d4b2abaf26d2534088241d6656d9b1a9.php:67
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:607
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:201
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:173
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:80
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:461
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:58
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:91
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:192
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:238
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:457
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:713
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:793
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2339
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:248
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:944
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:187
+#: cache/ce/ce52d2aeb2c4862f9ff23cfd3d67e2e7.php:70 lib/Controller/Task.php:151
+#: lib/Controller/Template.php:270 lib/Controller/Template.php:283
+#: lib/Controller/DisplayGroup.php:380 lib/Controller/DisplayGroup.php:393
+#: lib/Controller/DataSet.php:328 lib/Controller/DataSet.php:334
+#: lib/Controller/ScheduleReport.php:236 lib/Controller/ScheduleReport.php:242
+#: lib/Controller/Notification.php:287 lib/Controller/Campaign.php:394
+#: lib/Controller/Campaign.php:407 lib/Controller/SavedReport.php:177
+#: lib/Controller/SavedReport.php:183 lib/Controller/MenuBoard.php:223
+#: lib/Controller/Playlist.php:461 lib/Controller/Playlist.php:472
+#: lib/Controller/Library.php:719 lib/Controller/Library.php:725
+#: lib/Controller/SyncGroup.php:184 lib/Controller/DataSetColumn.php:181
+#: lib/Controller/Tag.php:239 lib/Controller/Tag.php:245
+#: lib/Controller/Layout.php:1973 lib/Controller/Layout.php:1979
+#: lib/Controller/MenuBoardCategory.php:201 lib/Controller/Font.php:172
+#: lib/Controller/Font.php:181 lib/Controller/DisplayProfile.php:223
+#: lib/Controller/User.php:351 lib/Controller/Applications.php:187
+#: lib/Controller/Resolution.php:172 lib/Controller/Display.php:850
+#: lib/Controller/Display.php:869 lib/Controller/MenuBoardProduct.php:201
+#: lib/Controller/MenuBoardProduct.php:207 lib/Controller/Developer.php:170
+#: lib/Controller/Developer.php:183 lib/Controller/PlayerSoftware.php:164
+#: lib/Controller/PlayerSoftware.php:173 lib/Controller/Command.php:187
+#: lib/Controller/Command.php:196 lib/Controller/DataSetRss.php:171
+#: lib/Controller/Schedule.php:2481 lib/Controller/Schedule.php:2490
+#: lib/Controller/UserGroup.php:165 lib/Controller/DayPart.php:162
+#: lib/Controller/DayPart.php:168
+msgid "Delete"
+msgstr ""
+
+#: cache/21/21c3aca8ab50157c5875f0a5530b47cc.php:368
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:475
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:635
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:744
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:356
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:498
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:290
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:296
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:319
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:224
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:337
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:243
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:567
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1634
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:141
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:411
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:417
+msgid "Thumbnail"
+msgstr ""
+
+#: cache/48/4832df7e87cac4c001ba6814607f0504.php:57
+msgid "Delete Display Group"
+msgstr ""
+
+#: cache/48/4832df7e87cac4c001ba6814607f0504.php:91
+msgid ""
+"Are you sure you want to delete this display group? This cannot be undone"
+msgstr ""
+
+#: cache/48/48656e53670f6713a8f84aa06269d04d.php:69
+#: cache/54/54d064f129447f51389aeea4a056750e.php:69
+#, no-php-format
+msgid ""
+"%themeName% needs an administrator user account to be the first user account "
+"that has access to the CMS. Please enter your chosen details below."
+msgstr ""
+
+#: cache/48/48656e53670f6713a8f84aa06269d04d.php:80
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:135
+msgid "Admin Username"
+msgstr ""
+
+#: cache/48/48656e53670f6713a8f84aa06269d04d.php:86
+msgid "Please enter a user name for the first administrator account."
+msgstr ""
+
+#: cache/48/48656e53670f6713a8f84aa06269d04d.php:97
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:152
+msgid "Admin Password"
+msgstr ""
+
+#: cache/48/48656e53670f6713a8f84aa06269d04d.php:103
+msgid ""
+"Please enter a password for this user. This user will have full access to "
+"the system"
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:64
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:62
+#: cache/d7/d750736be0a77fbe10db020765a473f7.php:62
+#: cache/47/4776440c03d7a667c7b7866fe9d18898.php:64
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:65
+msgid "Your platform provider has configured this connector for you."
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:77
+msgid "Enter your API Key from Twitter."
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:88
+msgid "API Secret"
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:94
+msgid "Enter your API Secret from Twitter."
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:105
+msgid "Needs user authorisation?"
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:111
+msgid ""
+"Should these API keys be used to obtain user authorisation? If unchecked the "
+"key owners account will be used."
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:121
+msgid ""
+"If you change the user authentication option please save and reopen this to "
+"complete authorisation."
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:133
+msgid ""
+"Enter the number of seconds you would like to cache twitter search results."
+msgstr ""
+
+#: cache/48/483bd6de4e22a8cf9098675c02627e73.php:150
+msgid "Enter the number of hours you would like to cache twitter images."
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:57
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:189
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:241
+msgid "Layout Editor"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:167
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:638
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:966
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:83
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:253
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:83
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:83
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:87
+msgid "Back"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:171
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:291
+msgid "Exit"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:179
+msgid "Toggle Fullscreen Mode"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:183
+msgid "Layer Manager"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:187
+msgid "Snap to Grid"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:191
+msgid "Snap to Borders"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:195
+msgid "Snap to Elements"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:199
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:63
+msgid "New"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:203
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:76
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:107
+#: lib/Controller/Template.php:188 lib/Controller/Layout.php:1803
+msgid "Publish"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:207
+msgid "Discard draft"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:215
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:144
+msgid ""
+"Are you sure you want to publish this Layout? If it is already in use the "
+"update will automatically get pushed."
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:219
+#: cache/6b/6b5b58443921708eea82a32bb9f87c4c.php:74
+#: lib/Controller/Template.php:205 lib/Controller/Layout.php:1820
+msgid "Checkout"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:227
+#: cache/e1/e135a48f6c008e1d5927ee93c2d3f474.php:57
+msgid "Clear Canvas"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:231
+msgid "Unlock"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:235
+#: lib/Controller/Layout.php:2009
+msgid "Save Template"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:239
+msgid "Read Only"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:243
+msgid ""
+"You are viewing this Layout in read only mode, checkout by clicking on this "
+"message or from the Options menu above!"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:247
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:236
+msgid "Locked"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:251
+msgid "This is being locked by another user. Lock expires on: [expiryDate]"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:255
+msgid "Not editable, please checkout!"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:259
+msgid ""
+"The current layout will be unlocked to other users. You will also be "
+"redirected to the Layouts page"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:263
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:193
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:936
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:179
+#: lib/Controller/Notification.php:239
+msgid "View"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:267
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1390
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1477
+msgid "Actions"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:271
+msgid ""
+"This is published and cannot be edited. You can checkout for editing below, "
+"or continue to view it in a read only mode."
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:275
+msgid "Showing sample data"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:279
+msgid "Has empty data"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:286
+msgid "Inline Editor"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:290
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:480
+msgid "Next widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:294
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:484
+msgid "Previous widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:298
+msgid "Add Widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:302
+msgid "Edit Group"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:306
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:326
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:59
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:409
+msgid "Edit Playlist"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:310
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:729
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1455
+msgid "Previous Widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:314
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:725
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1451
+msgid "Next Widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:318
+#: lib/Controller/Region.php:644
+msgid "Empty Playlist"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:322
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1822
+msgid "Invalid Region"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:330
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1205
+msgid "Dynamic Playlist"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:337
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:143
+msgid "Zoom in"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:341
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:147
+msgid "Zoom out"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:345
+msgid "Reset zoom"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:349
+msgid "Visible area time span"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:353
+msgid "Zoom out to see timeruler!"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:357
+msgid ""
+"No Regions: Add a Region to start creating content by clicking here or the "
+"Edit Layout icon below!"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:361
+msgid "Scroll to selected widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:365
+msgid "Visible area start time"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:369
+msgid "Visible area end time"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:373
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1658
+msgid "Layout name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:377
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1666
+msgid "Layout duration"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:381
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1674
+msgid "Layout dimensions"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:385
+msgid "Add to this position"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:389
+msgid "Zoom in to see more details!"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:393
+msgid "Edit region"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:397
+msgid "Open as playlist"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:401
+msgid "Widget Actions:"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:405
+msgid "Region Actions:"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:412
+msgid "Edit layout regions"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:416
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:289
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:331
+msgid "Add"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:420
+msgid "Add a new region"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:424
+msgid "Delete region"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:428
+msgid "Undo"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:432
+msgid "Revert last change"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:440
+msgid "Return to Layout View"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:448
+msgid "Save changes"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:452
+msgid "Go back to Layout view"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:456
+msgid "Save editor changes"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:460
+msgid "Play Layout preview"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:464
+msgid "Preview stopped!"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:468
+msgid "Click to Play again"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:472
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:58
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:599
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:57
+msgid "Edit Layout"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:476
+msgid "Stop Layout preview"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:488
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1103
+msgid "Widget Name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:492
+msgid "Widget Type"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:496
+msgid "Widget Template Name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:500
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1112
+msgid "Element Name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:504
+msgid "Media Name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:508
+#: cache/84/846b4e365858df7d149d45d99237360c.php:194
+msgid "Media ID"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:512
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1120
+msgid "Element Group Name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:516
+msgid "Region Name"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:520
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:161
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:142
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1786
+#: lib/Controller/Template.php:302
+msgid "Template"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:525
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:617
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:201
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:198
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:366
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:222
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:71
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:419
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:425
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:407
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:413
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:210
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:268
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:652
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:664
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1567
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:65
+#: lib/Controller/Campaign.php:683 lib/Entity/Schedule.php:2086
+msgid "Layout"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:529
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:89
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1738
+msgid "Region"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:533
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:496
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:656
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1555
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1571
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1750
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1810
+msgid "Zone"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:541
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:500
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:660
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1085
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1108
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1734
+msgid "Widget"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:545
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1559
+msgid "Element"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:549
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1563
+msgid "Element Group"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:560
+msgid "Upload Audio files to assign to Widgets"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:566
+msgid "Transition In"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:570
+msgid "Apply a Transition type for the start of a media item"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:576
+msgid "Transition Out"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:580
+msgid "Apply a Transition type for the end of a media item"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:586
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:483
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:231
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:772
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:182
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:384
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:100
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:228
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:104
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:126
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2857
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:328
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:291
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:151
+msgid "Sharing"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:590
+msgid "Set View, Edit and Delete Sharing for Widgets and Playlists"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:775
+msgid "Add Background Image"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:782
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:802
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:407
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:232
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:649
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:757
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:124
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:725
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:254
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:280
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:371
+#: lib/XTR/MaintenanceDailyTask.php:163 lib/XTR/NotificationTidyTask.php:115
+#: lib/XTR/MaintenanceRegularTask.php:153 lib/XTR/StatsArchiveTask.php:134
+#: lib/XTR/AuditLogArchiveTask.php:168 lib/XTR/RemoteDataSetFetchTask.php:228
+msgid "Done"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:795
+msgid "Browse/Add Image"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:799
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:388
+msgid "Start Upload"
+msgstr ""
+
+#: cache/b8/b897409d84b93a82e1cc954bd0c47657.php:803
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:392
+msgid "Cancel Upload"
+msgstr ""
+
+#: cache/b8/b827f37e4bcd9aceec71b399ad0e597f.php:57
+#: lib/Controller/DisplayGroup.php:500 lib/Controller/Display.php:1071
+#: lib/Controller/Display.php:1086
+msgid "Collect Now"
+msgstr ""
+
+#: cache/b8/b827f37e4bcd9aceec71b399ad0e597f.php:91
+msgid "Are you sure you want to request a collection to occur?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:58
+msgid "Edit User"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:96
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:110
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:96
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:109
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:109
+#: lib/Controller/Font.php:162
+msgid "Details"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:103
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:103
+msgid "Home Folder"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:109
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:146
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:109
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:113
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:109
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:105
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:120
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:142
+msgid "Reference"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:116
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:116
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:107
+#: cache/d7/d72fc98fb98b68f625501adbc327fe4a.php:52
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:107
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:538
+msgid "Notifications"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:142
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:140
+msgid "The Username of the user."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:153
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:201
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:168
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:258
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:828
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:170
+#: lib/Controller/User.php:304
+msgid "Email"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:159
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:174
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:264
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:176
+msgid "The Email Address for this user."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:175
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:83
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:136
+msgid "New Password"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:181
+msgid "The new Password for this user."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:192
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:100
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:153
+msgid "Retype New Password"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:198
+msgid "Repeat the new Password for this user."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:209
+msgid "Reset Two Factor Authentication"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:215
+msgid ""
+"Once ticked the two factor authentication will be set to ‘Off’ for this User "
+"Profile with any stored secret codes cleared. The User can now set up two "
+"factor authentication from the User Profile as before."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:229
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:193
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:264
+msgid "Homepage"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:235
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:270
+msgid ""
+"Homepage for this user. This is the page they will be taken to when they "
+"login."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:269
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:137
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:185
+msgid "User Type"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:275
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:191
+msgid "What is this users type?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:289
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:205
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:202
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:143
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:135
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:144
+msgid "Library Quota"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:295
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:208
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:149
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:150
+msgid "The quota that should be applied. Enter 0 for no quota."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:326
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:217
+msgid "Retired?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:332
+msgid "Is this user retired?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:347
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:615
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:299
+#: cache/71/7194942bf0bccb84d38e565de0fdafc8.php:100
+msgid "Set a home folder to use as the default folder for new content."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:362
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:162
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:225
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:314
+msgid "First Name"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:368
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:320
+msgid "The User's First Name."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:379
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:173
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:229
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:331
+msgid "Last Name"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:385
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:337
+msgid "The User's Last Name."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:396
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:348
+msgid "Phone Number"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:402
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:354
+msgid "The User's Phone Number."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:413
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:402
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:788
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:365
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:596
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:300
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:300
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:295
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:404
+msgid "Reference 1"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:419
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:436
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:453
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:470
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:487
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:371
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:388
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:405
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:422
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:439
+msgid "A reference field for custom user data"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:430
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:413
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:792
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:382
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:607
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:311
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:311
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:299
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:415
+msgid "Reference 2"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:447
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:424
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:796
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:399
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:618
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:322
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:322
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:303
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:426
+msgid "Reference 3"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:464
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:435
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:800
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:416
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:629
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:333
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:333
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:307
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:437
+msgid "Reference 4"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:481
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:446
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:804
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:433
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:640
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:344
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:344
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:311
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:448
+msgid "Reference 5"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:505
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:189
+msgid "Notification Type"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:519
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:163
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:222
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:419
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:501
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:627
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:709
+msgid "Inherited?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:537
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:621
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:512
+msgid "Should this User receive Layout notification emails?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:565
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:478
+msgid ""
+"Should this User receive Display notifications for Displays they have "
+"permission to see?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:593
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:495
+msgid "Should this User receive DataSet notification emails?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:645
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:56
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:122
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:146
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:222
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:288
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:127
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:259
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+msgid "Library"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:649
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:529
+msgid "Should this User receive Library notification emails?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:673
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:308
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:119
+msgid "Reports"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:677
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:546
+msgid "Should this User receive Report notification emails?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:705
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:563
+msgid "Should this User receive Schedule notification emails?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:733
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:580
+msgid ""
+"Should this User receive notifications emails for Notifications manually "
+"created in CMS?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:764
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:595
+msgid "Hide navigation?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:770
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:601
+msgid "Should the navigation side bar be hidden for this User?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:781
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:612
+msgid "Hide User Guide?"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:787
+msgid ""
+"Should this User see the new user guide when they log in? This will be set "
+"to hidden if the User has dismissed the guide themselves."
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:798
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:629
+msgid "Force Password Change"
+msgstr ""
+
+#: cache/b8/b8595be30db04c0387e925c662d42a5b.php:804
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:635
+msgid "Should this User be forced to change password next time they log in?"
+msgstr ""
+
+#: cache/b8/b86809ac450d70fe762a12de38b6b84f.php:91
+msgid ""
+"Verify all modules have been installed correctly by reinstalling any module "
+"related files"
+msgstr ""
+
+#: cache/ff/ff53ca9025540b897618be0c098d83cb.php:57
+#: lib/Controller/Display.php:928
+msgid "Set Default Layout"
+msgstr ""
+
+#: cache/ff/ff53ca9025540b897618be0c098d83cb.php:96
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:680
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:353
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:816
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:511
+#: lib/Controller/Display.php:914
+msgid "Default Layout"
+msgstr ""
+
+#: cache/ff/ff53ca9025540b897618be0c098d83cb.php:102
+msgid "The Default Layout to Display where there is no other content."
+msgstr ""
+
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:91
+msgid ""
+"Tidying the Library will delete any temporary files. Are you sure you want "
+"to proceed?"
+msgstr ""
+
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:102
+msgid "Remove old revisions"
+msgstr ""
+
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:108
+msgid ""
+"Cleaning up old revisions of media will result in any unused media revisions "
+"being permanently deleted."
+msgstr ""
+
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:119
+msgid "Remove all media not currently in use?"
+msgstr ""
+
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:125
+msgid ""
+"Selecting this option will remove any media that is not currently being used "
+"in Layouts or linked to Displays. This process cannot be reversed."
+msgstr ""
+
+#: cache/ff/ff603ebfb5b791b27a7e78226b63218a.php:142
+msgid ""
+"Selecting this option will remove any generic files that is are not "
+"currently linked to Displays. This process cannot be reversed."
+msgstr ""
+
+#: cache/55/55480053db687228f2af258c58523fef.php:57
+msgid "Convert Saved Report"
+msgstr ""
+
+#: cache/55/55480053db687228f2af258c58523fef.php:91
+msgid "After you convert a saved report you can export and preview the report."
+msgstr ""
+
+#: cache/55/55480053db687228f2af258c58523fef.php:93
+msgid ""
+"Are you sure you want to convert this saved report? This cannot be undone."
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:56
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:110
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:103
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:288
+#: cache/84/846b4e365858df7d149d45d99237360c.php:284
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:103
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:159
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:109
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:482
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:103
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:168
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:104
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:156
+msgid "Layouts"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:74
+msgid "Add a new Layout and jump to the layout editor."
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:82
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:372
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:595
+msgid "Add Layout"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:87
+msgid "Import a Layout from a ZIP file."
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:89
+msgid "Import"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:126
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:73
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:132
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:112
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:138
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:61
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:518
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:884
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:100
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:73
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:111
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:73
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:112
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:520
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:112
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:73
+msgid "Advanced"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:160
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:466
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:222
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:372
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:56
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:92
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:162
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:285
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:675
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:180
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:156
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:260
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:174
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:206
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:470
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:160
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:121
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:235
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:420
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:77
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:215
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:457
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:692
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:124
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:215
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:181
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:407
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:135
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:315
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:160
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:506
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1630
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2226
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:184
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:285
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:124
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:298
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:166
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:230
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:380
+msgid "Tags"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:166
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:331
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:291
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:383
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:166
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:370
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:130
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:413
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:141
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:190
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:130
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:339
+msgid "Exact match?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:172
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:337
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:297
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:389
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:172
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:376
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:136
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:419
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:147
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:196
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:136
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:345
+msgid "When filtering by multiple Tags, which logical operator should be used?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:178
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:303
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:178
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:142
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:425
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:153
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:202
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:142
+msgid ""
+"A comma separated list of tags to filter by. Enter a tag|tag value to filter "
+"tags with values. Enter --no-tag to filter all items without tags. Enter - "
+"before a tag or tag value to exclude from results."
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:206
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:320
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:480
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:532
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:286
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:337
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:175
+msgid "Display Group"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:212
+msgid "Show Layouts active on the selected Display / Display Group"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:229
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:479
+#: cache/86/86c186879b5295663da37409020c35ad.php:126
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:141
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:227
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:197
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:380
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:111
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:183
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:143
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:229
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:222
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:205
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:201
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:444
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:459
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:172
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:324
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1250
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1254
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:127
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:195
+msgid "Owner"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:235
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:147
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:203
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:117
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:149
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:465
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:178
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:133
+msgid "Show items owned by the selected User."
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:249
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:217
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:192
+msgid "Owner User Group"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:255
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:223
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:198
+msgid "Show items owned by users in the selected User Group."
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:269
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:471
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:472
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:665
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:310
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:278
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:91
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:220
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:479
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:293
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:496
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1258
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1284
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:200
+msgid "Orientation"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:277
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:478
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:321
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:286
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:487
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:299
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1318
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:206
+msgid "Landscape"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:280
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:484
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:324
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:289
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:490
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:299
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1322
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:206
+msgid "Portrait"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:298
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:148
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:248
+#: cache/ee/eed40b13770b97167f375466867a779e.php:142
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:219
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:119
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:142
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:242
+msgid "Retired"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:318
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:230
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:248
+msgid "Show"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:326
+msgid "Only Used"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:329
+msgid "Only Unused"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:349
+msgid "1st line"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:352
+msgid "Widget List"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:381
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:503
+#: cache/84/846b4e365858df7d149d45d99237360c.php:182
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:267
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:217
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:175
+msgid "Layout ID"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:392
+msgid "Modified Since"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:409
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:170
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:575
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:304
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:183
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:164
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:152
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:717
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:267
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1189
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1234
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:224
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:238
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:76
+msgid "Search"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:415
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:176
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:581
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:310
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:189
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:170
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:158
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:273
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:230
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:244
+msgid "Search in all folders"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:417
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:178
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:583
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:312
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:191
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:172
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:160
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:275
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:232
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:246
+msgid "All Folders"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:423
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:184
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:589
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:318
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:197
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:178
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:166
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:721
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:281
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:238
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:252
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:81
+msgid "No Folders matching the search term"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:432
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:192
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:597
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:326
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:171
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:186
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:174
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:289
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:246
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:260
+msgid "Open / Close Folder Search options"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:460
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:154
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:475
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:360
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:413
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:462
+#: cache/34/340ecc3756674aa339156a3d6b49cc46.php:77
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:309
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:206
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:963
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1045
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1201
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1296
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1662
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1790
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:303
+msgid "Duration"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:487
+msgid "Valid?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:491
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:400
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:340
+msgid "Stats?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:495
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:404
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:332
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:147
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:227
+msgid "Created"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:499
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:408
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:336
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:151
+msgid "Modified"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:648
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:350
+msgid "Add Thumbnail"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:771
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2009
+#: lib/Connector/XiboAudienceReportingConnector.php:899
+#: lib/Connector/XiboAudienceReportingConnector.php:974
+msgid "Unknown Error"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:795
+msgid "Upload Layout"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:832
+msgid "Add Layout Export ZIP Files"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:836
+msgid "Start Import"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:840
+msgid "Cancel Import"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:844
+msgid "Replace Existing Media?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:848
+msgid "Import Tags?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:852
+msgid "Use existing DataSets matched by name?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:856
+msgid "Import DataSet Data?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:860
+msgid "Import Widget Fallback Data?"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:864
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:169
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:111
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:126
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:118
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:134
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:112
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:171
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:128
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:170
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:674
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:179
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:107
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:145
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:713
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:124
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:102
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:105
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:362
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1849
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:100
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:148
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:130
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:176
+#: lib/Controller/Template.php:235 lib/Controller/DisplayGroup.php:352
+#: lib/Controller/DataSet.php:282 lib/Controller/Campaign.php:349
+#: lib/Controller/MenuBoard.php:170 lib/Controller/Playlist.php:411
+#: lib/Controller/Library.php:694 lib/Controller/Layout.php:1923
+#: lib/Controller/Display.php:943
+msgid "Select Folder"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:868
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:678
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:366
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1853
+msgid "Change Current Folder location"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:872
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:97
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:97
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:156
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:113
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:682
+#: cache/d2/d21e8a64f7edc7ce59554632a094e1cc.php:94
+#: cache/dc/dcfc1ae248c38fe9cf4cfee76751c346.php:94
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:165
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:130
+#: cache/1f/1fb3ca31aef0e3fe7b52bbfbb6a4af73.php:94
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:109
+#: cache/78/7875975092226a1328f8ec7d90ce4b25.php:94
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:370
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:133
+#: cache/b9/b9b5222ce78d1050139467177732ab76.php:94
+#: cache/3e/3e3117c1d042d50f9efa8908d5b286c1.php:94
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:115
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:161
+#: cache/20/20d56467e17c048a093ad7168955489f.php:136
+msgid "Current Folder"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:876
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:686
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:374
+msgid "Upload files to this Folder"
+msgstr ""
+
+#: cache/94/9440acb626b82e4f5798a0d37bdb9634.php:937
+msgid ""
+"Check to enable the collection of Proof of Play statistics for the selected "
+"items."
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:57
+#: cache/86/86c186879b5295663da37409020c35ad.php:93
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:368
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:572
+msgid "Applications"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:70
+msgid "Add an Application"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:74
+#: cache/63/63b15102c208fae253c32c2f77313cc2.php:57
+msgid "Add Application"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:142
+msgid "Connectors"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:190
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:144
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:115
+msgid "Copy to Clipboard"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:194
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:151
+msgid "Could not copy"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:198
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:155
+msgid "Copied!"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:431
+msgid "Installed"
+msgstr ""
+
+#: cache/86/86c186879b5295663da37409020c35ad.php:440
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:508
+#: lib/Controller/Module.php:121
+msgid "Configure"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:163
+msgid "Change Password"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:169
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:228
+msgid "Please enter the password you use to connect to %service%"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:180
+msgid "Change second factor secret"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:186
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:245
+msgid ""
+"We support Google Authenticator or similar two factor codes. You get this "
+"secret by scanning the QR code."
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:196
+msgid "Check to remove"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:211
+msgid "Please enter the username you use to connect to your dashboard service."
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:222
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:151
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:417
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:241
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:220
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:473
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:119
+msgid "Password"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:239
+msgid "Second factor secret"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:259
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:132
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:123
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:633
+msgid "URL"
+msgstr ""
+
+#: cache/5a/5a7b1a1f8291cdf2978f321f38fc0961.php:265
+msgid "The URL where the %service% is hosted"
+msgstr ""
+
+#: cache/5a/5ab03a5b2ec6fe2346c93676aa6a2ece.php:57
+msgid "Purge all Media files"
+msgstr ""
+
+#: cache/5a/5ab03a5b2ec6fe2346c93676aa6a2ece.php:91
+msgid ""
+"Caution! Trigerring this action will ask Player to remove every downloaded "
+"Media file from its storage."
+msgstr ""
+
+#: cache/5a/5ab03a5b2ec6fe2346c93676aa6a2ece.php:102
+msgid ""
+"This action will be immediately actioned. Player will remove all existing "
+"Media files from its local storage and request fresh copies of required "
+"files from the CMS"
+msgstr ""
+
+#: cache/5a/5ab03a5b2ec6fe2346c93676aa6a2ece.php:118
+#: cache/cd/cd143932270a543fae63974db1f8be19.php:118
+msgid ""
+"XMR is not working on this Player yet and therefore the licence check may "
+"not occur."
+msgstr ""
+
+#: cache/46/46a12bada7850bd93ae80ec4ec703020.php:60
+#, no-php-format
+msgid "Discard %layout%"
+msgstr ""
+
+#: cache/46/46a12bada7850bd93ae80ec4ec703020.php:74
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:111
+#: lib/Controller/Template.php:194 lib/Controller/Layout.php:1809
+msgid "Discard"
+msgstr ""
+
+#: cache/46/46a12bada7850bd93ae80ec4ec703020.php:94
+msgid ""
+"Are you sure you want to discard the changes to this Layout and revert to "
+"the previous Published version."
+msgstr ""
+
+#: cache/46/46aeacdba438f27962b12c30d28b0cbc.php:83
+msgid "Proof of Play"
+msgstr ""
+
+#: cache/46/46aeacdba438f27962b12c30d28b0cbc.php:89
+#: cache/09/090cf553152bf65c409fe77487c28249.php:57
+#: cache/09/090cf553152bf65c409fe77487c28249.php:71
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:73
+#: cache/02/02c84068a1b9b436fec1c165c50ea5c7.php:71
+#: lib/Controller/Template.php:342 lib/Controller/Layout.php:2018
+msgid "Export"
+msgstr ""
+
+#: cache/46/46aeacdba438f27962b12c30d28b0cbc.php:207
+msgid "Total number of records to be exported "
+msgstr ""
+
+#: cache/46/46aeacdba438f27962b12c30d28b0cbc.php:249
+msgid "Form field is required."
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:57
+msgid "Delete Media"
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:91
+#: cache/fb/fb7adbdacbb41d667f8ac87d10d7340e.php:91
+#: cache/23/23525fb78ad0912d772847c7fa4abf9c.php:91
+msgid "Are you sure you want to delete this file?"
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:102
+#: cache/fb/fb7adbdacbb41d667f8ac87d10d7340e.php:102
+#: cache/23/23525fb78ad0912d772847c7fa4abf9c.php:102
+msgid "Deleting a file cannot be reversed."
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:118
+msgid "Force delete from any existing layouts, assignments, etc"
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:124
+msgid ""
+"This library item has been used somewhere in the system. You will only be "
+"allowed to delete it if you check this."
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:135
+msgid ""
+"Please note that removing a media item from the Library will also delete it "
+"from the Published version of this Layout and you won't be able to undo that "
+"action by discarding your changes. Displays will also be immediately "
+"effected."
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:150
+msgid "Add to Purge list?"
+msgstr ""
+
+#: cache/46/46ff990bdf58719e036f58897a284e5e.php:156
+msgid ""
+"When set, each Display that has this Media in its local storage, will be "
+"notified to remove it."
+msgstr ""
+
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:65
+#, no-php-format
+msgid "Usage Report for %playlistName%"
+msgstr ""
+
+#: cache/a0/a04604fb20efefc2a61ba69af98dd605.php:113
+msgid ""
+"If the playlist is used in scheduled events it is shown below. To restrict "
+"to a specific time enter a date in the filter below."
+msgstr ""
+
+#: cache/e7/e7d5481c1fc1f99f2eb87ba3724b4798.php:57
+msgid "Export Database Backup"
+msgstr ""
+
+#: cache/e7/e7d5481c1fc1f99f2eb87ba3724b4798.php:71
+msgid "Export Database"
+msgstr ""
+
+#: cache/e7/e7d5481c1fc1f99f2eb87ba3724b4798.php:91
+msgid ""
+"This will create a dump file of your database that you can restore later "
+"using the import functionality."
+msgstr ""
+
+#: cache/e7/e7d5481c1fc1f99f2eb87ba3724b4798.php:102
+msgid "You should also manually take a backup of your library."
+msgstr ""
+
+#: cache/e7/e7d5481c1fc1f99f2eb87ba3724b4798.php:113
+msgid ""
+"Please note: The folder location for mysqldump must be available in your "
+"path environment variable for this to work and the php exec command must be "
+"enabled."
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:59
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:77
+msgid "Add Display Group"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:164
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:121
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:113
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:129
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:165
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:102
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:97
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:100
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1266
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:95
+msgid "Folder"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:191
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:198
+msgid "The Name for this Display Group"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:208
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:215
+msgid "A short description of this Display Group"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:228
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:236
+msgid ""
+"Tags for this Display Group - Comma separated string of Tags or Tag|Value "
+"format. If you choose a Tag that has associated values, they will be shown "
+"for selection below."
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:241
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:253
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:181
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:193
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:199
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:211
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:175
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:187
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:279
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:291
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:193
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:205
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:225
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:237
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:254
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:266
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:234
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:246
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:200
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:212
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:687
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:179
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:191
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:185
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:197
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:249
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:261
+msgid "Tag value"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:259
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:199
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:217
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:193
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:297
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:211
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:243
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:272
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:252
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:218
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:140
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:197
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:203
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:267
+msgid ""
+"Please provide the value for this Tag and confirm by pressing enter on your "
+"keyboard."
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:271
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:211
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:229
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:205
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:309
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:223
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:255
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:284
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:264
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:230
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:695
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:209
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:215
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:279
+msgid ""
+"This tag requires a set value, please select one from the Tag value dropdown "
+"or provide Tag value in the dedicated field."
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:281
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:289
+msgid "Dynamic Group?"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:287
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:295
+msgid "Are the members of this group dynamic?"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:305
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:121
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:118
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:274
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:313
+msgid "Criteria"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:311
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:319
+msgid ""
+"A comma separated set of regular expressions run against the Display name to "
+"determine membership."
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:325
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:281
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:333
+msgid "Criteria Tags"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:343
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:351
+msgid ""
+"A comma separated set of tags run against the Display tag to determine "
+"membership."
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:380
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:388
+msgid "Licence"
+msgstr ""
+
+#: cache/7e/7e5f0a5707887ce0f7e1cde17334b267.php:396
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:590
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:294
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:294
+msgid "Add reference fields if needed"
+msgstr ""
+
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:59
+msgid "Edit Menu Board"
+msgstr ""
+
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:106
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:107
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:166
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:123
+#: cache/d2/d21e8a64f7edc7ce59554632a094e1cc.php:104
+#: cache/dc/dcfc1ae248c38fe9cf4cfee76751c346.php:104
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:174
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:140
+#: cache/1f/1fb3ca31aef0e3fe7b52bbfbb6a4af73.php:104
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:119
+#: cache/78/7875975092226a1328f8ec7d90ce4b25.php:104
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:143
+#: cache/b9/b9b5222ce78d1050139467177732ab76.php:104
+#: cache/3e/3e3117c1d042d50f9efa8908d5b286c1.php:104
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:125
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:171
+#: cache/20/20d56467e17c048a093ad7168955489f.php:146
+msgid "Move to Selected Folder:"
+msgstr ""
+
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:133
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:124
+msgid "The Name for this Menu Board"
+msgstr ""
+
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:150
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:141
+msgid "The Code identifier for this Menu Board"
+msgstr ""
+
+#: cache/7e/7e6cb6df007c31b555594e9a332e577f.php:167
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:158
+msgid "An optional description of the Menu Board. (1 - 250 characters)"
+msgstr ""
+
+#: cache/36/36bf1042971e35f01c528382c4e2ebe9.php:57
+msgid "Cancel Transfer?"
+msgstr ""
+
+#: cache/36/36bf1042971e35f01c528382c4e2ebe9.php:91
+msgid ""
+"Are you sure you want to cancel this CMS transfer? This is only possible if "
+"the Display has not already transferred."
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:77
+msgid "Add a new Sync event"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:85
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:58
+msgid "Add Synchronised Event"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:91
+msgid "Add a new Scheduled event"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:97
+msgid "Add Event"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:132
+msgid "Range"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:138
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2022
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1469
+msgid "Select a range"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:144
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:609
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2026
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1473
+msgid "Today"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:150
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2034
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1481
+msgid "This Week"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:156
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2038
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1485
+msgid "This Month"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:162
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2042
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1489
+msgid "This Year"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:168
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2030
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1477
+msgid "Yesterday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:174
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2046
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1493
+msgid "Last Week"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:180
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2050
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1497
+msgid "Last Month"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:186
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:2054
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1501
+msgid "Last Year"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:192
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:632
+msgid "Agenda"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:249
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:500
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:199
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:191
+msgid "Event Type"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:260
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:294
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:298
+msgid "Layout / Campaign"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:266
+msgid "Please select a Layout or Campaign for this Event to show"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:284
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:90
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:144
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:426
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:56
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:97
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:331
+msgid "Campaigns"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:341
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:355
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:524
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:233
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:351
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:442
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:145
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:57
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:98
+#: lib/Controller/Display.php:1139
+msgid "Display Groups"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:373
+msgid "Direct Schedule?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:379
+msgid "Show only events scheduled directly on selected Displays/Groups"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:405
+msgid "Only show schedules which appear on all filtered displays/groups?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:411
+msgid "Shared Schedule?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:437
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:536
+msgid "Geo Aware?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:442
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:189
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:241
+msgid "Both"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:451
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:540
+msgid "Recurring?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:471
+msgid "Grid"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:508
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:141
+msgid "Start"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:512
+msgid "End"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:520
+msgid "Campaign ID"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:528
+msgid "SoV"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:532
+msgid "Max Plays per Hour"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:544
+msgid "Recurrence Description"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:548
+msgid "Recurrence Type"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:552
+msgid "Recurrence Interval"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:556
+msgid "Recurrence Repeats On"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:560
+msgid "Recurrence End"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:564
+msgid "Priority?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:568
+msgid "Criteria?"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:572
+msgid "Created On"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:576
+msgid "Updated On"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:580
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:205
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:155
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:172
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:368
+msgid "Modified By"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:596
+msgid ""
+"Please select a Display, Display Group or Layout / Campaign to view the "
+"calendar"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:604
+msgid "Prev"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:674
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:604
+msgid "Map"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:679
+msgid "Get browser location!"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:685
+msgid "Clear coordinates!"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:734
+msgid "Always showing"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:739
+msgid "Single Display"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:744
+msgid "Multi Display"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:754
+msgid "Recurring"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:759
+msgid "View Only"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:764
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:140
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:542
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:597
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:240
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:629
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:534
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:590
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:142
+#: cache/4a/4ab9918a9e09352e2b177d64bcd99b84.php:91
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:116
+#: lib/Entity/Schedule.php:2087 lib/Entity/DisplayEvent.php:221
+msgid "Command"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:769
+msgid "Interrupt"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:774
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:108
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:108
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:117
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:114
+msgid "Geo Location"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:779
+msgid "Interactive Action"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:785
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:506
+msgid "Synchronised"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:830
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:774
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:518
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2355
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:833
+msgid "Always"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:834
+msgid ""
+"Adjust the times of this timer. To add or remove a day, use the Display "
+"Profile."
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:839
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:979
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:450
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:612
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:497
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:802
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:48
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:821
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:232
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:522
+msgid "Monday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:843
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:983
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:456
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:616
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:503
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:808
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:52
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:827
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:236
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:528
+msgid "Tuesday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:847
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:987
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:462
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:620
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:509
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:814
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:56
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:833
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:240
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:534
+msgid "Wednesday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:851
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:991
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:468
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:624
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:515
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:820
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:60
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:839
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:244
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:540
+msgid "Thursday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:855
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:995
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:474
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:628
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:521
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:826
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:64
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:845
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:248
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:546
+msgid "Friday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:859
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:999
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:480
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:632
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:527
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:832
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:68
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:851
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:252
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:552
+msgid "Saturday"
+msgstr ""
+
+#: cache/36/36e3c408fb657f2f9d0dfdbc71bc027b.php:863
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:1003
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:486
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:636
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:533
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:838
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:72
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:655
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:857
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:256
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:558
+msgid "Sunday"
+msgstr ""
+
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:60
+msgid "Password change required"
+msgstr ""
+
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:72
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:224
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:157
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:108
+msgid "User Name"
+msgstr ""
+
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:89
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:142
+msgid "Please enter your new password"
+msgstr ""
+
+#: cache/95/9531123cf894798b48cb322b65ab8103.php:106
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:159
+msgid "Please repeat the new Password."
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:137
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:877
+msgid "No Image set, add from Toolbox or Upload!"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:145
+msgid "Background thumbnail"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:149
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:881
+msgid "Add background image"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:162
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:885
+msgid "Upload"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:170
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:160
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:889
+msgid "Remove"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:194
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:897
+msgid "Change the resolution"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:205
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:560
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:803
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:901
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:979
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1766
+msgid "Layer"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:211
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:905
+msgid ""
+"The layering order of the background image (z-index). Advanced use only."
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:222
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:909
+msgid "Automatically apply Transitions?"
+msgstr ""
+
+#: cache/14/14e96cdc3dad34922ef9efe4ecbd4487.php:228
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:913
+msgid ""
+"When enabled, the default Transition type and duration will be applied to "
+"all widgets on this Layout."
+msgstr ""
+
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:69
+msgid "Add a new Tag"
+msgstr ""
+
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:73
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:59
+msgid "Add Tag"
+msgstr ""
+
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:127
+msgid "Show System tags?"
+msgstr ""
+
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:138
+msgid "Show only tags with values?"
+msgstr ""
+
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:162
+msgid "isRequired"
+msgstr ""
+
+#: cache/f9/f9872913fa0d4cf6a0d72d55dff3a8fc.php:166
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:110
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:110
+msgid "Values"
+msgstr ""
+
+#: cache/f9/f9a33399b03db679e10227424da0e1e0.php:51
+msgid "Font Details"
+msgstr ""
+
+#: cache/f9/f9a33399b03db679e10227424da0e1e0.php:65
+msgid "Font details provided by fontLib"
+msgstr ""
+
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:58
+msgid "Configure Connector"
+msgstr ""
+
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:104
+msgid ""
+"When enabled, this Connector will start providing the services it lists in "
+"its description."
+msgstr ""
+
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:121
+msgid "Uninstall?"
+msgstr ""
+
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:127
+msgid "Tick to uninstall this Connector. All settings will be removed."
+msgstr ""
+
+#: cache/f9/f905a1c3d1866ae2088f604b532d292a.php:138
+msgid "This connector will be installed when you save."
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:61
+msgid "Edit Product "
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:114
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:113
+#: lib/Widget/DataType/Product.php:53
+msgid "Product Options"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:133
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:132
+msgid "The Name for this Menu Board Product"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:150
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:149
+msgid "The Price for this Menu Board Product"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:167
+msgid "Set a display order for this item to appear"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:178
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:177
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:225
+#: lib/Widget/DataType/Product.php:49
+msgid "Availability"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:184
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:183
+msgid "Should this Product appear as available?"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:201
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:200
+msgid "The Code identifier for this Menu Board Product"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:218
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:217
+msgid ""
+"Optionally select Image or Video to be associated with this Menu Board "
+"Product"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:240
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:239
+msgid "The Description for this Menu Board Product"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:251
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:250
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:229
+#: lib/Widget/DataType/Product.php:50
+msgid "Allergy Information"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:257
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:256
+msgid "The Allergy Information for this Menu Board Product"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:274
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:273
+msgid "How many calories are in this product?"
+msgstr ""
+
+#: cache/92/922bb67f22622a5dffa5487e87839c64.php:288
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:287
+msgid ""
+"If required please provide additional options and their prices for this "
+"Product."
+msgstr ""
+
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:58
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:76
+msgid "Add Template"
+msgstr ""
+
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:148
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:134
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:146
+msgid "The Name of the Template - (1 - 50 characters)"
+msgstr ""
+
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:168
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:166
+msgid ""
+"Tags for this Template - Comma separated string of Tags or Tag|Value format. "
+"If you choose a Tag that has associated values, they will be shown for "
+"selection below."
+msgstr ""
+
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:227
+msgid "Choose the resolution this Template should be designed for."
+msgstr ""
+
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:237
+msgid ""
+"You can also add a template from an existing Layout from the Layout Editor."
+msgstr ""
+
+#: cache/e5/e52cdab43cd21823b9a027f2d780a834.php:250
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:221
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:243
+msgid "An optional description of the Template. (1 - 250 characters)"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:59
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:100
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:181
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:270
+msgid "DataSets"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:75
+msgid "Add a new DataSet"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:79
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:58
+msgid "Add DataSet"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:130
+msgid "Show items which match the provided code"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:219
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:201
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:215
+msgid "Remote?"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:223
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:221
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:232
+msgid "Real time?"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:235
+msgid "Last Sync"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:239
+msgid "Data Last Modified"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:363
+msgid "Add CSV Files"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:367
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:241
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:666
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:272
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:310
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:299
+msgid "Start upload"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:371
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:245
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:670
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:276
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:314
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:303
+msgid "Cancel upload"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:375
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:949
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:318
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1857
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:307
+msgid "Processing..."
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:393
+msgid ""
+"If the CSV file contains non-ASCII characters please ensure the file is "
+"UTF-8 encoded"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:400
+msgid "CSV Import"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:703
+msgid "Overwrite existing data?"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:709
+msgid ""
+"Erase all content in this DataSet and overwrite it with the new content in "
+"this import."
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:720
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:581
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:637
+msgid "Ignore first row?"
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:726
+msgid "Ignore the first row? Useful if the CSV has headings."
+msgstr ""
+
+#: cache/2d/2dda3d21077534e0fe24b88c39f6d003.php:737
+msgid ""
+"In the fields below please enter the column number in the CSV file that "
+"corresponds to the Column Heading listed. This should be done before Adding "
+"the file."
+msgstr ""
+
+#: cache/7a/7a8ad14e9394ea474747eba4d5e8355e.php:57
+msgid "Delete Command"
+msgstr ""
+
+#: cache/7a/7a8ad14e9394ea474747eba4d5e8355e.php:91
+msgid "Are you sure you want to delete this command? This cannot be undone"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:61
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:61
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:122
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2310
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:61
+msgid "Network"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:69
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:69
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:134
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3228
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:69
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:664
+msgid "Troubleshooting"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:80
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:68
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:285
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:411
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:80
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:80
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:56
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:95
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:80
+msgid "Commands"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:99
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:98
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:99
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:121
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:110
+msgid "Collect interval"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:102
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:101
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:102
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:124
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:113
+msgid "How often should the Player check for new content."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "1 minute"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "5 minutes"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "10 minutes"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "30 minutes"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "1 hour"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "1 hour 30 minutes"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "2 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "2 hours 30 minutes"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "3 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "3 hours 30 minutes"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "4 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "5 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "6 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "7 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "8 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "9 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "10 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "11 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "12 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:105
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:104
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:105
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:127
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:116
+msgid "24 hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:113
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:112
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:113
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:893
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:135
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:124
+msgid "XMR WebSocket Address"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:116
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:115
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:138
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:127
+msgid "Override the CMS WebSocket address for XMR."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:124
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:123
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:124
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:930
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:146
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:135
+msgid "XMR Public Address"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:127
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:126
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:149
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:138
+msgid "Override the CMS public address for XMR."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:135
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:134
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:135
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:157
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:146
+msgid "Enable stats reporting?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:138
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:137
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:138
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:160
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:149
+msgid "Should the application send proof of play stats to the CMS."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:146
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:145
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:146
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1152
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:168
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:157
+msgid "Aggregation level"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:149
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:148
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:149
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:171
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:160
+msgid ""
+"Set the level of collection for Proof of Play Statistics to be applied to "
+"selected Layouts / Media and Widget items."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:152
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:151
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:152
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1166
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:174
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:163
+msgid "Individual"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:152
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:696
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:780
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:399
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:151
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:446
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:751
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:152
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:770
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1172
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:174
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:755
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:839
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:163
+msgid "Hourly"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:152
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:702
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:786
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:405
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:151
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:452
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:757
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:152
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:776
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1178
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:174
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:761
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:845
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:163
+msgid "Daily"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:160
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:182
+msgid "Record geolocation on each Proof of Play?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:163
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:185
+msgid ""
+"If the geolocation of the Display is known, enable to record that location "
+"against each proof of play record."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:171
+msgid "Enable PowerPoint?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:174
+msgid ""
+"Should Microsoft PowerPoint be Enabled? The Player will need PowerPoint "
+"installed to Display PowerPoint files."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:186
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:163
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:210
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:214
+msgid "Download Window Start Time"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:189
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:166
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:213
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:217
+msgid ""
+"The start of the time window to connect to the CMS and download updates."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:197
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:174
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:221
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:225
+msgid "Download Window End Time"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:200
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:177
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:224
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:228
+msgid "The end of the time window to connect to the CMS and download updates."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:208
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:185
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2736
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:254
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:258
+msgid "Force HTTPS?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:211
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:188
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:257
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:261
+msgid "Should Displays be forced to use HTTPS connection to the CMS?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:219
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:173
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:196
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:265
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:269
+msgid "Operating Hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:222
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:176
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:199
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:272
+msgid ""
+"Select a day part that should act as operating hours for this display - "
+"email alerts will not be sent outside of operating hours"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:233
+msgid "Authentication Whitelist"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:236
+msgid ""
+"A comma separated list of domains which should be allowed to perform NTML/"
+"Negotiate authentication."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:244
+msgid "Edge Browser Whitelist"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:247
+msgid ""
+"A comma separated list of website urls which should be rendered by the Edge "
+"Browser instead of Chromium."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:261
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:217
+msgid "The Width of the Display Window. 0 means full width."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:272
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:228
+msgid "The Height of the Display Window. 0 means full height."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:280
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:236
+msgid "Left Coordinate"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:283
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:239
+msgid "The left pixel position the display window should be sized from."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:291
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:247
+msgid "Top Coordinate"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:294
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:250
+msgid "The top pixel position the display window should be sized from."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:305
+msgid "CTRL Key required to access Player Information Screen?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:308
+msgid "Should the Player information screen require the CTRL key?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:316
+msgid "Key for Player Information Screen"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:319
+msgid ""
+"Which key should activate the Player information screen? A single character."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:327
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:191
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:262
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3305
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:355
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:287
+msgid "Log Level"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:330
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:194
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:265
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:358
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:290
+msgid "The resting logging level that should be recorded by the Player."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:333
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:197
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:268
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3251
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3319
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:361
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:293
+msgid "Emergency"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:333
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:197
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:268
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3263
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3331
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:361
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:293
+msgid "Critical"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:342
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:206
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:279
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:370
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:302
+msgid "Elevate Logging until"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:348
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:212
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:285
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:376
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:308
+msgid ""
+"Elevate log level for the specified time. Should only be used if there is a "
+"problem with the display."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:373
+msgid "Log file path name."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:376
+msgid ""
+"Create a log file on disk in this location. Please enter a fully qualified "
+"path."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:388
+msgid "Show the icon in the task bar?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:391
+msgid "Should the application icon be shown in the task bar?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:399
+msgid "Cursor Start Position"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:402
+msgid "The position of the cursor when the Player starts up."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:405
+msgid "Unchanged"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:405
+msgid "Top Left"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:405
+msgid "Top Right"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:405
+msgid "Bottom Left"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:405
+msgid "Bottom Right"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:413
+msgid "Enable Double Buffering"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:416
+msgid ""
+"Double buffering helps smooth the playback but should be disabled if "
+"graphics errors occur"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:424
+msgid "Duration for Empty Layouts"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:427
+msgid ""
+"If an empty layout is detected how long (in seconds) should it remain on "
+"screen? Must be greater than 1."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:435
+msgid "Enable Mouse"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:438
+msgid "Enable the mouse."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:446
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:314
+msgid "Enable Shell Commands"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:449
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:317
+msgid "Enable the Shell Command module."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:460
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:240
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:328
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:477
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:361
+msgid "Notify current layout"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:463
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:243
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:331
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:480
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:364
+msgid ""
+"When enabled the Player will send the current layout to the CMS each time it "
+"changes. Warning: This is bandwidth intensive and should be disabled unless "
+"on a LAN."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:474
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:342
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:491
+msgid "Expire Modified Layouts?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:477
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:345
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:494
+msgid ""
+"Expire Modified Layouts immediately on change. This means a layout can be "
+"cut during playback if it receives an update from the CMS"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:485
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:353
+msgid "Maximum concurrent downloads"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:488
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:356
+msgid "The maximum number of concurrent downloads the Player will attempt."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:496
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:364
+msgid "Shell Command Allow List"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:499
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:367
+msgid "Which shell commands should the Player execute?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:510
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:260
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:378
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:505
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:381
+msgid "Screen shot interval"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:513
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:270
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:381
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:508
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:391
+msgid ""
+"The duration between status screen shots in minutes. 0 to disable. Warning: "
+"This is bandwidth intensive."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:524
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:281
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:392
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:530
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:402
+msgid "Screen Shot Size"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:527
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:395
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:533
+msgid "The size of the largest dimension. Empty or 0 means the screen size."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:535
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:403
+msgid "Limit the number of log files uploaded concurrently"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:538
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:406
+msgid ""
+"The number of log files to upload concurrently. The lower the number the "
+"longer it will take, but the better for memory usage."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:546
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:414
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:591
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:439
+msgid "Embedded Web Server Port"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:549
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:417
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:594
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:442
+msgid ""
+"The port number to use for the embedded web server on the Player. Only "
+"change this if there is a port conflict reported on the status screen."
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:557
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:425
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:602
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:450
+msgid "Embedded Web Server allow WAN?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:560
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:428
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:605
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:453
+msgid ""
+"Should we allow access to the Player Embedded Web Server from WAN? You may "
+"need to adjust the device firewall to allow external traffic"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:568
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:436
+msgid "Prevent Sleep?"
+msgstr ""
+
+#: cache/7a/7ace3caa387751a93bc12788dd3e868e.php:571
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:439
+msgid "Stop the player PC power management from Sleeping the PC"
+msgstr ""
+
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:1862
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1406
+msgid ""
+"When filtering by multiple names, which logical operator should be used?"
+msgstr ""
+
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:1945
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:1956
+msgid "Choose"
+msgstr ""
+
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:1947
+#: cache/7a/7a25b81d60ccdc317bd1f648833da342.php:1954
+msgid "Change"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:55
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:567
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:947
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:235
+msgid "Report Fault"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:104
+msgid "Report an application fault"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:114
+#: lib/Middleware/Actions.php:104
+msgid ""
+"CMS configuration warning, it is very unlikely that /web/ should be in the "
+"URL. This usually means that the DocumentRoot of the web server is wrong and "
+"may put your CMS at risk if not corrected."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:125
+msgid ""
+"The CMS may not be working as expected because MySQL BINLOG format is set to "
+"STATEMENT. This can effect sessions and should be set to ROW or MIXED."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:133
+msgid ""
+"Before reporting a fault it would be appreciated if you follow the steps. "
+"Click start "
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:150
+msgid "Check that the Environment passes all the CMS Environment checks."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:158
+msgid "There is a critical error that you should resolve first."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:165
+msgid "There is a warning on the checklist that you should resolve."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:171
+msgid "All checks pass. Click next to continue"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:247
+msgid "All other checks passed"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:256
+msgid "I want to see the list anyway."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:332
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:366
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:404
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:494
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:530
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:974
+msgid "Previous"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:349
+msgid "Turn ON full auditing and debugging."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:357
+msgid " Turn ON Debugging"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:384
+msgid "Recreate the Problem in a new window."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:388
+msgid ""
+"Please open a new window and recreate the problem. While you do that we are "
+"going to log all of the actions taken in a text based log. We won't capture "
+"screenshots or videos, so if you feel that this would be useful please "
+"capture those manually and add them to the zip file you will download in the "
+"next step."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:395
+msgid "Open a new window"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:421
+msgid "Automatically collect and export relevant information into a text file."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:424
+msgid "Please save this file to your PC."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:430
+msgid "What items would you like to save?"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:439
+msgid "Version Information"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:446
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:528
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:902
+msgid "Log"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:453
+msgid "Environment Check"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:467
+msgid "Display List"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:474
+msgid "Display Settings Profile (included with each display)"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:487
+msgid "Collect and Save Data"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:512
+msgid "Turn full auditing and debugging OFF."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:521
+msgid " Turn OFF Debugging"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:549
+msgid "That's it!"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:555
+msgid "Click on the below link to open the bug report page for this release."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:558
+msgid ""
+"Describe the problem and include a link to the fault archive you obtained "
+"earlier - please check this archive for sensitive information, redact as "
+"appropriate, and upload it somewhere publically accessible."
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:569
+msgid "Ask a question"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:575
+msgid "Start again"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:653
+msgid "Downloading file"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:668
+msgid "Download selected items"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:679
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:683
+msgid "Hide Environment checks"
+msgstr ""
+
+#: cache/1e/1e61b9bdf05d56202929062eb1f56436.php:681
+msgid "Show Environment checks"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:58
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:76
+msgid "Add Module Template"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:110
+#: cache/9a/9aa73425f7ca637f225c7ea17827792f.php:100
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:181
+msgid "A unique ID for the module template"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:127
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:198
+msgid "A title for the module template"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:141
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:140
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:170
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:213
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:172
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:172
+msgid "Data Type"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:147
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:146
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:219
+msgid "Which data type does this template need?"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:167
+msgid "Optionally select existing template to use as a base for this Template"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:178
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:230
+msgid "Show In"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:184
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:236
+msgid "Which Editor should this template be available in?"
+msgstr ""
+
+#: cache/0f/0fcb49a08592c93a31aa8f0a52cede34.php:189
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:241
+msgid "Playlist Editor"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:56
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:112
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:190
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:211
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:329
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:482
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:138
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3489
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:122
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:173
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:123
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:190
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:211
+msgid "Users"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:87
+msgid "Add a new User"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:91
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:58
+msgid "Add User"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:197
+msgid "Home folder"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:209
+msgid "Last Login"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:213
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:198
+msgid "Logged In?"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:221
+msgid "Two Factor"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:233
+msgid "Phone"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:237
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:340
+msgid "Ref 1"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:241
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:344
+msgid "Ref 2"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:245
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:348
+msgid "Ref 3"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:249
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:352
+msgid "Ref 4"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:253
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:356
+msgid "Ref 5"
+msgstr ""
+
+#: cache/f5/f5a2c2f8b8f4e9220139a1c44da2c2f7.php:257
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:100
+msgid "Row Menu"
+msgstr ""
+
+#: cache/f4/f4b0861fe3bd8635c8e4460f698cac0e.php:60
+msgid "Pause Report Schedule"
+msgstr ""
+
+#: cache/f4/f4b0861fe3bd8635c8e4460f698cac0e.php:66
+msgid "Resume Report Schedule"
+msgstr ""
+
+#: cache/f4/f4b0861fe3bd8635c8e4460f698cac0e.php:106
+msgid "Are you sure you want to pause this report schedule?"
+msgstr ""
+
+#: cache/f4/f4b0861fe3bd8635c8e4460f698cac0e.php:115
+msgid "Are you sure you want to resume this report schedule?"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:53
+msgid "Welcome"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:92
+#, no-php-format
+msgid "Welcome to the %productName% CMS"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:96
+msgid ""
+"The Content Management System (CMS) allows users to create, manage and "
+"update content to be shown on Displays. Upload images and videos, create "
+"layout designs, schedule content and manage the display network."
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:200
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:272
+msgid "Existing Content"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:211
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:307
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:155
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:240
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:56
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:95
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:180
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1410
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2176
+msgid "Playlists"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:222
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:332
+msgid "Create Content"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:385
+msgid "Documentation"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:389
+msgid ""
+"Our documentation is there to help you at every turn. It’s updated regularly "
+"to reflect changes and additions to the platform, and so it’s a valuable "
+"reference tool for all users."
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:396
+msgid "User Manual"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:401
+msgid "Admin"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:405
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:584
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:974
+msgid "Developer"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:416
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:799
+msgid "Training"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:420
+msgid "A collection of training videos to help new users get started."
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:424
+msgid "New User Training"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:432
+msgid "Help"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:436
+msgid ""
+"We are here to help! All the support you’re looking for, at your fingertips."
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:441
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:759
+msgid "Help Centre"
+msgstr ""
+
+#: cache/4d/4d58ff7fc00fb2d29f0e6a7b4c3fe36c.php:445
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:791
+msgid "Community"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:73
+msgid "Add a Display via user_code displayed on the Player screen"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:77
+msgid "Add Display (Code)"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:169
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:204
+#: lib/Controller/Display.php:1307
+msgid "Up to date"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:175
+#: lib/Controller/Display.php:1308
+msgid "Downloading"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:181
+#: lib/Controller/Display.php:1309
+msgid "Out of date"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:226
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:641
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:474
+msgid "Authorised?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:254
+msgid "XMR Registered?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:340
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:700
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:531
+msgid "Display Profile"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:361
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:696
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:527
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:149
+msgid "Last Accessed"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:372
+msgid "Player Type"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:378
+msgid "Android"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:384
+msgid "ChromeOS"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:390
+msgid "Windows"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:396
+msgid "webOS"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:402
+msgid "Tizen"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:408
+msgid "Linux"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:428
+msgid "Player Code"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:439
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:808
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:651
+msgid "Custom ID"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:450
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:720
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:547
+msgid "Mac Address"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:461
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:716
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:161
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:208
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:543
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:161
+msgid "IP Address"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:500
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:764
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:587
+msgid "Commercial Licence"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:506
+msgid "Licensed fully"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:512
+msgid "Trial"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:518
+msgid "Not licenced"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:524
+msgid "Not applicable"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:542
+msgid "Player supported?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:610
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:425
+msgid "List"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:629
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:450
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:112
+msgid "Display Type"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:633
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:488
+msgid "Address"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:645
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:478
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:196
+msgid "Current Layout"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:649
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:482
+msgid "Storage Available"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:653
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:486
+msgid "Storage Total"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:657
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:490
+msgid "Storage Free %"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:684
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:859
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:515
+msgid "Interleave Default"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:688
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:519
+msgid "Email Alert"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:708
+msgid "Supported?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:712
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:539
+msgid "Device Name"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:724
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:411
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3072
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:551
+msgid "Timezone"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:728
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:433
+msgid "Languages"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:740
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:563
+msgid "Screen shot?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:748
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:571
+msgid "CMS Transfer?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:756
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:579
+msgid "Last Command"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:760
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:583
+msgid "XMR Registered"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:768
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:100
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:134
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:591
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1336
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:100
+msgid "Remote"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:776
+msgid "Screen Size"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:780
+msgid "Is Mobile?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:784
+msgid "Outdoor?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:812
+msgid "Cost Per Play"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:816
+msgid "Impressions Per Play"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:824
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:225
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:197
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:599
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1308
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:319
+msgid "Modified Date"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:828
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:603
+msgid "Faults?"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:832
+msgid "OS Version"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:836
+msgid "OS SDK"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:840
+msgid "Manufacturer"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:844
+msgid "Brand"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:848
+msgid "Model"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:1008
+msgid "Player Status Window"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:1012
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:1269
+msgid "VNC to this Display"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:1016
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:1279
+msgid "TeamViewer to this Display"
+msgstr ""
+
+#: cache/84/842455e3b8fa26bd84cea16e09a21f78.php:1020
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:1290
+msgid "Webkey to this Display"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:57
+#, no-php-format
+msgid "Manage %displayName%"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:75
+#, no-php-format
+msgid "Display %displayName% is currently logged-in, seen %timeAgo%."
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:84
+#, no-php-format
+msgid ""
+"Display %displayName% is not logged in at the moment and last accessed at "
+"%displayLastAccessed%"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:99
+msgid ""
+"This Display hasn't connected since updates have been made in the CMS. The "
+"below information will be updated when it has."
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:109
+msgid "File Status - Count of Files"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:120
+msgid "File Status - Size of Files"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:139
+msgid ""
+"This player is too old to show faults. Please upgrade it to v3 or later."
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:150
+msgid "Reported Player Faults"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:166
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:255
+msgid "Reason"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:174
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:412
+msgid "Expires"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:178
+msgid "Schedule ID"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:186
+msgid "Region ID"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:190
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:225
+msgid "Widget ID"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:212
+msgid "Dependencies"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:228
+#: cache/84/846b4e365858df7d149d45d99237360c.php:304
+#: cache/84/846b4e365858df7d149d45d99237360c.php:392
+#: cache/84/846b4e365858df7d149d45d99237360c.php:499
+msgid "Complete"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:232
+#: cache/84/846b4e365858df7d149d45d99237360c.php:308
+#: cache/84/846b4e365858df7d149d45d99237360c.php:396
+#: cache/84/846b4e365858df7d149d45d99237360c.php:503
+#: cache/84/846b4e365858df7d149d45d99237360c.php:580
+#: cache/84/846b4e365858df7d149d45d99237360c.php:812
+#: cache/84/846b4e365858df7d149d45d99237360c.php:835
+msgid "Downloaded"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:300
+#: cache/84/846b4e365858df7d149d45d99237360c.php:388
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:368
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:159
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1546
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:160
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:212
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:210
+msgid "Size"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:384
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:396
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:143
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:156
+msgid "File Name"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:400
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:392
+msgid "Released"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:483
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:176
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1342
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1469
+msgid "Widgets"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:564
+msgid "Widget Data"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:630
+#: cache/84/846b4e365858df7d149d45d99237360c.php:745
+#: lib/Report/Bandwidth.php:322
+msgid "Bandwidth"
+msgstr ""
+
+#: cache/84/846b4e365858df7d149d45d99237360c.php:814
+#: cache/84/846b4e365858df7d149d45d99237360c.php:839
+msgid "Pending"
+msgstr ""
+
+#: cache/84/84b99aef46296bdae8366b9b2cef050f.php:94
+#, no-php-format
+msgid "Are you sure you want to delete %groupName%?"
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:94
+#, no-php-format
+msgid ""
+"You have selected %layoutName% for export. A ZIP file will be downloaded "
+"which contains the layout, its widgets and media. It will also contain the "
+"structure for associated DataSets."
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:105
+msgid "Include DataSet data?"
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:111
+msgid "Do you want to include the DataSet data?"
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:122
+msgid "Include Widget fallback data?"
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:128
+msgid ""
+"Do you want to include fallback data added to Widgets used on this Layout?"
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:139
+msgid "Save as"
+msgstr ""
+
+#: cache/09/090cf553152bf65c409fe77487c28249.php:145
+msgid "Change the name of the downloaded file if desired."
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:157
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:247
+msgid "The Password for this user."
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:242
+msgid "Initial User Group"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:248
+msgid ""
+"Select a User Group for this User so that they get access to the parts of "
+"the application they need."
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:258
+msgid ""
+"User Groups are an easy way to configure a set of features and sharing which "
+"can be applied to multiple users. After adding a User they can be assigned "
+"to multiple Groups or have individual sharing assigned to them directly."
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:455
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:183
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:139
+msgid "Receive System Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:461
+msgid "Should this User receive system notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:472
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:200
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:143
+msgid "Receive Display Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:489
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:217
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:151
+msgid "Receive DataSet Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:506
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:234
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:155
+msgid "Receive Layout Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:523
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:251
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:159
+msgid "Receive Library Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:540
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:268
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:163
+msgid "Receive Report Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:557
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:285
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:167
+msgid "Receive Schedule Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:574
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:302
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:147
+msgid "Receive Custom Notifications?"
+msgstr ""
+
+#: cache/75/75c450b8caf31678df6c244b58a2926f.php:618
+msgid "Should this User see the new user guide when they log in?"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:58
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:76
+msgid "Add Campaign"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:143
+msgid "What type of Campaign would you like to create?"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:148
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:194
+msgid "Layout list"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:148
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:194
+msgid "Ad Campaign"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:166
+#: cache/3f/3fff772c4b50b4be1c278efa53e51975.php:100
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:130
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:167
+msgid "The Name for this Campaign"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:186
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:241
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:187
+msgid ""
+"Tags for this Campaign - Comma separated string of Tags or Tag|Value format. "
+"If you choose a Tag that has associated values, they will be shown for "
+"selection below."
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:239
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:240
+msgid "Enable cycle based playback"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:245
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:246
+msgid ""
+"When cycle based playback is enabled only 1 Layout from this Campaign will "
+"be played each time it is in a Schedule loop. The same Layout will be shown "
+"until the 'Play count' is achieved."
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:262
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:263
+msgid ""
+"In cycle based playback, how many plays should each Layout have before "
+"moving on?"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:273
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:274
+msgid "List play order"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:279
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:280
+msgid ""
+"When this campaign is scheduled alongside another campaign with the same "
+"display order, how should the layouts in both campaigns be ordered?"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:284
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:285
+msgid "Round-robin"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:284
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:285
+msgid "Block"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:293
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:195
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:318
+msgid "Target Type"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:299
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:201
+msgid "How would you like to set the target for this campaign?"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:304
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:206
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:491
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:326
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:433
+msgid "Plays"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:304
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:206
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:437
+msgid "Budget"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:304
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:528
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:599
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:206
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:511
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:334
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:441
+msgid "Impressions"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:313
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:215
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:233
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:677
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:322
+msgid "Target"
+msgstr ""
+
+#: cache/7c/7cb0562fa4a322576ebe09e5fee4ed3e.php:319
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:221
+msgid "What is the target number for this Campaign over its entire playtime"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:58
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:76
+msgid "Add User Group"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:111
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:111
+msgid "Onboarding Settings"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:132
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:97
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:133
+msgid "The Name for this User Group"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:189
+msgid "Should members of this Group receive system notifications?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:206
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:232
+msgid ""
+"Should members of this Group receive Display notifications for Displays they "
+"have permission to see?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:223
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:252
+msgid "Should members of this Group receive DataSet notification emails?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:240
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:212
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:272
+msgid "Should members of this Group receive Layout notification emails?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:257
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:292
+msgid "Should members of this Group receive Library notification emails?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:274
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:312
+msgid "Should members of this Group receive Report notification emails?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:291
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:332
+msgid "Should members of this Group receive Schedule notification emails?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:308
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:352
+msgid ""
+"Should members of this Group receive notifications emails for Notifications "
+"manually created in CMS?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:330
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:381
+msgid "An optional description of the user group. (1 - 500 characters)"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:347
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:398
+msgid "Show when onboarding a new user?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:353
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:404
+msgid ""
+"Should this User Group be available for selection when creating a New User "
+"via the onboarding form?"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:364
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:415
+msgid "Default Homepage"
+msgstr ""
+
+#: cache/7c/7cd1a0196c5569c971a713d848c6e62e.php:370
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:421
+msgid "Default Homepage for users created with this group."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:104
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:345
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:104
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:401
+msgid "Authentication"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:108
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:108
+msgid "Data"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:156
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:100
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:170
+msgid "A name for this DataSet"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:173
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:124
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:187
+msgid "An optional description"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:190
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:141
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:204
+msgid ""
+"A code which can be used to lookup this DataSet - usually for an API "
+"application"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:207
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:221
+msgid "Is this DataSet connected to a remote data source?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:227
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:238
+msgid "Is this DataSet connected to a real time data source?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:242
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:250
+msgid "Data Connector Source"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:248
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:256
+msgid "Select data connector source."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:262
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:315
+msgid "Method"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:268
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:321
+msgid "What type of request needs to be made to get the remote data?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:274
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:253
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:327
+msgid "GET"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:280
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:259
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:333
+msgid "POST"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:296
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:349
+msgid "URI"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:302
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:355
+msgid "The URI of the Remote Dataset used for GET and POST."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:315
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:368
+msgid "Replacements"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:319
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:372
+msgid "Request date: {{DATE}}"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:323
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:376
+msgid "Request time: {{TIME}}"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:327
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:380
+msgid ""
+"Dependant fields: {{COL.NAME}} where NAME is a FieldName from the dependant "
+"DataSet"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:333
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:386
+msgid ""
+"Data to add to this request. This should be URL encoded, e.g. paramA=1&"
+"paramB=2."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:351
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:407
+msgid ""
+"Select the authentication requirements for the remote data source. These "
+"will be added to the request."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:363
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:419
+msgid "Basic"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:369
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:425
+msgid "Digest"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:375
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:431
+msgid "NTLM"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:381
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:437
+msgid "Bearer"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:406
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:462
+msgid "Enter the authentication Username"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:423
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:479
+msgid "Corresponding Password"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:434
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:490
+msgid "Custom Headers"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:440
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:496
+msgid ""
+"Comma separated string of custom HTTP headers in headerName:headerValue "
+"format"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:451
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:507
+msgid "User Agent"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:457
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:513
+msgid ""
+"Optionally set specific User Agent for this request, provide only the value, "
+"relevant header will be added automatically"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:476
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:532
+msgid "Select source type of the provided remote Dataset URL"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:482
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:538
+msgid "JSON"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:488
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:544
+msgid "CSV"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:504
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:560
+msgid "Data root"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:510
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:566
+msgid ""
+"Please enter the element in your remote data which we should use as the "
+"starting point when we match the remote Columns. This should be an array or "
+"an object. You can use the test button below to see the structure that is "
+"returned."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:521
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:577
+msgid "CSV separator"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:527
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:583
+msgid "What separator should be used for CSV source?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:533
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:589
+msgid "Comma"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:540
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:596
+msgid "Semicolon"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:547
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:603
+msgid "Space"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:554
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:610
+msgid "Tab"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:561
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:617
+msgid "Pipe"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:587
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:643
+msgid "For CSV source, should the first row be ignored?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:600
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:656
+msgid "Test data URL"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:609
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:665
+msgid "Aggregation"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:615
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:671
+msgid "Aggregate received data by the given method"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:627
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:683
+msgid "Summarize"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:650
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:706
+msgid "By Field"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:657
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:716
+msgid ""
+"Using JSON syntax enter the path below the Data root by which the above "
+"aggregation should be applied."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:661
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:720
+msgid ""
+"Summarize: Values in this field will be summarized and stored in one column."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:665
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:724
+msgid ""
+"Count: All individual values in this field will be counted and stored in one "
+"Column for each value"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:676
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:81
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:735
+msgid "Refresh"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:682
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:741
+msgid "How often should this remote data be fetched and imported?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:690
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:749
+msgid "Constantly"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:708
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:792
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:411
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:458
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:763
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:782
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:767
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:851
+msgid "Weekly"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:714
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:798
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:773
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:857
+msgid "Every two Weeks"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:720
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:804
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:417
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:464
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:769
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:788
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:779
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:863
+msgid "Monthly"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:726
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:810
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:785
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:869
+msgid "Quaterly"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:732
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:816
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:423
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:470
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:775
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:794
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:791
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:875
+msgid "Yearly"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:754
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:813
+msgid "Truncate DataSet"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:760
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:819
+msgid ""
+"Select when you would like the Data to be truncated out of this DataSet. The "
+"criteria is assessed when synchronisation occurs and is truncated before "
+"adding new data."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:768
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2351
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:827
+#: lib/Controller/Library.php:608
+msgid "Never"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:822
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:881
+msgid "Every second Year"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:846
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:905
+msgid "Truncate with no new data?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:852
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:911
+msgid ""
+"Should the DataSet data be truncated even if no new data is pulled from the "
+"source?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:863
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:922
+msgid "Depends on DataSet"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:872
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:931
+msgid ""
+"The DataSet you select here will be processed in advance and have its values "
+"available for subsitution in the data to add to this request on the Remote "
+"tab."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:883
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:942
+msgid "Row Limit"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:889
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:948
+msgid ""
+"Optionally provide a row limit for this DataSet. When left empty the DataSet "
+"row limit from CMS Settings will be used."
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:900
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:959
+msgid "Limit Policy"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:906
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:965
+msgid "What should happen when this Dataset reaches the row limit?"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:912
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:971
+msgid "Stop Syncing"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:918
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:977
+msgid "First In First Out"
+msgstr ""
+
+#: cache/e2/e22de285ce0779a949f1068d4617e0b0.php:924
+#: cache/c6/c6d931f51a0aaa053f73e039b1fd4947.php:57
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:75
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:983
+msgid "Truncate"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:60
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:71
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:75
+msgid "Add RSS"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:215
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:215
+msgid "The title for this Rss"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:232
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:232
+msgid "The author for this Rss"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:243
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:243
+msgid "Title Column"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:249
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:249
+msgid "Please select a column to be the item title"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:260
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:260
+msgid "Summary Column"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:266
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:266
+msgid "Please select a column to be the item summary"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:277
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:277
+msgid "Content Column"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:283
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:283
+msgid "Please select a column to be the item content"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:294
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:294
+msgid "Published Date Column"
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:300
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:300
+msgid ""
+"Please select a column to be the item content. We will try to convert this "
+"to a date/time and if we can't we will use the current date/time."
+msgstr ""
+
+#: cache/24/240aa83b1921a267063dcda7d2e476e5.php:364
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:381
+msgid ""
+"The DataSet results can be filtered by any column and set below. New fields "
+"can be added by selecting the plus icon at the end of the current row. "
+"Should a more complicated filter be required the advanced checkbox can be "
+"selected to provide custom SQL syntax."
+msgstr ""
+
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:59
+msgid "Edit Module"
+msgstr ""
+
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:110
+msgid "Preview Enabled?"
+msgstr ""
+
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:116
+msgid ""
+"When Preview is Enabled users will be able to see a preview in the layout "
+"editor."
+msgstr ""
+
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:133
+msgid "When Enabled users will be able to add media using this module."
+msgstr ""
+
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:144
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:125
+msgid "Default Duration"
+msgstr ""
+
+#: cache/57/5766a6856bb31f0a2585819350b0b131.php:150
+msgid ""
+"The default duration for Widgets of this Module when the user has elected to "
+"not set a specific duration."
+msgstr ""
+
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:57
+msgid "Edit Version"
+msgstr ""
+
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:91
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:140
+msgid "Player Version Name"
+msgstr ""
+
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:97
+msgid ""
+"The Name of the player version application, this will be displayed in "
+"Version dropdowns in Display Profile and Display"
+msgstr ""
+
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:114
+msgid ""
+"The Version number of this installer file, this should be correctly "
+"populated on upload, otherwise adjust it here"
+msgstr ""
+
+#: cache/04/04c4194ba5665218932ecba11a2ee099.php:131
+msgid ""
+"The Code number of this installer file, this should be correctly populated "
+"on upload, otherwise adjust it here"
+msgstr ""
+
+#: cache/3f/3fff772c4b50b4be1c278efa53e51975.php:60
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:60
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:60
+#: cache/9a/9aa73425f7ca637f225c7ea17827792f.php:60
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:60
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:60
+#: cache/42/42493ba813fc77f7caf608638138cbdc.php:60
+#, no-php-format
+msgid "Copy %name%"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:56
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:92
+msgid "Audit Log"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:69
+msgid "Export raw data to CSV"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:128
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:196
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3592
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:170
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:120
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:216
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:283
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:210
+#: lib/Widget/DataType/SocialMedia.php:66
+msgid "User"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:139
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:200
+msgid "Entity"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:150
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:204
+msgid "Entity ID"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:172
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:212
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:100
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:351
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:412
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:100
+msgid "Message"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:216
+msgid "Object"
+msgstr ""
+
+#: cache/43/43ef97a63aab8b7a4bff59fc020ed2c6.php:315
+msgid "Property"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:50
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:87
+msgid "Activity Report"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:425
+msgid "Partner"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:442
+msgid "Apply"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:474
+msgid "Filter Options"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:504
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:371
+msgid "Hour"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:508
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:587
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:521
+msgid "Display ID"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:512
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:583
+#: cache/bf/bffb4da6719cf0b10a505165c869e456.php:96
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:419
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:407
+#: lib/Entity/Schedule.php:2090
+msgid "Campaign"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:516
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:311
+msgid "Play Count"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:520
+msgid "Error Count"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:524
+msgid "Misses Count"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:532
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:607
+msgid "Impression Actual"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:560
+msgid "Summary Chart"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:579
+msgid "Scheduled At"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:591
+msgid "Played?"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:595
+msgid "Errored?"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:603
+msgid "Impression Date"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:611
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:143
+msgid "Errors"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:615
+msgid "Error Date"
+msgstr ""
+
+#: cache/43/43afc9b964a828d53858304b89d00f89.php:619
+msgid "Error Code"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:57
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:74
+msgid "Add Command"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:109
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:109
+msgid "The Name for this Command"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:126
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:126
+msgid ""
+"A reference code for this command which is used to identify the command "
+"internally."
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:146
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:148
+msgid ""
+"The Command String for this Command. An override for this can be provided in "
+"Display Settings."
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:160
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:162
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:147
+msgid "Validation"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:166
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:168
+msgid ""
+"The Validation String for this Command. An override for this can be provided "
+"in Display Settings."
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:180
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:182
+msgid "Available on"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:186
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:188
+msgid ""
+"Leave empty if this command should be available on all types of Display."
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:202
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:204
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:181
+msgid "Create Alert On"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:208
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:210
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:187
+msgid "On command execution, when should a Display alert be created?"
+msgstr ""
+
+#: cache/88/88d41f9436f9f6a3b445d00917b83b4a.php:229
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:231
+msgid ""
+"This should be a textual description of what the command is trying to "
+"achieve. It should not be the command string."
+msgstr ""
+
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:57
+msgid "Save this Layout as a Template?"
+msgstr ""
+
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:162
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:172
+msgid ""
+"Tags for this Layout - Comma separated string of Tags or Tag|Value format. "
+"If you choose a Tag that has associated values, they will be shown for "
+"selection below."
+msgstr ""
+
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:232
+msgid "Include Widgets?"
+msgstr ""
+
+#: cache/d5/d5d8e7a40313b04429c6534ab6ef8cf7.php:238
+msgid "Add all the widgets to the template?"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:153
+msgid ""
+"Your API key allows for secure communication between the CMS and the Xibo "
+"SSP connector service. It is used to orchestrate the delivery of ads to your "
+"players. Enter your API Key from Xibo."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:170
+msgid "CMS URL"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:176
+msgid "The URL your players use to connect to your CMS."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:276
+msgid "Connect to this partner"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:293
+msgid "Enter your API Key from this SSP."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:304
+msgid "Test mode?"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:310
+msgid "Should we connect to this partners test or production system?"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:324
+msgid "Use the SSP widget to schedule ad requests manually?"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:330
+msgid ""
+"When using the SSP widget you do not need to configure a share of voice, "
+"duration or min/max duration."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:346
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:502
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:466
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:491
+msgid "Share of Voice"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:369
+msgid "How many seconds per hour would you like to dedicate to this SSP?"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:380
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:516
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:508
+msgid "As a percentage"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:388
+msgid "Duration (s)"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:394
+msgid "The expected duration of each ad served by the SSP."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:405
+msgid "Min Duration (s)"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:411
+msgid "The minimum duration of an ad served by the SSP."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:422
+msgid "Max Duration (s)"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:428
+msgid "The maximum duration of an ad served by the SSP."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:439
+msgid "Allowed content types"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:445
+msgid ""
+"Which content types should be allowed on these displays. Most SSPs will be "
+"able to further refine this by display."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:451
+msgid "Images and Video"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:457
+msgid "Images only"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:463
+msgid "Videos only"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:486
+msgid ""
+"Which displays would you like to enroll with this SSP. Leave blank to enroll "
+"them all."
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:510
+msgid "ID field"
+msgstr ""
+
+#: cache/0a/0add9a83a07c80d901e74df0bbc0bf5d.php:516
+msgid "Which field would you like to use as the ID for this SSP?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:59
+msgid "Edit Display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:117
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:118
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2081
+msgid "Maintenance"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:124
+#: lib/Controller/Display.php:1193
+msgid "Wake on LAN"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:197
+msgid ""
+"The Name of the Display - your administrator has locked this to the device "
+"name"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:209
+msgid "The Name of the Display - (1 - 50 characters)."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:223
+msgid "Display's Hardware Key"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:229
+msgid "A unique identifier for this display."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:246
+msgid "A description - (1 - 254 characters)."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:266
+msgid ""
+"Tags for this Display - Comma separated string of Tags or Tag|Value format. "
+"If you choose a Tag that has associated values, they will be shown for "
+"selection below."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:319
+msgid "Authorise display?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:325
+msgid "Use one of the available slots for this display?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:359
+msgid ""
+"Set the Default Layout to use when no other content is scheduled to this "
+"Display. This will override the global Default Layout as set in CMS "
+"Administrator Settings. If left blank a global Default Layout will be "
+"automatically set for this Display."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:380
+msgid "The Latitude of this display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:397
+msgid "The Longitude of this Display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:417
+msgid "The timezone for this display, or empty to use the CMS timezone"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:439
+msgid ""
+"The languages that the audience viewing this Display are likely to understand"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:456
+msgid "The Type of this Display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:467
+msgid "Venue"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:473
+msgid "The Location/Venue of this display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:494
+msgid "The Address of this Display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:505
+msgid "Screen size"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:511
+msgid "The Screen size of this Display"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:522
+msgid "Is mobile?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:528
+msgid "Is this display mobile?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:539
+msgid "Is outdoor?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:545
+msgid "Is your display located outdoors?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:556
+msgid "Cost per play"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:562
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:558
+msgid "The cost per play"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:573
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:592
+msgid "Impressions per play"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:579
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:598
+msgid "The impressions per play"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:664
+#: lib/XTR/MaintenanceRegularTask.php:149
+msgid "Email Alerts"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:670
+msgid ""
+"Do you want to be notified by email if there is a problem with this display?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:698
+msgid "Use the Global Timeout?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:704
+msgid ""
+"Should this display be tested against the global time out or the Player "
+"collection interval?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:721
+msgid "Enable Wake on LAN"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:727
+msgid ""
+"Wake on Lan requires the correct network configuration to route the magic "
+"packet to the display PC"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:738
+msgid "BroadCast Address"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:744
+msgid "The IP address of the remote host's broadcast address (or gateway)"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:755
+msgid "Wake on LAN SecureOn"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:761
+msgid ""
+"Enter a hexadecimal password of a SecureOn enabled Network Interface Card "
+"(NIC) of the remote host. Enter a value in this pattern: 'xx-xx-xx-xx-xx-"
+"xx'. Leave the following field empty, if SecureOn is not used (for example, "
+"because the NIC of the remote host does not support SecureOn)."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:772
+msgid "Wake on LAN Time"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:778
+msgid ""
+"The time this display should receive the WOL command, using the 24hr clock - "
+"e.g. 19:00. Maintenance must be enabled."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:790
+msgid "Wake on LAN CIDR"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:796
+msgid ""
+"Enter a number within the range of 0 to 32 in the following field. Leave the "
+"following field empty, if no subnet mask should be used (CIDR = 0). If the "
+"remote host's broadcast address is unknown: Enter the host name or IP "
+"address of the remote host in Broad Cast Address and enter the CIDR subnet "
+"mask of the remote host in this field."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:811
+msgid "Settings Profile?"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:817
+msgid ""
+"What display profile should this display use? To use the default profile "
+"leave this empty."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:828
+msgid ""
+"The settings for this display are shown below. They are taken from the "
+"active Display Profile for this Display, which can be changed in Display "
+"Settings. If you have altered the Settings Profile above, you will need to "
+"save and re-show the form."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:840
+msgid "Setting"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:844
+msgid "Profile"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:848
+msgid "Override"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:865
+msgid "Whether to always put the default layout into the cycle."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:893
+msgid "Auditing until"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:899
+msgid ""
+"Collect auditing from this Player. Should only be used if there is a problem "
+"with the display."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:962
+msgid "Clear Cached Data"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:968
+msgid "Remove any cached data for this display."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:979
+msgid "Reconfigure XMR"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:985
+msgid "Remove the XMR configuration for this Player and send a rekey action."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:998
+msgid "TeamViewer Serial"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:1004
+msgid "If TeamViewer is installed on the device, enter the serial number here."
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:1015
+msgid "Webkey Serial"
+msgstr ""
+
+#: cache/f8/f840b9a6fa76942cce9ab5ea1234b0b0.php:1021
+msgid "If Webkey is installed on the device, enter the serial number here."
+msgstr ""
+
+#: cache/d4/d4b2abaf26d2534088241d6656d9b1a9.php:57
+msgid "Edit Data"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:58
+msgid "Edit Media"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:143
+msgid "The Name of this item - Leave blank to use the file name"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:160
+msgid "The duration in seconds this item should be displayed"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:180
+msgid ""
+"Tags for this Media - Comma separated string of Tags or Tag|Value format. If "
+"you choose a Tag that has associated values, they will be shown for "
+"selection below."
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:233
+msgid "Expiry date"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:239
+msgid ""
+"Select the date and time after which this media should be removed from the "
+"CMS - it will be removed from any existing widgets as well"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:250
+msgid "Retire this media?"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:256
+msgid ""
+"Retired media remains on existing Layouts but is not available to assign to "
+"new Layouts."
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:267
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:157
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1259
+msgid "Enable Media Stats Collection?"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:273
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:163
+msgid ""
+"Enable the collection of Proof of Play statistics for this Media Item. "
+"Ensure that ‘Enable Stats Collection’ is set to ‘On’ in the Display Settings."
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:316
+msgid ""
+"Set intended orientation for this Media, this is for filtering purpose only."
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:336
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:802
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1862
+msgid "Update this media in all layouts it is assigned to?"
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:342
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:806
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1866
+msgid "Note: It will only be updated in layouts you have permission to edit."
+msgstr ""
+
+#: cache/d4/d4711a411b28de5f1541c08fdf81fc79.php:368
+#: lib/Controller/Library.php:1407
+msgid "Sorry, Fonts do not have any editable properties."
+msgstr ""
+
+#: cache/a3/a3ff07d97ab97e6099e8ff0c9fc8bc79.php:44
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:474
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:819
+msgid "All Reports"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:100
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:375
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:100
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:422
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:109
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:727
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:106
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:746
+msgid "Repeats"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:104
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:104
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:113
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:110
+msgid "Reminder"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:114
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:276
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:114
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:300
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:127
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:391
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:124
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:379
+msgid "Select the start time for this event"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:120
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:120
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:133
+msgid ""
+"Start and end time will be defined by the daypart's configuration for this "
+"day of the week. Use a repeating schedule to apply this event over multiple "
+"days"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:155
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:178
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:179
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:180
+msgid "Optional Name for this Event (1-50 characters)"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:166
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:189
+msgid "Sync Group"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:172
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:195
+msgid "Please select existing Sync Group"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:212
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:649
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:236
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:67
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:93
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:240
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:228
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:56
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:95
+msgid "Dayparting"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:218
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:242
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:246
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:234
+msgid ""
+"Select the dayparting information for this event. To set your own times "
+"select custom and to have the event run constantly select Always."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:293
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:317
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:408
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:396
+msgid "Select the end time for this event"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:310
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:334
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:662
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:657
+msgid ""
+"Please select the order this event should appear in relation to others when "
+"there is more than one event scheduled"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:327
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:351
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:679
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:674
+msgid ""
+"Sets the event priority - events with the highest priority play in "
+"preference to lower priority events."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:338
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:362
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:690
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:685
+msgid "Maximum plays per hour"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:344
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:368
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:696
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:691
+msgid ""
+"Limit the number of times this event will play per hour on each display. For "
+"unlimited plays set to 0."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:356
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:380
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:708
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:703
+msgid "Run at CMS Time?"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:362
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:386
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:714
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:709
+msgid ""
+"When selected, your event will run according to the timezone set on the CMS, "
+"otherwise the event will run at Display local time"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:381
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:428
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:733
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:752
+msgid "Select the type of Repeat required for this Event."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:393
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:440
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:745
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:764
+msgid "Per Minute"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:444
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:491
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:796
+msgid ""
+"Use the drop-down to select which days of the week this Event should be "
+"repeated."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:507
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:554
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:859
+msgid ""
+"Should this Event Repeat by Day of the month (eg. Monthly on Day 21) or by a "
+"Weekday in the month (eg. Monthly on the third Thursday)."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:512
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:559
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:864
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:884
+msgid "on the [DAY] day"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:512
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:559
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:864
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:884
+msgid "on the [POSITION] [WEEKDAY]"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:521
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:568
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:873
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:893
+msgid "Every"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:527
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:574
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:879
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:899
+msgid ""
+"Include a number to determine the Repeat frequency required for this Event."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:556
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:603
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:908
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:928
+msgid "Until"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:562
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:609
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:914
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:934
+msgid ""
+"Provide a date and time to end the Repeat for this Event. Leave empty to "
+"Repeat indefinitely."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:574
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:622
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:927
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:947
+msgid ""
+"Use the form fields below to create a set of reminders for this event. New "
+"fields can be added by clicking on the + icon at the end of the row. Use the "
+"tick box to receive a notification by email alternatively reminders will be "
+"shown in the message center."
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:591
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:639
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:944
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:964
+msgid "Geo Schedule?"
+msgstr ""
+
+#: cache/a3/a3baccfe9172aafc8074a3f40c93ca70.php:597
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:645
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:950
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:970
+msgid ""
+"Should this event be location aware? Enable this checkbox and select an area "
+"by drawing a polygon or rectangle layer on the map below."
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:57
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:102
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:594
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:984
+msgid "Module Templates"
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:70
+msgid "Add a new template"
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:81
+msgid "Add a new template by importing XML file"
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:83
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:228
+msgid "Import XML"
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:166
+msgid "Template ID"
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:237
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:662
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:268
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:306
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:295
+msgid "Add files"
+msgstr ""
+
+#: cache/b5/b5b8f12f26f31097df3f2322420aee1e.php:250
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:132
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:139
+#: lib/Connector/XiboSspConnector.php:103
+msgid "Unknown error"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:59
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:74
+msgid "Add Playlist"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:192
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:201
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:100
+msgid "The Name of the Playlist - (1 - 50 characters)"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:212
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:221
+msgid ""
+"Tags for this Playlist - Comma separated string of Tags or Tag|Value format. "
+"If you choose a Tag that has associated values, they will be shown for "
+"selection below."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:265
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:274
+#: cache/c0/c0b497d04a31013bafc687f41bad47f2.php:320
+msgid "Dynamic?"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:271
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:280
+msgid ""
+"Is the Playlist to have Library media assignments managed automatically by "
+"the CMS based on filter criteria? Set a filter on the next tab."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:282
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:291
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1320
+msgid "Enable Playlist Stats Collection?"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:288
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:297
+msgid ""
+"Enable the collection of Proof of Play statistics for this Playlist. Ensure "
+"that ‘Enable Stats Collection’ is set to ‘On’ in the Display Settings."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:296
+msgid ""
+"If you want to prepopulate your Playlist with Media using a search, then you "
+"can do so on the filter tab. Leave the Dynamic checkbox unticked to make it "
+"a one-time assignment."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:337
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:335
+msgid ""
+"Populate with Library Media matching the criteria below and automatically "
+"maintain the Playlist."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:346
+msgid ""
+"Populate with Library Media matching the criteria below. This is a one time "
+"assignment and is not automatically maintained."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:356
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:121
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:344
+msgid "Library Media"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:363
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:350
+msgid "Name filter"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:377
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:364
+msgid "Tag filter"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:395
+msgid "A comma separated set of tags to match against tags on library media."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:412
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:399
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:442
+msgid "Folder Filter"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:418
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:405
+msgid "Select a folder to filter the media items."
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:435
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:423
+msgid "Max number of Items"
+msgstr ""
+
+#: cache/b5/b59708d625fa87843ba420dc5ef055eb.php:441
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:429
+msgid ""
+"The upper limit on number of Media items that can be dynamically assigned to "
+"this Playlist"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:75
+msgid "Add a new media item to the library"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:77
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:642
+msgid "Add Media"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:81
+msgid "Add a new media item to the library via external URL"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:85
+msgid "Add media (URL)"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:94
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:72
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:171
+msgid "Run through the library and remove unused and unnecessary files"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:351
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1242
+msgid "Tag"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:364
+msgid "Duration (seconds)"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:372
+msgid "Size (bytes)"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:388
+msgid "Revised"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:738
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:259
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1833
+msgid "Replace"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:751
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:302
+msgid "Upload media"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:789
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1837
+msgid "Add Replacement"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:793
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1841
+msgid "Start Replace"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:797
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1845
+msgid "Cancel Replace"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:812
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1872
+msgid "Delete the old version?"
+msgstr ""
+
+#: cache/56/563260cc97c5b226608c880d40f8fa4e.php:816
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1876
+msgid ""
+"Completely remove the old version of this media item if a new file is being "
+"uploaded."
+msgstr ""
+
+#: cache/56/5629071fb0a52f06baa7fed9fc5c004d.php:382
+#: cache/56/5629071fb0a52f06baa7fed9fc5c004d.php:400
+msgid "Select a property to display inputs"
+msgstr ""
+
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:99
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:99
+msgid "The Name for this Tag"
+msgstr ""
+
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:116
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:116
+msgid ""
+"Comma separated string of additional values that should be associated with "
+"this Tag. Values entered here will be available for selection when assigning "
+"this Tag."
+msgstr ""
+
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:127
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:127
+msgid "Required Value?"
+msgstr ""
+
+#: cache/56/56deaf724dc20d22b4f15160b0294dbc.php:133
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:133
+msgid "Tick to ensure that a value is selected when assigning this Tag"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:58
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:93
+#, no-php-format
+msgid "RSS Feeds for %dataSetName%"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:226
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2063
+msgid "Ascending"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:230
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2067
+msgid "Descending"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:275
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2074
+msgid "starts with"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:281
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2080
+msgid "ends with"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:287
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2086
+msgid "contains"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:293
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2092
+msgid "equals"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:299
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2098
+msgid "does not start with"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:305
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2104
+msgid "does not end with"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:311
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2110
+msgid "does not contain"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:317
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2116
+msgid "does not equal"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:323
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2122
+msgid "greater than"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:329
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2128
+msgid "less than"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:335
+msgid "is empty"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:341
+msgid "is not empty"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:350
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2137
+msgid "Or"
+msgstr ""
+
+#: cache/56/562297c1aa09d6dbbef12b082b6ff6b1.php:356
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2143
+msgid "And"
+msgstr ""
+
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:60
+msgid "Add Product to "
+msgstr ""
+
+#: cache/56/56b42d182f8655a4058873c14e30ef8a.php:166
+msgid ""
+"Set a display order for this item to appear, leave empty to add to the end."
+msgstr ""
+
+#: cache/bf/bffb4da6719cf0b10a505165c869e456.php:60
+#, no-php-format
+msgid "Assign %name% to a Campaign"
+msgstr ""
+
+#: cache/bf/bffb4da6719cf0b10a505165c869e456.php:74
+msgid "Assign"
+msgstr ""
+
+#: cache/bf/bffb4da6719cf0b10a505165c869e456.php:105
+msgid ""
+"Please select the Campaign you would like to assign this Layout to. It will "
+"be assigned to the end of the Campaign."
+msgstr ""
+
+#: cache/64/64646a640c76027371e2935cda4b433c.php:97
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:97
+msgid "A name for this Resolution"
+msgstr ""
+
+#: cache/64/64646a640c76027371e2935cda4b433c.php:114
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:114
+msgid "The Width for this Resolution"
+msgstr ""
+
+#: cache/64/64646a640c76027371e2935cda4b433c.php:131
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:131
+msgid "The Height for this Resolution"
+msgstr ""
+
+#: cache/64/645346da179f810102af92dd23119517.php:57
+msgid "Delete Row"
+msgstr ""
+
+#: cache/64/645346da179f810102af92dd23119517.php:91
+msgid "Are you sure you want to delete this row?"
+msgstr ""
+
+#: cache/1c/1c65822405d03c36923ecc7399544cf8.php:65
+#, no-php-format
+msgid "Usage Report for %tagName%"
+msgstr ""
+
+#: cache/1c/1c65822405d03c36923ecc7399544cf8.php:129
+msgid "Tag Value"
+msgstr ""
+
+#: cache/ae/aef6424b4ad92ef4af8fe34e7b71d154.php:100
+#, no-php-format
+msgid "%themeName% Installation"
+msgstr ""
+
+#: cache/ae/ae9dbc50775e4a2e35f2b3d47ea4c903.php:57
+#: lib/Controller/DataSet.php:309 lib/Controller/Module.php:129
+msgid "Clear Cache"
+msgstr ""
+
+#: cache/ae/ae9dbc50775e4a2e35f2b3d47ea4c903.php:91
+msgid "Clear the cache for this Module. This proces is not reversable."
+msgstr ""
+
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:74
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:74
+#: cache/9a/9aa73425f7ca637f225c7ea17827792f.php:74
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:74
+#: lib/Controller/Template.php:259 lib/Controller/DisplayGroup.php:340
+#: lib/Controller/DataSet.php:264 lib/Controller/Campaign.php:377
+#: lib/Controller/Playlist.php:403 lib/Controller/Library.php:686
+#: lib/Controller/Layout.php:1940 lib/Controller/DisplayProfile.php:212
+#: lib/Controller/Developer.php:116 lib/Controller/UserGroup.php:174
+msgid "Copy"
+msgstr ""
+
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:100
+msgid "The Name of the Media - (1 - 50 characters)"
+msgstr ""
+
+#: cache/df/df11acaa2da419d5bb2507fbe8b2ebb9.php:127
+msgid "Tag this media. Comma Separated."
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:60
+#, no-php-format
+msgid "%campaignName% - Campaign Builder"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:147
+msgid "Select the start date for this campaign"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:164
+msgid "Select the end date for this campaign"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:181
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:226
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:214
+msgid ""
+"Please select one or more displays / groups for this event to be shown on."
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:186
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:205
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:259
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:231
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:219
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:205
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:259
+msgid "Groups"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:366
+msgid "Add a layout"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:378
+msgid "Please select a Layout to add to this Campaign"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:417
+msgid "Day Parts"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:421
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:641
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:644
+msgid "Days of the week"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:425
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:480
+msgid "Geofence"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:501
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:330
+#: lib/Report/DisplayAdPlay.php:461
+msgid "Spend"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:529
+msgid ""
+"Stats need to be enabled on the Displays and Layouts selected on this "
+"campaign"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:603
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:197
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:169
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:697
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:789
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2335
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:940
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:183
+#: lib/Controller/Task.php:144 lib/Controller/Template.php:227
+#: lib/Controller/DisplayGroup.php:334 lib/Controller/DataSet.php:274
+#: lib/Controller/ScheduleReport.php:216 lib/Controller/Notification.php:270
+#: lib/Controller/Campaign.php:328 lib/Controller/Campaign.php:336
+#: lib/Controller/MenuBoard.php:162 lib/Controller/Playlist.php:396
+#: lib/Controller/Transition.php:94 lib/Controller/Library.php:679
+#: lib/Controller/SyncGroup.php:170 lib/Controller/DataSetColumn.php:173
+#: lib/Controller/Tag.php:232 lib/Controller/Layout.php:1915
+#: lib/Controller/MenuBoardCategory.php:191
+#: lib/Controller/DisplayProfile.php:202 lib/Controller/User.php:337
+#: lib/Controller/Applications.php:180 lib/Controller/Resolution.php:161
+#: lib/Controller/Display.php:839 lib/Controller/MenuBoardProduct.php:191
+#: lib/Controller/Developer.php:102 lib/Controller/PlayerSoftware.php:157
+#: lib/Controller/Command.php:178 lib/Controller/DataSetRss.php:163
+#: lib/Controller/Schedule.php:2475 lib/Controller/UserGroup.php:157
+#: lib/Controller/DayPart.php:155
+msgid "Edit"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:645
+msgid "Which days of the week should the layout be active?"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:653
+msgid "Should this layout only be shown on selected day parts?"
+msgstr ""
+
+#: cache/54/548d32720de7c3e6ad7b59edc55f7eda.php:657
+msgid "Draw areas on the map where you want this layout to play"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:61
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:61
+msgid "Back to dashboard"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:67
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:487
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:834
+#: cache/d9/d9efdfaf56965172b4a6e54931ee7d81.php:63
+msgid "Report Schedules"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:86
+msgid "Saved reports"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:137
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:153
+msgid "Show items belong to a report."
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:151
+msgid "Only my reports?"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:167
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:87
+msgid "Report Schedule"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:171
+msgid "Saved as"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:175
+msgid "Report Type"
+msgstr ""
+
+#: cache/54/549d8973c2c7a0c8da61b5531e3f16e6.php:179
+msgid "Generated on"
+msgstr ""
+
+#: cache/54/54d064f129447f51389aeea4a056750e.php:80
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:158
+msgid "Library Location"
+msgstr ""
+
+#: cache/54/54d064f129447f51389aeea4a056750e.php:86
+#, no-php-format
+msgid ""
+"%themeName% needs somewhere to store the things you upload to be shown. "
+"Ideally, this should be somewhere outside the root of your web server - that "
+"is such that is not accessible by a web browser. Please input the full path "
+"to this folder. If the folder does not already exist, we will attempt to "
+"create it for you."
+msgstr ""
+
+#: cache/54/54d064f129447f51389aeea4a056750e.php:97
+msgid "Server Key"
+msgstr ""
+
+#: cache/54/54d064f129447f51389aeea4a056750e.php:103
+#, no-php-format
+msgid ""
+"%themeName% needs you to choose a \"key\". This will be required each time "
+"you set-up a new client. It should be complicated, and hard to remember. It "
+"is visible in the CMS interface, so it need not be written down separately."
+msgstr ""
+
+#: cache/54/54d064f129447f51389aeea4a056750e.php:114
+msgid "Statistics"
+msgstr ""
+
+#: cache/54/54d064f129447f51389aeea4a056750e.php:120
+#, no-php-format
+msgid ""
+"We'd love to know you're running %theme_name%. If you're happy for us to "
+"collect anonymous statistics (version number, number of displays) then "
+"please leave the box ticked. Please un tick the box if your server does not "
+"have direct access to the internet."
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:58
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:111
+#, no-php-format
+msgid "Data Entry for %dataSetName%"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:71
+msgid "Add a row to the end of this DataSet"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:75
+msgid "Add Row"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:79
+msgid "Click to toggle between Data Edit and Multi Select modes"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:81
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:216
+msgid "Multi Select Mode"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:85
+msgid "Click to delete selected rows"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:87
+msgid "Delete Rows"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:93
+#: lib/Controller/DataSet.php:226
+msgid "View Columns"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:120
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:212
+msgid "Edit Mode"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:122
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:220
+msgid "Click on any row to edit"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:224
+msgid "Select one or more rows to delete"
+msgstr ""
+
+#: cache/ea/ea01b6b7676c0d34431890737623602b.php:491
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:291
+msgid ""
+"No value columns have been configured for this dataset. Please configure "
+"your columns accordingly."
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:87
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:99
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:99
+msgid "Licence Code"
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:90
+msgid "Provide the Licence Code to license Players using this Display Profile."
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:159
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:193
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:171
+msgid "Player Version"
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:162
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:196
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:174
+msgid ""
+"Set the Player Version to install, making sure that the selected version is "
+"suitable for your device"
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:284
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:405
+msgid "The size of the screenshot to return when requested."
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:290
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:411
+msgid "HD"
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:290
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:411
+msgid "FHD"
+msgstr ""
+
+#: cache/ea/ea74b92e70ae30b26f1bfa76dd4831ea.php:296
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:417
+msgid "Standard"
+msgstr ""
+
+#: cache/d2/d21e8a64f7edc7ce59554632a094e1cc.php:60
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:60
+#, no-php-format
+msgid "Media %name%"
+msgstr ""
+
+#: cache/30/304a80e4331dc3ca1db9b9f51d833987.php:57
+#: lib/Controller/DisplayGroup.php:523 lib/Controller/DisplayGroup.php:536
+#: lib/Controller/Display.php:1100 lib/Controller/Display.php:1114
+msgid "Trigger a web hook"
+msgstr ""
+
+#: cache/30/304a80e4331dc3ca1db9b9f51d833987.php:90
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:558
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:232
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:550
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:761
+msgid "Trigger Code"
+msgstr ""
+
+#: cache/30/304a80e4331dc3ca1db9b9f51d833987.php:93
+msgid ""
+"Enter the code associated with the web hook you wish to trigger. Please note "
+"that for this action to work, the webhook trigger code has to be added to "
+"Interactive Actions in scheduled content for this Player. "
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:58
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:101
+#, no-php-format
+msgid "Columns for %dataSetName%"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:71
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:75
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:59
+msgid "Add Column"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:79
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:83
+#: lib/Controller/DataSet.php:215
+msgid "View Data"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:128
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:138
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:138
+msgid "Heading"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:132
+msgid "DataType"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:136
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:155
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:155
+msgid "Column Type"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:140
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:189
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:189
+msgid "List Content"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:144
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:255
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:255
+msgid "Tooltip"
+msgstr ""
+
+#: cache/30/30c2eb22b2e8664609ecc781924a5d95.php:152
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:346
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:346
+msgid "Required?"
+msgstr ""
+
+#: cache/b1/b1dc7b94fc6bf2d2aa104558aeac60a2.php:57
+msgid "Delete all saved reports for "
+msgstr ""
+
+#: cache/b1/b1dc7b94fc6bf2d2aa104558aeac60a2.php:91
+msgid ""
+"Are you sure you want to delete all saved report of this report schedule? "
+"This cannot be undone"
+msgstr ""
+
+#: cache/a4/a4d917f846495e6caa8f764f3bacf824.php:60
+#, no-php-format
+msgid "Unretire %layout%"
+msgstr ""
+
+#: cache/a4/a42e589153f031845c0d3304ed5d9d32.php:57
+msgid "Delete this Column?"
+msgstr ""
+
+#: cache/a4/a42e589153f031845c0d3304ed5d9d32.php:91
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:323
+#: cache/74/74834652bf0eeb1d711de01b8086ce3b.php:91
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:589
+#: cache/10/10a3d62a635559ddeaa4c9ab8b676aaf.php:91
+msgid "Are you sure you want to delete?"
+msgstr ""
+
+#: cache/f6/f670ecee482264686c6d025f5b04b60d.php:57
+msgid "Delete Menu Board Category"
+msgstr ""
+
+#: cache/f6/f670ecee482264686c6d025f5b04b60d.php:91
+msgid ""
+"Are you sure you want to delete this Menu Board Category? All Products "
+"linked to this Category will be removed as well. This cannot be undone"
+msgstr ""
+
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:56
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:84
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:381
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:602
+msgid "Modules"
+msgstr ""
+
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:129
+msgid "Preview Enabled"
+msgstr ""
+
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:133
+msgid "Can this module be assigned to a Layout?"
+msgstr ""
+
+#: cache/f6/f67db01351a24defb15f5f729444e88a.php:135
+msgid "Assignable"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:56
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:95
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:407
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:662
+msgid "Tasks"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:74
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:57
+msgid "Add Task"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:131
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:203
+msgid "Next Run"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:135
+#: lib/Controller/Task.php:122
+msgid "Run Now"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:139
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:199
+msgid "Last Run"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:143
+msgid "Last Status"
+msgstr ""
+
+#: cache/f6/f67369334c712ba7604e6266344b3690.php:147
+msgid "Last Duration"
+msgstr ""
+
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:66
+#, no-php-format
+msgid "Manage Membership for %userName%"
+msgstr ""
+
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:130
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:241
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:130
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:130
+msgid ""
+"Check or un-check the options against each display group to control whether "
+"they are a member or not."
+msgstr ""
+
+#: cache/4f/4fc6954fa776574871642122b81b40d1.php:170
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:342
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:512
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:56
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:97 lib/Controller/User.php:381
+msgid "User Groups"
+msgstr ""
+
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:110
+msgid ""
+"Copying this Layout will create an exact copy of the last time this Layout "
+"was Published.\n"
+" Any changes made to this Layout while it has "
+"been a Draft will not be copied. Publish the Layout before making a copy if "
+"the Draft changes should be included in the copy."
+msgstr ""
+
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:133
+msgid "The Name for the copy (1 - 50 characters)"
+msgstr ""
+
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:151
+msgid "Make new copies of all media?"
+msgstr ""
+
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:157
+msgid ""
+"This will duplicate all media that is currently assigned to the item being "
+"copied."
+msgstr ""
+
+#: cache/90/90deed442dd1bc64934a205b289a6e94.php:190
+msgid "An optional description (1 - 250 characters)"
+msgstr ""
+
+#: cache/90/90862a7ca7a5cceaa1f138c598b6b16d.php:57
+msgid "Delete Help Link"
+msgstr ""
+
+#: cache/90/90862a7ca7a5cceaa1f138c598b6b16d.php:91
+msgid "Are you sure you want to delete this Help page? This cannot be undone"
+msgstr ""
+
+#: cache/dc/dcfc1ae248c38fe9cf4cfee76751c346.php:60
+#, no-php-format
+msgid "Display Group %name%"
+msgstr ""
+
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:78
+msgid "Enter your API Key from Alpha Advantage"
+msgstr ""
+
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:89
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:110
+msgid "Paid plan?"
+msgstr ""
+
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:95
+msgid ""
+"Is the above key on a paid plan? You may want to use a paid plan for real "
+"time FX rates."
+msgstr ""
+
+#: cache/5e/5ea08a0b22514b4984e150ddcb88e23e.php:112
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:133
+msgid ""
+"This module uses 3rd party data. Please enter the number of seconds you "
+"would like to cache results."
+msgstr ""
+
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:58
+msgid "Edit Synchronised Event"
+msgstr ""
+
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:159
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:151
+msgid "Duplicate form loaded, make adjustments and press save."
+msgstr ""
+
+#: cache/5e/5e3e64d490df71fb7da5bae89448099f.php:406
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:729
+msgid ""
+"Editing the Start and or End date/time will create a new Recurring Event "
+"across the Schedule. Any previously deleted instances of this event will be "
+"recreated with edits made here."
+msgstr ""
+
+#: cache/5e/5e69109d3dfa07b978086492a87054fe.php:52
+msgid "Configuration Problem"
+msgstr ""
+
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:56
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:97
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:194
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:285
+msgid "Menu Boards"
+msgstr ""
+
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:72
+msgid "Add a new Menu Board"
+msgstr ""
+
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:76
+#: cache/2b/2bd356bf92c86f8073b15baa5842446e.php:59
+msgid "Add Menu Board"
+msgstr ""
+
+#: cache/96/9686af52c389cf3bd611902f767c0835.php:233
+msgid "Permissions"
+msgstr ""
+
+#: cache/96/9633909657b383876962dc7af40f1d9a.php:57
+msgid "Delete Campaign"
+msgstr ""
+
+#: cache/96/9633909657b383876962dc7af40f1d9a.php:91
+msgid "Are you sure you want to delete this campaign? This cannot be undone"
+msgstr ""
+
+#: cache/0d/0d095ec1390538dc32e5ec788e7c4a04.php:57
+msgid "Delete Profile"
+msgstr ""
+
+#: cache/0d/0d095ec1390538dc32e5ec788e7c4a04.php:91
+msgid "Are you sure you want to delete this display profile?"
+msgstr ""
+
+#: cache/fb/fb7adbdacbb41d667f8ac87d10d7340e.php:57
+msgid "Delete Version"
+msgstr ""
+
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:59
+msgid "Edit Category "
+msgstr ""
+
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:104
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:108
+msgid "The Name for this Menu Board Category"
+msgstr ""
+
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:121
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:125
+msgid "The description for this Menu Board Category"
+msgstr ""
+
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:138
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:142
+msgid "The Code identifier for this Menu Board Category"
+msgstr ""
+
+#: cache/aa/aaa59f408ff31eb213c8235f0665a52a.php:155
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:159
+msgid ""
+"Optionally select Image or Video to be associated with this Menu Board "
+"Category"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:58
+msgid "Edit Notification"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:104
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:104
+msgid "Audience"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:108
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:108
+msgid "Attachment"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:126
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:126
+msgid "A subject line for the notification - used as a title."
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:137
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:137
+msgid "Release Date"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:143
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:143
+msgid "The date when this notification will be published"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:154
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:154
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:184
+msgid "Interrupt?"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:160
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:160
+msgid ""
+"Should the notification interrupt nagivation in the Web Portal? Including "
+"Login."
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:173
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:173
+msgid ""
+"Add the body of your message in the box below. If you are going to target "
+"this message to a Display/DisplayGroup be aware that the formatting you "
+"apply here will be removed."
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:196
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:196
+msgid ""
+"Please select one or more users / groups who will receive this notification."
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:227
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:227
+msgid "Non users"
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:233
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:233
+msgid "Additional emails separated by a comma."
+msgstr ""
+
+#: cache/7f/7f41cecee6701c55edc8ced9da911ff2.php:250
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:250
+msgid ""
+"Please select one or more displays / groups for this notification to be "
+"shown on - Layouts will need the notification widget."
+msgstr ""
+
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:59
+msgid "Add Category to "
+msgstr ""
+
+#: cache/6d/6dc471423786e616a29f282db9e7cdc1.php:165
+msgid "Select an Image or Video"
+msgstr ""
+
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:142
+msgid "Enable?"
+msgstr ""
+
+#: cache/66/66405e4aac11a5b87f3f8c2b5454d3cd.php:148
+msgid "Is the Resolution enabled for use?"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:57
+msgid "Add New User"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:82
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:116
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:152
+msgid "Create"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:89
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:204
+msgid "Credentials"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:123
+msgid ""
+"Select the role which most closely matches what you want this User to do, or "
+"select manual."
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:128
+msgid "What does this mean?"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:135
+msgid ""
+"The User account you are using has full access to the CMS and all of its "
+"features and configuration. If you are not adding an administrator type "
+"User, then it is likely you'll want to restrict and simplify what this new "
+"User can do.
You may even want to create a simplified User for your "
+"own usage to administer the system in a way tailored to your needs."
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:179
+msgid "Manually create a user"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:183
+msgid ""
+"Selecting this option will direct you to the Add User form where you can "
+"manually\n"
+" create user."
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:230
+msgid "The Login Name of the user."
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:292
+msgid ""
+"Select any folders the new user should have shared with them for viewing and "
+"editing."
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:297
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:52
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:83
+msgid "Select All"
+msgstr ""
+
+#: cache/29/299995a3e8725ec4161753ab2dc1fd2f.php:301
+msgid "Clear Selection"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:44
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:43
+msgid "Dashboard"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:81
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:126
+#: lib/Controller/Playlist.php:1729 lib/Controller/Library.php:2231
+#: lib/Controller/Layout.php:1793
+msgid "Design"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:116
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:174
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:56
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:97
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:150
+msgid "Templates"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:246
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:366
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:57
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:98
+msgid "Sync Groups"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:259
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:381
+msgid "Display Settings"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:272
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:396
+msgid "Player Versions"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:319
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:461
+msgid "Administration"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:433
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:722
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:237
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:56
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:69
+msgid "Folders"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:446
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:752
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:56
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:375
+msgid "Fonts"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:464
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:799
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:178
+msgid "Reporting"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:500
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:849
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:67
+#: cache/d9/d9efdfaf56965172b4a6e54931ee7d81.php:55
+msgid "Saved Reports"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:541
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:917
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:56
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:84
+msgid "Sessions"
+msgstr ""
+
+#: cache/29/297687b2c773bf74ed941cb3b052eed5.php:554
+#: cache/ee/ee7f02394a0676a9def91248a826e622.php:932
+msgid "Audit Trail"
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:56
+msgid "Products for "
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:60
+msgid "in Menu Board "
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:78
+msgid "Add a new Menu Board Product"
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:82
+msgid "Add Product"
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:88
+#: lib/Controller/MenuBoard.php:156
+msgid "View Categories"
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:109
+msgid "Products for"
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:113
+msgid "in Menu Board"
+msgstr ""
+
+#: cache/81/8195f35b971b17682a823bc3ff3f37a6.php:147
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:133
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:326
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:1380
+msgid "Use Regex?"
+msgstr ""
+
+#: cache/25/25d4074e9eb61f4cf38f7925754d7e66.php:57
+msgid "Run Task Now"
+msgstr ""
+
+#: cache/25/25d4074e9eb61f4cf38f7925754d7e66.php:71
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:199
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:384
+msgid "Run"
+msgstr ""
+
+#: cache/25/25d4074e9eb61f4cf38f7925754d7e66.php:91
+msgid "Are you sure you want to run this task immediately?"
+msgstr ""
+
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:98
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1300
+msgid "File Size"
+msgstr ""
+
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:104
+msgid "Date Created"
+msgstr ""
+
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:110
+msgid "Date Modified"
+msgstr ""
+
+#: cache/25/2557fc12fde015a9197b8dc46055f8b2.php:118
+msgid "Go back to Library page"
+msgstr ""
+
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:95
+msgid "Canvas global widget"
+msgstr ""
+
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:115
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:85
+#, no-php-format
+msgid "Sharing for %objectName%"
+msgstr ""
+
+#: cache/d7/d7ffaa4ff1f75c83a9d5b79d91250b59.php:228
+msgid "Change the Owner of this item. Leave empty to keep the current owner."
+msgstr ""
+
+#: cache/d7/d750736be0a77fbe10db020765a473f7.php:79
+msgid "Enter your API Key from Pixabay."
+msgstr ""
+
+#: cache/d7/d72fc98fb98b68f625501adbc327fe4a.php:110
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:56
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:95
+msgid "Notification Centre"
+msgstr ""
+
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:382
+msgid ""
+"A comma separated set of tags run against the Media tag to determine "
+"membership."
+msgstr ""
+
+#: cache/d7/d75c1798ab816e7e82b5357adc6aa971.php:478
+msgid "Filter options are not available on a Playlist which isn't dynamic."
+msgstr ""
+
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:57
+msgid "Add Media via URL"
+msgstr ""
+
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:129
+msgid "Please provide the remote URL to the file"
+msgstr ""
+
+#: cache/d7/d70821dffc5d148cfd6d2ddc1d04e6c1.php:146
+msgid "Optional Media name, if left empty it will default to the file name"
+msgstr ""
+
+#: cache/ee/eef84193bcdd079248f9945fb2d47f22.php:57
+msgid "Delete Daypart"
+msgstr ""
+
+#: cache/ee/eef84193bcdd079248f9945fb2d47f22.php:91
+msgid "Are you sure you want to delete this Daypart? This cannot be undone"
+msgstr ""
+
+#: cache/ee/eef84193bcdd079248f9945fb2d47f22.php:105
+#, no-php-format
+msgid "There are %countSchedules% scheduled events that will also be deleted."
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:59
+msgid "Edit Daypart"
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:113
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:113
+msgid "Exceptions"
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:131
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:131
+msgid "The Name for this Daypart"
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:148
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:148
+msgid "Retire? It will no longer be visible when scheduling"
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:165
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:165
+msgid "Enter the start time for this daypart"
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:182
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:182
+msgid ""
+"Enter the end time for this daypart. If the end time is before the start "
+"time, then the daypart will cross midnight."
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:193
+msgid ""
+"If this daypart is already in use, the events will be adjusted to use the "
+"new times provided. If used on a recurring event and that event has already "
+"recurred. The event will be split in two and the future event time adjusted."
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:211
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:201
+msgid "A Description of Daypart"
+msgstr ""
+
+#: cache/ee/eed40b13770b97167f375466867a779e.php:223
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:213
+msgid ""
+"If there are any exceptions enter them below by selecting the Day from the "
+"list and entering a start/end time."
+msgstr ""
+
+#: cache/2f/2f4b514d07e8843555073481f1971ef4.php:72
+msgid "Add a new Template and jump to the layout editor."
+msgstr ""
+
+#: cache/2f/2ff5e074540e4fba11b17571f8a8f63e.php:57
+msgid "Delete Menu Board Product"
+msgstr ""
+
+#: cache/2f/2ff5e074540e4fba11b17571f8a8f63e.php:91
+msgid ""
+"Are you sure you want to delete this Menu Board Product? This cannot be "
+"undone"
+msgstr ""
+
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:57
+#: lib/Controller/Display.php:1043 lib/Controller/Display.php:1058
+msgid "Request Screen Shot"
+msgstr ""
+
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:91
+msgid "Are you sure you want to request a screenshot?"
+msgstr ""
+
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:102
+msgid ""
+"If the Player is configured for push messaging, screenshots are requested "
+"immediately and should be seen when the form closed. In some circumstances "
+"it may be necessary to refresh the page after a few seconds."
+msgstr ""
+
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:113
+msgid ""
+"Screenshots can be seen in the Display Grid by selecting Column Visibility "
+"and enabling the Screenshot column."
+msgstr ""
+
+#: cache/65/65a54a5ecfda3e3e4edf37057b77b8aa.php:129
+#, no-php-format
+msgid ""
+"XMR is not working on this Player yet, the screenshot will be requested the "
+"next time the Player connects on its collection interval, expected "
+"%nextCollect%."
+msgstr ""
+
+#: cache/9e/9e9c1705d2dde7e9814a0296aaa18613.php:54
+#: cache/23/231c68db651d297f7de6b2814e242527.php:103
+#: cache/7b/7bd5c7627387d102ce14defdbb83a5fa.php:52
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:329
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:331
+#: cache/8d/8dda87605cc543099df98448afa47ab0.php:215
+msgid "About"
+msgstr ""
+
+#: cache/9e/9e9c1705d2dde7e9814a0296aaa18613.php:112
+#: cache/83/83bb1c5c0884298aa59fc23624c344db.php:87
+#: cache/f7/f71f3735d3d07f236e070cd99325d907.php:64
+msgid "Home"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:73
+msgid "Add a new Sync Group"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:77
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:57
+msgid "Add Sync Group"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:133
+msgid "Lead Display ID"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:209
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:138
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:133
+msgid "Publisher Port"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:213
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:155
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:150
+msgid "Switch Delay"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:217
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:172
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:167
+msgid "Video Pause Delay"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:221
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:184
+msgid "Lead Display"
+msgstr ""
+
+#: cache/97/9795b4c42f7ee274ddea49c1b1677d42.php:329
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:595
+msgid "Check to confirm deletion of the selected records."
+msgstr ""
+
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:56
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:97
+msgid "Display Setting Profiles"
+msgstr ""
+
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:72
+msgid "Add a new Display Settings Profile"
+msgstr ""
+
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:76
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:57
+msgid "Add Profile"
+msgstr ""
+
+#: cache/9c/9c637e0b7bfbfe2bb8d1d385cc051233.php:146
+#: lib/Controller/Display.php:244
+msgid "Default"
+msgstr ""
+
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:60
+#, no-php-format
+msgid "Copy %dataSetName%"
+msgstr ""
+
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:152
+msgid "Copy rows?"
+msgstr ""
+
+#: cache/e0/e0a22190d625c5d91d7845e33a68e89d.php:158
+msgid "Should we copy all the row data from the original dataSet?"
+msgstr ""
+
+#: cache/ad/ad6451318b1bb1af5d4ae026008254d7.php:60
+#: cache/1f/1fb3ca31aef0e3fe7b52bbfbb6a4af73.php:60
+#, no-php-format
+msgid "Layout %name%"
+msgstr ""
+
+#: cache/ad/ad6451318b1bb1af5d4ae026008254d7.php:100
+msgid ""
+"Enable the collection of Proof of Play statistics for the selected Layout."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:58
+msgid "Schedule Event"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:205
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:197
+msgid "Select the type of event to schedule"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:298
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:286
+msgid "Use Relative time?"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:304
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:292
+msgid ""
+"Switch between relative time inputs and Date pickers for start and end time."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:315
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:303
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:171
+#: lib/Report/TimeDisconnectedSummary.php:273
+msgid "Hours"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:321
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:309
+msgid "Hours this event should be scheduled for"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:332
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:320
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:165
+#: lib/Report/TimeDisconnectedSummary.php:276
+msgid "Minutes"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:338
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:326
+msgid "Minutes this event should be scheduled for"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:349
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:337
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:159
+msgid "Seconds"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:355
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:343
+msgid "Seconds this event should be scheduled for"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:366
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:354
+msgid ""
+"Your event will be scheduled from [fromDt] to [toDt] in each of your "
+"selected Displays respective timezones"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:372
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:360
+msgid ""
+"Your event will be scheduled from [fromDt] to [toDt] in the CMS timezone, "
+"please check this covers each of your Displays in their respective timezones."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:419
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:431
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:407
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:419
+msgid "Please select a Layout for this Event to show"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:419
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:407
+msgid "Please select a Campaign for this Event to show"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:448
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:307
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:436
+msgid ""
+"Select a Media file from the Library to use. The selected file will be shown "
+"full screen for this event."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:465
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:317
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:453
+msgid ""
+"Select a Playlist to use. The selected playlist will be shown full screen "
+"for this event."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:489
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:478
+msgid "Preview"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:493
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:482
+msgid "Preview your selection in a new tab"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:508
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:500
+msgid "The amount of time this Layout should be shown, in seconds per hour."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:524
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:516
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:749
+msgid "Action Type"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:530
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:522
+msgid "Please select action Type"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:536
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:528
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:221
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:733
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1459
+msgid "Navigate to Layout"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:564
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:556
+msgid "Web hook trigger code for this Action"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:575
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:567
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:229
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:777
+msgid "Layout Code"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:581
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:573
+msgid ""
+"Please select the Code identifier for the Layout that Player should navigate "
+"to when this Action is triggered."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:603
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:596
+msgid "Please select a command for this Event."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:623
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:617
+msgid ""
+"Please select the real time DataSet related to this Data Connector event"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:639
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:634
+msgid "Data Connector Parameters"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:645
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:640
+msgid "Optionally provide any parameters to be used by the Data Connector."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:970
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:990
+msgid ""
+"Set criteria to determine when this event is active. All conditions must be "
+"true for an event to be included in the schedule loop. Events without "
+"criteria are always active."
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:986
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:1006
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:241
+msgid "Metric"
+msgstr ""
+
+#: cache/8c/8ca51f739500f2043df1546fd7a7b9f9.php:990
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:1010
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:683
+msgid "Condition"
+msgstr ""
+
+#: cache/8c/8ce3919b4df5d2f1c207d2919ac15a97.php:63
+#, no-php-format
+msgid "%themeName% Error"
+msgstr ""
+
+#: cache/8c/8ce3919b4df5d2f1c207d2919ac15a97.php:72
+msgid ""
+"We are really sorry, but there has been an error. It has been logged in "
+"install_log.txt and printed below."
+msgstr ""
+
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:56
+msgid "Categories for "
+msgstr ""
+
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:74
+msgid "Add a new Menu Board Category"
+msgstr ""
+
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:78
+msgid "Add Category"
+msgstr ""
+
+#: cache/fc/fcd3f0c64ac400f3ff951af6ea335320.php:99
+msgid "Menu Board Categories for"
+msgstr ""
+
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:57
+msgid "Add Display via Code"
+msgstr ""
+
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:91
+msgid ""
+"After submitting this form with valid code, your CMS Address and Key will be "
+"sent and stored in the temporary storage in our Authentication Service."
+msgstr ""
+
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:98
+msgid ""
+"The Player linked to the submitted code, will make regular calls to our "
+"Authentication Service to retrive the CMS details and configure itself with "
+"them.\n"
+" Your details are removed from the temporary storage "
+"once the Player is configured"
+msgstr ""
+
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:106
+msgid ""
+"Please note that your CMS needs to make a successful call to our "
+"Authentication Service for this feature to work."
+msgstr ""
+
+#: cache/e3/e35a2713d2aa5bfcd91e09e3f6777d1d.php:122
+msgid "Please provide the code displayed on the Player screen"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:190
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:233
+msgid "Add DMA"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:227
+msgid "Edit DMA"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:418
+msgid "Are you sure you want to delete this DMA?"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:447
+msgid "DMA deleted"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:476
+msgid "Date/Time"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:504
+msgid "The Name of this DMA - (1 - 50 characters)"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:521
+msgid "Set a priority for this DMA. Higher priorities take precedence."
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:538
+msgid "Which displays would you like this DMA to apply to?"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:584
+msgid "What is the source of this impression figure?"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:616
+msgid "Select the start date for this DMA"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:633
+msgid "Select the end date for this DMA"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:650
+msgid "Which days of the week should the DMA be active?"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:670
+msgid "Select the start time for this DMA"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:687
+msgid "Select the end time for this DMA"
+msgstr ""
+
+#: cache/44/449ff28e66efe45f3399d4a220fceb19.php:703
+msgid "Draw areas on the map where you want this DMA to be active."
+msgstr ""
+
+#: cache/74/74834652bf0eeb1d711de01b8086ce3b.php:57
+msgid "Delete this DataSet?"
+msgstr ""
+
+#: cache/74/74834652bf0eeb1d711de01b8086ce3b.php:102
+msgid "Delete any associated data?"
+msgstr ""
+
+#: cache/74/74834652bf0eeb1d711de01b8086ce3b.php:108
+msgid ""
+"Please tick the box if you would like to delete all the Data contained in "
+"this DataSet"
+msgstr ""
+
+#: cache/1b/1bb1add0325cf87ffe8364fea056a443.php:57
+msgid "Wake On Lan"
+msgstr ""
+
+#: cache/1b/1bb1add0325cf87ffe8364fea056a443.php:91
+msgid "Are you sure you want to send a Wake On Lan message to this display?"
+msgstr ""
+
+#: cache/23/23525fb78ad0912d772847c7fa4abf9c.php:57
+msgid "Delete Font"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:60
+#: cache/23/231c68db651d297f7de6b2814e242527.php:62
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:57
+msgid "Preferences"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:72
+#: cache/23/231c68db651d297f7de6b2814e242527.php:74
+#: cache/0b/0bb72264cd242f2afd564859f6bf11f9.php:59
+msgid "Edit Profile"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:84
+msgid "View my authenticated applications"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:86
+msgid "My Applications"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:94
+msgid "Reshow welcome"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:101
+msgid "About the CMS"
+msgstr ""
+
+#: cache/23/231c68db651d297f7de6b2814e242527.php:113
+#: cache/23/231c68db651d297f7de6b2814e242527.php:117
+#: lib/Controller/Sessions.php:119
+msgid "Logout"
+msgstr ""
+
+#: cache/2a/2a7041a7aaa1920fa6e115acab2baed8.php:57
+msgid "Clear DataSet cache"
+msgstr ""
+
+#: cache/2a/2a7041a7aaa1920fa6e115acab2baed8.php:91
+msgid "Should the cache for this remote DataSet be cleared?"
+msgstr ""
+
+#: cache/34/3439fa3af454e93f2ed9d709b898b56f.php:62
+#, no-php-format
+msgid "Delete %tag%"
+msgstr ""
+
+#: cache/34/3439fa3af454e93f2ed9d709b898b56f.php:96
+msgid "Are you sure you want to delete this tag? This cannot be undone"
+msgstr ""
+
+#: cache/34/340ecc3756674aa339156a3d6b49cc46.php:55
+msgid "Widgets that have been Published will be displayed below"
+msgstr ""
+
+#: cache/34/340ecc3756674aa339156a3d6b49cc46.php:118
+msgid ""
+"This Playlist has more than 10 widgets. The first ten widgets are shown as "
+"they appear"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:58
+msgid "Edit Application"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:116
+#: cache/63/63b15102c208fae253c32c2f77313cc2.php:95
+msgid "Application Name"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:127
+msgid "Client Id"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:138
+msgid "Client Secret"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:155
+msgid "Reset Secret?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:161
+msgid ""
+"Reset your client secret to prevent access from any existing application."
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:172
+msgid ""
+"Selecting only one of the Authorisation Code or Client Credentials grants "
+"improves security by allowing us to revoke access tokens more effectively."
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:183
+msgid "Authorization Code?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:189
+msgid "Allow the Authorization Code Grant for this Client?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:200
+msgid "Client Credentials?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:206
+msgid "Allow the Client Credentials Grant for this Client?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:217
+msgid "Is Confidential?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:223
+msgid "Can this Application keep a secret?"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:234
+msgid "New Redirect URI"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:240
+msgid ""
+"White listed redirect URI's that will be allowed, only application for "
+"Authorization Code Grants"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:251
+msgid "Existing Redirect URI"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:275
+msgid ""
+"Below information will be displayed for User on Application authorization "
+"page"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:286
+msgid "Application Description"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:297
+msgid "Logo"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:303
+msgid "Url pointing to the logo"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:314
+msgid "Cover Image"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:320
+msgid "Url pointing to the Cover Image"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:331
+msgid "Company Name"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:342
+msgid "Terms URL"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:348
+msgid "Url pointing to the terms for this Application"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:359
+msgid "Privacy Url"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:365
+msgid "Url pointing to the privacy policy for this Application"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:377
+msgid "Select sharing to grant to this application (scopes)."
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:387
+msgid ""
+"Scopes grant the Application access to specific routes, all GET,POST and PUT "
+"calls for the selected scopes, will be available to use by this Application."
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:397
+msgid ""
+"The delete scopes are separate, without these Application will not have "
+"access to delete any existing content"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:408
+msgid "Scopes"
+msgstr ""
+
+#: cache/34/34a04f8db9b05cf9adcd43c5f51af74c.php:450
+msgid ""
+"Set the owner of this Application. Leave empty to keep the current owner. If "
+"you are not an admin you will not be able to reverse this action"
+msgstr ""
+
+#: cache/76/76c39326d719a8082b68eae5f3612d78.php:95
+msgid "Continue..."
+msgstr ""
+
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:116
+msgid "Please enter the WebSocket address for XMR."
+msgstr ""
+
+#: cache/d6/d67f7c1643c930e6e9416cca58dbca0c.php:127
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:936
+msgid "Please enter the public address for XMR."
+msgstr ""
+
+#: cache/d6/d65647dc03c6485df9c4d3c7cebd6556.php:63
+#, no-php-format
+msgid "Edit Campaign \"%campaignName%\""
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:48
+msgid "With Selected"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:56
+msgid "Multiple Items Selected"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:60
+msgid "Indeterminate State"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:64
+msgid "Sorry, no items have been selected."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:68
+msgid ""
+"Caution, you have selected %1 items. Clicking save will run the %2 "
+"transaction on all these items."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:72
+#, php-format
+msgid "Valid extensions are %s"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:88
+msgid "Success"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:92
+msgid "Failure"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:96
+msgid "Enter text..."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:104
+msgid "No Data returned from the source"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:108
+msgid "Status Pending"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:112
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:461
+msgid "Duplicate"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:116
+msgid "Delete from Schedule"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:120
+msgid "Warning - starts with or ends with a space, or contains double spaces"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:128
+msgid "Free Text"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:132
+msgid "Edit Tags"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:136
+msgid ""
+"Provide an optional Value for this Tag. If no Value is required, this field "
+"can be left blank"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:144
+msgid ""
+"Before Uploading, scroll through the progress bar or play and pause to "
+"select a still to be used as the video file thumbnail."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:148
+#, php-format
+msgid "You can only upload a maximum of %s files."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:156
+msgid "Rename"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:164
+#: lib/Controller/Template.php:303 lib/Controller/Template.php:316
+#: lib/Controller/DisplayGroup.php:433 lib/Controller/DisplayGroup.php:446
+#: lib/Controller/DataSet.php:350 lib/Controller/DataSet.php:356
+#: lib/Controller/Campaign.php:423 lib/Controller/Campaign.php:429
+#: lib/Controller/MenuBoard.php:194 lib/Controller/MenuBoard.php:202
+#: lib/Controller/Playlist.php:492 lib/Controller/Playlist.php:504
+#: lib/Controller/Library.php:740 lib/Controller/Library.php:746
+#: lib/Controller/Layout.php:2028 lib/Controller/Layout.php:2034
+#: lib/Controller/Display.php:1150 lib/Controller/Display.php:1163
+#: lib/Controller/Developer.php:132 lib/Controller/Developer.php:145
+#: lib/Controller/Command.php:213 lib/Controller/Command.php:226
+#: lib/Controller/DayPart.php:186 lib/Controller/DayPart.php:192
+msgid "Share"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:168
+msgid "Set as Home"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:172
+msgid "Cannot modify root folder."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:176
+#: lib/Controller/Folder.php:433
+msgid "Cannot remove Folder with content"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:180
+msgid "New Folder"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:184
+msgid "Move Folder"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:188
+msgid "Right click a Folder for further Options"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:192
+msgid "You already set an exception for this day."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:196
+msgid "Online"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:200
+msgid "Offline"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:208
+msgid "Not up to date"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:212
+msgid "Publishing"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:216
+msgid "Publish failed."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:220
+msgid "Default Sorting"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:224
+msgid "Unlimited"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:228
+msgid ""
+"Warning : Image is too large and will not be displayed on the Players. "
+"Please check the allowed Resize Limit in Administration -> Settings"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:236
+msgid ""
+"Enter the code associated with the web hook you wish to trigger. Please note "
+"that for this action to work, the webhook trigger code has to be added to "
+"Interactive Actions in scheduled content for this Player."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:244
+#: cache/4a/4ab9918a9e09352e2b177d64bcd99b84.php:97
+msgid ""
+"Pick a command to send to the Player. If the CMS has XMR enabled this will "
+"be sent immediately, otherwise it will show an error."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:254
+#: lib/Event/ScheduleCriteriaRequestEvent.php:44
+msgid "Is set"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:258
+#: lib/Event/ScheduleCriteriaRequestEvent.php:45
+msgid "Less than"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:262
+#: lib/Event/ScheduleCriteriaRequestEvent.php:46
+msgid "Less than or equal to"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:266
+#: lib/Event/ScheduleCriteriaRequestEvent.php:47
+#: lib/Connector/CapConnector.php:536 lib/Connector/CapConnector.php:545
+#: lib/Connector/NationalWeatherServiceConnector.php:403
+#: lib/Connector/NationalWeatherServiceConnector.php:412
+msgid "Equal to"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:270
+#: lib/Event/ScheduleCriteriaRequestEvent.php:48
+msgid "Not equal to"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:274
+#: lib/Event/ScheduleCriteriaRequestEvent.php:49
+msgid "Greater than or equal to"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:278
+#: lib/Event/ScheduleCriteriaRequestEvent.php:50
+msgid "Greater than"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:282
+#: lib/Event/ScheduleCriteriaRequestEvent.php:51
+msgid "Contains"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:286
+#: lib/Event/ScheduleCriteriaRequestEvent.php:52
+msgid "Not contains"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:293
+msgid "Select Media"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:297
+msgid "Select Playlist"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:327
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:331
+msgid ""
+"Optionally select a Resolution to use for the selected Media. Leave blank to "
+"match with an existing Resolution closest in size to the selected media."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:335
+msgid ""
+"Optionally select a Resolution to use for the selected Playlist. Leave blank "
+"to default to a 1080p Resolution."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:341
+msgid "Duration in loop"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:345
+msgid ""
+"Set how long this item should be shown each time it appears in the schedule. "
+"Leave blank to use the Media Duration set in the Library."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:355
+msgid ""
+"Optionally set a colour to use as a background for if the item selected does "
+"not fill the entire screen."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:367
+msgid "Minute"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:389
+msgid "Before schedule starts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:393
+msgid "After schedule starts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:397
+msgid "Before schedule ends"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:401
+msgid "After schedule ends"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:406
+msgid "Notify by email?"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:412
+msgid "Lead"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:416
+msgid "Mirror"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:420
+msgid "Set The same Layout on all displays?"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:438
+msgid "Cycle Playback?"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:446
+msgid "Layout Name"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:462
+msgid "Layout Duration"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:478
+msgid "Visible"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:486
+msgid "Overlay Layouts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:490
+msgid "Interrupt Layouts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:494
+msgid "Campaign Layouts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:498
+msgid "Full Screen Video/Image"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:502
+msgid "Full Screen Playlist"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:510
+msgid "Number of Layouts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:514
+msgid ""
+"This layout will not be shown as there are higher priority layouts scheduled "
+"at this time"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:522
+msgid "Display not selected!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:526
+msgid ""
+"Show All option does not work for this filter, one or more specific Display/"
+"Display Group need to be selected!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:530
+msgid "No events for the chosen Display/Display Group on the selected date!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:534
+msgid "Data request failed!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:542
+msgid "Number of events"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:549
+msgid "Show command preview!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:553
+msgid "Invalid command!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:561
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:713
+msgid "Red"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:565
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:719
+msgid "Green"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:569
+msgid "Blue"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:573
+msgid "White"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:581
+msgid "Key"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:593
+msgid "Intent"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:597
+msgid "Extra"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:601
+msgid "Device Name/COM"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:605
+msgid "Baud Rate"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:609
+msgid "Data Bits"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:613
+msgid "Parity"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:617
+msgid "Stop Bits"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:621
+msgid "Handshake"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:625
+msgid "HexSupport"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:637
+msgid "Query params builder"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:641
+msgid "Query builder"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:645
+msgid "Query params"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:649
+msgid "Request method"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:653
+msgid "Show raw headers"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:657
+msgid "Headers"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:665
+msgid "Show raw body data"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:669
+msgid "Show raw data"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:673
+msgid "Body data"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:679
+msgid "New tags"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:683
+msgid "A comma separated list of tags to add to the selected elements."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:691
+msgid ""
+"Provide an optional Value for this Tag. If no Value is required, this field "
+"can be left blank."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:699
+msgid "Existing tags"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:703
+msgid ""
+"Remove tags from the list to remove them from elements that contain them."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:708
+msgid "Clear Filters"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:731
+msgid "Automatically submit this form?"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:735
+msgid ""
+"When enabled, this form will automatically submit in future. Reset this in "
+"your User Profile."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:741
+msgid "Play Preview"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:745
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1530
+msgid "Close Preview"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:749
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1534
+msgid "Change window size"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:753
+msgid "Preview in new window"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:763
+msgid "Guides"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:767
+msgid "Resources"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:771
+msgid "Visit the user manual"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:775
+msgid "Introduction to "
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:779
+msgid "Get to know your CMS"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:783
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:807
+msgid "Feedback"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:787
+msgid "Help us improve"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:795
+msgid "Connect and Grow"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:803
+msgid "Access guides and tutorials"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:811
+msgid "Thank you for leaving your feedback!"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:816
+msgid ""
+"The feedback you provide, along with your name and email address are used by "
+"Xibo to improve our products and services."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:820
+msgid "We’d love to hear your thoughts"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:832
+msgid "Comments and recommendations"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:836
+msgid "Attachments (optional)"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:840
+msgid "Drag and drop files or"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:844
+msgid "Browse files"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:848
+msgid "File types: .jpg, .png, .mov and .pdf"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:852
+msgid "Max size: 15MB"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:856
+msgid "Send feedback"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:860
+msgid "Maximum file upload of 3 files."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:864
+msgid "Remove one to add more."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:880
+msgid "Reporting an issue?"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:885
+msgid "Please provide a valid name"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:889
+msgid "Please provide a valid email"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:893
+msgid "Please provide any comment or recommendation"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:897
+msgid "Please fill all required fields"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:901
+msgid "Something went wrong. Please try again"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:905
+msgid "Maximum file number exceeded"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:909
+msgid "Invalid file type"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:913
+#: lib/Factory/MediaFactory.php:279
+msgid "File too large"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:923
+msgid "No data available in table"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:927
+msgid "Showing _START_ to _END_ of _TOTAL_ entries"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:931
+msgid "Showing 0 to 0 of 0 entries"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:935
+msgid "(filtered from _MAX_ total entries)"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:941
+msgid "Show _MENU_ entries"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:945
+msgid "Loading..."
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:953
+msgid "Search:"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:957
+msgid "No matching records found"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:962
+msgid "First"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:966
+msgid "Last"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:980
+msgid ": activate to sort column ascending"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:984
+msgid ": activate to sort column descending"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:990
+msgid "Column visibility"
+msgstr ""
+
+#: cache/99/9946a81b9fd1d99c9329b1695ebb0032.php:994
+msgid "Print"
+msgstr ""
+
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:58
+msgid "Edit Event"
+msgstr ""
+
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:815
+msgid ""
+"Use the drop-down to select which days of the week this Event should be "
+"repeated"
+msgstr ""
+
+#: cache/99/99685cb02a46ebebbfc067f158641951.php:878
+msgid ""
+"Should this Event Repeat by Day of the month (eg. Monthly on Day 21) or by a "
+"Weekday in the month (eg. Monthly on the third Thursday)"
+msgstr ""
+
+#: cache/38/38337c8b2011d0cb441c15e5f699421a.php:60
+#, no-php-format
+msgid "Remove %layout%"
+msgstr ""
+
+#: cache/38/38337c8b2011d0cb441c15e5f699421a.php:100
+msgid "Are you sure you want remove this layout from the campaign?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:106
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:343
+msgid "Defaults"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:130
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3026
+msgid "Regional"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:164
+msgid "The fully qualified path to the CMS library location."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:195
+msgid "CMS Secret Key"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:201
+msgid ""
+"This key must be entered into each Player to authenticate the Player with "
+"the CMS."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:241
+msgid "CMS Theme"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:247
+msgid "The Theme to apply to all pages by default"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:292
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:94
+msgid "Navigation Menu"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:298
+msgid ""
+"Select where the Navigation Menu should be positioned by default. Users can "
+"set an alternate view in their Preferences under their User Profile."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:306
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:114
+msgid "Horizontal along the top"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:312
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:120
+msgid "Vertically on the left"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:352
+msgid "Default update media in all layouts"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:358
+msgid ""
+"Default the checkbox for updating media on all layouts when editing in the "
+"library"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:377
+msgid "Default copy media when copying a layout?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:383
+msgid ""
+"Default the checkbox for making duplicates of media when copying layouts"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:402
+msgid ""
+"Default for \"Delete old version of Media\" checkbox. Shown when Editing "
+"Library Media."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:408
+msgid ""
+"Default the checkbox for Deleting Old Version of media when a new file is "
+"being uploaded to the library."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:427
+msgid "Should Layouts be automatically Published?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:433
+msgid ""
+"When enabled draft Layouts will be automatically published 30 minutes after "
+"the last edit"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:452
+msgid "Default Transition In"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:458
+msgid "Default Transition In that should be applied to widgets"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:492
+msgid "Default Transition Out"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:498
+msgid "Default Transition Out that should be applied to widgets"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:532
+msgid "Default Transition duration"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:538
+msgid "Default duration for in and out transitions"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:569
+msgid ""
+"Default value for \"Automatically apply Transitions?.\" checkbox on Layout "
+"add form"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:588
+msgid "Resize Threshold"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:594
+msgid ""
+"The maximum dimensions to be considered when an image is resized, based on "
+"the longest side"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:625
+msgid "Resize Limit"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:631
+msgid ""
+"Images that exceed the resize limit, based on the longest side, will not be "
+"processed"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:662
+msgid "DataSet maximum number of Rows"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:668
+msgid ""
+"The maximum number of rows per DataSet, once the limit is met the limit "
+"policy defined per DataSet will dictate further action."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:699
+msgid "Default ttl, in days, for records in purge_list table"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:705
+msgid ""
+"Entries in purge_list table with expiry date older than specified ttl will "
+"be removed."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:736
+msgid "The default value for max number of items on a new dynamic Playlist"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:742
+msgid ""
+"This value can be adjusted on a per dynamic Playlist basis, it cannot exceed "
+"value set in the Limit below"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:773
+msgid ""
+"The default upper limit of items that can be assigned to a dynamic Playlist"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:779
+msgid ""
+"When matching Media by Tags and name to a dynamic Playlist, this is the "
+"maximum number of allowed items that can be assigned to a dynamic Playlist"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:822
+msgid ""
+"The default layout to assign for new displays and displays which have their "
+"current default deleted."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:856
+msgid "XMR Private Address"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:862
+msgid "Please enter the private address for XMR."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:899
+msgid ""
+"Please enter the WebSocket address for XMR. Leaving this empty will mean the "
+"Player app connects to /xmr"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:967
+msgid "Default Latitude"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:973
+msgid "The Latitude to apply for any Geo aware Previews"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1004
+msgid "Default Longitude"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1010
+msgid "The longitude to apply for any Geo aware Previews"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1041
+msgid "Add a link to the Display name using this format mask?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1047
+#, php-format
+msgid ""
+"Turn the display name in display management into a link using the IP address "
+"last collected. The %s is replaced with the IP address. Leave blank to "
+"disable."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1078
+msgid "The target attribute for the above link"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1084
+msgid ""
+"If the display name is shown as a link in display management, what target "
+"should the link have? Set _top to open the link in the same window or _blank "
+"to open in a new window."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1115
+msgid "Number of display slots"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1121
+msgid ""
+"The maximum number of licensed Players for this server installation. 0 = "
+"unlimited"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1158
+msgid ""
+"Set the Default setting to use for the level of collection for Proof of Play "
+"Statistics to be applied to Layouts / Media and Widget items."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1219
+msgid ""
+"Set the Default Settings for Proof of Play statistics to apply to all "
+"Displays. This can be toggled off by using Display Profiles."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1236
+msgid "Enable Layout Stats Collection?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1242
+msgid ""
+"Select the Default setting to use for the collection of Proof of Play "
+"statistics for all Layout Items."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1265
+msgid ""
+"Select the Default setting to use for the collection of Proof of Play "
+"statistics for all Media Items."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1326
+msgid ""
+"Select the Default setting to use for the collection of Proof of Play "
+"statistics for all Playlists."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1381
+msgid "Enable Widget Stats Collection?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1387
+msgid ""
+"Select the Default setting to use for the collection for Proof of Play "
+"statistics for all Widgets."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1442
+msgid "Enable the option to report the current layout status?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1459
+msgid "Lock the Display Name to the device name provided by the Player?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1476
+msgid "Enable the option to set the screenshot interval?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1493
+msgid "Display Screenshot Default Size"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1499
+msgid "The default size in pixels for the Display Screenshots"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1530
+msgid "Display screenshot Time to keep (days)"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1536
+msgid ""
+"Display screenshots older than the TTL will be automatically removed. Set to "
+"0 to never remove old screenshots."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1567
+msgid "Automatically authorise new Displays?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1573
+msgid ""
+"If checked all new Displays registering with the CMS using the correct CMS "
+"key will automatically be set to authorised and display the Default Layout."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1590
+msgid "Default Folder for new Displays"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1596
+msgid ""
+"Set default folder for new Displays, by default the Root folder will be used"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1637
+msgid "Location of the Manual"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1643
+msgid ""
+"The address of the user manual, which will be used as a prefix for all help "
+"links."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1674
+msgid "Quick Chart URL"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1680
+msgid ""
+"Enter the URL to a Quick Chart service. This is used to draw charts in "
+"emailed reports and for showing a QR code during two factor authentication."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1711
+msgid "Should the CMS send anonymous statistics to help improve the software?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1717
+msgid ""
+"When this is enabled the CMS will periodically send usage information to the "
+"software authors so that improvements can be made to the product."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1736
+msgid "Phone home key"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1742
+msgid ""
+"Key used to distinguish each CMS instance. This is generated randomly based "
+"on the time you first installed the CMS, and is completely untraceable."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1773
+msgid "Phone home time"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1779
+msgid "The last time we PHONED_HOME in seconds since the epoch"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1810
+msgid "Send Schedule in advance?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1816
+msgid "Should the CMS send future schedule information to Players?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1835
+msgid "Send files in advance?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1841
+msgid ""
+"How many seconds in to the future should the calls to RequiredFiles look?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1872
+msgid "Allow Import?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1891
+msgid "Enable Library Tidy?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1910
+msgid "Status Dashboard Widget"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1916
+msgid "HTML to embed in an iframe on the Status Dashboard"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1947
+msgid "Defaults Imported?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1953
+msgid "Has the default layout been imported?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1972
+msgid "Enable Latest News?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1978
+msgid ""
+"Should the Dashboard show latest news? The address is provided by the theme."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:1997
+msgid "Instance Suspended"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2003
+msgid "Is this instance suspended?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2022
+msgid "Latest News URL"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2028
+msgid "RSS/Atom Feed to be displayed on the Status Dashboard"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2059
+msgid "Show the Logo on report exports?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2065
+msgid ""
+"When exporting a saved report to PDF, should the logo be shown on the PDF?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2090
+msgid "Enable Maintenance?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2096
+msgid "Allow the maintenance script to run if it is called?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2116
+msgid "Protected"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2151
+msgid "Enable Email Alerts?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2157
+msgid "Global switch for email alerts to be sent"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2176
+msgid "Max Log Age"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2182
+msgid ""
+"Maximum age for log entries in days. Set to 0 to keep logs indefinitely."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2213
+msgid "Max Statistics Age"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2219
+msgid ""
+"Maximum age for statistics entries in days. Entries older than this will not "
+"be processed and existing entries will be removed. Set to 0 to keep "
+"statistics indefinitely."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2250
+msgid "Max Display Timeout"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2256
+msgid ""
+"How long in minutes after the last time a Player connects should we send an "
+"alert? Can be overridden on a per Player basis."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2287
+msgid "Send repeat Display Timeouts"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2293
+msgid ""
+"Should the CMS send an email if a display is in an error state every time "
+"maintenance runs?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2319
+msgid "Admin email address"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2325
+msgid ""
+"This is the overall CMS adminstrator who will receive copies of all email "
+"notifications generated by the CMS."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2356
+msgid "Sending email address"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2362
+msgid "Mail will be sent from this address"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2393
+msgid "Sending email name"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2399
+msgid "Mail will be sent under this name"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2430
+msgid "File download mode"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2436
+msgid ""
+"Should the CMS use Apache X-Sendfile, Nginx X-Accel, or PHP (Off) to return "
+"the files from the library?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2477
+msgid "Proxy URL"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2483
+msgid "The Proxy URL"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2514
+msgid "Proxy Port"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2520
+msgid "The Proxy Port"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2551
+msgid "Proxy Credentials"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2557
+msgid "The Authentication information for this proxy. username:password"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2588
+msgid "Proxy Exceptions"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2594
+msgid ""
+"Hosts and Keywords that should not be loaded via the Proxy Specified. These "
+"should be comma separated."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2625
+msgid "CDN Address"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2631
+msgid "Content Delivery Network Address for serving file requests to Players"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2662
+msgid "Monthly bandwidth Limit"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2668
+msgid "XMDS Transfer Limit in KB/month"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2699
+msgid "Library Size Limit"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2705
+msgid "The Limit for the Library Size in KB"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2742
+msgid "Force the portal into HTTPS?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2761
+msgid "Enable STS?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2767
+msgid ""
+"Add STS to the response headers? Make sure you fully understand STS before "
+"turning it on as it will prevent access via HTTP after the first successful "
+"HTTPS connection."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2786
+msgid "STS Time out"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2792
+msgid "The Time to Live (maxage) of the STS header expressed in seconds."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2823
+msgid "Whitelist Load Balancers"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2829
+msgid ""
+"If the CMS is behind a load balancer, what are the load balancer IP "
+"addresses, comma delimited."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2866
+msgid "Widget colouring in Playlist editor"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2874
+msgid "Media Colouring"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2880
+msgid "Sharing Colouring"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2914
+msgid "Schedule with view sharing?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2920
+msgid ""
+"Should users with View sharing on displays be allowed to schedule to them?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2937
+msgid "Show event Layout regardless of User permission?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2943
+msgid ""
+"If checked then the Schedule will show the Layout for existing events even "
+"if the logged in User does not have permission to see that Layout."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2960
+msgid "Lock Task Config"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2966
+msgid "Is the task config locked? Useful for Service providers."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2983
+msgid "Is the Transition config locked?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:2989
+msgid "Allow modifications to the transition configuration?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3006
+msgid "Allow saving in the root folder?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3012
+msgid ""
+"Users can use the top level folder to store content. Disable to force the "
+"use of folders."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3035
+msgid "Default Language"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3041
+msgid "The default language to use"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3078
+msgid "Set the default timezone for the application"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3115
+msgid "The Date Format to use when displaying dates in the CMS."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3154
+msgid "Detect language?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3160
+msgid "Detect the browser language?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3177
+msgid "Calendar Type"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3183
+msgid "Which Calendar Type should the CMS use?"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3191
+msgid "Gregorian"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3197
+msgid "Jalali"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3237
+msgid "Resting Log Level"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3243
+msgid ""
+"Set the level of the resting log level. The CMS will revert to this log "
+"level after an elevated period ends. In production systems \"error\" is "
+"recommended."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3311
+msgid ""
+"Set the level of logging the CMS should record. In production systems "
+"\"error\" is recommended."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3343
+msgid "Warning"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3349
+msgid "Notice"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3355
+msgid "Information"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3361
+msgid "Debug"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3401
+msgid "Elevate Log Until"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3407
+msgid "Elevate the log level until this date."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3438
+msgid "Server Mode"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3444
+msgid ""
+"This should only be set if you want to display the maximum allowed error "
+"messaging through the user interface. Useful for capturing critical "
+"php errors and environment issues."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3452
+msgid "Production"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3498
+msgid "System User"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3504
+msgid "The system User for this CMS"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3538
+msgid "Default User Group"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3544
+msgid "The default User Group for new Users"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3578
+msgid "Default User Type"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3584
+msgid ""
+"Sets the default user type selected when creating a user. We recommend that "
+"this is set to User"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3598
+msgid "Group Admin"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3604
+msgid "Super Admin"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3639
+msgid "Password Policy Regular Expression"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3645
+msgid "Regular Expression for password complexity, leave blank for no policy."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3676
+msgid "Description of Password Policy"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3682
+msgid ""
+"A text description of this password policy will be shown to users if they "
+"enter a password that does not meet the policy requirements set above."
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3713
+msgid "Password Reminder"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3719
+msgid ""
+"Enable password reminder on CMS login page? Valid sending email address is "
+"required"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3733
+msgid "On except Admin"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3774
+msgid "Two Factor Issuer"
+msgstr ""
+
+#: cache/51/512a5a99da9d6137ff075d9f5fc3e17f.php:3780
+msgid ""
+"Name that should appear as Issuer when two factor authorisation is enabled"
+msgstr ""
+
+#: cache/c0/c0be2434c592f085d1c7d2a30346ab1f.php:57
+msgid "Delete Menu Board"
+msgstr ""
+
+#: cache/c0/c0be2434c592f085d1c7d2a30346ab1f.php:91
+msgid ""
+"Are you sure you want to delete this Menu Board? This action will remove all "
+"Categories and their Products linked to this Menu Board. This cannot be "
+"undone"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:60
+#, no-php-format
+msgid "%templateName% - Module Template"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:112
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:269
+msgid "Properties"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:120
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:318
+msgid "Twig"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:128
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:336
+msgid "HBS"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:144
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:372
+msgid "Head"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:152
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:390
+msgid "onTemplateRender"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:160
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:408
+msgid "onTemplateVisible"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:256
+msgid "Is this template enabled?"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:279
+msgid "Go to start"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:285
+msgid "Add new property"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:294
+msgid "Go to end"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:425
+msgid "Invalidate any widgets using this template"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:455
+msgid "Move"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:474
+msgid "No properties, click Add to create one!"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:502
+msgid "Add option"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:519
+msgid "Delete option"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:588
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:629
+msgid "Add test"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:646
+msgid "Delete test"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:671
+msgid "Add condition"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:689
+msgid "Delete condition"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:744
+msgid "Default value"
+msgstr ""
+
+#: cache/c0/c0eaac88ab4b5ef2a85a25c1eb154c6c.php:795
+msgid "Options for the dropdown control"
+msgstr ""
+
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:66
+#, no-php-format
+msgid "Manage Membership for %displayGroupName%"
+msgstr ""
+
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:149
+msgid "Relationship Tree"
+msgstr ""
+
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:303
+msgid "Below is the family tree for this Display Group."
+msgstr ""
+
+#: cache/0b/0bf0c070de7a1159f73da487732bf8b3.php:314
+msgid ""
+"The Display Group being edited is in bold. The list is ordered so that items "
+"above the current Display Group are its ancestors and items below are its "
+"descendants."
+msgstr ""
+
+#: cache/0b/0bfce4ff89cb4e56420dcb4f282c333f.php:57
+msgid "Delete Saved Report"
+msgstr ""
+
+#: cache/0b/0bfce4ff89cb4e56420dcb4f282c333f.php:91
+#: cache/ce/cee07744a1d4a288c81e3d8649a8ddc2.php:91
+msgid ""
+"Are you sure you want to delete this report schedule? This cannot be undone"
+msgstr ""
+
+#: cache/0b/0bb72264cd242f2afd564859f6bf11f9.php:168
+msgid ""
+"This is an unknown type of Player and there are no special settings for it."
+msgstr ""
+
+#: cache/6f/6f5500847ad35b6287fc71dae4857235.php:57
+msgid "Authorize Request"
+msgstr ""
+
+#: cache/6f/6f5500847ad35b6287fc71dae4857235.php:93
+msgid "would like access to the following scopes"
+msgstr ""
+
+#: cache/6f/6f5500847ad35b6287fc71dae4857235.php:128
+msgid "Terms"
+msgstr ""
+
+#: cache/6f/6f5500847ad35b6287fc71dae4857235.php:137
+msgid "Privacy Policy"
+msgstr ""
+
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:65
+#, no-php-format
+msgid "Usage Report for %mediaName%"
+msgstr ""
+
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:113
+#, no-php-format
+msgid ""
+"This media is directly assigned to %countDisplays% displays, the ones you "
+"have permission to see are shown below."
+msgstr ""
+
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:116
+msgid ""
+"Direct assignment is where Layouts/Media are assigned to a Display/"
+"DisplayGroup without being in a Schedule."
+msgstr ""
+
+#: cache/6f/6fac8be02513dde84394bb089836b1db.php:122
+msgid ""
+"If the media is used in scheduled events it is also shown below. To restrict "
+"to a specific time enter a date in the filter below."
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:100
+#: cache/42/42493ba813fc77f7caf608638138cbdc.php:100
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:101
+msgid "The Name for this Display Profile"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:124
+msgid "The description for this Display Profile"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:144
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:108
+msgid "Copy Members?"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:150
+msgid "Should we copy all members to the new Display Group?"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:164
+msgid "Copy Assignments?"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:170
+msgid ""
+"Should we copy all file and layout assignments to the new Display Group?"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:181
+msgid "Copy Tags?"
+msgstr ""
+
+#: cache/47/47d9ca731e359b072fc4520a04670902.php:187
+msgid "Should we copy all tags to the new Display Group?"
+msgstr ""
+
+#: cache/47/4776440c03d7a667c7b7866fe9d18898.php:71
+msgid "NWS Atom Feed URL"
+msgstr ""
+
+#: cache/47/4776440c03d7a667c7b7866fe9d18898.php:77
+msgid ""
+"This is the default URL for the NWS Atom Feed. You can update\n"
+" it if the URL changes in the future."
+msgstr ""
+
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:57
+msgid "Edit Template"
+msgstr ""
+
+#: cache/05/059921bb852121abe6fed4e3def91eff.php:225
+msgid "Retire this template or not? It will no longer be visible in lists"
+msgstr ""
+
+#: cache/05/05385bdc36a6c45137ac86c578961bef.php:74
+#: cache/06/062deb84a7c00d52988e2d045744b2a7.php:59
+msgid "Add Daypart"
+msgstr ""
+
+#: cache/fd/fddc43e726be2bc7dfd3ad2aeb4e2104.php:57
+msgid "Delete Resolution"
+msgstr ""
+
+#: cache/fd/fddc43e726be2bc7dfd3ad2aeb4e2104.php:91
+msgid "Are you sure you want to delete this Resolution? This cannot be undone"
+msgstr ""
+
+#: cache/5d/5d623050cad32f95845b24c7abbaeaf9.php:128
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:318
+#, no-php-format
+msgid "Version %version%"
+msgstr ""
+
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:72
+msgid "Add a new User Group"
+msgstr ""
+
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:127
+msgid "User Group"
+msgstr ""
+
+#: cache/e8/e8ea6931a2932f5dbdc69778e9b8ab62.php:171
+msgid "Is shown for Add User?"
+msgstr ""
+
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:57
+msgid "Copy User Group"
+msgstr ""
+
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:114
+msgid "Assign the members to the new group"
+msgstr ""
+
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:125
+msgid "Copy existing Feature sets?"
+msgstr ""
+
+#: cache/2b/2bf23650a482ca7f75416abfdeff2cb6.php:131
+msgid "Assign the feature sets to the new group"
+msgstr ""
+
+#: cache/2b/2b1a2c99481cb6bf3041ec49215e7147.php:58
+msgid "Delete Event"
+msgstr ""
+
+#: cache/2b/2b1a2c99481cb6bf3041ec49215e7147.php:100
+msgid ""
+"Are you sure you want to delete this event from all displays? If you "
+"only want to delete this item from certain displays, please deselect the "
+"displays in the edit dialogue and click Save."
+msgstr ""
+
+#: cache/3b/3b4bef6c60897261edd71b8b1cbee730.php:66
+#, no-php-format
+msgid "Manage Membership for %userGroupName%"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:102
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:102
+msgid ""
+"Provide the Licence Code (formerly Licence email address) to license Players "
+"using this Display Profile."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:110
+msgid "Password Protect Settings"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:113
+msgid "Provide a Password which will be required to access settings"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:232
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:236
+msgid "Update Window Start Time"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:235
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:239
+msgid "The start of the time window to install application updates."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:243
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:247
+msgid "Update Window End Time"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:246
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:250
+msgid "The end of the time window to install application updates."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:268
+msgid ""
+"Select a day part that should act as operating hours for this display - "
+"email alerts will not be sent outside of operating hours"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:279
+msgid "Restart Wifi on connection failure?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:282
+msgid ""
+"If an attempted connection to the CMS fails 10 times in a row, restart the "
+"Wifi adaptor."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:296
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:203
+msgid ""
+"Set the orientation of the device (portrait mode will only work if supported "
+"by the hardware) Application Restart Required."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:299
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:630
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:655
+msgid "Device Default"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:299
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:206
+msgid "Reverse Landscape"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:299
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:206
+msgid "Reverse Portrait"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:307
+msgid "Screen Dimensions"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:310
+msgid ""
+"Set dimensions to be used for the Player window ensuring that they do not "
+"exceed the actual screen size. Enter the following values representing the "
+"pixel sizings for; Top,Left,Width,Height. This requires a Player Restart to "
+"action."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:322
+msgid "Blacklist Videos?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:325
+msgid "Should Videos we fail to play be blacklisted and no longer attempted?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:333
+msgid "Store HTML resources on the Internal Storage?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:336
+msgid ""
+"Store all HTML resources on the Internal Storage? Should be selected if the "
+"device cannot display text, ticker, dataset media."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:344
+msgid "Use a SurfaceView for Video Rendering?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:347
+msgid ""
+"If the device is having trouble playing video, it may be useful to switch to "
+"a Surface View for Video Rendering."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:405
+msgid "Start during device start up?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:408
+msgid ""
+"When the device starts and Android finishes loading, should the Player start "
+"up and come to the foreground?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:416
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:333
+msgid "Action Bar Mode"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:419
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:336
+msgid "How should the action bar behave?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:422
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:339
+msgid "Hide"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:422
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:339
+msgid "Timed"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:422
+msgid "Run Intent"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:430
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:347
+msgid "Action Bar Display Duration"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:433
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:350
+msgid "How long should the Action Bar be shown for, in seconds?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:441
+msgid "Action Bar Intent"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:444
+msgid ""
+"When set to Run Intent, which intent should be run. Format is: Action|"
+"ExtraKey,ExtraMsg"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:452
+msgid "Automatic Restart"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:455
+msgid "Automatically Restart the application if we detect it is not visible."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:463
+msgid "Start delay for device start up"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:466
+msgid ""
+"The number of seconds to wait before starting the application after the "
+"device has started. Minimum 10."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:519
+msgid "Action for Screen Shot Intent"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:522
+msgid ""
+"The Intent Action to use for requesting a screen shot. Leave empty to "
+"natively create an image from the player screen content."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:541
+msgid "WebView Plugin State"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:544
+msgid "What plugin state should be used when starting a web view."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:547
+msgid "On Demand"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:555
+msgid "Hardware Accelerate Web Content"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:558
+msgid "Mode for hardware acceleration of web based content."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:561
+msgid "Off when transparent"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:569
+msgid "Use CMS time?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:572
+msgid ""
+"Set the device time using the CMS. Only available on rooted devices or "
+"system signed players."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:580
+msgid "Enable caching of Web Resources?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:583
+msgid ""
+"The standard browser cache will be used - we recommend this is switched off "
+"unless specifically required. Effects Web Page and Embedded."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:613
+msgid "Load Link Libraries for APK Update"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:616
+msgid ""
+"Should the update command include dynamic link libraries? Only change this "
+"if your updates are failing."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:624
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:461
+msgid "Use Multiple Video Decoders"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:627
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:464
+msgid ""
+"Should the Player try to use Multiple Video Decoders when preparing and "
+"showing Video content."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:638
+msgid "Maximum Region Count"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:641
+msgid ""
+"This setting is a memory limit protection setting which will stop rendering "
+"regions beyond the limit set. Leave at 0 for no limit."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:649
+msgid "Video Engine"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:652
+msgid ""
+"Select which video engine should be used to playback video. ExoPlayer is "
+"usually better, but if you experience issues you can revert back to Android "
+"Media Player. HLS always uses ExoPlayer. Available from v3 R300."
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:655
+msgid "ExoPlayer"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:655
+msgid "Android Media Player"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:663
+msgid "Enable touch capabilities on the device?"
+msgstr ""
+
+#: cache/3b/3b6eaf64c218bf75a573540d24c29ae3.php:666
+msgid ""
+"If this device will be used as a touch screen check this option. Checking "
+"this option will cause a message to appear on the player which needs to be "
+"manually dismissed once. If this option is disabled, touching the screen "
+"will show the action bar according to the Action Bar Mode option. Available "
+"from v3 R300."
+msgstr ""
+
+#: cache/71/7194942bf0bccb84d38e565de0fdafc8.php:58 lib/Controller/User.php:365
+msgid "Set home folder"
+msgstr ""
+
+#: cache/5c/5c6b86794d0e31f65e8e29e779a5be10.php:60
+#, no-php-format
+msgid "Delete %syncGroupName%"
+msgstr ""
+
+#: cache/5c/5c6b86794d0e31f65e8e29e779a5be10.php:94
+#: cache/fe/fe96f5539f69eac563b59351cac89453.php:100
+msgid "Are you sure you want to delete this item?"
+msgstr ""
+
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:57
+msgid "Edit Transition"
+msgstr ""
+
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:91
+msgid "Available for In Transitions?"
+msgstr ""
+
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:97
+msgid "Can this transition be used for media start?"
+msgstr ""
+
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:108
+msgid "Available for Out Transitions?"
+msgstr ""
+
+#: cache/7b/7b797c5c0cef98a99686f325a267c36f.php:114
+msgid "Can this transition be used for media end?"
+msgstr ""
+
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:133
+msgid ""
+"Are you sure you want to delete? You may not be able to delete this user if "
+"they have associated content. You can retire users by using the Edit Button."
+msgstr ""
+
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:147
+msgid "Delete all items owned by this User?"
+msgstr ""
+
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:153
+msgid ""
+"Check to delete all items owned by this user, including Layouts, Media, "
+"Schedules, etc."
+msgstr ""
+
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:167
+msgid "Reassign items to another User"
+msgstr ""
+
+#: cache/02/02ac92ba654c2b4d6ab071290ef45cef.php:173
+msgid "Reassign all items this User owns to the selected User."
+msgstr ""
+
+#: cache/02/02c84068a1b9b436fec1c165c50ea5c7.php:57
+msgid "Output Audit Trail as CSV"
+msgstr ""
+
+#: cache/c5/c5a4b0bd39b2aa631b6171f9c78f8ca6.php:57
+msgid "Edit Command"
+msgstr ""
+
+#: cache/c5/c5f5f0eed7841b69d6adcea9d536fe13.php:59
+msgid "Edit Tag"
+msgstr ""
+
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:96
+msgid "Task"
+msgstr ""
+
+#: cache/9d/9df9143bf1ffb814765b88fbdf8e8769.php:102
+msgid "Select the task you would like to run"
+msgstr ""
+
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:72
+msgid "Add a new Font"
+msgstr ""
+
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:74
+msgid "Upload Font"
+msgstr ""
+
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:139
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:126
+msgid "name"
+msgstr ""
+
+#: cache/9d/9d28df19522f9491cff695a13ec79fc9.php:247
+msgid "Add Font"
+msgstr ""
+
+#: cache/50/50d14368a7ff5330505ebb92bb2ad8e8.php:57
+msgid "Delete Notification"
+msgstr ""
+
+#: cache/50/50d14368a7ff5330505ebb92bb2ad8e8.php:91
+msgid ""
+"Are you sure you want to delete this notification? It will be deleted for "
+"all CMS users. This cannot be undone"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:60
+#, no-php-format
+msgid "%dataSetName% - Data Connector"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:119
+msgid "Data Connector JavaScript"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:153
+msgid "Test Params"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:161
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:56
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:96
+msgid "Logs"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:169
+msgid "DataSet Data"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:177
+msgid "Other Data"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:185
+msgid "Schedule Criteria"
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:194
+msgid ""
+"You can test passing parameters that would otherwise be set when this Data "
+"Connector is scheduled."
+msgstr ""
+
+#: cache/50/50ae7e3524663a958e3538f2f09a5ba1.php:199
+msgid "Test Parameters"
+msgstr ""
+
+#: cache/e1/e135a48f6c008e1d5927ee93c2d3f474.php:91
+msgid ""
+"Are you sure you want to start again with a blank canvas? All elements and "
+"widgets will be removed from your draft layout."
+msgstr ""
+
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:62
+#, no-php-format
+msgid "Publish %layout%"
+msgstr ""
+
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:150
+msgid "Publish Now?"
+msgstr ""
+
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:156
+msgid ""
+"When selected, layout will be published immediately, if it should be "
+"published at a specific time, uncheck this checkbox and pick a date in the "
+"field below"
+msgstr ""
+
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:167
+msgid "Publish Date"
+msgstr ""
+
+#: cache/6a/6a5edb56dad9922a66a4d507a3d84d10.php:173
+msgid "Select the date and time to publish the layout"
+msgstr ""
+
+#: cache/c6/c6262ec146dbd007ce95466935f53e2c.php:100
+msgid "Enable the collection of Proof of Play statistics for this Media Item."
+msgstr ""
+
+#: cache/c6/c6d931f51a0aaa053f73e039b1fd4947.php:91
+msgid "Are you sure you want to truncate?"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:99
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:102
+msgid "Library Size"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:152
+msgid "Now Showing"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:173
+#, no-php-format
+msgid "Bandwidth Usage. Limit %xmdsLimit%"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:179
+#, no-php-format
+msgid "Bandwidth Usage (%bandwidthSuffix%)"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:188
+msgid "More Statistics"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:209
+#, no-php-format
+msgid "Library Usage. Limit %libraryLimit%"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:215
+msgid "Library Usage"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:235
+msgid "Display Activity"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:272
+msgid "Latest News"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:301
+msgid "Full Article"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:327
+msgid "Display Status"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:334
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:355
+msgid "Click on the chart for a breakdown"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:348
+msgid "Display Content Status"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:373
+msgid "Display Groups Status"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:380
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:402
+msgid "Click on the chart to view Display information"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:395
+msgid "Display Groups Content Status"
+msgstr ""
+
+#: cache/b0/b0cc6380fa4d85f959aac58f62cbf429.php:423
+msgid "Displays Page"
+msgstr ""
+
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:57
+msgid "Edit Help Link"
+msgstr ""
+
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:91
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:91
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:112
+msgid "Topic"
+msgstr ""
+
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:97
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:97
+msgid "The Topic for this Help Link"
+msgstr ""
+
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:114
+msgid "he Category for this Help Link"
+msgstr ""
+
+#: cache/b0/b0d1fc190c12d424aae2666fa76d745d.php:131
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:131
+msgid "The Link to open for this help topic and category"
+msgstr ""
+
+#: cache/b0/b0b923280921575816524c84c5dc4bf9.php:57
+#: lib/Controller/Display.php:905
+msgid "Toggle Authorise"
+msgstr ""
+
+#: cache/b0/b0b923280921575816524c84c5dc4bf9.php:94
+msgid "Are you sure you want to de-authorise this Display?"
+msgstr ""
+
+#: cache/b0/b0b923280921575816524c84c5dc4bf9.php:103
+msgid "Are you sure you want to authorise this Display?"
+msgstr ""
+
+#: cache/fe/fe96f5539f69eac563b59351cac89453.php:60
+#, no-php-format
+msgid "Delete %layout%"
+msgstr ""
+
+#: cache/fe/fe96f5539f69eac563b59351cac89453.php:70
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:103
+#: lib/Controller/Layout.php:1948 lib/Controller/Layout.php:1954
+msgid "Retire"
+msgstr ""
+
+#: cache/fe/fe96f5539f69eac563b59351cac89453.php:111
+msgid ""
+"All media will be unassigned and any widgets such as text/rss will be lost, "
+"unless they are on playlists. The item will be removed from all Schedules."
+msgstr ""
+
+#: cache/4e/4e4d410560dee1c82b8fd849dba6db02.php:58
+msgid "Delete Recurring Event."
+msgstr ""
+
+#: cache/4e/4e4d410560dee1c82b8fd849dba6db02.php:100
+msgid ""
+"Are you sure you want to delete this recurring event? Clicking Yes will "
+"delete this event from all displays."
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:65
+#, no-php-format
+msgid "Layout Preview for %campaignName%"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:71
+#, no-php-format
+msgid "Campaign Preview for %campaignName%"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:80
+msgid "total duration"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:84
+msgid "hours:min:sec"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:88
+msgid "number of layouts"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:120
+msgid "id"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:132
+msgid "duration"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:144
+msgid "Open full screen"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:197
+msgid "Webhook Controller"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:201
+msgid "Navigate to layout with code [layoutTag]?"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:205
+msgid "Empty region!"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:209
+msgid "Next Item"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:213
+msgid "Previous Item"
+msgstr ""
+
+#: cache/4e/4ee07d265a2e50ed51bdaf3cf4bc0140.php:217
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:737
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1463
+msgid "Navigate to Widget"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:152
+msgid "Connect with the Central Authentication Server"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:173
+msgid "CAS Login"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:211
+msgid "Please provide your credentials"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:241
+msgid "Login"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:248
+msgid "Forgotten your password?"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:279
+msgid "Please provide your user name"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:304
+msgid "Send Reset"
+msgstr ""
+
+#: cache/4e/4ed6b91f3cc2f5fd9b57329b0162363b.php:309
+msgid "Login instead?"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:61
+#, no-php-format
+msgid "Edit %type% Transition for %name%"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:151
+msgid "What transition should be applied when this item starts?"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:172
+msgid "What transition should be applied when this item finishes?"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:181
+msgid "Unknown Transition Type Requested"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:195
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:532
+msgid "Transition"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:212
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:967
+msgid "The duration for this transition, in milliseconds."
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:223
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:971
+msgid "Direction"
+msgstr ""
+
+#: cache/bc/bced9f58619b8f3ede399122d7e8dc15.php:229
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:975
+msgid ""
+"The direction for this transition. Only appropriate for transitions that "
+"move, such as Fly."
+msgstr ""
+
+#: cache/bc/bc9269a5f1accd4b0930cf946764c3dc.php:57
+msgid "Delete this Display?"
+msgstr ""
+
+#: cache/bc/bc9269a5f1accd4b0930cf946764c3dc.php:91
+msgid "Are you sure you want to delete this display? This cannot be undone"
+msgstr ""
+
+#: cache/4a/4ab9918a9e09352e2b177d64bcd99b84.php:57
+#: lib/Controller/DisplayGroup.php:476 lib/Controller/DisplayGroup.php:489
+#: lib/Controller/Display.php:1201 lib/Controller/Display.php:1214
+msgid "Send Command"
+msgstr ""
+
+#: cache/83/83bb1c5c0884298aa59fc23624c344db.php:75
+msgid "Sorry there has been an unknown error."
+msgstr ""
+
+#: cache/83/83bb1c5c0884298aa59fc23624c344db.php:81
+#: cache/f7/f71f3735d3d07f236e070cd99325d907.php:58
+msgid ""
+"Please press the button below to go to your homepage or press back in your "
+"browser"
+msgstr ""
+
+#: cache/83/839dd152c7d5ed96bcc1f45bc24333d5.php:58
+msgid "Edit User Group"
+msgstr ""
+
+#: cache/f7/f71f3735d3d07f236e070cd99325d907.php:54
+#: lib/Middleware/Handlers.php:146
+msgid "Sorry we could not find that page."
+msgstr ""
+
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:127
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:122
+msgid "A name for this Sync Group"
+msgstr ""
+
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:144
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:139
+msgid "The port on which players will communicate"
+msgstr ""
+
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:161
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:156
+msgid ""
+"The delay (in ms) when displaying the changes in content. If the network is "
+"unstable this value can be raised to compensate."
+msgstr ""
+
+#: cache/31/3131f92c3d53c933161ad8d51cab0da1.php:178
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:173
+msgid ""
+"The delay (in ms) before unpausing the video on start. If some of the "
+"devices in the group do not support gapless, this value can be raised to "
+"compensate."
+msgstr ""
+
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:120
+msgid "Guest"
+msgstr ""
+
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:126
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2048
+#: lib/Controller/Library.php:1819 web/xmds.php:139
+msgid "Expired"
+msgstr ""
+
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:165
+msgid "Browser"
+msgstr ""
+
+#: cache/ec/ecc543a00f9a3a373b2b98cdddbbf0a4.php:169
+msgid "Expires At"
+msgstr ""
+
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:118
+msgid "Make new copies of all media on this playlist?"
+msgstr ""
+
+#: cache/c1/c1f5c09de17d8a99af8cd59c92dd5380.php:124
+msgid ""
+"This will duplicate all media that is currently assigned to the Playlist "
+"being copied."
+msgstr ""
+
+#: cache/78/7875975092226a1328f8ec7d90ce4b25.php:60
+#, no-php-format
+msgid "Menu Board %name%"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:69
+msgid "Truncate the Log"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:79
+msgid "Refresh the Log"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:142
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:400
+msgid "Level"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:153
+msgid "Interval"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:188
+msgid "Duration back"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:225
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:392
+msgid "Channel"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:236
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:408
+msgid "Page"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:247
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:396
+msgid "Function"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:265
+msgid "PUT"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:271
+msgid "DELETE"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:283
+msgid "PATCH"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:317
+msgid "Display Name"
+msgstr ""
+
+#: cache/08/08a4c8f7ec041a569df62a210541e0e5.php:362
+msgid "Exclude logs common to each request?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:55
+msgid "Delete %obj%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:59
+msgid "Changes that you have made may not be saved!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:63
+msgid "Content editing works best with a higher resolution"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:67
+msgid ""
+"Please resize your window to at least 1200 pixels by 600 pixels, or reduce "
+"your zoom level, for the best experience"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:71
+msgid "Hide message and go back to editing"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:95
+msgid "Load %prop% for %obj%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:99
+msgid "Loading"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:119
+msgid "View Source Code"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:123
+msgid "Detach Editor"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:127
+msgid "Attach Editor"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:131
+msgid "Scale to view"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:135
+msgid "Scale to width"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:139
+msgid "Scale to height"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:159
+msgid ""
+"This widget isn't enabled and can't be configured, please contact your "
+"administrator for help."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:163
+#: lib/Controller/Playlist.php:386
+msgid "Timeline"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:172
+msgid "Zones"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:184
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1485
+msgid "Elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:188
+msgid "Element Groups"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:200
+msgid "Search for Layouts"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:204
+msgid ""
+"Search for Layouts by the Code Identifier. Layouts must have a Code "
+"Identifier set in the Layout Edit form before creating a Navigate to Layout "
+"Action Type."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:208
+msgid "Search for Layouts by Code"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:212
+msgid "No results"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:216
+msgid "Remove Layout from recents"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:220
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1521
+msgid "Show more"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:225
+msgid "Delete Action"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:229
+msgid "Are you sure you want to delete this action?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:246
+msgid "Replace Layout"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:250
+msgid "Are you sure you want to replace your Layout with a template?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:266
+msgid "Select widget or create a new one"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:270
+msgid "Create new"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:282
+msgid "# of elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:286
+msgid "# of element groups"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:295
+msgid "Playlist converted to global!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:322
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:215
+msgid "Start time"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:326
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:219
+msgid "End time"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:330
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1881
+msgid "Set Expiry Dates"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:334
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1885
+msgid ""
+"Use the check box to set Start and End dates/times for media files and use "
+"the Start Upload button to apply to all files or the row upload button to "
+"upload individually."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:338
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1889
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:101
+msgid "Select the start time for this widget"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:342
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1893
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:118
+msgid "Select the end time for this widget"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:346
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1897
+msgid "Delete on Expiry"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:350
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1901
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:135
+msgid ""
+"When the End Time for this Widget passes should the Widget be removed from "
+"the Playlist?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:354
+msgid "Delete from Library"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:358
+msgid "Remove file from the Media Library"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:381
+msgid "Edit Attached Audio"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:385
+msgid "Edit Expiry Dates"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:389
+msgid "Edit Transition In"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:393
+msgid "Edit Transition Out"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:397
+msgid "Edit Sharing"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:401
+msgid "Edit Playlist Sharing"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:405
+msgid "Edit Widget Sharing"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:413
+msgid "Convert Playlist"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:417
+msgid "Convert Layout playlist into a Global playlist."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:425
+msgid "Move one step left"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:429
+msgid "Move one step right"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:433
+msgid "Move to the top left"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:437
+msgid "Move to the top right"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:441
+msgid "Bring to front"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:445
+msgid "Bring forward"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:449
+msgid "Send backwards"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:453
+msgid "Send to back"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:465
+msgid "New Configuration"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:469
+msgid "Edit Text"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:473
+msgid "Group elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:477
+msgid "Ungroup elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:481
+msgid "Add elements to group"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:485
+msgid "Delete all"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:512
+msgid "Appearance"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:516
+msgid "Fallback Data"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:528
+msgid "Positioning"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:544
+msgid "Widget Dimensions"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:556
+msgid "Scale"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:564
+msgid "This widget needs to be configured before it will be shown."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:568
+msgid ""
+"This widget needs to have at least one of the following elements: %elements%."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:572
+msgid "Data Slot"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:576
+msgid ""
+"When there are more than one of the same element for a widget you can set "
+"the slot for each element. For example with two of the same element you'd "
+"have data slot 1 and data slot 2. If 10 items were returned slot 1 would "
+"receive items 1,3,5,7,9 and slot 2 would receive items 2,4,6,8,10."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:580
+msgid "Pin this slot?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:584
+msgid ""
+"The first item that appears in a slot will be pinned and will not cycle with "
+"the rest of the items."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:588
+msgid "Scale with group"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:592
+msgid "Scale element when scaling containing group."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:600
+msgid "Horizontal alignment when scaling the containing group."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:608
+msgid "Vertical alignment when scaling the containing group."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:638
+msgid "Something went wrong!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:642
+msgid "Selected item is not shared with you with edit permission!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:647
+msgid "* Not Defined"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:673
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:769
+msgid "Trigger"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:681
+msgid ""
+"If your Widget is a Shell Command you can select to target 'Screen' to run "
+"the command without affecting any Zones. For all other Widgets select 'Zone' "
+"as target."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:685
+msgid "Touch/Click"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:689
+msgid "Web hook"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:693
+msgid "Add an Action"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:705
+msgid "Continue"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:717
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1443
+msgid "Next Layout"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:721
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1447
+msgid "Previous Layout"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:741
+msgid "Widget to Load"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:745
+msgid "Create or edit the Widget to be loaded"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:753
+msgid "Trigger Type"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:757
+msgid "How should the Player listen for this Action to be triggered?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:765
+msgid ""
+"If this Action is triggered by a Web Hook then this Trigger Code must be "
+"present in the URL `trigger=` parameter."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:773
+msgid "Select the target for the Trigger"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:781
+msgid ""
+"Please enter the Code identifier for the Layout as assigned in the Add / "
+"Edit Layout form."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:785
+msgid "Create Widget"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:797
+msgid "Add a widget from the Toolbox to the target area!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:807
+msgid "Layer related to all layout objects"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:811
+msgid "Element Layer"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:815
+msgid "Layer for the element related to other elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:819
+msgid "Element Group Layer"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:823
+msgid ""
+"Layer for the element group related to other groups or elements without group"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:827
+msgid "Canvas Layer"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:831
+msgid "Layer for the canvas containing all elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:851
+msgid "Rotation"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:855
+msgid ""
+"Make this full screen, dimensions will be set to %layout.width% by %layout."
+"height%."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:859
+msgid "Bring selected object back to the Layout view."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:923
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1033
+msgid "An optional name for this widget"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:927
+msgid "Content Synchronisation Key"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:931
+msgid ""
+"If this layout is scheduled using a synchronised event, this key will be "
+"used to match with other layouts in the same event."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:939
+msgid "Enable Widget loop?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:943
+msgid "When should the Widget Loop be enabled?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:947
+msgid ""
+"* If the Widget is a 'fixed' item (eg Text), Loop should not be enabled."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:951
+msgid ""
+"* If the Widget needs to update periodically (eg RSS Ticker Widget), Loop "
+"can be enabled ONLY if the Widget needs to update MORE frequently than the "
+"duration of the overall Layout."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:955
+msgid "Exit Transition"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:959
+msgid "What transition should be applied when this region is finished?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:983
+msgid "The layering order of this %regionType% (z-index). Advanced use only."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:991
+msgid "The top position of the %regionType%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:999
+msgid "The left position of the %regionType%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1007
+msgid "The width of the %regionType%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1015
+msgid "The height of the %regionType%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1019
+msgid ""
+"Make this %regionType% full screen, dimensions will be set to %layout.width% "
+"by %layout.height%."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1023
+msgid "Bring selected %regionType% back to the Layout view."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1029
+msgid "Configuration Name"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1037
+msgid "Set a duration?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1041
+msgid "Select to provide a specific duration for this Widget"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1049
+msgid "The duration of the widget in seconds"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1057
+msgid ""
+"Enable the collection of Proof of Play statistics for this Widget. Ensure "
+"that ‘Enable Stats Collection’ is set to ‘On’ in the Display Settings."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1073
+msgid "Repeat items to fill all data slots?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1077
+msgid ""
+"Once all items have been placed in a slot, any empty slots will be filled "
+"with items from the start."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1089
+msgid ""
+"Widgets of the same type, change to transfer the source to a different "
+"widget."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1093
+msgid "Transfer"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1097
+msgid "Transfer the currently selected elements into a new widget!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1116
+msgid "An optional name for this element"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1124
+msgid "An optional name for this group of elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1129
+msgid "Color 1"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1133
+msgid "Color 2"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1141
+msgid "Radial"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1145
+msgid "Linear"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1149
+msgid "Angle"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1157
+msgid "Delete selected %object%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1161
+msgid "Delete selected objects"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1165
+msgid "Change Layout"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1173
+msgid "Tooltips?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1177
+msgid ""
+"Show/Hide tooltips which provide help; informational tooltips will remain."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1181
+msgid "Delete confirmation?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1185
+msgid ""
+"Do we want to show confirmation modals when deleting critical Layout content?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1193
+msgid "Select"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1197
+msgid "Deselect"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1209
+msgid "Preview media"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1213
+#: lib/Controller/SavedReport.php:141
+msgid "Open"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1217
+msgid "Mark as favourite"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1221
+msgid "Upload new"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1225
+msgid "Upload %obj%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1229
+msgid "New Playlist"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1262
+msgid "Provider"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1276
+msgid "Media Id"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1332
+msgid "Local"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1346
+msgid "Add widgets"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1354
+msgid "Global Elements"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1362
+msgid "Library image search"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1370
+msgid "Library audio search"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1378
+msgid "Library video search"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1382
+#: lib/Entity/DisplayEvent.php:222 lib/Connector/CapConnector.php:559
+msgid "Other"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1386
+msgid "Library other media search"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1394
+msgid "Interactive actions"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1398
+msgid "Layout Templates"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1402
+msgid "Search for Layout Templates"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1406
+msgid "Search for templates available from the %obj%."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1414
+msgid "Add Playlists"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1418
+msgid "Provider: %obj%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1424
+msgid "Move Window"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1428
+msgid "Close Window"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1432
+msgid "Minimise Window"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1436
+msgid "New Tab"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1473
+msgid "Data Widgets"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1481
+msgid "Favourites"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1489
+msgid "Stencils"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1493
+msgid "Static Templates"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1497
+msgid "Close content"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1501
+msgid "No widgets to display"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1505
+msgid "No actions to display"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1509
+msgid "No templates to display"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1513
+msgid "No media to display!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1517
+msgid "No playlists to display!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1525
+msgid "No more results for this filter!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1538
+msgid "Select media to add"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1542
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1670
+msgid "Dimensions"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1575
+msgid "Replace your Layout with a template?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1579
+msgid "Replace your Layout with a %obj% template?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1583
+msgid "Required"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1645
+msgid "Use this item to be used as a placeholder to add images."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1678
+msgid "Layout Actions:"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1682
+msgid "My Layouts?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1687
+msgid "Interactive Mode"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1691
+msgid "ON"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1695
+msgid "OFF"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1704
+msgid "Revert %target% save"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1708
+msgid "Revert %target% order"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1712
+msgid "Revert %target% transformation"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1716
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1724
+msgid "Revert %target% creation"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1720
+msgid "Revert %target% assignment"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1728
+msgid "Revert %target% elements change"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1746
+msgid "Frame"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1762
+msgid "Layers"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1774
+#: cache/b9/b9d92c7ddaef58f9505bac7e251248d7.php:932
+msgid "Group"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1778
+msgid "In %groupId%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1794
+msgid "Empty layout"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1798
+msgid "Expand"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1802
+msgid "Shrink"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1814
+msgid "Layout Background"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1818
+msgid ""
+"Outside View Area! Go to Position tab on Properties Panel to bring back to "
+"view."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1826
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2013
+msgid "Region is invalid: Please delete it to validate the Layout!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1909
+msgid "ERROR"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1913
+msgid "There was a problem loading the Layout!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1917
+msgid "There was a problem loading the Playlist!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1921
+msgid "User save preferences failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1925
+msgid "User load preferences failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1929
+msgid "Library load failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1933
+msgid "Form load failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1937
+msgid "Convert playlist failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1941
+msgid "Playlist needs a name to be converted!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1945
+msgid "Revert failed: %error%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1949
+msgid "Save order failed: %error%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1953
+msgid "Delete failed: %error%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1957
+msgid "Save all changes failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1961
+msgid "Remove all changes failed!!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1965
+msgid "Importing media failed!!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1969
+msgid "Add media failed: %error%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1973
+msgid "Add module failed: %error%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1977
+msgid "Create region failed: %error%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1981
+msgid "List order not Changed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1985
+msgid "Playlist save order failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1989
+msgid "Get form failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1993
+msgid "Transform zone failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:1997
+msgid "Preview failed!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2001
+msgid "No widgets need saving!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2005
+msgid "Missing required property %property%"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2017
+msgid "Failed to import media!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2021
+msgid "This Canvas is not shared with you with edit permission!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2025
+msgid "Failed to load media providers!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2029
+msgid "Failed Action creation!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2033
+msgid "Failed to get all actions!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2040
+msgid "Set to start"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2044
+msgid "Set to expire"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2156
+msgid "Fill - use the first Playlist to fill any remaining Spots"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2160
+msgid "Pad"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2164
+msgid "Pad - use the first Playlist to pad any remaining Spots"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2168
+msgid "Repeat"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2172
+msgid ""
+"Repeat - repeat the Widgets in this Playlist until the number of Spots have "
+"been filled"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2180
+msgid ""
+"How many spots would you like on this Sub-Playlist? This is used before "
+"ordering to expand or shrink the list to the specified size. Leave empty to "
+"use the count of Widgets."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2184
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:80
+msgid "Spots"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2188
+msgid ""
+"Set the duration of all Widgets in the Playlist to a specific value in "
+"seconds. Leave empty to use each Widget duration."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2192
+msgid "Spot Length"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2196
+msgid ""
+"If there are not enough Widgets fill all spots, how should the remaining "
+"spots be filled?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2200
+msgid "Spot Fill"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2204
+msgid "You do not have access to this playlist"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2208
+msgid "Playlist Id"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2215
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2386
+msgid "Columns Available"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2219
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2390
+msgid "Columns Selected"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2230
+msgid "Drag tags to the right column so thy can be displayed on the marquee."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2234
+#: lib/Controller/StatusDashboard.php:231
+msgid "Available"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2238
+msgid "Selected"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2323
+msgid ""
+"Fallback data for this Data Widget can be provided below and included "
+"according to the property \"Show fallback data\". Fallback data will be "
+"shown with the same appearance as data returned from the source, and can be "
+"edited using the form below."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2327
+msgid "Add New"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2343
+msgid "Show fallback data"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2347
+msgid "If provided, when should we show fallback data for this Widget?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2359
+msgid "When no data is returned"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2363
+msgid "When there is an error"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2367
+msgid "Undefined"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2371
+msgid "Please fill out all least one field!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2375
+msgid "Please fill out all the required fields!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2379
+msgid "Required Field!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2475
+msgid "There can only be one category per zone!"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2486
+msgid "Show All"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2490
+msgid "Show Paged"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2498
+msgid "No Transition"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2502
+msgid "Marquee Left"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2506
+msgid "Marquee Right"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2510
+msgid "Marquee Up"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2514
+msgid "Marquee Down"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2518
+msgid "Fade"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2526
+msgid "Scroll Horizontal"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2530
+msgid "Scroll Vertical"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2534
+msgid "Flip Horizontal"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2538
+msgid "Flip Vertical"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2542
+msgid "Shuffle"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2546
+msgid "Tile Slide"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2550
+msgid "Tile Blind"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2558
+msgid "Edit Playlist - %playlistName% - "
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2562
+msgid "Widgets count"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2566
+msgid "Editing source playlist %playlistName% "
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2571
+msgid "Zoom In"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2575
+msgid "Zoom Out"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2579
+msgid "Default zoom"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2583
+msgid "Change scale mode"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2588
+msgid "Your changes will apply anywhere this Playlist is used."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2596
+msgid "Delete Playlist"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2600
+msgid "Are you sure you want to delete a non-empty Playlist?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2606
+msgid "Delete Region"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2610
+msgid "Are you sure you want to delete this region?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2616
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:58
+msgid "Delete Widget"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2620
+msgid "Are you sure you want to delete this widget?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2626
+msgid "Delete Element"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2630
+msgid ""
+"Are you sure you want to delete this element? Widget will also be deleted "
+"and configuration will be lost."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2636
+msgid "Delete Element Group"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2640
+msgid ""
+"Are you sure you want to delete this element group? Widget will also be "
+"deleted and configuration will be lost."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2646
+msgid "Delete all selected objects?"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2650
+msgid ""
+"Are you sure you want to delete all selected objects? Widgets might also be "
+"deleted and configuration will be lost."
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2658
+#: lib/Connector/OpenWeatherMapConnector.php:519
+msgid "Afrikaans"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2662
+msgid "Arabic (Algeria)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2666
+msgid "Arabic (Kuwait)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2670
+msgid "Arabic (Libya)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2674
+msgid "Arabic (Morocco)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2678
+msgid "Arabic (Saudi Arabia)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2682
+msgid "Arabic (Tunisia)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2686
+#: lib/Connector/OpenWeatherMapConnector.php:520
+msgid "Arabic"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2690
+#: lib/Connector/OpenWeatherMapConnector.php:521
+msgid "Azerbaijani"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2694
+msgid "Belarusian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2698
+#: lib/Connector/OpenWeatherMapConnector.php:522
+msgid "Bulgarian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2702
+msgid "Bambara"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2706
+msgid "Bengali (Bangladesh)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2710
+msgid "Bengali"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2714
+msgid "Tibetan"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2718
+msgid "Breton"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2722
+msgid "Bosnian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2726
+#: lib/Connector/OpenWeatherMapConnector.php:523
+msgid "Catalan"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2730
+#: lib/Connector/OpenWeatherMapConnector.php:526
+msgid "Czech"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2734
+msgid "Chuvash"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2738
+msgid "Welsh"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2742
+#: lib/Connector/OpenWeatherMapConnector.php:527
+msgid "Danish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2746
+msgid "German (Austria)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2750
+msgid "German (Switzerland)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2754
+#: lib/Connector/OpenWeatherMapConnector.php:528
+msgid "German"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2758
+msgid "Divehi"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2762
+#: lib/Connector/OpenWeatherMapConnector.php:529
+msgid "Greek"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2766
+#: lib/Connector/OpenWeatherMapConnector.php:530
+msgid "English"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2770
+msgid "English (Australia)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2774
+msgid "English (Canada)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2778
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2782
+msgid "English (United Kingdom)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2786
+msgid "English (Ireland)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2790
+msgid "English (Israel)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2794
+msgid "English (India)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2798
+msgid "English (New Zealand)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2802
+msgid "English (Singapore)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2806
+msgid "Esperanto"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2810
+msgid "Spanish (Dominican Republic)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2814
+msgid "Spanish (Mexico)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2818
+msgid "Spanish (United States)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2822
+#: lib/Connector/OpenWeatherMapConnector.php:557
+msgid "Spanish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2826
+msgid "Estonian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2830
+#: lib/Connector/OpenWeatherMapConnector.php:531
+msgid "Basque"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2834
+msgid "Persian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2838
+#: lib/Connector/OpenWeatherMapConnector.php:533
+msgid "Finnish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2842
+msgid "Filipino"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2846
+msgid "Faroese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2850
+msgid "French (Canada)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2854
+msgid "French (Switzerland)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2858
+#: lib/Connector/OpenWeatherMapConnector.php:534
+msgid "French"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2862
+msgid "Western Frisian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2866
+msgid "Scottish Gaelic"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2870
+#: lib/Connector/OpenWeatherMapConnector.php:535
+msgid "Galician"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2874
+msgid "gom (Latin)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2878
+msgid "Gujarati"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2882
+#: lib/Connector/OpenWeatherMapConnector.php:536
+msgid "Hebrew"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2886
+#: lib/Connector/OpenWeatherMapConnector.php:537
+msgid "Hindi"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2890
+#: lib/Connector/OpenWeatherMapConnector.php:538
+msgid "Croatian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2894
+#: lib/Connector/OpenWeatherMapConnector.php:539
+msgid "Hungarian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2898
+msgid "Armenian (Armenia)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2902
+#: lib/Connector/OpenWeatherMapConnector.php:540
+msgid "Indonesian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2906
+msgid "Icelandic"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2910
+msgid "Italian (Switzerland)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2914
+#: lib/Connector/OpenWeatherMapConnector.php:541
+msgid "Italian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2918
+#: lib/Connector/OpenWeatherMapConnector.php:542
+msgid "Japanese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2922
+msgid "Javanese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2926
+msgid "Georgian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2930
+msgid "Kazakh"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2934
+msgid "Khmer"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2938
+msgid "Kannada"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2942
+#: lib/Connector/OpenWeatherMapConnector.php:543
+msgid "Korean"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2946
+msgid "Kurdish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2950
+msgid "Kirghiz"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2954
+msgid "Luxembourgish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2958
+msgid "Lao"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2962
+#: lib/Connector/OpenWeatherMapConnector.php:545
+msgid "Lithuanian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2966
+#: lib/Connector/OpenWeatherMapConnector.php:544
+msgid "Latvian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2970
+msgid "Montenegrin"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2974
+msgid "Maori"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2978
+#: lib/Connector/OpenWeatherMapConnector.php:546
+msgid "Macedonian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2982
+msgid "Malayalam"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2986
+msgid "Mongolian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2990
+msgid "Marathi"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2994
+msgid "Malay (Malaysia)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:2998
+msgid "Malay"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3002
+msgid "Maltese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3006
+msgid "Burmese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3010
+msgid "Norwegian Bokmål"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3014
+msgid "Nepali"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3018
+msgid "Dutch (Belgium)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3022
+#: lib/Connector/OpenWeatherMapConnector.php:548
+msgid "Dutch"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3026
+msgid "Norwegian Nynorsk"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3030
+msgid "Punjabi (India)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3034
+#: lib/Connector/OpenWeatherMapConnector.php:549
+msgid "Polish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3038
+msgid "Portuguese (Brazil)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3042
+#: lib/Connector/OpenWeatherMapConnector.php:550
+msgid "Portuguese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3046
+#: lib/Connector/OpenWeatherMapConnector.php:552
+msgid "Romanian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3050
+#: lib/Connector/OpenWeatherMapConnector.php:553
+msgid "Russian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3054
+msgid "Sindhi"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3058
+msgid "Northern Sami"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3062
+msgid "Sinhala"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3066
+#: lib/Connector/OpenWeatherMapConnector.php:555
+msgid "Slovak"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3070
+#: lib/Connector/OpenWeatherMapConnector.php:556
+msgid "Slovenian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3074
+msgid "Albanian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3078
+msgid "Serbian (Cyrillic)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3082
+#: lib/Connector/OpenWeatherMapConnector.php:558
+msgid "Serbian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3086
+msgid "Swati"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3090
+#: lib/Connector/OpenWeatherMapConnector.php:554
+msgid "Swedish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3094
+msgid "Swahili"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3098
+msgid "Tamil"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3102
+msgid "Telugu"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3106
+msgid "Tetum"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3110
+msgid "Tajik"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3114
+#: lib/Connector/OpenWeatherMapConnector.php:559
+msgid "Thai"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3118
+msgid "Turkmen"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3122
+msgid "Tagalog (Philippines)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3126
+msgid "Klingon"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3130
+#: lib/Connector/OpenWeatherMapConnector.php:560
+msgid "Turkish"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3134
+msgid "Talossan"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3138
+msgid "Central Atlas Tamazight (Latin)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3142
+msgid "Central Atlas Tamazight"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3146
+msgid "Uyghur (China)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3150
+#: lib/Connector/OpenWeatherMapConnector.php:561
+msgid "Ukrainian"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3154
+msgid "Urdu"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3158
+msgid "Uzbek (Latin)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3162
+msgid "Uzbek"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3166
+#: lib/Connector/OpenWeatherMapConnector.php:562
+msgid "Vietnamese"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3170
+msgid "Pseudo"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3174
+msgid "Yoruba (Nigeria)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3178
+msgid "Chinese (China)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3182
+msgid "Chinese (Hong Kong)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3186
+msgid "Chinese (Macau)"
+msgstr ""
+
+#: cache/ab/abd86a2b650eb5eb15adc42f8f2eedde.php:3190
+msgid "Chinese (Taiwan)"
+msgstr ""
+
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:57
+msgid "Edit Sync Group"
+msgstr ""
+
+#: cache/ab/ab370d611219600ccf9a3be2b7f7710d.php:190
+msgid "Select Lead Display for this sync group"
+msgstr ""
+
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:57
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:73
+msgid "Add Help Link"
+msgstr ""
+
+#: cache/8f/8f2924a91801a8eaa3dd2089ab572507.php:114
+msgid "The Category for this Help Link"
+msgstr ""
+
+#: cache/8f/8fe7bf7394647124fbd03d8b9c3afd98.php:58
+msgid "The Name of the Profile - (1 - 50 characters)"
+msgstr ""
+
+#: cache/8f/8fe7bf7394647124fbd03d8b9c3afd98.php:68
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:129
+msgid "Default Profile?"
+msgstr ""
+
+#: cache/8f/8fe7bf7394647124fbd03d8b9c3afd98.php:73
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:135
+msgid ""
+"Is this the default profile for all Displays of this type? Only 1 profile "
+"can be the default."
+msgstr ""
+
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:58
+msgid "Edit DataSet"
+msgstr ""
+
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:276
+msgid ""
+"No remote columns have been configured for this dataset. Please configure "
+"your columns accordingly."
+msgstr ""
+
+#: cache/8f/8fc8a15bcb40188cefc141011f1ab52a.php:305
+msgid ""
+"This DataSet has been accessed or updated recently, which means the CMS will "
+"keep it active."
+msgstr ""
+
+#: cache/cd/cd143932270a543fae63974db1f8be19.php:57
+msgid "Check Commercial Licence"
+msgstr ""
+
+#: cache/cd/cd143932270a543fae63974db1f8be19.php:91
+msgid "Are you sure you want to ask this Player to check its Licence?"
+msgstr ""
+
+#: cache/cd/cd143932270a543fae63974db1f8be19.php:102
+msgid ""
+"The result of this check will be immediately actioned and the status "
+"reported in Commercial Licence column."
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:62
+msgid "Upload a new Player Software file"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:64
+msgid "Add Version"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:85
+msgid "Player Software"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:136
+msgid "Version ID"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:164
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:360
+msgid "Created At"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:168
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:364
+msgid "Modified At"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:248
+msgid "Please set Player Software Version"
+msgstr ""
+
+#: cache/3a/3aa37d24a7b61ab6ce560c96614351e5.php:273
+msgid "Upload Version"
+msgstr ""
+
+#: cache/ca/ca9b899371870f4435fd70e881698a42.php:57
+msgid "Add Data"
+msgstr ""
+
+#: cache/ca/ca9b899371870f4435fd70e881698a42.php:141
+msgid "Select an Image"
+msgstr ""
+
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:59
+msgid ""
+"This Playlist does not have any Spots for you to manage. Please choose "
+"another."
+msgstr ""
+
+#: cache/37/37a9af5de3183eeeb9ae0bc5f57721a5.php:337
+#: lib/Controller/StatusDashboard.php:302 lib/Report/LibraryUsage.php:207
+#: lib/Report/LibraryUsage.php:555
+msgid "Empty"
+msgstr ""
+
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:93
+msgid "Open Weather Map API Key"
+msgstr ""
+
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:99
+msgid "Enter your API Key from Open Weather Map."
+msgstr ""
+
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:116
+msgid ""
+"Is the above key on an Open Weather Map paid plan? Do NOT tick this if you "
+"have subscribed to One Call API 3.0."
+msgstr ""
+
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:144
+msgid "Schedule Criteria Cache Period"
+msgstr ""
+
+#: cache/fa/fa97908447705f6e627aaec443db6ecb.php:150
+msgid ""
+"If a player has weather based schedule criteria, how many hours should this "
+"connector cache that weather data for?"
+msgstr ""
+
+#: cache/c9/c9a376b6be90c2a17c251d27704daba1.php:60
+#, no-php-format
+msgid "Delete %playlistName%"
+msgstr ""
+
+#: cache/c9/c9a376b6be90c2a17c251d27704daba1.php:94
+msgid "Are you sure you want to delete this Playlist?"
+msgstr ""
+
+#: cache/c9/c9a376b6be90c2a17c251d27704daba1.php:105
+msgid ""
+"All media will be unassigned and any playlist specific media such as text/"
+"rss will be lost. The playlist will be removed from all Layouts."
+msgstr ""
+
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:58
+msgid "Authorized applications for user"
+msgstr ""
+
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:98
+msgid "Approved Date"
+msgstr ""
+
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:102
+msgid "Approved IP Address"
+msgstr ""
+
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:129
+msgid "Revoke Access for this Application"
+msgstr ""
+
+#: cache/c9/c90b82868f9eaeb259263d51142c92a4.php:135
+msgid "Revoke"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:58
+msgid "Edit User Profile"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:125
+msgid ""
+"If you are changing your password or two factor settings, then please enter "
+"your current password"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:187
+#: lib/Controller/Login.php:528
+msgid "Two Factor Authentication"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:193
+msgid ""
+"Enable an option to provide a two factor authentication code to log into the "
+"CMS for added security."
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:211
+msgid "Please scan the following image with your app:"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:218
+msgid "Access Code"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:231
+msgid ""
+"Please use the buttons below to generate or show your two factor recovery "
+"codes."
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:237
+msgid ""
+"Generate Recovery codes, this action will invalidate all existing recovery "
+"codes."
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:239
+msgid "Generate"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:246
+msgid "Show existing recovery codes"
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:256
+msgid ""
+"Here are your recovery codes, please make sure to store them in a safe "
+"place, like password manager."
+msgstr ""
+
+#: cache/c9/c94693e619e4bcb5dc34c0360ec23fb6.php:260
+msgid ""
+"Recovery codes will become active only after this form is successfully saved"
+msgstr ""
+
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:73
+msgid "Add a new Display Group"
+msgstr ""
+
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:142
+msgid "Return Display Groups that directly contain the selected Display."
+msgstr ""
+
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:153
+msgid "Nested Display"
+msgstr ""
+
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:159
+msgid ""
+"Return Display Groups that contain the selected Display somewhere in the "
+"nested Display Group relationship tree."
+msgstr ""
+
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:170
+msgid "Dynamic Criteria"
+msgstr ""
+
+#: cache/c9/c94bec895bd9a3188079316f3633aef6.php:270
+msgid "Is Dynamic?"
+msgstr ""
+
+#: cache/42/4224ff7432c1fc00e7bc2ab35b918dc1.php:143
+msgid "Available On"
+msgstr ""
+
+#: cache/6b/6b5b58443921708eea82a32bb9f87c4c.php:60
+#, no-php-format
+msgid "Checkout %layout%"
+msgstr ""
+
+#: cache/6b/6b5b58443921708eea82a32bb9f87c4c.php:94
+msgid ""
+"Are you sure you want to checkout this Layout? It will be put in a Draft "
+"state so you can make edits."
+msgstr ""
+
+#: cache/72/7223733b6d46a336184288000193d298.php:57
+msgid "Reset Report Schedule"
+msgstr ""
+
+#: cache/72/7223733b6d46a336184288000193d298.php:91
+msgid ""
+"Are you sure you want to reset this report schedule? This cannot be undone"
+msgstr ""
+
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:72
+msgid "Add a new Campaign"
+msgstr ""
+
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:206
+msgid "Cycle Based Playback"
+msgstr ""
+
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:218
+#: lib/Controller/User.php:306 lib/XTR/MaintenanceDailyTask.php:165
+#: lib/XTR/StatsArchiveTask.php:137 lib/XTR/StatsArchiveTask.php:332
+msgid "Disabled"
+msgstr ""
+
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:292
+msgid "# Layouts"
+msgstr ""
+
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:307
+msgid "Cycle based Playback"
+msgstr ""
+
+#: cache/53/53d73ff91d1c6c35c08017ede844ddfe.php:429
+msgid "Ad"
+msgstr ""
+
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:56
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:92
+msgid "Help Links"
+msgstr ""
+
+#: cache/53/5389d71d5cffdce9690ac31a85334b7d.php:69
+msgid "Add a new Help page"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:100
+msgid ""
+"Select where the Navigation Menu should be positioned. Once selected please "
+"refresh your browser window to apply changes."
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:108
+msgid "Use the default configured by your administrator"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:140
+msgid "Force current Library duration?"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:146
+msgid ""
+"Assign all Media items to Playlists based on their Library duration, and "
+"make it sticky so that changes in the library are not pulled into Layouts."
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:157
+msgid "Auto show thumbnail column?"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:163
+msgid ""
+"When requesting a screenshot from a display should the Thumbnail column be "
+"automatically shown if it's not visible?"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:174
+msgid "Clear all auto submit form choices?"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:180
+msgid ""
+"If you have selected to automatically submit any forms, tick here to reset."
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:191
+msgid "Always use manual Add User form?"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:197
+msgid ""
+"If selected the manual Add User form will always open when you click Add "
+"User, otherwise the onboarding form will open."
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:208
+msgid "Remember Folder tree state globally?"
+msgstr ""
+
+#: cache/ac/ac3e584ac0c213f72d59f9ca30c4d6d0.php:214
+msgid ""
+"When enabled the Folder tree state will be saved globally, each Page will "
+"remember the same state. If disabled, the Folder tree state will be saved "
+"per Page."
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:60
+#, no-php-format
+msgid "Turn Features on/off for %group%"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:100
+msgid ""
+"Check or un-check the options against each item to control whether access to "
+"a Feature is allowed or not."
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:145
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:204
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:401
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:483
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:609
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:691
+msgid "Feature"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:178
+msgid ""
+"Run reporting on a variety of different KPI's and metrics applicable to the "
+"Features enabled."
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:237
+msgid "Organise content sharing with Folders"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:259
+msgid ""
+"Media Library that stores file based content for use in Layouts, DataSets, "
+"Playlists and Menu Boards"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:309
+msgid "Layout Design"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:309
+msgid ""
+"Allow content creators to create Layouts - which hold the content you want "
+"to show on your Displays"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:331
+msgid "Ensure ordering by grouping Layouts into Campaigns"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:353
+msgid "Tagging"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:353
+msgid "Organise and filter items by using Tags"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:375
+msgid "Administrative access to Fonts"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:434
+msgid "Scheduling"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:434
+msgid "Create and manage Scheduled Events for Displays and Display Groups"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:456
+msgid "Display Management"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:456
+msgid "Connect and manage Displays."
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:516
+msgid "User functions"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:516
+msgid "User profile preferences for the logged in User"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:538
+msgid ""
+"Notification Centre allows for users to to create/edit Notifications sent to "
+"other Users or used in Layouts"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:560
+msgid "User Management"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:560
+msgid ""
+"Manage Users that can authenticate with the CMS. Create and organise them "
+"into User Groups to enable 'Group Features'"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:582
+msgid "Dashboards bring together key features for Users"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:642
+msgid ""
+"Restricted high level access advised - potentially damaging system settings"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:664
+msgid "Tools to diagnose problems when seeking help"
+msgstr ""
+
+#: cache/ac/acd2becdd62dc03267f16bd82bccca02.php:724
+msgid "Third party extensions to the platform."
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:57
+#: lib/Controller/Display.php:1227 lib/Controller/Display.php:1240
+msgid "Transfer to another CMS"
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:91
+msgid ""
+"Please note: Once the CMS Address and Key are authenticated in this form the "
+"Display will attempt to register with the CMS Instance details entered. Once "
+"transferred the Display will stop communicating with this CMS Instance."
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:99
+msgid "New CMS Address"
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:102
+msgid "Full URL to the new CMS, including https://"
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:110
+msgid "New CMS Key"
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:113
+msgid "CMS Secret Key associated with the provided new CMS Address"
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:121
+msgid "Two Factor Code"
+msgstr ""
+
+#: cache/ac/ac5b1aad2b6205f9bc274f1359b75b09.php:124
+msgid "Please enter your Two Factor authentication code"
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:60
+#, no-php-format
+msgid "Audio for %name%"
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:128
+msgid "Audio Media"
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:134
+msgid "Select the audio file that should be played when this Widget starts."
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:148
+msgid ""
+"The currently selected audio has been retired, please select a new item or "
+"cancel to keep the current one."
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:162
+msgid "Volume"
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:168
+msgid "Enter the volume percentage for this audio to play at."
+msgstr ""
+
+#: cache/cb/cb859ea5ab72e9f3493885ef86b7e00e.php:185
+msgid "Should the audio loop if it finishes before the widget has finished?"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:82
+msgid "Library Count"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:117
+msgid "Number of media items"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:134
+msgid "Size of media items"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:166
+msgid "Tidy library"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:189
+msgid "Unused media"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:232
+msgid "Unreleased media"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:251
+msgid "Filename"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:259
+msgid "Widget cache?"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:353
+msgid "No unused media in the Library"
+msgstr ""
+
+#: cache/a6/a6961ad1309d0252e74523600434b891.php:399
+msgid "No unreleased media in the Library"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:67
+msgid "Existing"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:79
+#, no-php-format
+msgid "%themeName% needs to set-up a connection to your MySQL database."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:90
+msgid ""
+"If you have not yet created an empty database and database user for Xibo to "
+"use, and know the user name / password of a MySQL administrator stay on this "
+"tab, otherwise click \"Use Existing\"."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:101
+msgid "Create a new database"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:107
+msgid "Select to create a new database"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:118
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:272
+msgid "Host"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:124
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:278
+msgid ""
+"Please enter the hostname for the MySQL server. This is usually localhost."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:141
+msgid ""
+"Please enter the user name of an account that has administrator privileges "
+"on the MySQL server."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:158
+msgid "Please enter password for the Admin account."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:169
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:289
+msgid "Database Name"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:175
+msgid "Please enter the name of the database that should be created."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:186
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:306
+msgid "Database Username"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:192
+msgid "Please enter the name of the database user that should be created."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:203
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:323
+msgid "Database Password"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:209
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:329
+msgid "Please enter a password for this user."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:220
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:340
+msgid "CA File"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:226
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:346
+msgid "To connect to a MySQL server over SSL, enter the path to the CA file."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:237
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:357
+msgid "Verify CA Identity?"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:243
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:363
+msgid "Turn this off for self-signed certificates."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:255
+msgid "Use an existing database"
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:261
+msgid ""
+"Select to use an existing database. Please note that when you use an "
+"existing database it must be empty of all other contents."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:295
+msgid "Please enter the name of the database that should be used."
+msgstr ""
+
+#: cache/a6/a68cd8fbba21a5d01d8df07d772babb8.php:312
+msgid "Please enter the name of the database user that should be used."
+msgstr ""
+
+#: cache/a6/a67305b481b575d0fe4a9a139d9bd3a7.php:118
+msgid "What type of display is this profile intended for?"
+msgstr ""
+
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:91
+msgid "The report will be available 6AM every day."
+msgstr ""
+
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:167
+msgid "Only my schedules?"
+msgstr ""
+
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:187
+msgid "Report Name"
+msgstr ""
+
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:207
+msgid "Previous Run"
+msgstr ""
+
+#: cache/10/109505e4a319268e245a38a61bbaa9ff.php:223
+msgid "Failed Message"
+msgstr ""
+
+#: cache/10/10a3d62a635559ddeaa4c9ab8b676aaf.php:57
+msgid "Delete this RSS?"
+msgstr ""
+
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:57
+msgid "Edit Report Schedule"
+msgstr ""
+
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:92
+msgid "Schedule Name"
+msgstr ""
+
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:109
+msgid ""
+"Set a future date and time to run this report. Leave blank to run from the "
+"next collection point."
+msgstr ""
+
+#: cache/d1/d110e14d54599ecfdbf41959db2f2aaa.php:126
+msgid ""
+"Set a future date and time to end the schedule. Leave blank to run "
+"indefinitely."
+msgstr ""
+
+#: cache/d0/d0cbe599603d68ffbcfef9b7b507a1dc.php:57
+msgid "Delete Application"
+msgstr ""
+
+#: cache/d0/d0cbe599603d68ffbcfef9b7b507a1dc.php:91
+msgid "Are you sure you want to delete this application? This cannot be undone"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:59
+msgid "Edit Column"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:144
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:144
+msgid "The heading for this Column. You cannot use a column name with spaces."
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:161
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:161
+msgid "Select the Column Type"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:178
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:178
+msgid "The DataType of the Intended Data"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:195
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:195
+msgid "A comma separated list of items to present in a combo box"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:206
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:206
+msgid "Remote Data Path"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:215
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:215
+msgid ""
+"Give the JSON-path in the remote data for the value that you want to fill "
+"this column. This path should be relative to the DataRoot configured on the "
+"DataSet."
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:224
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:224
+msgid ""
+"Provide Column number relative to the spreadsheet, numeration starts from 0 "
+"ie to get values from Column A from spreadsheet to this column enter 0"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:238
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:238
+msgid "Column Order"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:244
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:244
+msgid "The order this column should be displayed in when entering data"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:261
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:261
+msgid ""
+"Optional message to be displayed under the input when entering data for this "
+"column"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:278
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:278
+msgid "Enter a MySQL statement suitable to use in a 'SELECT' statement"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:289
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:289
+msgid "Filter?"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:295
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:295
+msgid "Show as a filter option on the Data Entry Page?"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:318
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:318
+msgid "Enter a PHP date format to parse the dates from the source."
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:329
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:329
+msgid "Sort?"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:335
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:335
+msgid ""
+"Enable sorting on the Data Entry Page? We recommend that the number of "
+"sortable columns is kept to a minimum."
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:352
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:352
+msgid "Should the value for this Column be required?"
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:363
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:363
+msgid ""
+"Two substitutions are available for Formula columns: [DisplayId] and "
+"[DisplayGeoLocation]. They will be substituted at run time with the Display "
+"ID / Display Geo Location (MySQL GEOMETRY)."
+msgstr ""
+
+#: cache/15/15895aad4f7efd6a8c70d32de3acade4.php:369
+#: cache/13/131c00db72c2b75f26550bfaa5001cca.php:369
+msgid ""
+"Client side formula is also available for Formula columns : "
+"$dateFormat(columnName,format,language), for example $dateFormat(date,l,de), "
+"would return textual representation of a day in German language from the "
+"full date in date column"
+msgstr ""
+
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:58
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:74
+msgid "Add Notification"
+msgstr ""
+
+#: cache/70/70de6bd7664e78871df5ca572035dc0b.php:287
+msgid "Add an attachment?"
+msgstr ""
+
+#: cache/70/70969bdf4db43f658c0b2fed463339eb.php:66
+#, no-php-format
+msgid "Manage Membership for %displayName%"
+msgstr ""
+
+#: cache/3e/3e3117c1d042d50f9efa8908d5b286c1.php:60
+#, no-php-format
+msgid "Data set %name%"
+msgstr ""
+
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:113
+msgid "My Read"
+msgstr ""
+
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:116
+msgid "My Unread"
+msgstr ""
+
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:139
+msgid "Report"
+msgstr ""
+
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:362
+msgid "Browse/Add attachment"
+msgstr ""
+
+#: cache/8e/8ed097fa892b566aceba8918bea76fa5.php:384
+msgid "Browse/Add Attachment"
+msgstr ""
+
+#: cache/5b/5b618f049877ed3965ece85d731e1cc7.php:57
+msgid "Delete Task"
+msgstr ""
+
+#: cache/5b/5b618f049877ed3965ece85d731e1cc7.php:91
+msgid "Are you sure you want to delete this task? This cannot be undone"
+msgstr ""
+
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:60
+msgid "Edit RSS"
+msgstr ""
+
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:311
+msgid "Generate new address?"
+msgstr ""
+
+#: cache/5b/5bc2d3db7641d3297c49eb9f84284e6b.php:317
+msgid ""
+"Tick this box if you want to generate a new address for this RSS feed. You "
+"might want to do this if you think someone is accessing it unauthorised."
+msgstr ""
+
+#: cache/5b/5b72c53542bfbaa4cd431216826e8495.php:57
+msgid "Logout User"
+msgstr ""
+
+#: cache/5b/5b72c53542bfbaa4cd431216826e8495.php:91
+msgid "Are you sure you want to logout this user?"
+msgstr ""
+
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:100
+msgid "Are you sure you want to remove this widget?"
+msgstr ""
+
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:102
+msgid "This action cannot be undone."
+msgstr ""
+
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:114
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:119
+msgid "Also delete from the Library?"
+msgstr ""
+
+#: cache/b3/b382fc831f12781ef0db75b1f3e0d869.php:125
+msgid ""
+"This widget is linked to Media in the Library. Check this option to also "
+"delete that Media."
+msgstr ""
+
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:152
+msgid "The Name of the Layout - (1 - 100 characters)"
+msgstr ""
+
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:225
+msgid "Code Identifier"
+msgstr ""
+
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:231
+msgid ""
+"Enter letters/numbers, without spaces, which will be used to identify this "
+"Layout when creating Interactive Actions."
+msgstr ""
+
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:248
+msgid "Retire this layout or not? It will no longer be visible in lists"
+msgstr ""
+
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:265
+msgid ""
+"Enable the collection of Proof of Play statistics for this Layout. Ensure "
+"that ‘Enable Stats Collection’ is set to ‘On’ in the Display Settings."
+msgstr ""
+
+#: cache/b3/b34a9706d2d88f5712b31fc2a7efef34.php:283
+msgid "An optional description of the Layout. (1 - 250 characters)"
+msgstr ""
+
+#: cache/11/11aa147fd2ce03630383759787d9ec7f.php:59
+msgid "Edit Display Group"
+msgstr ""
+
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:104
+msgid "This Command has a default Command String."
+msgstr ""
+
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:122
+msgid "The Command String for this Command on this display"
+msgstr ""
+
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:135
+msgid "This Command has a default Valildation String."
+msgstr ""
+
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:153
+msgid "The Validation String for this Command on this display"
+msgstr ""
+
+#: cache/b7/b706afc3aec6f4121ddaf447759d601b.php:166
+msgid "This Command has a default setting for creating alerts."
+msgstr ""
+
+#: cache/20/20d56467e17c048a093ad7168955489f.php:61
+#, no-php-format
+msgid "Move Folder - %name%"
+msgstr ""
+
+#: cache/20/20d56467e17c048a093ad7168955489f.php:159
+msgid "Merge?"
+msgstr ""
+
+#: cache/20/20d56467e17c048a093ad7168955489f.php:165
+msgid ""
+"Should we merge content of the original folder into the selected folder?"
+msgstr ""
+
+#: cache/20/20d56467e17c048a093ad7168955489f.php:176
+msgid ""
+"With merge selected, all Objects (Media, Layouts etc) and any sub-folders "
+"currently in the original folder will be moved to the selected folder. "
+"Original folder will be deleted"
+msgstr ""
+
+#: cache/27/27244fa48a299a5327098c472aa8fcba.php:159
+msgid ""
+"Edit multiple sharing at the same time. Elements shown with an indeterminate "
+"state [-], result from a difference in sharing already set. All changes set "
+"here will be applied to all selected elements."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:61
+msgid "On/Off Timers"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:65
+msgid "Picture Settings"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:69
+msgid "Lock Settings"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:428
+msgid "Send progress while downloading"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:431
+msgid ""
+"How often, in minutes, should the Display send its download progress while "
+"it is downloading new content?"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:481
+msgid ""
+"Use the form fields to create On/Off timings for the monitor for specific "
+"days of the week as required."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:491
+msgid "Please note:"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:501
+msgid ""
+"When the monitor is 'Off' it will not be able to receive content updates. "
+"With the next timed 'On' the monitor will connect to the CMS and get content/"
+"schedule updates."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:591
+msgid ""
+"Control picture settings using the fields below. Use the sliders to set the "
+"required range for each setting."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:608
+msgid "Backlight"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:614
+msgid "Contrast"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:620
+msgid "Brightness"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:626
+msgid "Sharpness"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:632
+msgid "Horizontal Sharpness"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:638
+msgid "Vertical Sharpness"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:644
+msgid "Color"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:650
+msgid "Tint"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:656
+msgid "Color Temperature"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:662
+msgid "Dynamic Contrast"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:668
+msgid "Super Resolution"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:674
+msgid "Color Gamut"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:680
+msgid "Dynamic Color"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:686
+msgid "Noise Reduction"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:692
+msgid "MPEG Noise Reduction"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:698
+msgid "Black Level"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:704
+msgid "Gamma"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:725
+msgid "Warm"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:731
+msgid "Cool"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:777
+msgid "usblock"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:783
+msgid ""
+"Set access to any device that uses the monitors USB port. Set to ‘False’ the "
+"monitor will not accept input or read from USB ports."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:789
+msgid "osdlock"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:795
+msgid ""
+"Set access to the monitor settings via the remote control. Set To ‘False’ "
+"the remote control will not change the volume, brightness etc of the monitor."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:801
+msgid "False"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:807
+msgid "True"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:895
+msgid "Keylock (local)"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:901
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:913
+msgid "Set the allowed key input for the monitor."
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:907
+msgid "Keylock (remote)"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:919
+msgid "Allow All"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:925
+msgid "Block All"
+msgstr ""
+
+#: cache/b2/b242b0ea0328919f86e7bbc8b15d11ae.php:931
+msgid "Power Only"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:166
+msgid "Folder info"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:172
+msgid "Number of times used as a home folder:"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:178
+msgid "Shared with:"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:187
+msgid "Not shared"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:194
+msgid "Contents:"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:202
+msgid "Section"
+msgstr ""
+
+#: cache/93/930c4e6077a0eebe7e269a3f0e9d54fd.php:227
+msgid "Empty folder"
+msgstr ""
+
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:60
+#, no-php-format
+msgid "Expiry for %name%"
+msgstr ""
+
+#: cache/19/194e22f151dbadd4e3a62e9371602741.php:129
+msgid "Delete on Expiry?"
+msgstr ""
+
+#: cache/19/199e4757d5dd04d073c54aef2b324867.php:57
+msgid "Delete Module Template"
+msgstr ""
+
+#: cache/19/199e4757d5dd04d073c54aef2b324867.php:91
+msgid "Are you sure you want to delete this Template? This cannot be undone"
+msgstr ""
+
+#: cache/ce/ce52d2aeb2c4862f9ff23cfd3d67e2e7.php:60
+#, no-php-format
+msgid "Retire %layout%"
+msgstr ""
+
+#: cache/ce/ce52d2aeb2c4862f9ff23cfd3d67e2e7.php:100
+msgid "Are you sure you want to retire this item?"
+msgstr ""
+
+#: cache/ce/cee07744a1d4a288c81e3d8649a8ddc2.php:57
+msgid "Delete Report Schedule"
+msgstr ""
+
+#: cache/98/986f7021e2045bcdd928290f19f07a9c.php:121
+msgid ""
+"The CMS is temporarily off-line as an upgrade is in progress. Please check "
+"with your system administrator for updates or refresh your page in a few "
+"minutes."
+msgstr ""
+
+#: lib/Controller/Task.php:223 lib/Controller/Widget.php:304
+#: lib/Controller/Template.php:588 lib/Controller/DisplayGroup.php:809
+#: lib/Controller/DisplayGroup.php:2697 lib/Controller/DataSet.php:698
+#: lib/Controller/Notification.php:639 lib/Controller/Campaign.php:650
+#: lib/Controller/Campaign.php:1387 lib/Controller/Folder.php:271
+#: lib/Controller/Playlist.php:798 lib/Controller/SyncGroup.php:293
+#: lib/Controller/DataSetColumn.php:389 lib/Controller/Tag.php:361
+#: lib/Controller/Layout.php:496 lib/Controller/CypressTest.php:284
+#: lib/Controller/DisplayProfile.php:320 lib/Controller/DisplayProfile.php:691
+#: lib/Controller/User.php:636 lib/Controller/Applications.php:459
+#: lib/Controller/Resolution.php:315 lib/Controller/Region.php:234
+#: lib/Controller/Command.php:421 lib/Controller/DataSetRss.php:332
+#: lib/Controller/UserGroup.php:432 lib/Controller/DayPart.php:389
+#, php-format
+msgid "Added %s"
+msgstr ""
+
+#: lib/Controller/Task.php:292 lib/Controller/Widget.php:600
+#: lib/Controller/DisplayGroup.php:1038 lib/Controller/DataSet.php:1029
+#: lib/Controller/DataSet.php:1091 lib/Controller/ScheduleReport.php:426
+#: lib/Controller/Notification.php:761 lib/Controller/Campaign.php:961
+#: lib/Controller/MenuBoard.php:446 lib/Controller/Folder.php:339
+#: lib/Controller/Playlist.php:998 lib/Controller/Transition.php:158
+#: lib/Controller/Library.php:1466 lib/Controller/SyncGroup.php:575
+#: lib/Controller/DataSetColumn.php:596 lib/Controller/Tag.php:547
+#: lib/Controller/Connector.php:216 lib/Controller/Layout.php:675
+#: lib/Controller/Layout.php:797 lib/Controller/Layout.php:942
+#: lib/Controller/MenuBoardCategory.php:439
+#: lib/Controller/DisplayProfile.php:507 lib/Controller/User.php:929
+#: lib/Controller/User.php:2560 lib/Controller/Applications.php:565
+#: lib/Controller/Resolution.php:393 lib/Controller/Region.php:384
+#: lib/Controller/Region.php:554 lib/Controller/Display.php:1939
+#: lib/Controller/MenuBoardProduct.php:640 lib/Controller/Developer.php:404
+#: lib/Controller/PlayerSoftware.php:379 lib/Controller/Command.php:528
+#: lib/Controller/DataSetRss.php:561 lib/Controller/UserGroup.php:618
+#: lib/Controller/DayPart.php:501
+#, php-format
+msgid "Edited %s"
+msgstr ""
+
+#: lib/Controller/Task.php:339 lib/Controller/Widget.php:685
+#: lib/Controller/DisplayGroup.php:1094 lib/Controller/DataSet.php:1181
+#: lib/Controller/ScheduleReport.php:464 lib/Controller/Notification.php:820
+#: lib/Controller/Campaign.php:1040 lib/Controller/SavedReport.php:283
+#: lib/Controller/MenuBoard.php:523 lib/Controller/Folder.php:441
+#: lib/Controller/Playlist.php:1079 lib/Controller/Library.php:1064
+#: lib/Controller/SyncGroup.php:653 lib/Controller/DataSetColumn.php:685
+#: lib/Controller/Tag.php:632 lib/Controller/Layout.php:1084
+#: lib/Controller/MenuBoardCategory.php:519
+#: lib/Controller/DisplayProfile.php:586 lib/Controller/User.php:1046
+#: lib/Controller/Applications.php:598 lib/Controller/Resolution.php:442
+#: lib/Controller/Region.php:441 lib/Controller/Display.php:1995
+#: lib/Controller/MenuBoardProduct.php:722 lib/Controller/Developer.php:693
+#: lib/Controller/PlayerSoftware.php:285 lib/Controller/Command.php:580
+#: lib/Controller/DataSetRss.php:656 lib/Controller/UserGroup.php:672
+#: lib/Controller/DayPart.php:616
+#, php-format
+msgid "Deleted %s"
+msgstr ""
+
+#: lib/Controller/Task.php:387
+#, php-format
+msgid "Run Now set on %s"
+msgstr ""
+
+#: lib/Controller/Task.php:422
+#, php-format
+msgid "Task with class name %s not found"
+msgstr ""
+
+#: lib/Controller/Widget.php:181
+msgid "This Playlist is not shared with you with edit permission"
+msgstr ""
+
+#: lib/Controller/Widget.php:187
+msgid ""
+"Sorry there is an error with this request, cannot set inherited permissions"
+msgstr ""
+
+#: lib/Controller/Widget.php:193 lib/Controller/Widget.php:470
+#: lib/Controller/Widget.php:654 lib/Controller/Widget.php:816
+#: lib/Controller/Widget.php:985 lib/Controller/Widget.php:1078
+#: lib/Controller/Widget.php:1520 lib/Controller/Widget.php:1633
+#: lib/Controller/Widget.php:1721 lib/Controller/Playlist.php:1337
+#: lib/Controller/Playlist.php:1502 lib/Controller/Playlist.php:2033
+#: lib/Controller/Layout.php:749 lib/Controller/Layout.php:852
+#: lib/Controller/Layout.php:1135 lib/Controller/Region.php:201
+#: lib/Controller/Region.php:348 lib/Controller/Region.php:434
+#: lib/Controller/Region.php:499 lib/Controller/Region.php:720
+#: lib/Controller/Region.php:818 lib/Helper/XiboUploadHandler.php:489
+msgid "This Layout is not a Draft, please checkout."
+msgstr ""
+
+#: lib/Controller/Widget.php:197 lib/Controller/Widget.php:474
+#: lib/Controller/Widget.php:658 lib/Controller/Widget.php:820
+#: lib/Controller/Widget.php:989 lib/Controller/Widget.php:1082
+#: lib/Controller/Widget.php:1524 lib/Controller/Playlist.php:1340
+#: lib/Controller/Library.php:1204
+msgid ""
+"This Playlist is dynamically managed so cannot accept manual assignments."
+msgstr ""
+
+#: lib/Controller/Widget.php:211
+msgid "No module enabled of that type."
+msgstr ""
+
+#: lib/Controller/Widget.php:217
+msgid "Sorry but a file based Widget must be assigned not created"
+msgstr ""
+
+#: lib/Controller/Widget.php:225
+msgid "Canvas Widgets can only be added to a Zone"
+msgstr ""
+
+#: lib/Controller/Widget.php:230
+msgid "Only one Canvas Widget allowed per Playlist"
+msgstr ""
+
+#: lib/Controller/Widget.php:255
+msgid "Please select a template"
+msgstr ""
+
+#: lib/Controller/Widget.php:265
+msgid "Expecting a static template"
+msgstr ""
+
+#: lib/Controller/Widget.php:327 lib/Controller/Widget.php:462
+#: lib/Controller/Widget.php:713 lib/Controller/Widget.php:808
+#: lib/Controller/Widget.php:881 lib/Controller/Widget.php:977
+#: lib/Controller/Widget.php:1070 lib/Controller/Widget.php:1426
+#: lib/Controller/Widget.php:1512 lib/Controller/Widget.php:1625
+#: lib/Controller/Widget.php:1713 lib/Controller/WidgetData.php:76
+#: lib/Controller/WidgetData.php:129 lib/Controller/WidgetData.php:205
+#: lib/Controller/WidgetData.php:267 lib/Controller/WidgetData.php:345
+msgid "This Widget is not shared with you with edit permission"
+msgstr ""
+
+#: lib/Controller/Widget.php:502
+msgid "Duration needs to be a positive value"
+msgstr ""
+
+#: lib/Controller/Widget.php:507
+msgid "Duration must be lower than 526000"
+msgstr ""
+
+#: lib/Controller/Widget.php:518
+msgid "This widget uses elements and can not be changed to a static template"
+msgstr ""
+
+#: lib/Controller/Widget.php:529
+msgid "You can only change to another template of the same type"
+msgstr ""
+
+#: lib/Controller/Widget.php:582
+#, php-format
+msgid "Your library reference %d does not exist."
+msgstr ""
+
+#: lib/Controller/Widget.php:644
+msgid "This Widget is not shared with you with delete permission"
+msgstr ""
+
+#: lib/Controller/Widget.php:687
+msgid "Deleted Widget"
+msgstr ""
+
+#: lib/Controller/Widget.php:726 lib/Controller/Region.php:662
+#: lib/Connector/OpenWeatherMapConnector.php:689
+msgid "North"
+msgstr ""
+
+#: lib/Controller/Widget.php:727 lib/Controller/Region.php:663
+msgid "North East"
+msgstr ""
+
+#: lib/Controller/Widget.php:728 lib/Controller/Region.php:664
+#: lib/Connector/OpenWeatherMapConnector.php:691
+msgid "East"
+msgstr ""
+
+#: lib/Controller/Widget.php:729 lib/Controller/Region.php:665
+msgid "South East"
+msgstr ""
+
+#: lib/Controller/Widget.php:730 lib/Controller/Region.php:666
+#: lib/Connector/OpenWeatherMapConnector.php:693
+msgid "South"
+msgstr ""
+
+#: lib/Controller/Widget.php:731 lib/Controller/Region.php:667
+msgid "South West"
+msgstr ""
+
+#: lib/Controller/Widget.php:732 lib/Controller/Region.php:668
+#: lib/Connector/OpenWeatherMapConnector.php:695
+msgid "West"
+msgstr ""
+
+#: lib/Controller/Widget.php:733 lib/Controller/Region.php:669
+msgid "North West"
+msgstr ""
+
+#: lib/Controller/Widget.php:850
+msgid "Unknown transition type"
+msgstr ""
+
+#: lib/Controller/Widget.php:857
+msgid "Edited Transition"
+msgstr ""
+
+#: lib/Controller/Widget.php:887 lib/Controller/Widget.php:995
+msgid ""
+"Audio cannot be attached to a Sub-Playlist Widget. Please attach it to the "
+"Widgets inside the Playlist"
+msgstr ""
+
+#: lib/Controller/Widget.php:1026
+msgid "Edited Audio"
+msgstr ""
+
+#: lib/Controller/Widget.php:1095
+msgid "Removed Audio"
+msgstr ""
+
+#: lib/Controller/Widget.php:1116 lib/Controller/Widget.php:1335
+msgid "This Region is not shared with you"
+msgstr ""
+
+#: lib/Controller/Widget.php:1121 lib/Controller/Widget.php:1340
+msgid "This Widget is not shared with you"
+msgstr ""
+
+#: lib/Controller/Widget.php:1281
+msgid "No data providers configured"
+msgstr ""
+
+#: lib/Controller/Widget.php:1408
+msgid "Problem rendering widget"
+msgstr ""
+
+#: lib/Controller/Widget.php:1576
+msgid "Edited Expiry"
+msgstr ""
+
+#: lib/Controller/Widget.php:1640
+msgid "You can only set a target region on a Widget in the drawer."
+msgstr ""
+
+#: lib/Controller/Widget.php:1664
+msgid "Target region set"
+msgstr ""
+
+#: lib/Controller/Widget.php:1744
+msgid "Invalid element JSON"
+msgstr ""
+
+#: lib/Controller/Widget.php:1750
+msgid ""
+"At least one element is required for this Widget. Please delete it if you no "
+"longer need it."
+msgstr ""
+
+#: lib/Controller/Widget.php:1925
+msgid "Saved elements"
+msgstr ""
+
+#: lib/Controller/Widget.php:1951
+msgid "Please supply a propertyId"
+msgstr ""
+
+#: lib/Controller/Widget.php:2011
+msgid "Please provide a widgetId"
+msgstr ""
+
+#: lib/Controller/Widget.php:2030
+msgid "Widget does not have a data type"
+msgstr ""
+
+#: lib/Controller/Template.php:179
+msgid "Alter Template"
+msgstr ""
+
+#: lib/Controller/Template.php:248 lib/Controller/DisplayGroup.php:365
+#: lib/Controller/DataSet.php:291 lib/Controller/Campaign.php:362
+#: lib/Controller/MenuBoard.php:179 lib/Controller/Playlist.php:422
+#: lib/Controller/Library.php:704 lib/Controller/Layout.php:1929
+#: lib/Controller/Display.php:957
+msgid "Move to Folder"
+msgstr ""
+
+#: lib/Controller/Template.php:469 lib/Controller/Template.php:666
+msgid "You do not have permissions to view this layout"
+msgstr ""
+
+#: lib/Controller/Template.php:735
+#, php-format
+msgid "Saved %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:324 lib/Controller/SyncGroup.php:176
+#: lib/Controller/UserGroup.php:184
+msgid "Members"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:411 lib/Controller/Display.php:1028
+msgid "Assign Files"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:418 lib/Controller/Display.php:1035
+msgid "Assign Layouts"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1085
+msgid "Displays should be deleted using the Display delete operation"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1155 lib/Controller/DisplayGroup.php:1291
+#: lib/Controller/DisplayGroup.php:1391 lib/Controller/DisplayGroup.php:1495
+msgid ""
+"This is a Display specific Display Group and its assignments cannot be "
+"modified."
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1166
+msgid "Displays cannot be manually assigned to a Dynamic Group"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1191 lib/Controller/DisplayGroup.php:1214
+#: lib/Controller/DisplayGroup.php:1316
+msgid "Access Denied to Display"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1237 lib/Controller/SyncGroup.php:422
+#, php-format
+msgid "Displays assigned to %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1305
+msgid "Displays cannot be manually unassigned to a Dynamic Group"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1329
+#, php-format
+msgid "Displays unassigned from %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1405
+msgid "DisplayGroups cannot be manually assigned to a Dynamic Group"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1416 lib/Controller/DisplayGroup.php:1429
+#: lib/Controller/Display.php:2164 lib/Controller/Display.php:2178
+msgid "Access Denied to DisplayGroup"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1441
+#, php-format
+msgid "DisplayGroups assigned to %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1509
+msgid "DisplayGroups cannot be manually unassigned to a Dynamic Group"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1525
+#, php-format
+msgid "DisplayGroups unassigned from %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1635 lib/Controller/DisplayGroup.php:1649
+msgid ""
+"You have selected media that you no longer have permission to use. Please "
+"reload the form."
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1661
+#, php-format
+msgid "Files assigned to %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1733
+#, php-format
+msgid "Files unassigned from %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1842 lib/Controller/DisplayGroup.php:1854
+msgid ""
+"You have selected a layout that you no longer have permission to use. Please "
+"reload the form."
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1866
+#, php-format
+msgid "Layouts assigned to %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:1939
+#, php-format
+msgid "Layouts unassigned from %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2025 lib/Controller/DisplayGroup.php:2074
+#: lib/Controller/DisplayGroup.php:2229 lib/Controller/DisplayGroup.php:2279
+#: lib/Controller/DisplayGroup.php:2422 lib/Controller/DisplayGroup.php:2534
+#: lib/Controller/DisplayGroup.php:2900
+#, php-format
+msgid "Command Sent to %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2161 lib/Controller/DisplayGroup.php:2359
+msgid "Please provide a Layout ID or Campaign ID"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2171 lib/Controller/DisplayGroup.php:2369
+msgid "Please provide Layout specific campaign ID"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2177 lib/Controller/DisplayGroup.php:2375
+msgid "Cannot find layout by campaignId"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2182 lib/Controller/DisplayGroup.php:2380
+msgid "Please provide Layout id or Campaign id"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2557 lib/Controller/DisplayProfile.php:578
+#: lib/Controller/DisplayProfile.php:608 lib/Controller/DisplayProfile.php:667
+msgid "You do not have permission to delete this profile"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2809
+#, php-format
+msgid "Display %s moved to Folder %s"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2889
+msgid "Please provide a Trigger Code"
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2965
+msgid "No criteria found."
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:2984
+msgid ""
+"Invalid criteria format. Metric, value, and ttl must all be present and not "
+"empty."
+msgstr ""
+
+#: lib/Controller/DisplayGroup.php:3005
+msgid "Schedule criteria updates sent to players."
+msgstr ""
+
+#: lib/Controller/DataSet.php:234
+msgid "View RSS"
+msgstr ""
+
+#: lib/Controller/DataSet.php:244
+msgid "View Data Connector"
+msgstr ""
+
+#: lib/Controller/DataSet.php:256
+msgid "Import CSV"
+msgstr ""
+
+#: lib/Controller/DataSet.php:302
+msgid "Export (CSV)"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1086
+msgid "This DataSet does not have a data connector"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1119 lib/Entity/DataSet.php:1037
+msgid "Lookup Tables cannot be deleted"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1173
+msgid "There is data assigned to this data set, cannot delete."
+msgstr ""
+
+#: lib/Controller/DataSet.php:1282
+#, php-format
+msgid "DataSet %s moved to Folder %s"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1406
+#, php-format
+msgid "Copied %s as %s"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1564
+msgid "Missing JSON Body"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1571
+msgid "Malformed JSON body, rows and uniqueKeys are required"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1611
+#, php-format
+msgid "Incorrect date provided %s, expected date format Y-m-d H:i:s "
+msgstr ""
+
+#: lib/Controller/DataSet.php:1665
+msgid "No data found in request body"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1669
+#, php-format
+msgid "Imported JSON into %s"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1710 lib/Entity/DataSet.php:893
+msgid "URI can not be longer than 250 characters"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1738
+#, php-format
+msgid "Run Test-Request for %s"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1853
+#, php-format
+msgid "Cache cleared for %s"
+msgstr ""
+
+#: lib/Controller/DataSet.php:1950
+msgid "URL not found in data connector script"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:154
+msgid "Unknown or removed report."
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:159
+msgid "Run once a day, midnight"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:163
+msgid "Run once a week, midnight on Monday"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:168
+msgid "Run once a month, midnight, first of month"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:173
+msgid "Run once a year, midnight, Jan. 1"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:180
+msgid "This report schedule is active"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:184
+msgid "This report schedule is paused"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:197
+msgid "Open last saved report"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:207 lib/Controller/SavedReport.php:149
+msgid "Back to Reports"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:225
+msgid "Reset to previous run"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:254
+msgid "Pause"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:254
+msgid "Resume"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:269
+msgid "Delete all saved reports"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:335 lib/Controller/ScheduleReport.php:408
+msgid "Start time cannot be earlier than today"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:341 lib/Controller/ScheduleReport.php:414
+msgid "End time cannot be earlier than today"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:368
+msgid "Added Report Schedule"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:452 lib/Controller/ScheduleReport.php:691
+#: lib/Controller/SavedReport.php:242 lib/Controller/SavedReport.php:272
+msgid "You do not have permissions to delete this report schedule"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:458
+msgid ""
+"Report schedule cannot be deleted. Please ensure there are no saved reports "
+"against the schedule."
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:489
+msgid ""
+"You do not have permissions to delete the saved report of this report "
+"schedule"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:508
+msgid "Saved report cannot be deleted"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:515
+#, php-format
+msgid "Deleted all saved reports of %s"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:539 lib/Controller/ScheduleReport.php:749
+msgid "You do not have permissions to pause/resume this report schedule"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:544
+#, php-format
+msgid "Paused %s"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:547
+#, php-format
+msgid "Resumed %s"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:662
+msgid "You do not have permissions to reset this report schedule"
+msgstr ""
+
+#: lib/Controller/ScheduleReport.php:720
+msgid ""
+"You do not have permissions to delete saved reports of this report schedule"
+msgstr ""
+
+#: lib/Controller/Notification.php:252
+msgid "Mark as read?"
+msgstr ""
+
+#: lib/Controller/Notification.php:300
+msgid "Delete?"
+msgstr ""
+
+#: lib/Controller/Notification.php:630
+msgid "Problem moving uploaded file into the Attachment Folder"
+msgstr ""
+
+#: lib/Controller/Stats.php:413 lib/Report/ProofOfPlay.php:511
+msgid "Deleted from Layout"
+msgstr ""
+
+#: lib/Controller/Stats.php:415 lib/Controller/Stats.php:416
+#: lib/Controller/Stats.php:808 lib/Controller/Stats.php:810
+#: lib/Report/ProofOfPlay.php:518 lib/Report/ProofOfPlay.php:520
+msgid "Not Found"
+msgstr ""
+
+#: lib/Controller/Stats.php:483 lib/Controller/Stats.php:659
+#: lib/Controller/Stats.php:725 lib/Controller/Stats.php:1061
+#: lib/Report/ReportDefaultTrait.php:329
+msgid "No displays with View permissions"
+msgstr ""
+
+#: lib/Controller/Stats.php:553 lib/Report/Bandwidth.php:297
+msgid "Deleted Displays"
+msgstr ""
+
+#: lib/Controller/Stats.php:743
+msgid "Both fromDt/toDt should be provided"
+msgstr ""
+
+#: lib/Controller/Campaign.php:109
+msgid "This campaign is not compatible with the Campaign builder"
+msgstr ""
+
+#: lib/Controller/Campaign.php:311
+msgid "Preview Campaign"
+msgstr ""
+
+#: lib/Controller/Campaign.php:625
+msgid "Cannot assign layouts to an ad campaign during its creation"
+msgstr ""
+
+#: lib/Controller/Campaign.php:634
+msgid "You do not have permission to assign this Layout"
+msgstr ""
+
+#: lib/Controller/Campaign.php:931
+msgid "You are trying to assign a Layout that is not shared with you."
+msgstr ""
+
+#: lib/Controller/Campaign.php:1118 lib/Controller/Campaign.php:1245
+msgid "You cannot change the assignment of a Layout Specific Campaign"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1131
+msgid "Please select a Layout to assign."
+msgstr ""
+
+#: lib/Controller/Campaign.php:1137
+msgid "You do not have permission to assign the provided Layout"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1160
+#, php-format
+msgid "Assigned Layouts to %s"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1253
+msgid "Please provide a layout"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1327 lib/Controller/Campaign.php:1357
+msgid "You do not have permission to copy this Campaign"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1517
+#, php-format
+msgid "Layout %s moved to Folder %s"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1530
+msgid "Cannot assign a Draft Layout to a Campaign"
+msgstr ""
+
+#: lib/Controller/Campaign.php:1535
+msgid "Cannot assign a Template to a Campaign"
+msgstr ""
+
+#: lib/Controller/SavedReport.php:134
+msgid "Convert"
+msgstr ""
+
+#: lib/Controller/SavedReport.php:156
+msgid "Go to schedule"
+msgstr ""
+
+#: lib/Controller/SavedReport.php:167
+msgid "Export as PDF"
+msgstr ""
+
+#: lib/Controller/SavedReport.php:354 lib/XTR/ReportScheduleTask.php:254
+msgid ""
+"Chart could not be drawn because the CMS has not been configured with a "
+"Quick Chart URL."
+msgstr ""
+
+#: lib/Controller/SavedReport.php:457
+msgid "This report has already been converted to the latest version."
+msgstr ""
+
+#: lib/Controller/SavedReport.php:469
+msgid "Saved Report Converted to Schema Version 2"
+msgstr ""
+
+#: lib/Controller/MenuBoard.php:328
+msgid "Added Menu Board"
+msgstr ""
+
+#: lib/Controller/MenuBoard.php:624
+#, php-format
+msgid "Menu Board %s moved to Folder %s"
+msgstr ""
+
+#: lib/Controller/Action.php:349
+msgid "Please provide LayoutId"
+msgstr ""
+
+#: lib/Controller/Action.php:356 lib/Controller/Action.php:506
+#: lib/Controller/Action.php:570 lib/Controller/Layout.php:3129
+msgid "Layout is not checked out"
+msgstr ""
+
+#: lib/Controller/Action.php:364 lib/Controller/Action.php:521
+msgid "Action with specified Trigger Type already exists"
+msgstr ""
+
+#: lib/Controller/Action.php:384
+msgid "Added Action"
+msgstr ""
+
+#: lib/Controller/Action.php:528
+msgid "Edited Action"
+msgstr ""
+
+#: lib/Controller/Action.php:579
+msgid "Deleted Action"
+msgstr ""
+
+#: lib/Controller/Action.php:602
+msgid "Provided source is invalid. "
+msgstr ""
+
+#: lib/Controller/Module.php:261
+msgid "Preview is disabled"
+msgstr ""
+
+#: lib/Controller/Module.php:269
+#, php-format
+msgid "Configured %s"
+msgstr ""
+
+#: lib/Controller/Module.php:318
+msgid "Cleared the Cache"
+msgstr ""
+
+#: lib/Controller/Module.php:360
+msgid "Please provide a datatype"
+msgstr ""
+
+#: lib/Controller/Module.php:448
+msgid "Please provide an assetId"
+msgstr ""
+
+#: lib/Controller/Fault.php:109 lib/Entity/Layout.php:1958
+msgid "Can't create ZIP. Error Code: "
+msgstr ""
+
+#: lib/Controller/Fault.php:127
+msgid "Please select at least one option"
+msgstr ""
+
+#: lib/Controller/Fault.php:245
+msgid "Switched to Debug Mode"
+msgstr ""
+
+#: lib/Controller/Fault.php:265
+msgid "Switched to Normal Mode"
+msgstr ""
+
+#: lib/Controller/Folder.php:203
+msgid "Private Folder"
+msgstr ""
+
+#: lib/Controller/Folder.php:326
+msgid "Cannot edit root Folder"
+msgstr ""
+
+#: lib/Controller/Folder.php:385
+msgid "Cannot remove root Folder"
+msgstr ""
+
+#: lib/Controller/Folder.php:394
+msgid "Cannot remove Folder set as home Folder for a user"
+msgstr ""
+
+#: lib/Controller/Folder.php:396
+msgid "Change home Folder for Users using this Folder before deleting"
+msgstr ""
+
+#: lib/Controller/Folder.php:402
+msgid "Cannot remove Folder set as default Folder for new Displays"
+msgstr ""
+
+#: lib/Controller/Folder.php:404
+msgid "Change Default Folder for new Displays before deleting"
+msgstr ""
+
+#: lib/Controller/Folder.php:422
+msgid "Cannot remove Folder with content: "
+msgstr ""
+
+#: lib/Controller/Folder.php:424 lib/Controller/Folder.php:435
+msgid "Reassign objects from this Folder before deleting."
+msgstr ""
+
+#: lib/Controller/Folder.php:532
+msgid "Please select different folder, cannot move Folder to the same Folder"
+msgstr ""
+
+#: lib/Controller/Folder.php:538
+msgid ""
+"Please select different folder, cannot move Folder inside of one of its sub-"
+"folders"
+msgstr ""
+
+#: lib/Controller/Folder.php:543
+msgid ""
+"This Folder is already a sub-folder of the selected Folder, if you wish to "
+"move its content to the parent Folder, please check the merge checkbox."
+msgstr ""
+
+#: lib/Controller/DataSetData.php:164
+msgid "Error getting DataSet data, failed with following message: "
+msgstr ""
+
+#: lib/Controller/DataSetData.php:291
+msgid "Cannot add new rows to remote dataSet"
+msgstr ""
+
+#: lib/Controller/DataSetData.php:305
+msgid "Added Row"
+msgstr ""
+
+#: lib/Controller/DataSetData.php:478
+msgid "Cannot edit data of remote columns"
+msgstr ""
+
+#: lib/Controller/DataSetData.php:485
+msgid "Edited Row"
+msgstr ""
+
+#: lib/Controller/DataSetData.php:575
+msgid "row not found"
+msgstr ""
+
+#: lib/Controller/DataSetData.php:587
+msgid "Deleted Row"
+msgstr ""
+
+#: lib/Controller/MediaManager.php:117
+msgid "Widget cache"
+msgstr ""
+
+#: lib/Controller/Playlist.php:358
+msgid "This Playlist has enable stat collection set to ON"
+msgstr ""
+
+#: lib/Controller/Playlist.php:365
+msgid "This Playlist has enable stat collection set to OFF"
+msgstr ""
+
+#: lib/Controller/Playlist.php:372
+msgid "This Playlist has enable stat collection set to INHERIT"
+msgstr ""
+
+#: lib/Controller/Playlist.php:433 lib/Controller/Playlist.php:444
+#: lib/Controller/Library.php:775 lib/Controller/Library.php:781
+#: lib/Controller/Layout.php:1990 lib/Controller/Layout.php:1996
+msgid "Enable stats collection?"
+msgstr ""
+
+#: lib/Controller/Playlist.php:525 lib/Controller/Library.php:794
+msgid "Usage Report"
+msgstr ""
+
+#: lib/Controller/Playlist.php:674
+msgid "Please enter playlist name"
+msgstr ""
+
+#: lib/Controller/Playlist.php:717 lib/Controller/Playlist.php:975
+msgid ""
+"No filters have been set for this dynamic Playlist, please click the Filters "
+"tab to define"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1220 lib/Controller/Library.php:2383
+#: lib/Controller/Layout.php:2320 lib/Controller/Developer.php:632
+#, php-format
+msgid "Copied as %s"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1346
+msgid "Please provide Media to Assign"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1362
+msgid "You do not have permissions to use this media"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1367
+#, php-format
+msgid "You cannot assign file type %s to a playlist"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1427
+msgid "Media Assigned"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1512
+msgid "Cannot Save empty region playlist. Please add widgets"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1531
+msgid "Order Changed"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1669 lib/Controller/Playlist.php:1747
+msgid "Specified Playlist item is not in use."
+msgstr ""
+
+#: lib/Controller/Playlist.php:1740 lib/Controller/Library.php:2242
+#: lib/Controller/Layout.php:1840
+msgid "Preview Layout"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1815
+#, php-format
+msgid "For Playlist %s Enable Stats Collection is set to %s"
+msgstr ""
+
+#: lib/Controller/Playlist.php:1948
+#, php-format
+msgid "Playlist %s moved to Folder %s"
+msgstr ""
+
+#: lib/Controller/Playlist.php:2011
+msgid "Not a Region Playlist"
+msgstr ""
+
+#: lib/Controller/Playlist.php:2019
+msgid "Not a Playlist"
+msgstr ""
+
+#: lib/Controller/Playlist.php:2044 lib/Controller/Layout.php:381
+#, php-format
+msgid "Untitled %s"
+msgstr ""
+
+#: lib/Controller/Playlist.php:2108
+msgid "Conversion Successful"
+msgstr ""
+
+#: lib/Controller/Transition.php:120 lib/Controller/Transition.php:147
+msgid "Transition Config Locked"
+msgstr ""
+
+#: lib/Controller/Library.php:368
+#, php-format
+msgid "For Media %s Enable Stats Collection is set to %s"
+msgstr ""
+
+#: lib/Controller/Library.php:606
+#, php-format
+msgid "Expires %s"
+msgstr ""
+
+#: lib/Controller/Library.php:607
+msgid "Expired "
+msgstr ""
+
+#: lib/Controller/Library.php:630
+msgid ""
+"The uploaded image is too large and cannot be processed, please use another "
+"image."
+msgstr ""
+
+#: lib/Controller/Library.php:637
+msgid "This image will be resized according to set thresholds and limits."
+msgstr ""
+
+#: lib/Controller/Library.php:645
+msgid "This Media has enable stat collection set to ON"
+msgstr ""
+
+#: lib/Controller/Library.php:652
+msgid "This Media has enable stat collection set to OFF"
+msgstr ""
+
+#: lib/Controller/Library.php:659
+msgid "This Media has enable stat collection set to INHERIT"
+msgstr ""
+
+#: lib/Controller/Library.php:763 lib/Controller/Font.php:155
+#: lib/Controller/PlayerSoftware.php:186
+msgid "Download"
+msgstr ""
+
+#: lib/Controller/Library.php:1050
+msgid "This library item is in use."
+msgstr ""
+
+#: lib/Controller/Library.php:1230 lib/Controller/Library.php:1442
+#: lib/Controller/Library.php:2572
+msgid "Cannot set Expiry date in the past"
+msgstr ""
+
+#: lib/Controller/Library.php:1487 lib/Controller/Library.php:1552
+#: lib/Controller/Maintenance.php:97
+msgid "Sorry this function is disabled."
+msgstr ""
+
+#: lib/Controller/Library.php:1584 lib/Controller/Maintenance.php:269
+msgid "Library Tidy Complete"
+msgstr ""
+
+#: lib/Controller/Library.php:1667
+msgid "Cannot download region specific module"
+msgstr ""
+
+#: lib/Controller/Library.php:1836 web/xmds.php:156
+msgid "Invalid URL"
+msgstr ""
+
+#: lib/Controller/Library.php:1863
+msgid "Route is available through the API"
+msgstr ""
+
+#: lib/Controller/Library.php:1932 lib/Controller/Layout.php:2385
+msgid "No tags to assign"
+msgstr ""
+
+#: lib/Controller/Library.php:1943 lib/Controller/Layout.php:2396
+#, php-format
+msgid "Tagged %s"
+msgstr ""
+
+#: lib/Controller/Library.php:2006 lib/Controller/Layout.php:2461
+msgid "No tags to unassign"
+msgstr ""
+
+#: lib/Controller/Library.php:2017 lib/Controller/Layout.php:2471
+#, php-format
+msgid "Untagged %s"
+msgstr ""
+
+#: lib/Controller/Library.php:2164 lib/Controller/Library.php:2249
+msgid "Specified Media item is not in use."
+msgstr ""
+
+#: lib/Controller/Library.php:2456 lib/Middleware/Theme.php:143
+#, php-format
+msgid "This form accepts files up to a maximum size of %s"
+msgstr ""
+
+#: lib/Controller/Library.php:2580
+msgid "Provided URL is invalid"
+msgstr ""
+
+#: lib/Controller/Library.php:2596
+#, php-format
+msgid "Extension %s is not supported."
+msgstr ""
+
+#: lib/Controller/Library.php:2621
+#, php-format
+msgid ""
+"Invalid Module type or extension. Module type %s does not allow for %s "
+"extension"
+msgstr ""
+
+#: lib/Controller/Library.php:2654
+msgid "Download rejected for an unknown reason."
+msgstr ""
+
+#: lib/Controller/Library.php:2658
+#, php-format
+msgid "Download rejected due to %s"
+msgstr ""
+
+#: lib/Controller/Library.php:2665
+msgid "Media upload from URL was successful"
+msgstr ""
+
+#: lib/Controller/Library.php:2710
+msgid "Invalid image data"
+msgstr ""
+
+#: lib/Controller/Library.php:2820
+#, php-format
+msgid "Media %s moved to Folder %s"
+msgstr ""
+
+#: lib/Controller/Library.php:2916
+msgid "Not configured by any active connector."
+msgstr ""
+
+#: lib/Controller/Library.php:2966
+msgid "Download failed"
+msgstr ""
+
+#: lib/Controller/Library.php:2975
+msgid "Imported"
+msgstr ""
+
+#: lib/Controller/StatusDashboard.php:223
+msgid "Used"
+msgstr ""
+
+#: lib/Controller/StatusDashboard.php:296 lib/Report/LibraryUsage.php:201
+#: lib/Report/LibraryUsage.php:549
+msgid "Free"
+msgstr ""
+
+#: lib/Controller/StatusDashboard.php:410
+msgid "Latest news not available."
+msgstr ""
+
+#: lib/Controller/StatusDashboard.php:413
+msgid "Latest news not enabled."
+msgstr ""
+
+#: lib/Controller/Tag.php:255
+msgid "Usage"
+msgstr ""
+
+#: lib/Controller/Tag.php:493
+msgid "Access denied System tags cannot be edited"
+msgstr ""
+
+#: lib/Controller/Tag.php:620
+msgid "Access denied System tags cannot be deleted"
+msgstr ""
+
+#: lib/Controller/Tag.php:718
+msgid "Edit multiple tags is not supported on this item"
+msgstr ""
+
+#: lib/Controller/Tag.php:761
+msgid "Tags Edited"
+msgstr ""
+
+#: lib/Controller/Connector.php:188
+#, php-format
+msgid "Uninstalled %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:599
+msgid "Cannot edit Layout properties on a Draft"
+msgstr ""
+
+#: lib/Controller/Layout.php:611
+msgid ""
+"Cannot assign a Template tag to a Layout, to create a template use the Save "
+"Template button instead."
+msgstr ""
+
+#: lib/Controller/Layout.php:966 lib/Controller/Layout.php:1071
+msgid "You do not have permissions to delete this layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:994
+msgid "You do not have permissions to clear this layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:1023 lib/Controller/Layout.php:1203
+#: lib/Controller/Layout.php:1251 lib/Controller/Layout.php:1300
+#: lib/Controller/Layout.php:1369 lib/Controller/Layout.php:1408
+#: lib/Controller/Layout.php:2830 lib/Controller/Layout.php:2881
+#: lib/Controller/Layout.php:2920 lib/Controller/Layout.php:2989
+#: lib/Controller/Layout.php:3074 lib/Controller/Layout.php:3124
+msgid "You do not have permissions to edit this layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:1076
+msgid "Cannot delete Layout from its Draft, delete the parent"
+msgstr ""
+
+#: lib/Controller/Layout.php:1161
+#, php-format
+msgid "Cleared %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:1208 lib/Controller/Layout.php:1305
+#: lib/Controller/Layout.php:1374
+msgid "Cannot modify Layout from its Draft"
+msgstr ""
+
+#: lib/Controller/Layout.php:1213
+msgid "This Layout is used as the global default and cannot be retired"
+msgstr ""
+
+#: lib/Controller/Layout.php:1228
+#, php-format
+msgid "Retired %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:1320
+#, php-format
+msgid "Unretired %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:1385
+#, php-format
+msgid "For Layout %s Enable Stats Collection is set to %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:1623 lib/Controller/Layout.php:1624
+msgid "Invalid Module"
+msgstr ""
+
+#: lib/Controller/Layout.php:1766 lib/Controller/Layout.php:2523
+msgid "This Layout is ready to play"
+msgstr ""
+
+#: lib/Controller/Layout.php:1767 lib/Controller/Layout.php:2527
+msgid "There are items on this Layout that can only be assessed by the Display"
+msgstr ""
+
+#: lib/Controller/Layout.php:1768 lib/Controller/Layout.php:2531
+msgid "This Layout has not been built yet"
+msgstr ""
+
+#: lib/Controller/Layout.php:1769 lib/Controller/Layout.php:2535
+msgid "This Layout is invalid and should not be scheduled"
+msgstr ""
+
+#: lib/Controller/Layout.php:1773
+msgid "This Layout has enable stat collection set to ON"
+msgstr ""
+
+#: lib/Controller/Layout.php:1774
+msgid "This Layout has enable stat collection set to OFF"
+msgstr ""
+
+#: lib/Controller/Layout.php:1851
+msgid "Preview Draft Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:1872
+msgid "Assign to Campaign"
+msgstr ""
+
+#: lib/Controller/Layout.php:1883
+msgid "Jump to Playlists included on this Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:1892
+msgid "Jump to Campaigns containing this Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:1901
+msgid "Jump to Media included on this Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:1963
+msgid "Unretire"
+msgstr ""
+
+#: lib/Controller/Layout.php:2234
+msgid "Cannot copy a Draft Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:2380 lib/Controller/Layout.php:2456
+msgid "Cannot manage tags on a Draft Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:2585 lib/Controller/Layout.php:2623
+msgid "Cannot export Draft Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:2759
+msgid "Layout background must be an image"
+msgstr ""
+
+#: lib/Controller/Layout.php:2886
+msgid "Layout is already checked out"
+msgstr ""
+
+#: lib/Controller/Layout.php:2895
+#, php-format
+msgid "Checked out %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:3036
+#, php-format
+msgid "Published %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:3043
+#, php-format
+msgid "Layout will be published on %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:3141
+#, php-format
+msgid "Discarded %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:3192
+msgid ""
+"This function is available only to User who originally locked this Layout."
+msgstr ""
+
+#: lib/Controller/Layout.php:3281 lib/Entity/Layout.php:2312
+msgid "Empty Region"
+msgstr ""
+
+#: lib/Controller/Layout.php:3385
+msgid "Incorrect image data"
+msgstr ""
+
+#: lib/Controller/Layout.php:3410
+msgid "Thumbnail not found for Layout"
+msgstr ""
+
+#: lib/Controller/Layout.php:3502
+#, php-format
+msgid "Please select %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:3520
+#, php-format
+msgid "Fetched %s"
+msgstr ""
+
+#: lib/Controller/Layout.php:3650
+#, php-format
+msgid "Created %s"
+msgstr ""
+
+#: lib/Controller/MenuBoardCategory.php:185
+msgid "View Products"
+msgstr ""
+
+#: lib/Controller/MenuBoardCategory.php:323
+msgid "Added Menu Board Category"
+msgstr ""
+
+#: lib/Controller/DisplayProfileConfigFields.php:801
+msgid ""
+"On/Off Timers: Please check the days selected and remove the duplicates or "
+"empty"
+msgstr ""
+
+#: lib/Controller/DisplayProfileConfigFields.php:810
+msgid ""
+"On/Off Timers: Please enter a on and off date for any row with a day "
+"selected, or remove that row"
+msgstr ""
+
+#: lib/Controller/DisplayProfileConfigFields.php:854
+msgid ""
+"Picture: Please check the settings selected and remove the duplicates or "
+"empty"
+msgstr ""
+
+#: lib/Controller/CypressTest.php:160 lib/Controller/Schedule.php:1097
+#: lib/Controller/Schedule.php:1790
+msgid "Direct scheduling of an Ad Campaign is not allowed"
+msgstr ""
+
+#: lib/Controller/CypressTest.php:171 lib/Controller/Schedule.php:1108
+#: lib/Controller/Schedule.php:1819 lib/Entity/Schedule.php:690
+msgid "Share of Voice must be a whole number between 0 and 3600"
+msgstr ""
+
+#: lib/Controller/CypressTest.php:197 lib/Controller/Schedule.php:1309
+msgid "Added Event"
+msgstr ""
+
+#: lib/Controller/CypressTest.php:334
+msgid "Added campaign"
+msgstr ""
+
+#: lib/Controller/Login.php:119 lib/Controller/Login.php:122
+#: lib/Controller/Login.php:160
+msgid "This link has expired."
+msgstr ""
+
+#: lib/Controller/Login.php:211
+msgid ""
+"Sorry this account does not exist or does not have permission to access the "
+"web portal."
+msgstr ""
+
+#: lib/Controller/Login.php:228 lib/Entity/User.php:639
+#: lib/Factory/UserFactory.php:97 lib/Factory/UserFactory.php:131
+#: lib/Factory/UserFactory.php:147
+msgid "User not found"
+msgstr ""
+
+#: lib/Controller/Login.php:234
+msgid "Username or Password incorrect"
+msgstr ""
+
+#: lib/Controller/Login.php:265
+msgid "This feature has been disabled by your administrator"
+msgstr ""
+
+#: lib/Controller/Login.php:282 lib/Controller/Login.php:478
+msgid "No email"
+msgstr ""
+
+#: lib/Controller/Login.php:319
+msgid "Password Reset"
+msgstr ""
+
+#: lib/Controller/Login.php:324
+msgid ""
+"You are receiving this email because a password reminder was requested for "
+"your account. If you did not make this request, please report this email to "
+"your administrator immediately."
+msgstr ""
+
+#: lib/Controller/Login.php:324
+msgid "Reset Password"
+msgstr ""
+
+#: lib/Controller/Login.php:331 lib/Controller/Login.php:342
+msgid "A reminder email will been sent to this user if they exist"
+msgstr ""
+
+#: lib/Controller/Login.php:467
+msgid "Session has expired, please log in again"
+msgstr ""
+
+#: lib/Controller/Login.php:492
+msgid "Sending email address in CMS Settings is not configured"
+msgstr ""
+
+#: lib/Controller/Login.php:534
+msgid ""
+"You are receiving this email because two factor email authorisation is "
+"enabled in your CMS user account. If you did not make this request, please "
+"report this email to your administrator immediately."
+msgstr ""
+
+#: lib/Controller/Login.php:537
+msgid ""
+"Unable to send two factor code to email address associated with this user"
+msgstr ""
+
+#: lib/Controller/Login.php:539
+msgid "Two factor code email has been sent to your email address"
+msgstr ""
+
+#: lib/Controller/Login.php:620
+msgid "Authentication code incorrect"
+msgstr ""
+
+#: lib/Controller/Font.php:416 lib/Controller/PlayerSoftware.php:632
+#: lib/Helper/XiboUploadHandler.php:474
+msgid ""
+"Sorry this is a corrupted upload, the file size doesn't match what we're "
+"expecting."
+msgstr ""
+
+#: lib/Controller/Font.php:578
+msgid "Unable to write to the library"
+msgstr ""
+
+#: lib/Controller/DisplayProfile.php:346 lib/Controller/DisplayProfile.php:457
+#: lib/Controller/DisplayProfile.php:532
+msgid "You do not have permission to edit this profile"
+msgstr ""
+
+#: lib/Controller/User.php:305
+msgid "Google Authenticator"
+msgstr ""
+
+#: lib/Controller/User.php:323
+msgid "Unknown homepage, please edit to update."
+msgstr ""
+
+#: lib/Controller/User.php:359
+msgid "Set Home Folder"
+msgstr ""
+
+#: lib/Controller/User.php:392 lib/Controller/UserGroup.php:193
+msgid "Features"
+msgstr ""
+
+#: lib/Controller/User.php:393 lib/Controller/UserGroup.php:194
+msgid "Turn Features on/off for this User"
+msgstr ""
+
+#: lib/Controller/User.php:566 lib/Controller/User.php:1064
+#: lib/Controller/User.php:1142 lib/Controller/User.php:2489
+msgid "Only super and group admins can create users"
+msgstr ""
+
+#: lib/Controller/User.php:615
+msgid "Invalid user group selected"
+msgstr ""
+
+#: lib/Controller/User.php:629 lib/Controller/User.php:885
+msgid "User does not have the enabled Feature for this Dashboard"
+msgstr ""
+
+#: lib/Controller/User.php:903 lib/Controller/User.php:1301
+#: lib/Controller/User.php:1552
+msgid "Passwords do not match"
+msgstr ""
+
+#: lib/Controller/User.php:994
+msgid "This User is set as System User and cannot be deleted."
+msgstr ""
+
+#: lib/Controller/User.php:1002
+msgid "Cannot delete your own User from the CMS."
+msgstr ""
+
+#: lib/Controller/User.php:1006
+msgid "Group Admin cannot remove Super Admins or other Group Admins."
+msgstr ""
+
+#: lib/Controller/User.php:1010
+msgid ""
+"Cannot delete all items owned by a Super Admin, please reassign to a "
+"different User."
+msgstr ""
+
+#: lib/Controller/User.php:1035
+#, php-format
+msgid "This user cannot be deleted as it has %d child items"
+msgstr ""
+
+#: lib/Controller/User.php:1118
+msgid "Homepage not found"
+msgstr ""
+
+#: lib/Controller/User.php:1295
+msgid "Please enter your password"
+msgstr ""
+
+#: lib/Controller/User.php:1313
+msgid "Please provide valid email address"
+msgstr ""
+
+#: lib/Controller/User.php:1318
+msgid ""
+"Please provide valid sending email address in CMS Settings on Network tab"
+msgstr ""
+
+#: lib/Controller/User.php:1336
+msgid "Access Code is empty"
+msgstr ""
+
+#: lib/Controller/User.php:1343
+msgid "Access Code is incorrect"
+msgstr ""
+
+#: lib/Controller/User.php:1368
+msgid "User Profile Saved"
+msgstr ""
+
+#: lib/Controller/User.php:1548
+msgid "Please enter the password"
+msgstr ""
+
+#: lib/Controller/User.php:1558
+msgid "Please choose a new password"
+msgstr ""
+
+#: lib/Controller/User.php:1573
+msgid "Password Changed"
+msgstr ""
+
+#: lib/Controller/User.php:1631 lib/Controller/User.php:1773
+msgid "You do not have permission to edit these permissions."
+msgstr ""
+
+#: lib/Controller/User.php:1691 lib/Controller/User.php:1820
+#: lib/Controller/User.php:2056 lib/Controller/Display.php:2053
+#: lib/Controller/Display.php:2088 lib/Controller/Display.php:2093
+msgid "The array of ids is empty!"
+msgstr ""
+
+#: lib/Controller/User.php:1710
+msgid "You do not have permission to edit all the entities permissions."
+msgstr ""
+
+#: lib/Controller/User.php:1899
+msgid "This object is not shared with you with edit permission"
+msgstr ""
+
+#: lib/Controller/User.php:1903
+msgid "You cannot share the root folder"
+msgstr ""
+
+#: lib/Controller/User.php:1908
+msgid "You cannot share the Canvas on a Layout, share the layout instead."
+msgstr ""
+
+#: lib/Controller/User.php:1939
+msgid "Cannot change owner on this Object"
+msgstr ""
+
+#: lib/Controller/User.php:1986 lib/Controller/User.php:2067
+msgid "Share option Updated"
+msgstr ""
+
+#: lib/Controller/User.php:2082
+msgid "Sharing requested without an entity"
+msgstr ""
+
+#: lib/Controller/User.php:2086
+msgid "Sharing form requested without an object"
+msgstr ""
+
+#: lib/Controller/User.php:2250
+msgid "Updated Preference"
+msgstr ""
+
+#: lib/Controller/User.php:2250 lib/Controller/User.php:2470
+msgid "Updated Preferences"
+msgstr ""
+
+#: lib/Controller/User.php:2314 lib/Controller/User.php:2326
+msgid "Access Denied to UserGroup"
+msgstr ""
+
+#: lib/Controller/User.php:2336
+#, php-format
+msgid "%s assigned to User Groups"
+msgstr ""
+
+#: lib/Controller/User.php:2361
+#, php-format
+msgid "%s has started the welcome tutorial"
+msgstr ""
+
+#: lib/Controller/User.php:2385
+#, php-format
+msgid "%s has seen the welcome tutorial"
+msgstr ""
+
+#: lib/Controller/Applications.php:213 lib/Controller/Applications.php:279
+msgid "Authorisation Parameters missing from session."
+msgstr ""
+
+#: lib/Controller/Applications.php:242
+msgid "This application has not requested access to anything."
+msgstr ""
+
+#: lib/Controller/Applications.php:451
+msgid "Please enter Application name"
+msgstr ""
+
+#: lib/Controller/Applications.php:555
+msgid "You do not have permission to assign this user"
+msgstr ""
+
+#: lib/Controller/Applications.php:617
+msgid "No User ID provided"
+msgstr ""
+
+#: lib/Controller/Applications.php:621
+msgid "No Client id provided"
+msgstr ""
+
+#: lib/Controller/Applications.php:627
+msgid "Access denied: You do not own this authorization."
+msgstr ""
+
+#: lib/Controller/Applications.php:647
+#, php-format
+msgid "Access to %s revoked"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:148
+msgid "Added data for Widget"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:212 lib/Controller/WidgetData.php:274
+#: lib/Controller/WidgetData.php:358
+msgid "This widget data does not belong to this widget"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:223
+msgid "Edited data for Widget"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:281
+msgid "Deleted"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:367
+msgid "Updated the display order for data on Widget"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:385
+msgid "This is not a data widget"
+msgstr ""
+
+#: lib/Controller/WidgetData.php:389
+msgid "Fallback data is not expected for this Widget"
+msgstr ""
+
+#: lib/Controller/Region.php:506
+msgid "No regions present"
+msgstr ""
+
+#: lib/Controller/Region.php:515
+msgid "Missing regionid property"
+msgstr ""
+
+#: lib/Controller/Region.php:518
+msgid "Missing top property"
+msgstr ""
+
+#: lib/Controller/Region.php:521
+msgid "Missing left property"
+msgstr ""
+
+#: lib/Controller/Region.php:524
+msgid "Missing width property"
+msgstr ""
+
+#: lib/Controller/Region.php:527
+msgid "Missing height property"
+msgstr ""
+
+#: lib/Controller/Region.php:647
+msgid "Please correct the error with this Widget"
+msgstr ""
+
+#: lib/Controller/Region.php:752
+#, php-format
+msgid "Added drawer %s"
+msgstr ""
+
+#: lib/Controller/Region.php:829
+#, php-format
+msgid "Edited Drawer %s"
+msgstr ""
+
+#: lib/Controller/Display.php:196
+msgid "OpenOOH specification missing"
+msgstr ""
+
+#: lib/Controller/Display.php:748 lib/Controller/Display.php:1332
+msgid " (Default)"
+msgstr ""
+
+#: lib/Controller/Display.php:772
+msgid "Display is up to date"
+msgstr ""
+
+#: lib/Controller/Display.php:773
+msgid "Display is downloading new files"
+msgstr ""
+
+#: lib/Controller/Display.php:774
+msgid "Display is out of date but has not yet checked in with the server"
+msgstr ""
+
+#: lib/Controller/Display.php:775
+msgid "Unknown Display Status"
+msgstr ""
+
+#: lib/Controller/Display.php:780
+msgid "Display is fully licensed"
+msgstr ""
+
+#: lib/Controller/Display.php:781
+msgid "Display is on a trial licence"
+msgstr ""
+
+#: lib/Controller/Display.php:782
+msgid "Display is not licensed"
+msgstr ""
+
+#: lib/Controller/Display.php:788
+msgid "The status will be updated with each Commercial Licence check"
+msgstr ""
+
+#: lib/Controller/Display.php:829
+msgid "Manage"
+msgstr ""
+
+#: lib/Controller/Display.php:890
+msgid "Authorise"
+msgstr ""
+
+#: lib/Controller/Display.php:968 lib/Controller/Display.php:983
+msgid "Check Licence"
+msgstr ""
+
+#: lib/Controller/Display.php:1020
+msgid "Jump to Scheduled Layouts"
+msgstr ""
+
+#: lib/Controller/Display.php:1124
+msgid "Purge All"
+msgstr ""
+
+#: lib/Controller/Display.php:1261
+msgid "Set Bandwidth"
+msgstr ""
+
+#: lib/Controller/Display.php:1276
+msgid "Cancel CMS Transfer"
+msgstr ""
+
+#: lib/Controller/Display.php:1986
+msgid "Cannot delete a Lead Display of a Sync Group"
+msgstr ""
+
+#: lib/Controller/Display.php:2130
+msgid "Displays Updated"
+msgstr ""
+
+#: lib/Controller/Display.php:2191
+#, php-format
+msgid "%s assigned to Display Groups"
+msgstr ""
+
+#: lib/Controller/Display.php:2282
+msgid "once it has connected for the first time"
+msgstr ""
+
+#: lib/Controller/Display.php:2348 lib/Controller/Display.php:3068
+#: lib/Controller/Display.php:3192
+#, php-format
+msgid "Request sent for %s"
+msgstr ""
+
+#: lib/Controller/Display.php:2376 lib/Controller/Display.php:2428
+msgid ""
+"This display has no mac address recorded against it yet. Make sure the "
+"display is running."
+msgstr ""
+
+#: lib/Controller/Display.php:2451
+#, php-format
+msgid "Wake on Lan sent for %s"
+msgstr ""
+
+#: lib/Controller/Display.php:2561
+#, php-format
+msgid "Alert for Display %s"
+msgstr ""
+
+#: lib/Controller/Display.php:2563
+#, php-format
+msgid "Display ID %d is offline since %s."
+msgstr ""
+
+#: lib/Controller/Display.php:2666
+#, php-format
+msgid "Authorised set to %d for %s"
+msgstr ""
+
+#: lib/Controller/Display.php:2767
+#, php-format
+msgid "Default Layout with name %s set for %s"
+msgstr ""
+
+#: lib/Controller/Display.php:2853
+msgid "Provided CMS URL is invalid"
+msgstr ""
+
+#: lib/Controller/Display.php:2857
+msgid "New CMS URL can have maximum of 1000 characters"
+msgstr ""
+
+#: lib/Controller/Display.php:2861
+msgid "Provided CMS Key is invalid"
+msgstr ""
+
+#: lib/Controller/Display.php:2869
+msgid "Invalid Two Factor Authentication Code"
+msgstr ""
+
+#: lib/Controller/Display.php:2919
+#, php-format
+msgid "Cancelled CMS Transfer for %s"
+msgstr ""
+
+#: lib/Controller/Display.php:2957
+msgid "Code cannot be empty"
+msgstr ""
+
+#: lib/Controller/Display.php:2985
+msgid ""
+"The code provided does not match. Please double-check the code shown on the "
+"device you are trying to connect."
+msgstr ""
+
+#: lib/Controller/Display.php:3061 lib/Controller/Display.php:3185
+msgid "XMR is not configured for this Display"
+msgstr ""
+
+#: lib/Controller/Settings.php:232
+msgid "The Library Location you have picked is not writeable"
+msgstr ""
+
+#: lib/Controller/Settings.php:340 lib/Entity/Display.php:822
+#: lib/Widget/Validator/DisplayOrGeoValidator.php:55
+msgid "The latitude entered is not valid."
+msgstr ""
+
+#: lib/Controller/Settings.php:350 lib/Entity/Display.php:818
+#: lib/Widget/Validator/DisplayOrGeoValidator.php:60
+msgid "The longitude entered is not valid."
+msgstr ""
+
+#: lib/Controller/Settings.php:770
+msgid "Settings Updated"
+msgstr ""
+
+#: lib/Controller/AuditLog.php:161
+msgid "Please provide a from/to date."
+msgstr ""
+
+#: lib/Controller/Pwa.php:64 lib/Controller/Pwa.php:121
+msgid "Missing Version"
+msgstr ""
+
+#: lib/Controller/Pwa.php:69 lib/Controller/Pwa.php:126
+msgid "PWA supported from XMDS schema 7 onward."
+msgstr ""
+
+#: lib/Controller/Pwa.php:76 lib/Controller/Pwa.php:133
+msgid "Please use XMDS API"
+msgstr ""
+
+#: lib/Controller/Pwa.php:81 lib/Controller/Pwa.php:138 web/xmds.php:178
+#: web/xmds.php:366
+msgid "Display unauthorised"
+msgstr ""
+
+#: lib/Controller/Pwa.php:167
+msgid "Unknown version"
+msgstr ""
+
+#: lib/Controller/MenuBoardProduct.php:447
+msgid "Added Menu Board Product"
+msgstr ""
+
+#: lib/Controller/Developer.php:110
+msgid "Export XML"
+msgstr ""
+
+#: lib/Controller/Developer.php:248 lib/Controller/Developer.php:322
+msgid "Please supply a unique template ID"
+msgstr ""
+
+#: lib/Controller/Developer.php:251 lib/Controller/Developer.php:325
+msgid "Please supply a title"
+msgstr ""
+
+#: lib/Controller/Developer.php:254 lib/Controller/Developer.php:328
+msgid "Please supply a data type"
+msgstr ""
+
+#: lib/Controller/Developer.php:258 lib/Controller/Developer.php:332
+msgid "Please select relevant editor which should show this Template"
+msgstr ""
+
+#: lib/Controller/Developer.php:301
+msgid "Added"
+msgstr ""
+
+#: lib/Controller/PlayerSoftware.php:476
+msgid "File available only for SSSP displays"
+msgstr ""
+
+#: lib/Controller/Sessions.php:177
+msgid "User Logged Out."
+msgstr ""
+
+#: lib/Controller/DataSetRss.php:305 lib/Controller/DataSetRss.php:534
+msgid "Please enter title"
+msgstr ""
+
+#: lib/Controller/DataSetRss.php:309 lib/Controller/DataSetRss.php:538
+msgid "Please enter author name"
+msgstr ""
+
+#: lib/Controller/Schedule.php:319
+#, php-format
+msgid "%s scheduled on sync group %s"
+msgstr ""
+
+#: lib/Controller/Schedule.php:325 lib/Controller/Schedule.php:339
+#: lib/Entity/Schedule.php:1960 lib/Entity/Schedule.php:1972
+#, php-format
+msgid "%s scheduled on %s"
+msgstr ""
+
+#: lib/Controller/Schedule.php:334 lib/Controller/Schedule.php:592
+#: lib/Controller/Schedule.php:2368 lib/Entity/Schedule.php:1969
+msgid "Private Item"
+msgstr ""
+
+#: lib/Controller/Schedule.php:345
+#, php-format
+msgid " with Share of Voice %d seconds per hour"
+msgstr ""
+
+#: lib/Controller/Schedule.php:354
+#, php-format
+msgid ", Repeats every %s %s"
+msgstr ""
+
+#: lib/Controller/Schedule.php:1122 lib/Controller/Schedule.php:1833
+msgid "Please select a DataSet"
+msgstr ""
+
+#: lib/Controller/Schedule.php:1173 lib/Controller/Schedule.php:1875
+msgid "Please enter a from date"
+msgstr ""
+
+#: lib/Controller/Schedule.php:1501 lib/Controller/Schedule.php:2138
+msgid "Deleted Event"
+msgstr ""
+
+#: lib/Controller/Schedule.php:1797
+msgid "Cannot schedule Layout as a Campaign, please select a Campaign instead."
+msgstr ""
+
+#: lib/Controller/Schedule.php:1807
+msgid ""
+"Cannot schedule Campaign in selected event type, please select a Layout "
+"instead."
+msgstr ""
+
+#: lib/Controller/Schedule.php:2057
+msgid "Edited Event"
+msgstr ""
+
+#: lib/Controller/Schedule.php:2541 lib/Factory/RequiredFileFactory.php:414
+msgid "Unknown type"
+msgstr ""
+
+#: lib/Controller/UserGroup.php:740
+msgid "Features form requested without a User Group"
+msgstr ""
+
+#: lib/Controller/UserGroup.php:755
+#, php-format
+msgid "Features updated for %s"
+msgstr ""
+
+#: lib/Controller/UserGroup.php:862 lib/Controller/UserGroup.php:878
+msgid "Access Denied to User"
+msgstr ""
+
+#: lib/Controller/UserGroup.php:887 lib/Controller/UserGroup.php:963
+#, php-format
+msgid "Membership set for %s"
+msgstr ""
+
+#: lib/Controller/UserGroup.php:889
+#, php-format
+msgid "No changes for %s"
+msgstr ""
+
+#: lib/Controller/UserGroup.php:1090
+#, php-format
+msgid "Copied %s"
+msgstr ""
+
+#: lib/Controller/Logging.php:134 lib/Controller/Logging.php:154
+msgid "Only Administrator Users can truncate the log"
+msgstr ""
+
+#: lib/Controller/Logging.php:161
+msgid "Log Truncated"
+msgstr ""
+
+#: lib/Controller/DayPart.php:606
+msgid "Cannot Delete system specific DayParts"
+msgstr ""
+
+#: lib/Controller/Base.php:301
+msgid "Template Missing"
+msgstr ""
+
+#: lib/Controller/Base.php:312 lib/Controller/Base.php:339
+msgid "Unable to view this page"
+msgstr ""
+
+#: lib/Controller/Base.php:349 lib/Controller/Base.php:376
+msgid "Problem with Form Template"
+msgstr ""
+
+#: lib/Controller/Base.php:508
+msgid "Saving into root folder is disabled, please select a different folder"
+msgstr ""
+
+#: lib/XMR/ScheduleCriteriaUpdateAction.php:62
+msgid "Criteria updates not provided."
+msgstr ""
+
+#: lib/XMR/OverlayLayoutAction.php:65
+msgid "Layout Details not provided"
+msgstr ""
+
+#: lib/XMR/OverlayLayoutAction.php:69
+msgid "Duration not provided"
+msgstr ""
+
+#: lib/Middleware/FeatureAuth.php:79
+msgid "Feature not enabled"
+msgstr ""
+
+#: lib/Middleware/FeatureAuth.php:79
+msgid "This feature has not been enabled for your user."
+msgstr ""
+
+#: lib/Middleware/SuperAdminAuth.php:64
+msgid "You do not have sufficient access"
+msgstr ""
+
+#: lib/Middleware/CsrfGuard.php:111
+msgid "Sorry the form has expired. Please refresh."
+msgstr ""
+
+#: lib/Middleware/Handlers.php:83 lib/Middleware/Handlers.php:156
+msgid "Unexpected Error, please contact support."
+msgstr ""
+
+#: lib/Middleware/LayoutLock.php:134
+#, php-format
+msgid "Layout Lock Middleware called with incorrect route %s"
+msgstr ""
+
+#: lib/Middleware/LayoutLock.php:189
+#, php-format
+msgid "Layout ID %d is locked by another User! Lock expires on: %s"
+msgstr ""
+
+#: lib/Middleware/Actions.php:94
+msgid ""
+"There is a problem with this installation, the web/install folder should be "
+"deleted."
+msgstr ""
+
+#: lib/Middleware/SAMLAuthentication.php:102
+msgid "Your authentication provider could not log you in."
+msgstr ""
+
+#: lib/Middleware/SAMLAuthentication.php:116
+msgid "No attributes retrieved from the IdP"
+msgstr ""
+
+#: lib/Middleware/SAMLAuthentication.php:130
+msgid "No attributes could be mapped"
+msgstr ""
+
+#: lib/Middleware/SAMLAuthentication.php:140
+#, php-format
+msgid ""
+"%s not retrieved from the IdP and required since is the field to identify "
+"the user"
+msgstr ""
+
+#: lib/Middleware/SAMLAuthentication.php:170
+msgid "Invalid field_to_identify value. Review settings."
+msgstr ""
+
+#: lib/Middleware/SAMLAuthentication.php:178
+msgid ""
+"User logged at the IdP but the account does not exist in the CMS and Just-In-"
+"Time provisioning is disabled"
+msgstr ""
+
+#: lib/Middleware/ApiAuthorization.php:110
+msgid "Sorry this account does not exist or cannot be authenticated."
+msgstr ""
+
+#: lib/Middleware/ApiAuthorization.php:164
+msgid "Access to this route is denied for this scope"
+msgstr ""
+
+#: lib/Event/DataConnectorSourceRequestEvent.php:49
+msgid "User-Defined JavaScript"
+msgstr ""
+
+#: lib/Event/ScheduleCriteriaRequestEvent.php:94
+#: lib/Event/ScheduleCriteriaRequestEvent.php:140
+#: lib/Event/ScheduleCriteriaRequestEvent.php:207
+msgid "Current type is not set."
+msgstr ""
+
+#: lib/Event/ScheduleCriteriaRequestEvent.php:146
+#, php-format
+msgid "Invalid condition ID: %s"
+msgstr ""
+
+#: lib/Event/ScheduleCriteriaRequestEvent.php:213
+msgid "Invalid input type."
+msgstr ""
+
+#: lib/Event/ScheduleCriteriaRequestEvent.php:224
+msgid "Input type does not match."
+msgstr ""
+
+#: lib/Entity/Task.php:113
+msgid "No config file recorded for task. Please recreate."
+msgstr ""
+
+#: lib/Entity/Task.php:117
+msgid "Config file not found for Task"
+msgstr ""
+
+#: lib/Entity/Task.php:132
+msgid "Please enter a CRON expression in the Schedule"
+msgstr ""
+
+#: lib/Entity/Task.php:147
+msgid "Invalid CRON expression in the Schedule"
+msgstr ""
+
+#: lib/Entity/Widget.php:414
+msgid "Widget Option not found"
+msgstr ""
+
+#: lib/Entity/Widget.php:557
+msgid "No file to return"
+msgstr ""
+
+#: lib/Entity/DisplayGroup.php:585
+msgid "Please enter a display group name"
+msgstr ""
+
+#: lib/Entity/DisplayGroup.php:589 lib/Entity/DataSet.php:878
+#: lib/Entity/Layout.php:1113
+msgid "Description can not be longer than 254 characters"
+msgstr ""
+
+#: lib/Entity/DisplayGroup.php:600
+#, php-format
+msgid ""
+"You already own a display group called \"%s\". Please choose another name."
+msgstr ""
+
+#: lib/Entity/DisplayGroup.php:604
+msgid "Dynamic Display Groups must have at least one Criteria specified."
+msgstr ""
+
+#: lib/Entity/DisplayGroup.php:920
+msgid "This assignment creates a circular reference"
+msgstr ""
+
+#: lib/Entity/DataSet.php:413 lib/Entity/DataSet.php:436
+#, php-format
+msgid "Column %s not found"
+msgstr ""
+
+#: lib/Entity/DataSet.php:470
+msgid "Unknown Column "
+msgstr ""
+
+#: lib/Entity/DataSet.php:874
+msgid "Name must be between 1 and 50 characters"
+msgstr ""
+
+#: lib/Entity/DataSet.php:884
+msgid "A remote DataSet must have a URI."
+msgstr ""
+
+#: lib/Entity/DataSet.php:888
+msgid "DataSet row limit cannot be larger than the CMS dataSet row limit"
+msgstr ""
+
+#: lib/Entity/DataSet.php:901
+#, php-format
+msgid "There is already dataSet called %s. Please choose another name."
+msgstr ""
+
+#: lib/Entity/DataSet.php:1047
+msgid ""
+"Cannot delete because this DataSet is set as dependent DataSet for another "
+"DataSet"
+msgstr ""
+
+#: lib/Entity/DataSet.php:1058
+msgid "Cannot delete because DataSet is in use on one or more Layouts."
+msgstr ""
+
+#: lib/Entity/DataSet.php:1067
+msgid ""
+"Cannot delete because DataSet is in use on one or more Data Connector "
+"schedules."
+msgstr ""
+
+#: lib/Entity/Notification.php:227
+msgid "Please provide a subject"
+msgstr ""
+
+#: lib/Entity/Notification.php:231
+msgid "Please provide a body"
+msgstr ""
+
+#: lib/Entity/Campaign.php:476
+msgid "Invalid type"
+msgstr ""
+
+#: lib/Entity/Campaign.php:480 lib/Entity/MenuBoard.php:249
+#: lib/Entity/SyncGroup.php:289 lib/Entity/MenuBoardCategory.php:167
+#: lib/Entity/MenuBoardProduct.php:151 lib/Entity/DayPart.php:160
+msgid "Name cannot be empty"
+msgstr ""
+
+#: lib/Entity/Campaign.php:484
+msgid "Please enter play count"
+msgstr ""
+
+#: lib/Entity/Campaign.php:489
+msgid "Invalid target type"
+msgstr ""
+
+#: lib/Entity/Campaign.php:493
+msgid "Please enter a target"
+msgstr ""
+
+#: lib/Entity/Campaign.php:497
+msgid "Please select one or more displays"
+msgstr ""
+
+#: lib/Entity/Campaign.php:502
+msgid "Cannot set end date to be earlier than the start date."
+msgstr ""
+
+#: lib/Entity/Campaign.php:509
+msgid "Please choose either round-robin or block play order for this list"
+msgstr ""
+
+#: lib/Entity/PlayerVersion.php:271
+msgid "Package file unsupported or invalid"
+msgstr ""
+
+#: lib/Entity/PlayerVersion.php:276 lib/Service/ReportService.php:305
+#: lib/Service/ReportService.php:342 lib/Factory/LayoutFactory.php:1300
+msgid "Unable to open ZIP"
+msgstr ""
+
+#: lib/Entity/PlayerVersion.php:281
+msgid "Software package does not contain a manifest"
+msgstr ""
+
+#: lib/Entity/PlayerVersion.php:389
+msgid "You already own Player Version file with this name."
+msgstr ""
+
+#: lib/Entity/MenuBoardProductOption.php:83
+msgid "Each value needs a corresponding option"
+msgstr ""
+
+#: lib/Entity/MenuBoardProductOption.php:89
+msgid "Each option needs a corresponding value"
+msgstr ""
+
+#: lib/Entity/Action.php:174
+msgid "No layoutId specified"
+msgstr ""
+
+#: lib/Entity/Action.php:178
+msgid "Invalid action type"
+msgstr ""
+
+#: lib/Entity/Action.php:182
+msgid "Invalid source"
+msgstr ""
+
+#: lib/Entity/Action.php:186
+msgid "Invalid target"
+msgstr ""
+
+#: lib/Entity/Action.php:190
+msgid "Please select a Region"
+msgstr ""
+
+#: lib/Entity/Action.php:194
+msgid "Please provide trigger code"
+msgstr ""
+
+#: lib/Entity/Action.php:198
+msgid "Invalid trigger type"
+msgstr ""
+
+#: lib/Entity/Action.php:202
+msgid "Please enter Layout code"
+msgstr ""
+
+#: lib/Entity/Action.php:206
+msgid "Please create a Widget to be loaded"
+msgstr ""
+
+#: lib/Entity/ScheduleCriteria.php:62
+msgid "Criteria must be attached to an event"
+msgstr ""
+
+#: lib/Entity/ScheduleCriteria.php:66
+msgid "Please select a metric"
+msgstr ""
+
+#: lib/Entity/ScheduleCriteria.php:70
+msgid "Please enter a valid condition"
+msgstr ""
+
+#: lib/Entity/Module.php:551
+msgid "Default Duration is a required field."
+msgstr ""
+
+#: lib/Entity/Folder.php:155
+msgid "Folder needs to have a name, between 1 and 254 characters."
+msgstr ""
+
+#: lib/Entity/Folder.php:159
+msgid "Folder needs a specified parent Folder id"
+msgstr ""
+
+#: lib/Entity/Playlist.php:364
+#, php-format
+msgid "You already own a Playlist called '%s'. Please choose another name."
+msgstr ""
+
+#: lib/Entity/Playlist.php:369
+msgid "Maximum number of items cannot exceed the limit set in CMS Settings"
+msgstr ""
+
+#: lib/Entity/Playlist.php:422
+#, php-format
+msgid "Widget not found at index %d"
+msgstr ""
+
+#: lib/Entity/Playlist.php:443
+#, php-format
+msgid "Widget not found with ID %d"
+msgstr ""
+
+#: lib/Entity/Playlist.php:489
+msgid "Cannot delete a Widget that isn't assigned to me"
+msgstr ""
+
+#: lib/Entity/Playlist.php:691
+msgid "This Playlist belongs to a Region, please delete the Region instead."
+msgstr ""
+
+#: lib/Entity/ReportSchedule.php:114 lib/Entity/DisplayProfile.php:404
+msgid "Missing name"
+msgstr ""
+
+#: lib/Entity/SyncGroup.php:293
+msgid "Sync Publisher Port cannot be empty"
+msgstr ""
+
+#: lib/Entity/SyncGroup.php:297
+msgid "Please select lead Display for this sync group"
+msgstr ""
+
+#: lib/Entity/SyncGroup.php:301
+msgid "Switch Delay value cannot be negative"
+msgstr ""
+
+#: lib/Entity/SyncGroup.php:305
+msgid "Video Pause Delay value cannot be negative"
+msgstr ""
+
+#: lib/Entity/SyncGroup.php:315
+msgid ""
+"Please make sure to select a Layout for all Displays in this Sync Group."
+msgstr ""
+
+#: lib/Entity/SyncGroup.php:420
+#, php-format
+msgid "Display %s already belongs to a different sync group ID %d"
+msgstr ""
+
+#: lib/Entity/TagLink.php:72
+#, php-format
+msgid ""
+"Provided tag value %s, not found in tag %s options, please select the "
+"correct value"
+msgstr ""
+
+#: lib/Entity/TagLink.php:84
+#, php-format
+msgid ""
+"Selected Tag %s requires a value, please enter the Tag in %s|Value format or "
+"provide Tag value in the dedicated field."
+msgstr ""
+
+#: lib/Entity/Media.php:292
+#, php-format
+msgid "Copy of %s on %s"
+msgstr ""
+
+#: lib/Entity/Media.php:379
+msgid "Unknown Media Type"
+msgstr ""
+
+#: lib/Entity/Media.php:383
+msgid "The name must be between 1 and 100 characters"
+msgstr ""
+
+#: lib/Entity/Media.php:408
+msgid "Media you own already has this name. Please choose another."
+msgstr ""
+
+#: lib/Entity/Media.php:765
+msgid "Problem copying file in the Library Folder"
+msgstr ""
+
+#: lib/Entity/Media.php:771
+msgid "Problem moving uploaded file into the Library Folder"
+msgstr ""
+
+#: lib/Entity/Media.php:789
+msgid "Problem moving downloaded file into the Library Folder"
+msgstr ""
+
+#: lib/Entity/Media.php:796
+msgid "Problem copying provided file into the Library Folder"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:207
+msgid "Missing dataSetId"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:211
+msgid "Missing dataTypeId"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:215
+msgid "Missing dataSetColumnTypeId"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:219
+msgid "Please provide a column heading."
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:226
+#, php-format
+msgid "Headings cannot contain reserved words, such as %s"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:239
+#, php-format
+msgid "Please provide an alternative column heading %s can not be used."
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:245
+msgid "Please enter a valid formula"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:256
+msgid "A column already exists with this name, please choose another"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:266
+msgid "Provided Data Type doesn't exist"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:276
+msgid "Remote field is required when the column type is set to Remote"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:282
+msgid "Provided DataSet Column Type doesn't exist"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:318
+msgid ""
+"New list content value is invalid as it does not include values for existing "
+"data"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:341
+msgid "Formula contains disallowed keywords."
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:356
+msgid "Provided formula is invalid"
+msgstr ""
+
+#: lib/Entity/DataSetColumn.php:483
+msgid "Existing data is incompatible with your new configuration"
+msgstr ""
+
+#: lib/Entity/Tag.php:102
+msgid "Tag must be between 1 and 50 characters"
+msgstr ""
+
+#: lib/Entity/Tag.php:113
+#, php-format
+msgid "You already own a Tag called '%s'. Please choose another name."
+msgstr ""
+
+#: lib/Entity/Connector.php:128
+msgid "Sorry we cannot delete a system connector."
+msgstr ""
+
+#: lib/Entity/Layout.php:599
+msgid "Cannot find region"
+msgstr ""
+
+#: lib/Entity/Layout.php:617
+msgid "Cannot find drawer region"
+msgstr ""
+
+#: lib/Entity/Layout.php:638
+msgid "Cannot find Region or Drawer"
+msgstr ""
+
+#: lib/Entity/Layout.php:1005
+msgid "This layout is used as the global default and cannot be deleted"
+msgstr ""
+
+#: lib/Entity/Layout.php:1099
+msgid "The layout dimensions cannot be empty"
+msgstr ""
+
+#: lib/Entity/Layout.php:1106
+msgid "Layout Name must be between 1 and 100 characters"
+msgstr ""
+
+#: lib/Entity/Layout.php:1134
+#, php-format
+msgid "You already own a Layout called '%s'. Please choose another name."
+msgstr ""
+
+#: lib/Entity/Layout.php:1142 lib/Entity/Region.php:421
+msgid "Layer must be 0 or a positive number"
+msgstr ""
+
+#: lib/Entity/Layout.php:1148
+msgid "Please use only alphanumeric characters in Layout Code identifier"
+msgstr ""
+
+#: lib/Entity/Layout.php:1160
+msgid "Layout with provided code already exists"
+msgstr ""
+
+#: lib/Entity/Layout.php:1283
+#, php-format
+msgid "%s set as the Layout background image is pending conversion"
+msgstr ""
+
+#: lib/Entity/Layout.php:1291
+#, php-format
+msgid ""
+"%s set as the Layout background image is too large. Please ensure that none "
+"of the images in your layout are larger than %s pixels on their longest "
+"edge. Please check the allowed Resize Limit in Administration -> Settings"
+msgstr ""
+
+#: lib/Entity/Layout.php:1863
+#, php-format
+msgid "%s is pending conversion"
+msgstr ""
+
+#: lib/Entity/Layout.php:1869
+#, php-format
+msgid ""
+"%s is too large. Please ensure that none of the images in your layout are "
+"larger than your Resize Limit on their longest edge."
+msgstr ""
+
+#: lib/Entity/Layout.php:1874
+#, php-format
+msgid "%s failed validation and cannot be published."
+msgstr ""
+
+#: lib/Entity/Layout.php:1888
+msgid "Misconfigured Playlist"
+msgstr ""
+
+#: lib/Entity/Layout.php:2301
+#, php-format
+msgid "There is an error with this Layout: %s"
+msgstr ""
+
+#: lib/Entity/Layout.php:2380 lib/Entity/Layout.php:2509
+msgid "Not a Draft"
+msgstr ""
+
+#: lib/Entity/Layout.php:2595
+msgid "Draft Layouts must have a parent"
+msgstr ""
+
+#: lib/Entity/Layout.php:2731 lib/Listener/WidgetListener.php:199
+msgid "Cannot add the same SubPlaylist twice."
+msgstr ""
+
+#: lib/Entity/DisplayProfile.php:407
+msgid "Missing type"
+msgstr ""
+
+#: lib/Entity/DisplayProfile.php:411
+msgid "Concurrent downloads must be a positive number"
+msgstr ""
+
+#: lib/Entity/DisplayProfile.php:415
+msgid "Maximum Region Count must be a positive number"
+msgstr ""
+
+#: lib/Entity/DisplayProfile.php:436
+msgid "Only 1 default per display type is allowed."
+msgstr ""
+
+#: lib/Entity/DisplayProfile.php:468
+msgid "This Display Profile is currently assigned to one or more Displays"
+msgstr ""
+
+#: lib/Entity/DisplayProfile.php:472
+msgid "Cannot delete default Display Profile."
+msgstr ""
+
+#: lib/Entity/User.php:518
+msgid "User Option not found"
+msgstr ""
+
+#: lib/Entity/User.php:608
+msgid "Please enter a Password."
+msgstr ""
+
+#: lib/Entity/User.php:744
+msgid "User name must be between 1 and 50 characters."
+msgstr ""
+
+#: lib/Entity/User.php:747 lib/Entity/UserGroup.php:275
+msgid "Library Quota must be a whole number."
+msgstr ""
+
+#: lib/Entity/User.php:750
+msgid "Please enter a valid email address or leave it empty."
+msgstr ""
+
+#: lib/Entity/User.php:756
+msgid "There is already a user with this name. Please choose another."
+msgstr ""
+
+#: lib/Entity/User.php:761
+msgid "This User is set as System User and needs to be super admin"
+msgstr ""
+
+#: lib/Entity/User.php:765
+msgid "This User is set as System User and cannot be retired"
+msgstr ""
+
+#: lib/Entity/User.php:770
+msgid "Library Quota must be a positive number."
+msgstr ""
+
+#: lib/Entity/User.php:1246
+msgid "Provided Object not under permission management"
+msgstr ""
+
+#: lib/Entity/User.php:1502
+msgid "You have exceeded your library quota"
+msgstr ""
+
+#: lib/Entity/User.php:1520
+msgid "Your password does not meet the required complexity"
+msgstr ""
+
+#: lib/Entity/Resolution.php:128
+msgid "Please provide a name"
+msgstr ""
+
+#: lib/Entity/Resolution.php:132
+msgid "Please provide a width"
+msgstr ""
+
+#: lib/Entity/Resolution.php:136
+msgid "Please provide a height"
+msgstr ""
+
+#: lib/Entity/Region.php:313
+msgid "Region Option not found"
+msgstr ""
+
+#: lib/Entity/Region.php:416
+msgid "The Region dimensions cannot be empty or negative"
+msgstr ""
+
+#: lib/Entity/Display.php:765
+msgid "Can not have a display without a name"
+msgstr ""
+
+#: lib/Entity/Display.php:769
+msgid "Can not have a display without a hardware key"
+msgstr ""
+
+#: lib/Entity/Display.php:774
+msgid ""
+"Wake on Lan is enabled, but you have not specified a time to wake the display"
+msgstr ""
+
+#: lib/Entity/Display.php:781
+msgid "BroadCast Address is not a valid IP Address"
+msgstr ""
+
+#: lib/Entity/Display.php:789
+msgid "CIDR subnet mask is not a number within the range of 0 to 32."
+msgstr ""
+
+#: lib/Entity/Display.php:803
+msgid ""
+"Pattern of secureOn-password is not \"xx-xx-xx-xx-xx-xx\" (x = digit or "
+"CAPITAL letter)"
+msgstr ""
+
+#: lib/Entity/Display.php:827
+msgid "Bandwidth limit must be a whole number greater than 0."
+msgstr ""
+
+#: lib/Entity/Display.php:850
+msgid ""
+"Please set a Default Layout directly on this Display or in CMS Administrator "
+"Settings"
+msgstr ""
+
+#: lib/Entity/Display.php:937
+#, php-format
+msgid "You have exceeded your maximum number of authorised displays. %d"
+msgstr ""
+
+#: lib/Entity/MenuBoardProduct.php:156
+msgid "Calories must be a whole number between 0 and 32767"
+msgstr ""
+
+#: lib/Entity/Command.php:241
+msgid "Please enter a command name between 1 and 254 characters"
+msgstr ""
+
+#: lib/Entity/Command.php:248
+msgid ""
+"Please enter a code between 1 and 50 characters containing only alpha "
+"characters and no spaces"
+msgstr ""
+
+#: lib/Entity/Command.php:255
+msgid "Please enter a description between 1 and 1000 characters"
+msgstr ""
+
+#: lib/Entity/Schedule.php:642
+msgid "No display groups selected"
+msgstr ""
+
+#: lib/Entity/Schedule.php:652
+msgid "The from date is too far in the past."
+msgstr ""
+
+#: lib/Entity/Schedule.php:657
+msgid "Name cannot be longer than 50 characters."
+msgstr ""
+
+#: lib/Entity/Schedule.php:671
+msgid "Please select a Campaign/Layout for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:678 lib/Entity/Schedule.php:738
+msgid "Can not have an end time earlier than your start time"
+msgstr ""
+
+#: lib/Entity/Schedule.php:699 lib/Entity/Schedule.php:717
+msgid "Please select a Command for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:705
+msgid "Please select a Action Type for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:710
+msgid "Please select a Action trigger code for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:722
+msgid "Please select a Layout code for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:731
+msgid "Please select a Sync Group for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:745
+msgid "Please select a DataSet for this event."
+msgstr ""
+
+#: lib/Entity/Schedule.php:750
+msgid "Please select the Event Type"
+msgstr ""
+
+#: lib/Entity/Schedule.php:756
+msgid "Repeats selection is invalid for Always or Daypart events"
+msgstr ""
+
+#: lib/Entity/Schedule.php:762
+msgid "Display Order must be 0 or a positive number"
+msgstr ""
+
+#: lib/Entity/Schedule.php:766
+msgid "Priority must be 0 or a positive number"
+msgstr ""
+
+#: lib/Entity/Schedule.php:770
+msgid "Maximum plays per hour must be 0 or a positive number"
+msgstr ""
+
+#: lib/Entity/Schedule.php:777
+msgid "Repeat every must be a positive number"
+msgstr ""
+
+#: lib/Entity/Schedule.php:812
+msgid "Unknown repeat type"
+msgstr ""
+
+#: lib/Entity/Schedule.php:817
+msgid ""
+"An event cannot repeat more often than the interval between its start and "
+"end date"
+msgstr ""
+
+#: lib/Entity/Schedule.php:1210
+msgid "Cache pool not available"
+msgstr ""
+
+#: lib/Entity/Schedule.php:1213
+msgid "Unable to generate schedule, unknown event"
+msgstr ""
+
+#: lib/Entity/Schedule.php:1587 lib/Entity/Schedule.php:1899
+msgid "Invalid recurrence type"
+msgstr ""
+
+#: lib/Entity/Schedule.php:1930
+msgid "reminderDt not found as next event does not exist"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2088
+msgid "Overlay Layout"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2089
+msgid "Interrupt Layout"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2091
+msgid "Action"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2092
+msgid "Video/Image"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2094
+msgid "Data Connector"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2105
+msgid "Synchronised Event"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2121
+msgid "Synchronised Mirrored Content"
+msgstr ""
+
+#: lib/Entity/Schedule.php:2122
+msgid "Synchronised Content"
+msgstr ""
+
+#: lib/Entity/UserGroup.php:271
+msgid "User Group Name cannot be empty."
+msgstr ""
+
+#: lib/Entity/UserGroup.php:283
+msgid "There is already a group with this name. Please choose another."
+msgstr ""
+
+#: lib/Entity/DayPart.php:164
+msgid "Start/End time are empty or in an incorrect format"
+msgstr ""
+
+#: lib/Entity/DayPart.php:168
+#, php-format
+msgid "Exception Start/End time for %s are empty or in an incorrect format"
+msgstr ""
+
+#: lib/Entity/DisplayEvent.php:215
+msgid "Display Up/down"
+msgstr ""
+
+#: lib/Entity/DisplayEvent.php:216
+msgid "App Start"
+msgstr ""
+
+#: lib/Entity/DisplayEvent.php:217
+msgid "Power Cycle"
+msgstr ""
+
+#: lib/Entity/DisplayEvent.php:218
+msgid "Network Cycle"
+msgstr ""
+
+#: lib/Entity/DisplayEvent.php:219
+msgid "TV Monitoring"
+msgstr ""
+
+#: lib/Entity/DisplayEvent.php:220
+msgid "Player Fault"
+msgstr ""
+
+#: lib/Service/ReportService.php:190
+msgid "Get Report By Name: No file to return"
+msgstr ""
+
+#: lib/Service/ReportService.php:202
+msgid "Report class not found"
+msgstr ""
+
+#: lib/Service/ReportService.php:212
+msgid "Get report class: No file to return"
+msgstr ""
+
+#: lib/Service/ReportService.php:221
+#, php-format
+msgid "Class %s not found"
+msgstr ""
+
+#: lib/Service/ReportService.php:299 lib/Service/ReportService.php:336
+#: lib/Factory/LayoutFactory.php:1294
+msgid "File does not exist"
+msgstr ""
+
+#: lib/Service/ReportService.php:360 lib/XTR/StatsArchiveTask.php:215
+#: lib/XTR/ReportScheduleTask.php:176 lib/XTR/AuditLogArchiveTask.php:228
+#, php-format
+msgid "Can't create ZIP. Error Code: %s"
+msgstr ""
+
+#: lib/Service/PlayerActionService.php:80
+msgid "XMR address is not set"
+msgstr ""
+
+#: lib/Service/PlayerActionService.php:95
+#: lib/Service/PlayerActionService.php:109
+#, php-format
+msgid ""
+"%s is not configured or ready to receive push commands over XMR. Please "
+"contact your administrator."
+msgstr ""
+
+#: lib/Service/PlayerActionService.php:131
+#, php-format
+msgid "%s Invalid XMR registration"
+msgstr ""
+
+#: lib/Service/PlayerActionService.php:188
+#, php-format
+msgid "%d of %d player actions failed"
+msgstr ""
+
+#: lib/Service/ConfigService.php:295
+#, php-format
+msgid "The theme \"%s\" does not exist"
+msgstr ""
+
+#: lib/Service/ConfigService.php:644
+msgid "PHP Version"
+msgstr ""
+
+#: lib/Service/ConfigService.php:646
+#, php-format
+msgid "PHP version %s or later required."
+msgstr ""
+
+#: lib/Service/ConfigService.php:649
+msgid "Cache File System Permissions"
+msgstr ""
+
+#: lib/Service/ConfigService.php:651
+msgid "Write permissions are required for cache/"
+msgstr ""
+
+#: lib/Service/ConfigService.php:654
+msgid "MySQL database (PDO MySql)"
+msgstr ""
+
+#: lib/Service/ConfigService.php:656
+msgid "PDO support with MySQL drivers must be enabled in PHP."
+msgstr ""
+
+#: lib/Service/ConfigService.php:659
+msgid "JSON Extension"
+msgstr ""
+
+#: lib/Service/ConfigService.php:661
+msgid "PHP JSON extension required to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:664
+msgid "SOAP Extension"
+msgstr ""
+
+#: lib/Service/ConfigService.php:666
+msgid "PHP SOAP extension required to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:669
+msgid "GD Extension"
+msgstr ""
+
+#: lib/Service/ConfigService.php:671
+msgid "PHP GD extension required to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:674
+msgid "Session"
+msgstr ""
+
+#: lib/Service/ConfigService.php:676
+msgid "PHP session support required to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:679
+msgid "FileInfo"
+msgstr ""
+
+#: lib/Service/ConfigService.php:681
+msgid ""
+"Requires PHP FileInfo support to function. If you are on Windows you need to "
+"enable the php_fileinfo.dll in your php.ini file."
+msgstr ""
+
+#: lib/Service/ConfigService.php:684
+msgid "PCRE"
+msgstr ""
+
+#: lib/Service/ConfigService.php:686
+msgid "PHP PCRE support to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:689
+msgid "Gettext"
+msgstr ""
+
+#: lib/Service/ConfigService.php:691
+msgid "PHP Gettext support to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:694
+msgid "DOM Extension"
+msgstr ""
+
+#: lib/Service/ConfigService.php:696
+msgid "PHP DOM core functionality enabled."
+msgstr ""
+
+#: lib/Service/ConfigService.php:699
+msgid "DOM XML Extension"
+msgstr ""
+
+#: lib/Service/ConfigService.php:701
+msgid "PHP DOM XML extension to function."
+msgstr ""
+
+#: lib/Service/ConfigService.php:704
+msgid "Allow PHP to open external URLs"
+msgstr ""
+
+#: lib/Service/ConfigService.php:706
+msgid ""
+"You must have the curl extension enabled or PHP configured with "
+"\"allow_url_fopen = On\" for the CMS to access external resources. We "
+"strongly recommend curl."
+msgstr ""
+
+#: lib/Service/ConfigService.php:710
+msgid "DateTimeZone"
+msgstr ""
+
+#: lib/Service/ConfigService.php:712
+msgid ""
+"This enables us to get a list of time zones supported by the hosting server."
+msgstr ""
+
+#: lib/Service/ConfigService.php:716
+msgid "ZIP"
+msgstr ""
+
+#: lib/Service/ConfigService.php:718
+msgid "This enables import / export of layouts."
+msgstr ""
+
+#: lib/Service/ConfigService.php:721
+msgid "Support for uploading large files is recommended."
+msgstr ""
+
+#: lib/Service/ConfigService.php:722
+msgid ""
+"We suggest setting your PHP post_max_size and upload_max_filesize to at "
+"least 128M, and also increasing your max_execution_time to at least 120 "
+"seconds."
+msgstr ""
+
+#: lib/Service/ConfigService.php:724
+msgid "Large File Uploads"
+msgstr ""
+
+#: lib/Service/ConfigService.php:730
+msgid "cURL"
+msgstr ""
+
+#: lib/Service/ConfigService.php:732
+msgid "cURL is used to fetch data from the Internet or Local Network"
+msgstr ""
+
+#: lib/Service/ConfigService.php:735
+msgid "OpenSSL"
+msgstr ""
+
+#: lib/Service/ConfigService.php:737
+msgid "OpenSSL is used to seal and verify messages sent to XMR"
+msgstr ""
+
+#: lib/Service/ConfigService.php:741
+msgid "SimpleXML"
+msgstr ""
+
+#: lib/Service/ConfigService.php:743
+msgid "SimpleXML is used to parse RSS feeds and other XML data sources"
+msgstr ""
+
+#: lib/Service/ConfigService.php:746
+msgid "GNUPG"
+msgstr ""
+
+#: lib/Service/ConfigService.php:748
+msgid ""
+"checkGnu is used to verify the integrity of Player Software versions "
+"uploaded to the CMS"
+msgstr ""
+
+#: lib/Service/MediaService.php:143
+#, php-format
+msgid "Your library is full. Library Limit: %s MB"
+msgstr ""
+
+#: lib/Service/MediaService.php:158
+#, php-format
+msgid "This file size exceeds your environment Max Upload Size %s"
+msgstr ""
+
+#: lib/Service/MediaService.php:317
+msgid "Library not writable"
+msgstr ""
+
+#: lib/Factory/SyncGroupFactory.php:79
+msgid "Sync Group not found"
+msgstr ""
+
+#: lib/Factory/FolderFactory.php:73 lib/Factory/FolderFactory.php:89
+msgid "Folder not found"
+msgstr ""
+
+#: lib/Factory/ConnectorFactory.php:86
+#, php-format
+msgid "Class %s does not exist"
+msgstr ""
+
+#: lib/Factory/ConnectorFactory.php:127 lib/Factory/ConnectorFactory.php:257
+msgid "Connector not found"
+msgstr ""
+
+#: lib/Factory/DisplayFactory.php:142
+msgid "Hardware key cannot be empty"
+msgstr ""
+
+#: lib/Factory/TagFactory.php:166
+#, php-format
+msgid "Unable to find Tag %s"
+msgstr ""
+
+#: lib/Factory/TagFactory.php:196
+msgid "Tag not found"
+msgstr ""
+
+#: lib/Factory/SavedReportFactory.php:118
+msgid "Cannot find saved report"
+msgstr ""
+
+#: lib/Factory/DisplayProfileFactory.php:107
+#, php-format
+msgid "No default display profile for %s"
+msgstr ""
+
+#: lib/Factory/DisplayProfileFactory.php:595
+#: lib/Factory/DisplayProfileFactory.php:609
+#: lib/Factory/DisplayProfileFactory.php:632
+#, php-format
+msgid "Custom Display Profile not registered correctly for type %s"
+msgstr ""
+
+#: lib/Factory/DisplayProfileFactory.php:599
+#, php-format
+msgid "Custom template not registered correctly for type %s"
+msgstr ""
+
+#: lib/Factory/DisplayProfileFactory.php:613
+#, php-format
+msgid "Custom config not registered correctly for type %s"
+msgstr ""
+
+#: lib/Factory/DisplayProfileFactory.php:636
+#, php-format
+msgid "Custom fields handling not registered correctly for type %s"
+msgstr ""
+
+#: lib/Factory/ReportScheduleFactory.php:75
+msgid "Report Schedule not found"
+msgstr ""
+
+#: lib/Factory/PlayerVersionFactory.php:118
+msgid "Cannot find version"
+msgstr ""
+
+#: lib/Factory/PlayerVersionFactory.php:134
+msgid "Cannot find Player Version"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:321
+msgid "LayoutId is 0"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:327 lib/Factory/LayoutFactory.php:369
+#: lib/Factory/LayoutFactory.php:414 lib/Factory/LayoutFactory.php:435
+#: lib/Factory/LayoutFactory.php:490 lib/Factory/LayoutFactory.php:544
+msgid "Layout not found"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:344 lib/Factory/LayoutFactory.php:386
+msgid "Invalid Input"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:350 lib/Factory/LayoutFactory.php:392
+msgid "Layout does not exist"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:662
+msgid "Layout import failed, invalid xlf supplied"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:1306
+msgid "Unable to read layout details from ZIP"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:1358
+msgid ""
+"Unsupported format. Missing Layout definitions from layout.json file in the "
+"archive."
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:1485
+msgid "Empty file in ZIP"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:1491
+msgid "Cannot save media file from ZIP file"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:1852
+#, php-format
+msgid "DataSets have different number of columns imported = %d, existing = %d"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:1869
+msgid "DataSets have different column names"
+msgstr ""
+
+#: lib/Factory/LayoutFactory.php:3098
+msgid "Module not found"
+msgstr ""
+
+#: lib/Factory/FontFactory.php:69
+msgid "Font file is not embeddable due to its permissions"
+msgstr ""
+
+#: lib/Factory/RegionFactory.php:109
+msgid "Incorrect type"
+msgstr ""
+
+#: lib/Factory/RegionFactory.php:113
+msgid "Size and coordinates must be generic"
+msgstr ""
+
+#: lib/Factory/RegionFactory.php:117
+msgid "Width must be greater than 0"
+msgstr ""
+
+#: lib/Factory/RegionFactory.php:121
+msgid "Height must be greater than 0"
+msgstr ""
+
+#: lib/Factory/RegionFactory.php:195
+msgid "Region not found"
+msgstr ""
+
+#: lib/Factory/ActionFactory.php:114
+msgid "Action not found"
+msgstr ""
+
+#: lib/Factory/ActionFactory.php:153
+msgid "not found"
+msgstr ""
+
+#: lib/Factory/WidgetFactory.php:194 lib/Factory/WidgetFactory.php:211
+msgid "Widget not found"
+msgstr ""
+
+#: lib/Factory/ScheduleReminderFactory.php:100
+msgid "Cannot find schedule reminder"
+msgstr ""
+
+#: lib/Factory/MediaFactory.php:379 lib/Factory/MediaFactory.php:396
+#: lib/Factory/MediaFactory.php:413 lib/Factory/MediaFactory.php:430
+msgid "Cannot find media"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:407
+#, php-format
+msgid "Extension %s does not match any enabled Module"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:471
+msgid "DataType not found"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:535 lib/Factory/ModuleFactory.php:554
+#: lib/Factory/ModuleFactory.php:628 lib/Factory/ModuleTemplateFactory.php:151
+#: lib/Factory/ModuleTemplateFactory.php:169
+msgid "Asset not found"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:815
+msgid "Invalid legacyType"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:834 lib/Factory/ModuleTemplateFactory.php:485
+msgid "Invalid assets"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:850
+msgid "Invalid settings"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:894 lib/Factory/ModuleTemplateFactory.php:458
+msgid "Invalid properties"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:902 lib/Factory/ModuleTemplateFactory.php:467
+msgid "Invalid property groups"
+msgstr ""
+
+#: lib/Factory/ModuleFactory.php:918 lib/Factory/ModuleTemplateFactory.php:476
+msgid "Invalid stencils"
+msgstr ""
+
+#: lib/Factory/PlaylistFactory.php:100
+msgid ""
+"One of the Regions on this Layout does not have a Playlist, please contact "
+"your administrator."
+msgstr ""
+
+#: lib/Factory/PlaylistFactory.php:127
+msgid "Cannot find playlist"
+msgstr ""
+
+#: lib/Factory/WidgetDataFactory.php:61
+msgid "Missing ID"
+msgstr ""
+
+#: lib/Factory/WidgetDataFactory.php:81 lib/Factory/WidgetDataFactory.php:102
+msgid "Missing Widget ID"
+msgstr ""
+
+#: lib/Factory/ModuleTemplateFactory.php:71
+#, php-format
+msgid "%s not found for %s"
+msgstr ""
+
+#: lib/Factory/ModuleTemplateFactory.php:83
+#, php-format
+msgid "Template not found for %s"
+msgstr ""
+
+#: lib/Factory/ModuleTemplateFactory.php:102
+#, php-format
+msgid "Template not found for %s and %s"
+msgstr ""
+
+#: lib/Factory/ModuleTemplateFactory.php:449
+msgid "Invalid Extends"
+msgstr ""
+
+#: lib/Factory/CampaignFactory.php:127
+msgid "Campaign not found"
+msgstr ""
+
+#: lib/Factory/MenuBoardFactory.php:133 lib/Factory/MenuBoardFactory.php:161
+msgid "Menu Board not found"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:449
+#, php-format
+msgid ""
+"The request %d is too large to fit inside the configured memory limit. %d"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:519
+#, php-format
+msgid "Unable to get Data for %s because the response was not valid JSON."
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:561
+#, php-format
+msgid "Unable to get Data for %s because %s."
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:609
+#, php-format
+msgid "Processing %d results into %d potential columns"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:612
+#, php-format
+msgid "Processing Result with Data Root %s"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:655
+msgid "Processing as a Single Row"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:660
+msgid "Processing as Multiple Rows"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:670
+#, php-format
+msgid "No data found at the DataRoot %s"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:673
+msgid "Consolidating entries"
+msgstr ""
+
+#: lib/Factory/DataSetFactory.php:678
+#, php-format
+msgid "There are %d entries in total"
+msgstr ""
+
+#: lib/Factory/ResolutionFactory.php:90 lib/Factory/ResolutionFactory.php:118
+#: lib/Factory/ResolutionFactory.php:141
+msgid "Resolution not found"
+msgstr ""
+
+#: lib/Factory/MenuBoardCategoryFactory.php:145
+msgid "Menu Board Category not found"
+msgstr ""
+
+#: lib/Factory/MenuBoardCategoryFactory.php:182
+msgid "Menu Board Product not found"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:90
+msgid "Required file not found for Display and Layout Combination"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:106
+msgid "Required file not found for Display and Media Combination"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:123
+msgid "Required file not found for Display and Layout Widget"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:158
+msgid "Required file not found for Display and Dependency"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:203
+msgid "Required file not found for Display and Path"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:230
+msgid "Required file not found for Display and Dependency ID"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:392
+msgid "Missing fileType"
+msgstr ""
+
+#: lib/Factory/RequiredFileFactory.php:409
+#: lib/Widget/Render/WidgetDownloader.php:273 lib/Xmds/Soap3.php:239
+#: lib/Xmds/Soap4.php:398
+msgid "File not found"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:88 lib/Factory/UserGroupFactory.php:105
+#: lib/Factory/UserGroupFactory.php:120
+msgid "Group not found"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:501
+msgid ""
+"Page which shows all Events added to the Calendar for the purposes of "
+"Schedule Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:506
+msgid "Include the Agenda View on the Calendar"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:511
+msgid ""
+"Include \"Add Event\" button to allow for the creation of new Scheduled "
+"Events"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:516
+msgid "Allow edits including deletion of existing Scheduled Events"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:521
+msgid "Allow creation of Synchronised Schedules"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:526
+msgid "Allow creation of Data Connector Schedules"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:531
+msgid "Page which shows all Dayparts that have been created"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:536
+msgid ""
+"Include \"Add Daypart\" button to allow for the creation of new Dayparts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:541
+msgid "Allow edits including deletion to be made to all created Dayparts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:546
+msgid ""
+"Page which shows all items that have been uploaded to the Library for the "
+"purposes of Media Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:551
+msgid ""
+"Include \"Add Media\" buttons to allow for additional content to be uploaded "
+"to the Media Library"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:556
+msgid ""
+"Allow edits including deletion to all items uploaded to the Media Library"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:561
+msgid ""
+"Page which shows all DataSets that have been created which can be used in "
+"multiple Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:566
+msgid ""
+"Include \"Add DataSet\" button to allow for additional DataSets to be "
+"created independently to Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:571
+msgid ""
+"Allow edits including deletion to all created DataSets independently to "
+"Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:576
+msgid ""
+"Allow edits including deletion to all data contained within a DataSet "
+"independently to Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:581
+msgid "Create and update real time DataSets"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:586
+msgid ""
+"Page which shows all Layouts that have been created for the purposes of "
+"Layout Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:591
+msgid ""
+"Include \"Add Layout\" button to allow for additional Layouts to be created"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:596
+msgid "Allow edits including deletion to be made to all created Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:601
+msgid ""
+"Include the Export function for all editable Layouts to allow a User to "
+"export a Layout and its contents regardless of the share options that have "
+"been set"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:606
+msgid ""
+"Page which shows all Campaigns that have been created for the purposes of "
+"Campaign Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:611
+msgid ""
+"Include \"Add Campaign\" button to allow for additional Campaigns to be "
+"created"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:616
+msgid "Allow edits including deletion to all created Campaigns"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:621
+msgid "Access to Ad Campaigns"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:626
+msgid "Page which shows all Templates that have been saved"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:631
+msgid "Add \"Save Template\" function for all Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:636
+msgid "Allow edits to be made to all saved Templates"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:641
+msgid "Page which shows all Resolutions that have been added to the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:646
+msgid "Add Resolution button to allow for additional Resolutions to be added"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:651
+msgid "Allow edits including deletion to all added Resolutions"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:656
+msgid ""
+"Page which shows all Tags that have been added for the purposes of Tag "
+"Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:661
+msgid "Ability to add and edit Tags when assigning to items"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:666
+msgid ""
+"Page which shows all Playlists that have been created which can be used in "
+"multiple Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:671
+msgid ""
+"Include \"Add Playlist\" button to allow for additional Playlists to be "
+"created independently to Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:676
+msgid ""
+"Allow edits including deletion to all created Playlists independently to "
+"Layouts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:681
+msgid ""
+"Ability to update own Profile, including changing passwords and "
+"authentication preferences"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:686
+msgid "Notifications appear in the navigation bar"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:691
+msgid "Access to the Notification Centre to view past notifications"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:696
+msgid "Access to API applications"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:701
+msgid "Allow Sharing capabilities for all User objects"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:706
+msgid ""
+"Include \"Add Notification\" button to allow for the creation of new "
+"notifications"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:711
+msgid ""
+"Allow edits including deletion for all notifications in the Notification "
+"Centre"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:716
+msgid ""
+"Page which shows all Users in the platform for the purposes of User "
+"Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:721
+msgid ""
+"Include \"Add User\" button to allow for additional Users to be added to the "
+"platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:726
+msgid ""
+"Allow Group Admins to edit including deletion, for all added Users within "
+"their group"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:731
+msgid "Page which shows all User Groups that have been created"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:736
+msgid "Allow edits including deletion for all created User Groups"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:741
+msgid ""
+"Status Dashboard showing key platform metrics, suitable for an Administrator."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:746
+msgid ""
+"Media Manager Dashboard showing only the Widgets the user has access to "
+"modify."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:751
+msgid ""
+"Playlist Dashboard showing only the Playlists configured in Layouts the user "
+"has access to modify."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:756
+msgid ""
+"Page which shows all Displays added to the platform for the purposes of "
+"Display Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:761
+msgid ""
+"Include \"Add Display\" button to allow additional Displays to be added to "
+"the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:766
+msgid "Allow edits including deletion for all added Displays"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:771
+msgid "Allow access to non-destructive edit-only features"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:776
+msgid "Page which shows all Display Groups that have been created"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:781
+msgid ""
+"Include \"Add Display Group\" button to allow for the creation of additional "
+"Display Groups"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:786
+msgid "Allow edits including deletion for all created Display Groups"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:791
+msgid "Allow access to non-destructive edit-only features in a Display Group"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:796
+msgid "Page which shows all Display Setting Profiles that have been added"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:801
+msgid ""
+"Include \"Add Profile\" button to allow for additional Display Setting "
+"Profiles to be added to the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:806
+msgid "Allow edits including deletion for all created Display Setting Profiles"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:811
+msgid "Page to view/add/edit/delete/download Player Software Versions"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:816
+msgid "Page to view/add/edit/delete Commands"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:821
+msgid ""
+"Page which shows all Sync Groups added to the platform for the purposes of "
+"Sync Group Management"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:826
+msgid "Allow creation of Synchronised Groups"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:831
+msgid "Allow edits of Synchronised Groups"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:836
+msgid ""
+"Access to a Report Fault wizard for collecting reports to forward to the "
+"support team for analysis, which may contain sensitive data."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:841
+msgid "Page to show debug and error logging which may contain sensitive data"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:846
+msgid "Page to show all User Sessions throughout the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:851
+msgid ""
+"Page to show the Audit Trail for all created/modified and removed items "
+"throughout the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:856
+msgid "Page which allows for Module Management for the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:861
+msgid "Add/Edit custom modules and templates"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:866
+msgid "Delete custom modules and templates"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:871
+msgid "Page which allows for Transition Management for the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:876
+msgid "Page which allows for Task Management for the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:881
+msgid "Dashboard which shows all available Reports"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:886
+msgid ""
+"Display Reports to show bandwidth usage and time connected / disconnected"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:891
+msgid ""
+"Proof of Play Reports which include summary and distribution by Layout, "
+"Media or Event"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:896
+msgid "Page which shows all Reports that have been Scheduled"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:901
+msgid "Page which shows all Reports that have been Saved"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:906
+msgid "View Folder Tree on Grids and Forms"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:911
+msgid ""
+"Allow users to create Sub-Folders under Folders they have access to. (Except "
+"the Root Folder)"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:916
+msgid "Rename and Delete existing Folders"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:921
+msgid "Set a home folder for a user"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:926
+msgid "View the Menu Board page"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:931
+msgid ""
+"Include \"Add Menu Board\" button to allow for additional Menu Boards to be "
+"added to the platform"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:936
+msgid ""
+"Allow edits, creation of Menu Board Categories and Products including "
+"deletion for all created Menu Board content"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:941
+msgid "View the Fonts page"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:946
+msgid "Upload new Fonts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:951
+msgid "Delete existing Fonts"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:966
+msgid "Homepage has not been set"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:972
+#, php-format
+msgid "Homepage %s not found."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:989
+msgid ""
+"Status Dashboard showing key platform metrics, usually for an administrator."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:995
+msgid ""
+"Icon Dashboard showing an easy access set of feature icons the user can "
+"access."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:1000
+msgid "Media Manager Dashboard"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:1001
+msgid ""
+"Media Manager Dashboard showing all Widgets the user has access to modify."
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:1006
+msgid "Playlist Dashboard"
+msgstr ""
+
+#: lib/Factory/UserGroupFactory.php:1007
+msgid ""
+"Playlist Dashboard showing all Playlists configured in Layouts the user has "
+"access to modify."
+msgstr ""
+
+#: lib/Factory/PermissionFactory.php:62 lib/Factory/PermissionFactory.php:110
+msgid "Entity not found: "
+msgstr ""
+
+#: lib/Factory/PermissionFactory.php:176
+msgid "Entity not found"
+msgstr ""
+
+#: lib/Widget/RssProvider.php:53
+msgid "Please enter the URI to a valid RSS feed."
+msgstr ""
+
+#: lib/Widget/RssProvider.php:198 lib/Widget/IcsProvider.php:252
+msgid "Unable to download feed"
+msgstr ""
+
+#: lib/Widget/RssProvider.php:211
+msgid "Unable to parse feed"
+msgstr ""
+
+#: lib/Widget/MastodonProvider.php:156
+msgid "Unable to download posts"
+msgstr ""
+
+#: lib/Widget/MastodonProvider.php:161
+msgid "Unknown issue getting posts"
+msgstr ""
+
+#: lib/Widget/IcsProvider.php:54
+msgid "Please enter the URI to a valid ICS feed."
+msgstr ""
+
+#: lib/Widget/IcsProvider.php:209
+msgid "The iCal provided is not valid, please choose a valid feed"
+msgstr ""
+
+#: lib/Widget/Render/WidgetDownloader.php:219
+#: lib/Widget/Render/WidgetDownloader.php:283
+msgid "Image too large"
+msgstr ""
+
+#: lib/Widget/Render/WidgetDownloader.php:334
+msgid "Cannot parse image."
+msgstr ""
+
+#: lib/Widget/Definition/Asset.php:132
+#, php-format
+msgid "Asset %s not found"
+msgstr ""
+
+#: lib/Widget/Definition/Asset.php:164
+msgid "Asset file does not exist"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:225
+#, php-format
+msgid "Value too large for %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:265
+#, php-format
+msgid "Missing required property %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:276
+#, php-format
+msgid "%s must be a valid URI"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:292
+#, php-format
+msgid "%s must be a valid Windows path"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:306
+#: lib/Widget/Definition/Property.php:318
+msgid ""
+"That is not a valid date interval, please use natural language such as 1 week"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:328
+#, php-format
+msgid "%s must equal %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:337
+#, php-format
+msgid "%s must not equal %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:346
+#, php-format
+msgid "%s must contain %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:356
+#, php-format
+msgid "%s must not contain %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:367
+#, php-format
+msgid "%s must be less than %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:378
+#, php-format
+msgid "%s must be less than or equal to %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:389
+#, php-format
+msgid "%s must be greater than or equal to %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:400
+#, php-format
+msgid "%s must be greater than %s"
+msgstr ""
+
+#: lib/Widget/Definition/Property.php:472
+#, php-format
+msgid "%s is not a valid option"
+msgstr ""
+
+#: lib/Widget/Validator/ZeroDurationValidator.php:47
+#, php-format
+msgid "Duration needs to be above 0 for %s"
+msgstr ""
+
+#: lib/Widget/Validator/RemoteUrlsZeroDurationValidator.php:53
+msgid "The duration needs to be greater than 0 for remote URLs"
+msgstr ""
+
+#: lib/Widget/Validator/RemoteUrlsZeroDurationValidator.php:59
+msgid "The duration needs to be above 0 for a locally stored file "
+msgstr ""
+
+#: lib/Widget/Validator/ShellCommandValidator.php:49
+msgid "You must enter a command"
+msgstr ""
+
+#: lib/Widget/DataType/Product.php:45
+msgid "Product"
+msgstr ""
+
+#: lib/Widget/DataType/Article.php:67
+msgid "Article"
+msgstr ""
+
+#: lib/Widget/DataType/Event.php:70
+msgid "All Day Event"
+msgstr ""
+
+#: lib/Widget/DataType/SocialMedia.php:63
+msgid "Social Media"
+msgstr ""
+
+#: lib/Widget/DataType/SocialMedia.php:67
+msgid "Profile Image"
+msgstr ""
+
+#: lib/Widget/DataType/SocialMedia.php:68
+msgid "Mini Profile Image"
+msgstr ""
+
+#: lib/Widget/DataType/SocialMedia.php:69
+msgid "Bigger Profile Image"
+msgstr ""
+
+#: lib/Widget/DataType/SocialMedia.php:71
+msgid "Screen Name"
+msgstr ""
+
+#: lib/Widget/DataType/ProductCategory.php:40
+msgid "Product Category"
+msgstr ""
+
+#: lib/Widget/DataType/Forecast.php:120
+msgid "Forecast"
+msgstr ""
+
+#: lib/Xmds/Soap3.php:258 lib/Xmds/Soap4.php:472
+msgid "Unknown FileType Requested."
+msgstr ""
+
+#: lib/Xmds/Soap4.php:405 lib/Xmds/Soap4.php:455
+msgid "Unable to get file pointer"
+msgstr ""
+
+#: lib/Xmds/Soap4.php:415 lib/Xmds/Soap4.php:466
+msgid "Empty file"
+msgstr ""
+
+#: lib/Xmds/Soap4.php:448
+msgid "Media exists but file missing from library."
+msgstr ""
+
+#: lib/Xmds/Soap4.php:810
+msgid "Incorrect Screen shot Format"
+msgstr ""
+
+#: lib/Xmds/Listeners/XmdsDataConnectorListener.php:62
+#, php-format
+msgid "Data Connector %s not found"
+msgstr ""
+
+#: lib/Xmds/Soap.php:589
+#, php-format
+msgid ""
+"Scheduled Action Event ID %d contains an invalid Layout linked to it by the "
+"Layout code."
+msgstr ""
+
+#: lib/Xmds/Soap.php:2641
+#, php-format
+msgid "Recovery for Display %s"
+msgstr ""
+
+#: lib/Xmds/Soap.php:2643
+#, php-format
+msgid "Display ID %d is now back online %s"
+msgstr ""
+
+#: lib/Xmds/Soap.php:2785
+msgid "Bandwidth allowance exceeded"
+msgstr ""
+
+#: lib/Xmds/Soap6.php:157
+msgid "All Player faults cleared"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:47
+msgid ""
+"Pattern of MAC-address is not \"xx-xx-xx-xx-xx-xx\" (x = digit or letter)"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:73
+msgid ""
+"Pattern of SecureOn-password is not \"xx-xx-xx-xx-xx-xx\" (x = digit or "
+"CAPITAL letter)"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:90
+msgid "No IP Address Specified"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:99
+msgid "IP Address Incorrectly Formed"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:108
+msgid "CIDR subnet mask is not a number within the range of 0 till 32."
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:166
+msgid "Port is not a number within the range of 0 till 65536. Port Provided: "
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:170
+msgid ""
+"No magic packet can been sent, since UDP is unsupported (not a registered "
+"socket transport)"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:200
+msgid "Using \"fwrite()\" failed, due to error: "
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:207
+msgid "Using fsockopen() failed, due to denied permission"
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:230
+msgid "Using \"socket_set_option()\" failed, due to error: "
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:254 lib/Helper/WakeOnLan.php:263
+msgid "Using \"socket_sendto()\" failed, due to error: "
+msgstr ""
+
+#: lib/Helper/WakeOnLan.php:268
+msgid "Wake On Lan Failed as there are no functions available to transmit it"
+msgstr ""
+
+#: lib/Helper/DataSetUploadHandler.php:76
+msgid "Import failed: No value columns defined in the dataset."
+msgstr ""
+
+#: lib/Helper/DataSetUploadHandler.php:153
+#, php-format
+msgid "Unable to import row %d"
+msgstr ""
+
+#: lib/Helper/UploadHandler.php:77 lib/Helper/XiboUploadHandler.php:66
+#: lib/Helper/LayoutUploadHandler.php:56
+#, php-format
+msgid "Your library is full. Library Limit: %s K"
+msgstr ""
+
+#: lib/Helper/Install.php:137 lib/Helper/Install.php:229
+msgid "Please provide a database host. This is usually localhost."
+msgstr ""
+
+#: lib/Helper/Install.php:141
+msgid "Please provide a user for the new database."
+msgstr ""
+
+#: lib/Helper/Install.php:145
+msgid "Please provide a password for the new database."
+msgstr ""
+
+#: lib/Helper/Install.php:149
+msgid "Please provide a name for the new database."
+msgstr ""
+
+#: lib/Helper/Install.php:153
+msgid "Please provide an admin user name."
+msgstr ""
+
+#: lib/Helper/Install.php:169 lib/Helper/Install.php:257
+#, php-format
+msgid ""
+"Could not connect to MySQL with the administrator details. Please check and "
+"try again. Error Message = [%s]"
+msgstr ""
+
+#: lib/Helper/Install.php:179
+#, php-format
+msgid ""
+"Could not create a new database with the administrator details [%s]. Please "
+"check and try again. Error Message = [%s]"
+msgstr ""
+
+#: lib/Helper/Install.php:210
+#, php-format
+msgid ""
+"Could not create a new user with the administrator details. Please check and "
+"try again. Error Message = [%s]. SQL = [%s]."
+msgstr ""
+
+#: lib/Helper/Install.php:233
+msgid "Please provide a user for the existing database."
+msgstr ""
+
+#: lib/Helper/Install.php:237
+msgid "Please provide a password for the existing database."
+msgstr ""
+
+#: lib/Helper/Install.php:241
+msgid "Please provide a name for the existing database."
+msgstr ""
+
+#: lib/Helper/Install.php:267 lib/Helper/Install.php:284
+msgid ""
+"Unable to write to settings.php. We already checked this was possible "
+"earlier, so something changed."
+msgstr ""
+
+#: lib/Helper/Install.php:321
+msgid "Missing the admin username."
+msgstr ""
+
+#: lib/Helper/Install.php:325
+msgid "Missing the admin password."
+msgstr ""
+
+#: lib/Helper/Install.php:345
+#, php-format
+msgid ""
+"Unable to set the user details. This is an unexpected error, please contact "
+"support. Error Message = [%s]"
+msgstr ""
+
+#: lib/Helper/Install.php:378
+msgid "Missing the server key."
+msgstr ""
+
+#: lib/Helper/Install.php:382
+msgid "Missing the library location."
+msgstr ""
+
+#: lib/Helper/Install.php:391
+msgid ""
+"A file exists with the name you gave for the Library Location. Please choose "
+"another location"
+msgstr ""
+
+#: lib/Helper/Install.php:398
+msgid ""
+"Could not create the Library Location directory for you. Please ensure the "
+"webserver has permission to create a folder in this location, or create the "
+"folder manually and grant permission for the webserver to write to the "
+"folder."
+msgstr ""
+
+#: lib/Helper/Install.php:404
+msgid ""
+"The Library Location you gave is not writable by the webserver. Please fix "
+"the permissions and try again."
+msgstr ""
+
+#: lib/Helper/Install.php:409
+msgid ""
+"The Library Location you gave is not empty. Please give the location of an "
+"empty folder"
+msgstr ""
+
+#: lib/Helper/Install.php:419
+msgid ""
+"Could not create the fonts sub-folder under Library Location directory for "
+"you. Please ensure the webserver has permission to create a folder in this "
+"location, or create the folder manually and grant permission for the "
+"webserver to write to the folder."
+msgstr ""
+
+#: lib/Helper/Install.php:443
+#, php-format
+msgid ""
+"An error occurred updating these settings. This is an unexpected error, "
+"please contact support. Error Message = [%s]"
+msgstr ""
+
+#: lib/Helper/Install.php:448
+msgid ""
+"Unable to delete install/index.php. Please ensure the web server has "
+"permission to unlink this file and retry"
+msgstr ""
+
+#: lib/Helper/XiboUploadHandler.php:108
+msgid "Access denied replacing old media"
+msgstr ""
+
+#: lib/Helper/XiboUploadHandler.php:114 lib/Helper/XiboUploadHandler.php:231
+msgid "You cannot replace this media with an item of a different type"
+msgstr ""
+
+#: lib/Helper/XiboUploadHandler.php:384
+msgid "You do not have permission to delete the old version."
+msgstr ""
+
+#: lib/Helper/Pbkdf2Hash.php:42
+msgid "Invalid password hash - not enough hash sections"
+msgstr ""
+
+#: lib/Listener/DataSetDataProviderListener.php:274
+msgid "DataSet Invalid"
+msgstr ""
+
+#: lib/Listener/CampaignListener.php:155
+msgid "This is inuse and cannot be deleted."
+msgstr ""
+
+#: lib/Listener/WidgetListener.php:143
+msgid "Number of spots must be empty, 0 or a positive number"
+msgstr ""
+
+#: lib/Listener/WidgetListener.php:150
+msgid "Spot length must be empty, 0 or a positive number"
+msgstr ""
+
+#: lib/Listener/WidgetListener.php:162
+msgid "Please select at least 1 Playlist to embed"
+msgstr ""
+
+#: lib/Listener/WidgetListener.php:224
+msgid ""
+"This assignment creates a loop because the Playlist being assigned contains "
+"the Playlist being worked on."
+msgstr ""
+
+#: lib/Connector/CapConnector.php:130
+msgid "Missing CAP URL"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:193
+msgid "Unable to get Common Alerting Protocol (CAP) results."
+msgstr ""
+
+#: lib/Connector/CapConnector.php:358
+msgid "Failed to retrieve CAP data from the specified URL."
+msgstr ""
+
+#: lib/Connector/CapConnector.php:533
+#: lib/Connector/NationalWeatherServiceConnector.php:400
+msgid "Emergency Alerts"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:539
+#: lib/Connector/NationalWeatherServiceConnector.php:406
+msgid "Actual Alerts"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:540
+#: lib/Connector/NationalWeatherServiceConnector.php:407
+msgid "Test Alerts"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:541
+#: lib/Connector/NationalWeatherServiceConnector.php:408
+msgid "No Alerts"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:548
+msgid "Geo"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:549
+#: lib/Connector/NationalWeatherServiceConnector.php:415
+msgid "Met"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:550
+msgid "Safety"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:551
+msgid "Security"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:552
+msgid "Rescue"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:553
+msgid "Fire"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:554
+msgid "Health"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:555
+msgid "Env"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:556
+msgid "Transport"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:557
+msgid "Infra"
+msgstr ""
+
+#: lib/Connector/CapConnector.php:558
+msgid "CBRNE"
+msgstr ""
+
+#: lib/Connector/XiboSspConnector.php:119
+msgid "Please enter a CMS URL, including http(s)://"
+msgstr ""
+
+#: lib/Connector/XiboSspConnector.php:213
+#: lib/Connector/XiboDashboardConnector.php:205
+#: lib/Connector/XiboDashboardConnector.php:283
+msgid "Empty response from the dashboard service"
+msgstr ""
+
+#: lib/Connector/XiboSspConnector.php:222
+msgid "API key not valid"
+msgstr ""
+
+#: lib/Connector/XiboSspConnector.php:233
+#: lib/Connector/XiboSspConnector.php:244
+#: lib/Connector/XiboSspConnector.php:310
+#: lib/Connector/XiboSspConnector.php:314
+#: lib/Connector/XiboSspConnector.php:403
+#: lib/Connector/XiboSspConnector.php:407
+msgid "Cannot contact SSP service, please try again shortly."
+msgstr ""
+
+#: lib/Connector/XiboSspConnector.php:453
+#: lib/Connector/XiboAudienceReportingConnector.php:718
+#: lib/Connector/XiboAudienceReportingConnector.php:776
+#: lib/Connector/XiboAudienceReportingConnector.php:829
+msgid "No response"
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:216
+msgid "Cannot register those credentials."
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:293
+#: lib/Connector/XiboDashboardConnector.php:302
+msgid "Cannot contact dashboard service, please try again shortly."
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:376
+msgid "Error calling Dashboard service"
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:405
+msgid "Cannot decode token"
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:445
+#, php-format
+msgid "No credentials logged for %s"
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:515
+msgid "Dashboard Connector not configured"
+msgstr ""
+
+#: lib/Connector/XiboDashboardConnector.php:528
+#: lib/Connector/XiboDashboardConnector.php:533
+msgid "No token returned"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:348
+#, php-format
+msgid "There were %d campaigns which failed. A summary is in the error log."
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:387
+msgid "Failed to send stats to audience API"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:423
+msgid "Cannot get watermark"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:445
+msgid "Cannot set watermark"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:490
+#, php-format
+msgid "Cannot update campaign status for %d"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:496
+msgid "Failed to update campaign totals."
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:872
+#: lib/Connector/XiboAudienceReportingConnector.php:876
+msgid "Please provide an API key"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:948
+msgid "An unknown error has occurred."
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:959
+msgid "Access denied, please check your API key"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:963
+#, php-format
+msgid "Unknown client exception processing your request, error code is %s"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:968
+msgid "Invalid request"
+msgstr ""
+
+#: lib/Connector/XiboAudienceReportingConnector.php:972
+msgid "There was a problem processing your request, please try again"
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:123
+msgid "Unable to contact the AlphaVantage API"
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:238
+msgid "Add some stock symbols"
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:286
+msgid "Invalid symbol "
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:323
+msgid "Stocks data invalid"
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:399
+msgid ""
+"Missing Items for Currencies Module. Please provide items in order to "
+"proceed."
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:420
+msgid ""
+"Base currency must not be included in the Currencies list. Please remove it "
+"and try again."
+msgstr ""
+
+#: lib/Connector/AlphaVantageConnector.php:553
+#: lib/Connector/AlphaVantageConnector.php:560
+#: lib/Connector/AlphaVantageConnector.php:565
+#: lib/Connector/AlphaVantageConnector.php:630
+#: lib/Connector/AlphaVantageConnector.php:635
+msgid "Currency data invalid"
+msgstr ""
+
+#: lib/Connector/NationalWeatherServiceConnector.php:195
+msgid "No alerts are available for the selected area at the moment."
+msgstr ""
+
+#: lib/Connector/NationalWeatherServiceConnector.php:350
+msgid "Failed to retrieve NWS alerts from specified Atom Feed URL."
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:130
+msgid "Unable to get weather results."
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:524
+msgid "Chinese Simplified"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:525
+msgid "Chinese Traditional"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:532
+msgid "Persian (Farsi)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:547
+msgid "Norwegian"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:551
+msgid "Português Brasil"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:563
+msgid "Zulu"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:668
+msgid "Weather Condition"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:670
+msgid "Thunderstorm"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:671
+msgid "Drizzle"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:672
+msgid "Rain"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:673
+msgid "Snow"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:675
+msgid "Clouds"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:677
+msgid "Temperature (Imperial)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:679
+msgid "Temperature (Metric)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:681
+msgid "Apparent Temperature (Imperial)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:683
+msgid "Apparent Temperature (Metric)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:690
+msgid "Northeast"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:692
+msgid "Southeast"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:694
+msgid "Southwest"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:696
+msgid "Northwest"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:698
+msgid "Wind Direction (degrees)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:700
+msgid "Humidity (Percent)"
+msgstr ""
+
+#: lib/Connector/OpenWeatherMapConnector.php:704
+msgid "Visibility (meters)"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:96
+msgid "Daily Maintenance"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:107
+msgid "Library structure invalid"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:117
+msgid "## Build caches"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:130
+msgid "Failure to build caches"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:152
+msgid "Tidy Logs"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:174
+msgid "Tidy Cache"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:176 lib/XTR/MaintenanceDailyTask.php:272
+#: lib/XTR/StatsMigrationTask.php:218 lib/XTR/StatsMigrationTask.php:357
+#: lib/XTR/StatsMigrationTask.php:458 lib/XTR/StatsMigrationTask.php:625
+#: lib/XTR/WidgetCompatibilityTask.php:118
+#: lib/XTR/ClearCachedMediaDataTask.php:81 lib/XTR/MediaOrientationTask.php:93
+#: lib/XTR/SeedDatabaseTask.php:160
+msgid "Done."
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:185
+msgid "Import Layouts and Fonts"
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:274
+msgid "Not Required."
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:297
+#, php-format
+msgid "Assets cached, %d failed."
+msgstr ""
+
+#: lib/XTR/MaintenanceDailyTask.php:313
+msgid "Player bundle cached"
+msgstr ""
+
+#: lib/XTR/RemoveOldScreenshotsTask.php:47
+msgid "Remove Old Screenshots"
+msgstr ""
+
+#: lib/XTR/RemoveOldScreenshotsTask.php:68
+#, php-format
+msgid "Removed %d old Display screenshots"
+msgstr ""
+
+#: lib/XTR/RemoveOldScreenshotsTask.php:70
+msgid "Display Screenshot Time to keep set to 0, nothing to remove."
+msgstr ""
+
+#: lib/XTR/WidgetCompatibilityTask.php:75
+msgid "Widget Compatibility"
+msgstr ""
+
+#: lib/XTR/NotificationTidyTask.php:36
+msgid "Notification Tidy"
+msgstr ""
+
+#: lib/XTR/ClearCachedMediaDataTask.php:49
+msgid "Clear Cached Media Data"
+msgstr ""
+
+#: lib/XTR/MediaOrientationTask.php:46
+msgid "Media Orientation"
+msgstr ""
+
+#: lib/XTR/ScheduleReminderTask.php:76
+msgid "Schedule reminder"
+msgstr ""
+
+#: lib/XTR/ScheduleReminderTask.php:148
+#, php-format
+msgid "Reminder for %s"
+msgstr ""
+
+#: lib/XTR/ScheduleReminderTask.php:150
+#, php-format
+msgid "The event (%s) is %s in %d %s"
+msgstr ""
+
+#: lib/XTR/ScheduleReminderTask.php:152
+#, php-format
+msgid "The event (%s) has %s %d %s ago"
+msgstr ""
+
+#: lib/XTR/EmailNotificationsTask.php:60 lib/XTR/EmailNotificationsTask.php:71
+msgid "Email Notifications"
+msgstr ""
+
+#: lib/XTR/PurgeListCleanupTask.php:49
+msgid "Purge List Cleanup Start"
+msgstr ""
+
+#: lib/XTR/PurgeListCleanupTask.php:56
+msgid "Nothing to remove"
+msgstr ""
+
+#: lib/XTR/PurgeListCleanupTask.php:58
+#, php-format
+msgid "Removed %d rows"
+msgstr ""
+
+#: lib/XTR/ImageProcessingTask.php:59
+msgid "Image Processing"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:109
+msgid "Regular Maintenance"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:164
+msgid "Licence Slot Validation"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:191
+#, php-format
+msgid "Disabling %s"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:213
+msgid "Wake On LAN"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:269
+msgid "Build Layouts"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:329
+msgid "Library allowance exceeded"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:389
+#, php-format
+msgid "%s is downloading %d files too many times"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:402
+#, php-format
+msgid ""
+"Please check the bandwidth graphs and display status for %s to investigate "
+"the issue."
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:432
+msgid "Playlist Duration Updates"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:456
+msgid "Publishing layouts with set publish dates"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:555
+msgid "Assess Dynamic Display Groups"
+msgstr ""
+
+#: lib/XTR/MaintenanceRegularTask.php:601
+msgid "Tidy Ad Campaign Schedules"
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:79
+msgid "Stats Archive"
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:96 lib/XTR/AuditLogArchiveTask.php:145
+msgid "Nothing to archive"
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:230
+#, php-format
+msgid "Stats Export %s to %s - %s"
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:280 lib/XTR/AuditLogArchiveTask.php:262
+msgid ""
+"No super admins to use as the archive owner, please set one in the "
+"configuration."
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:288 lib/XTR/AuditLogArchiveTask.php:270
+msgid "Archive Owner not found"
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:300
+msgid "Tidy Stats"
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:326
+#, php-format
+msgid "Done - %d deleted."
+msgstr ""
+
+#: lib/XTR/StatsArchiveTask.php:329
+msgid "Error."
+msgstr ""
+
+#: lib/XTR/ReportScheduleTask.php:90
+msgid "Report schedule"
+msgstr ""
+
+#: lib/XTR/ReportScheduleTask.php:160
+#, php-format
+msgid "Run report results: %s."
+msgstr ""
+
+#: lib/XTR/ReportScheduleTask.php:313
+#, php-format
+msgid "Attached please find the report for %s"
+msgstr ""
+
+#: lib/XTR/SeedDatabaseTask.php:119
+msgid "Seeding Database"
+msgstr ""
+
+#: lib/XTR/SeedDatabaseTask.php:307
+msgid "Import Layout To Seed Database"
+msgstr ""
+
+#: lib/XTR/AuditLogArchiveTask.php:80
+msgid "AuditLog Delete"
+msgstr ""
+
+#: lib/XTR/AuditLogArchiveTask.php:134
+msgid "AuditLog Archive"
+msgstr ""
+
+#: lib/XTR/AuditLogArchiveTask.php:198
+msgid "No audit log found for these dates"
+msgstr ""
+
+#: lib/XTR/AuditLogArchiveTask.php:239
+#, php-format
+msgid "AuditLog Export %s to %s"
+msgstr ""
+
+#: lib/XTR/RemoteDataSetFetchTask.php:69
+msgid "Fetching Remote-DataSets"
+msgstr ""
+
+#: lib/XTR/RemoteDataSetFetchTask.php:190
+#, php-format
+msgid "No results for %s, truncate with no new data enabled"
+msgstr ""
+
+#: lib/XTR/RemoteDataSetFetchTask.php:192
+#, php-format
+msgid "No results for %s"
+msgstr ""
+
+#: lib/XTR/RemoteDataSetFetchTask.php:200
+#, php-format
+msgid "Error syncing DataSet %s"
+msgstr ""
+
+#: lib/XTR/RemoteDataSetFetchTask.php:206
+#, php-format
+msgid "Remote DataSet %s failed to synchronise"
+msgstr ""
+
+#: lib/routes-install.php:63 lib/routes-install.php:74
+#: lib/routes-install.php:85
+msgid ""
+"The CMS has already been installed. Please contact your system administrator."
+msgstr ""
+
+#: lib/Storage/MySqlTimeSeriesStore.php:202
+#: lib/Storage/MongoDbTimeSeriesStore.php:441
+msgid "Invalid statId provided"
+msgstr ""
+
+#: lib/Storage/MongoDbTimeSeriesStore.php:596
+#: lib/Storage/MongoDbTimeSeriesStore.php:655
+msgid ""
+"Sorry we encountered an error getting Proof of Play data, please consult "
+"your administrator"
+msgstr ""
+
+#: lib/Storage/MongoDbTimeSeriesStore.php:705
+msgid ""
+"Sorry we encountered an error getting total number of Proof of Play data, "
+"please consult your administrator"
+msgstr ""
+
+#: lib/Storage/PdoStorageService.php:417
+#, php-format
+msgid "Failed to write to database after %d retries. Please try again later."
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:128 lib/Report/MobileProofOfPlay.php:122
+#: lib/Report/CampaignProofOfPlay.php:123
+msgid "Select a display"
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:186 lib/Report/DisplayPercentage.php:189
+#: lib/Report/MobileProofOfPlay.php:178 lib/Report/CampaignProofOfPlay.php:179
+#: lib/Report/ProofOfPlay.php:210
+#, php-format
+msgid "%s report for "
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:404
+msgid "Total ad plays"
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:410
+msgid "Total impressions"
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:418
+msgid "Total spend"
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:437
+msgid "Ad Play(s)"
+msgstr ""
+
+#: lib/Report/DisplayAdPlay.php:449
+msgid "Impression(s)"
+msgstr ""
+
+#: lib/Report/DisplayPercentage.php:128
+msgid "Select a campaign"
+msgstr ""
+
+#: lib/Report/DisplayPercentage.php:283 lib/Report/MobileProofOfPlay.php:372
+#: lib/Report/MobileProofOfPlay.php:386
+msgid "Not found"
+msgstr ""
+
+#: lib/Report/TimeDisconnectedSummary.php:164
+#, php-format
+msgid "%s time disconnected summary report"
+msgstr ""
+
+#: lib/Report/TimeDisconnectedSummary.php:228
+#: lib/Report/DistributionReport.php:303
+msgid "No display groups with View permissions"
+msgstr ""
+
+#: lib/Report/TimeDisconnectedSummary.php:270
+msgid "Days"
+msgstr ""
+
+#: lib/Report/TimeDisconnectedSummary.php:456
+msgid "Downtime"
+msgstr ""
+
+#: lib/Report/TimeDisconnectedSummary.php:461
+msgid "Uptime"
+msgstr ""
+
+#: lib/Report/Bandwidth.php:133
+#, php-format
+msgid "%s bandwidth report"
+msgstr ""
+
+#: lib/Report/ApiRequests.php:138
+#, php-format
+msgid "%s API requests %s log report for User"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:135 lib/Report/TimeConnected.php:109
+#: lib/Report/ProofOfPlay.php:139 lib/Report/SummaryReport.php:126
+msgid "Select a type and an item (i.e., layout/media/tag)"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:236 lib/Report/SummaryReport.php:230
+#, php-format
+msgid "%s report for Layout %s"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:240
+#, php-format
+msgid "%s report for Media %s"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:242 lib/Report/SummaryReport.php:236
+msgid "Media not found"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:245 lib/Report/SummaryReport.php:239
+#, php-format
+msgid "%s report for Event %s"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:255
+msgid "(DisplayId: Not Found)"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:453
+#: lib/Report/SummaryDistributionCommonTrait.php:52
+#: lib/Report/SummaryReport.php:445
+msgid "Total duration"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:459
+#: lib/Report/SummaryDistributionCommonTrait.php:58
+#: lib/Report/SummaryReport.php:451
+msgid "Total count"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:478
+#: lib/Report/SummaryDistributionCommonTrait.php:77
+#: lib/Report/SummaryReport.php:470
+msgid "Duration(s)"
+msgstr ""
+
+#: lib/Report/DistributionReport.php:687 lib/Report/ReportDefaultTrait.php:273
+#: lib/Report/SummaryReport.php:687
+msgid "Unknown Grouping "
+msgstr ""
+
+#: lib/Report/LibraryUsage.php:291
+#, php-format
+msgid "%s library usage report"
+msgstr ""
+
+#: lib/Report/TimeConnected.php:182 lib/Report/DisplayAlerts.php:136
+#, php-format
+msgid "%s report for Display"
+msgstr ""
+
+#: lib/Report/ReportDefaultTrait.php:155
+#, php-format
+msgid "%s report"
+msgstr ""
+
+#: lib/Report/SummaryDistributionCommonTrait.php:117
+#: lib/Report/SummaryDistributionCommonTrait.php:124
+#: lib/Report/SummaryDistributionCommonTrait.php:132
+#, php-format
+msgid "Add Report Schedule for %s - %s"
+msgstr ""
+
+#: lib/Report/SummaryDistributionCommonTrait.php:137
+msgid "Unknown type "
+msgstr ""
+
+#: lib/Report/SessionHistory.php:133
+#, php-format
+msgid "%s Session %s log report for User"
+msgstr ""
+
+#: lib/Report/ProofOfPlay.php:378
+msgid "Invalid Sort By"
+msgstr ""
+
+#: lib/Report/ProofOfPlay.php:946
+msgid "Incorrect Tag type selected"
+msgstr ""
+
+#: lib/Report/SummaryReport.php:234
+#, php-format
+msgid "%s report for Media"
+msgstr ""
+
+#: lib/Report/SummaryReport.php:772
+msgid "No match for event type"
+msgstr ""
+
+#: web/xmds.php:131
+msgid "Missing params"
+msgstr ""
+
+#: web/xmds.php:404
+msgid "Your client is not the correct version to communicate with this CMS."
+msgstr ""
+
+#: web/settings.php:11
+msgid "Sorry, you are not allowed to directly access this page."
+msgstr ""
+
+#: web/settings.php:11
+msgid "Please press the back button in your browser."
+msgstr ""
diff --git a/locale/el.mo b/locale/el.mo
new file mode 100644
index 0000000..7a03651
Binary files /dev/null and b/locale/el.mo differ
diff --git a/locale/en_GB.mo b/locale/en_GB.mo
new file mode 100644
index 0000000..368b8f4
Binary files /dev/null and b/locale/en_GB.mo differ
diff --git a/locale/es.mo b/locale/es.mo
new file mode 100644
index 0000000..b201598
Binary files /dev/null and b/locale/es.mo differ
diff --git a/locale/et.mo b/locale/et.mo
new file mode 100644
index 0000000..4e0d384
Binary files /dev/null and b/locale/et.mo differ
diff --git a/locale/eu.mo b/locale/eu.mo
new file mode 100644
index 0000000..d11f497
Binary files /dev/null and b/locale/eu.mo differ
diff --git a/locale/fa.mo b/locale/fa.mo
new file mode 100644
index 0000000..5a8a748
Binary files /dev/null and b/locale/fa.mo differ
diff --git a/locale/fi.mo b/locale/fi.mo
new file mode 100644
index 0000000..6956221
Binary files /dev/null and b/locale/fi.mo differ
diff --git a/locale/fr.mo b/locale/fr.mo
new file mode 100644
index 0000000..06a17fe
Binary files /dev/null and b/locale/fr.mo differ
diff --git a/locale/fr_CA.mo b/locale/fr_CA.mo
new file mode 100644
index 0000000..b8b7e6b
Binary files /dev/null and b/locale/fr_CA.mo differ
diff --git a/locale/he.mo b/locale/he.mo
new file mode 100644
index 0000000..e8ca153
Binary files /dev/null and b/locale/he.mo differ
diff --git a/locale/hi.mo b/locale/hi.mo
new file mode 100644
index 0000000..ac4072f
Binary files /dev/null and b/locale/hi.mo differ
diff --git a/locale/hr.mo b/locale/hr.mo
new file mode 100644
index 0000000..d8b0c39
Binary files /dev/null and b/locale/hr.mo differ
diff --git a/locale/hu.mo b/locale/hu.mo
new file mode 100644
index 0000000..4733faa
Binary files /dev/null and b/locale/hu.mo differ
diff --git a/locale/id.mo b/locale/id.mo
new file mode 100644
index 0000000..c137b78
Binary files /dev/null and b/locale/id.mo differ
diff --git a/locale/it.mo b/locale/it.mo
new file mode 100644
index 0000000..fe95750
Binary files /dev/null and b/locale/it.mo differ
diff --git a/locale/ja.mo b/locale/ja.mo
new file mode 100644
index 0000000..bf83ffa
Binary files /dev/null and b/locale/ja.mo differ
diff --git a/locale/ko.mo b/locale/ko.mo
new file mode 100644
index 0000000..3edb36a
Binary files /dev/null and b/locale/ko.mo differ
diff --git a/locale/ku.mo b/locale/ku.mo
new file mode 100644
index 0000000..92629bf
Binary files /dev/null and b/locale/ku.mo differ
diff --git a/locale/lb.mo b/locale/lb.mo
new file mode 100644
index 0000000..ae02135
Binary files /dev/null and b/locale/lb.mo differ
diff --git a/locale/lo.mo b/locale/lo.mo
new file mode 100644
index 0000000..cd68fa8
Binary files /dev/null and b/locale/lo.mo differ
diff --git a/locale/lt.mo b/locale/lt.mo
new file mode 100644
index 0000000..51d6fbc
Binary files /dev/null and b/locale/lt.mo differ
diff --git a/locale/nb.mo b/locale/nb.mo
new file mode 100644
index 0000000..aceec73
Binary files /dev/null and b/locale/nb.mo differ
diff --git a/locale/nl.mo b/locale/nl.mo
new file mode 100644
index 0000000..8c2c011
Binary files /dev/null and b/locale/nl.mo differ
diff --git a/locale/nl_NL.mo b/locale/nl_NL.mo
new file mode 100644
index 0000000..a1bcfa8
Binary files /dev/null and b/locale/nl_NL.mo differ
diff --git a/locale/pl.mo b/locale/pl.mo
new file mode 100644
index 0000000..1017ee1
Binary files /dev/null and b/locale/pl.mo differ
diff --git a/locale/pt.mo b/locale/pt.mo
new file mode 100644
index 0000000..419252e
Binary files /dev/null and b/locale/pt.mo differ
diff --git a/locale/pt_BR.mo b/locale/pt_BR.mo
new file mode 100644
index 0000000..c69d52f
Binary files /dev/null and b/locale/pt_BR.mo differ
diff --git a/locale/ro.mo b/locale/ro.mo
new file mode 100644
index 0000000..472761b
Binary files /dev/null and b/locale/ro.mo differ
diff --git a/locale/ru.mo b/locale/ru.mo
new file mode 100644
index 0000000..bd1b900
Binary files /dev/null and b/locale/ru.mo differ
diff --git a/locale/sk.mo b/locale/sk.mo
new file mode 100644
index 0000000..e485712
Binary files /dev/null and b/locale/sk.mo differ
diff --git a/locale/sl.mo b/locale/sl.mo
new file mode 100644
index 0000000..3ec2edb
Binary files /dev/null and b/locale/sl.mo differ
diff --git a/locale/sr@latin.mo b/locale/sr@latin.mo
new file mode 100644
index 0000000..b2d442f
Binary files /dev/null and b/locale/sr@latin.mo differ
diff --git a/locale/sv.mo b/locale/sv.mo
new file mode 100644
index 0000000..7fe2526
Binary files /dev/null and b/locale/sv.mo differ
diff --git a/locale/th.mo b/locale/th.mo
new file mode 100644
index 0000000..1609434
Binary files /dev/null and b/locale/th.mo differ
diff --git a/locale/tr.mo b/locale/tr.mo
new file mode 100644
index 0000000..b92dbc6
Binary files /dev/null and b/locale/tr.mo differ
diff --git a/locale/vi.mo b/locale/vi.mo
new file mode 100644
index 0000000..ab68bf9
Binary files /dev/null and b/locale/vi.mo differ
diff --git a/locale/zh_CN.mo b/locale/zh_CN.mo
new file mode 100644
index 0000000..f12e1e3
Binary files /dev/null and b/locale/zh_CN.mo differ
diff --git a/locale/zh_TW.mo b/locale/zh_TW.mo
new file mode 100644
index 0000000..ec06888
Binary files /dev/null and b/locale/zh_TW.mo differ
diff --git a/modules/README.md b/modules/README.md
new file mode 100644
index 0000000..e584a50
--- /dev/null
+++ b/modules/README.md
@@ -0,0 +1,2 @@
+# Core modules, templates and data types
+https://xibosignage.com/docs/developer/widgets/creating-a-module
diff --git a/modules/assets/clock_bg_modern_dark.png b/modules/assets/clock_bg_modern_dark.png
new file mode 100644
index 0000000..6925b01
Binary files /dev/null and b/modules/assets/clock_bg_modern_dark.png differ
diff --git a/modules/assets/clock_bg_modern_light.png b/modules/assets/clock_bg_modern_light.png
new file mode 100644
index 0000000..a60b4a0
Binary files /dev/null and b/modules/assets/clock_bg_modern_light.png differ
diff --git a/modules/assets/common/FontAwesome.otf b/modules/assets/common/FontAwesome.otf
new file mode 100644
index 0000000..401ec0f
Binary files /dev/null and b/modules/assets/common/FontAwesome.otf differ
diff --git a/modules/assets/common/font-awesome.min.css b/modules/assets/common/font-awesome.min.css
new file mode 100644
index 0000000..1a097c2
--- /dev/null
+++ b/modules/assets/common/font-awesome.min.css
@@ -0,0 +1,4 @@
+/*!
+ * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
+ */@font-face{font-family:'FontAwesome';src:url('fontawesome-webfont.eot?v=4.3.0');src:url('fontawesome-webfont.eot?#iefix&v=4.3.0') format('embedded-opentype'),url('fontawesome-webfont.woff2?v=4.3.0') format('woff2'),url('fontawesome-webfont.woff?v=4.3.0') format('woff'),url('fontawesome-webfont.ttf?v=4.3.0') format('truetype'),url('fontawesome-webfont.svg?v=4.3.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transform:translate(0, 0)}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-genderless:before,.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}
\ No newline at end of file
diff --git a/modules/assets/common/fontawesome-webfont.eot b/modules/assets/common/fontawesome-webfont.eot
new file mode 100644
index 0000000..e9f60ca
Binary files /dev/null and b/modules/assets/common/fontawesome-webfont.eot differ
diff --git a/modules/assets/common/fontawesome-webfont.svg b/modules/assets/common/fontawesome-webfont.svg
new file mode 100644
index 0000000..855c845
--- /dev/null
+++ b/modules/assets/common/fontawesome-webfont.svg
@@ -0,0 +1,2671 @@
+
+
+
diff --git a/modules/assets/common/fontawesome-webfont.ttf b/modules/assets/common/fontawesome-webfont.ttf
new file mode 100644
index 0000000..35acda2
Binary files /dev/null and b/modules/assets/common/fontawesome-webfont.ttf differ
diff --git a/modules/assets/common/fontawesome-webfont.woff b/modules/assets/common/fontawesome-webfont.woff
new file mode 100644
index 0000000..400014a
Binary files /dev/null and b/modules/assets/common/fontawesome-webfont.woff differ
diff --git a/modules/assets/common/fontawesome-webfont.woff2 b/modules/assets/common/fontawesome-webfont.woff2
new file mode 100644
index 0000000..4d13fc6
Binary files /dev/null and b/modules/assets/common/fontawesome-webfont.woff2 differ
diff --git a/modules/assets/currency/flags.css b/modules/assets/currency/flags.css
new file mode 100644
index 0000000..54ba144
--- /dev/null
+++ b/modules/assets/currency/flags.css
@@ -0,0 +1,458 @@
+.flag-icon-container {
+ overflow: hidden;
+ position: relative;
+}
+.flag-icon {
+ position: relative;
+ width: 1000%;
+ top: -1100%;
+ left: -100%;
+}
+.flag-icon-AFN {
+ top: 0;
+ left: 0;
+}
+.flag-icon-ALL {
+ top: 0;
+ left: -100%;
+}
+.flag-icon-ANG {
+ top: 0;
+ left: -200%
+}
+.flag-icon-ARS {
+ top: 0;
+ left: -300%;
+}
+.flag-icon-AUD {
+ top: 0;
+ left: -400%;
+}
+.flag-icon-AWG {
+ top: 0;
+ left: -500%;
+}
+.flag-icon-AZN {
+ top: 0;
+ left: -600%;
+}
+.flag-icon-BAM {
+ top: 0;
+ left: -700%;
+}
+.flag-icon-BBD {
+ top: 0;
+ left: -800%;
+}
+.flag-icon-BGN {
+ top: 0;
+ left: -900%;
+}
+.flag-icon-BMD {
+ top: -100%;
+ left: 0%;
+}
+.flag-icon-BND {
+ top: -100%;
+ left: -100%;
+}
+.flag-icon-BOB {
+ top: -100%;
+ left: -200%;
+}
+.flag-icon-BRL {
+ top: -100%;
+ left: -300%;
+}
+.flag-icon-BWP {
+ top: -100%;
+ left: -400%;
+}
+.flag-icon-BYR {
+ top: -100%;
+ left: -500%;
+}
+.flag-icon-BZD {
+ top: -100%;
+ left: -600%;
+}
+.flag-icon-CAD {
+ top: -100%;
+ left: -700%;
+}
+.flag-icon-CHF {
+ top: -100%;
+ left: -800%;
+}
+.flag-icon-CLP {
+ top: -100%;
+ left: -900%;
+}
+.flag-icon-CNY {
+ top: -200%;
+ left: 0;
+}
+.flag-icon-COP {
+ top: -200%;
+ left: -100%;
+}
+.flag-icon-CRC {
+ top: -200%;
+ left: -200%;
+}
+.flag-icon-CUP {
+ top: -200%;
+ left: -300%;
+}
+.flag-icon-CZK {
+ top: -200%;
+ left: -400%;
+}
+.flag-icon-default {
+ top: -1100%;
+ left: -100%;
+}
+.flag-icon-DKK {
+ top: -200%;
+ left: -500%;
+}
+.flag-icon-DOP {
+ top: -200%;
+ left: -600%;
+}
+.flag-icon-EEK {
+ top: -200%;
+ left: -700%;
+}
+.flag-icon-EGP {
+ top: -200%;
+ left: -800%;
+}
+.flag-icon-EUR {
+ top: -200%;
+ left: -900%;
+}
+.flag-icon-FJD {
+ top: -300%;
+ left: 0;
+}
+.flag-icon-FKP {
+ top: -300%;
+ left: -100%;
+}
+.flag-icon-GBP {
+ top: -300%;
+ left: -200%;
+}
+.flag-icon-GEL {
+ top: -300%;
+ left: -300%;
+}
+.flag-icon-GGP {
+ top: -300%;
+ left: -400%;
+}
+.flag-icon-GHC {
+ top: -300%;
+ left: -500%;
+}
+.flag-icon-GIP {
+ top: -300%;
+ left: -600%;
+}
+.flag-icon-GTQ {
+ top: -300%;
+ left: -700%;
+}
+.flag-icon-GYD {
+ top: -300%;
+ left: -800%;
+}
+.flag-icon-HKD {
+ top: -300%;
+ left: -900%;
+}
+.flag-icon-HNL {
+ top: -400%;
+ left: 0;
+}
+.flag-icon-HRK {
+ top: -400%;
+ left: -100%;
+}
+.flag-icon-HUF {
+ top: -400%;
+ left: -200%;
+}
+.flag-icon-IDR {
+ top: -400%;
+ left: -300%;
+}
+.flag-icon-ILS {
+ top: -400%;
+ left: -400%;
+}
+.flag-icon-IMP {
+ top: -400%;
+ left: -500%;
+}
+.flag-icon-INR {
+ top: -400%;
+ left: -600%;
+}
+.flag-icon-IRR {
+ top: -400%;
+ left: -700%;
+}
+.flag-icon-ISK {
+ top: -400%;
+ left: -800%;
+}
+.flag-icon-JEP {
+ top: -400%;
+ left: -900%;
+}
+.flag-icon-JMD {
+ top: -500%;
+ left: 0;
+}
+.flag-icon-JPY {
+ top: -500%;
+ left: -100%;
+}
+.flag-icon-KGS {
+ top: -500%;
+ left: -200%;
+}
+.flag-icon-KHR {
+ top: -500%;
+ left: -300%;
+}
+.flag-icon-KPW {
+ top: -500%;
+ left: -400%;
+}
+.flag-icon-KRW {
+ top: -500%;
+ left: -500%;
+}
+.flag-icon-KYD {
+ top: -500%;
+ left: -600%;
+}
+.flag-icon-KZT {
+ top: -500%;
+ left: -700%;
+}
+.flag-icon-LAK {
+ top: -500%;
+ left: -800%;
+}
+.flag-icon-LBP {
+ top: -500%;
+ left: -900%;
+}
+.flag-icon-LKR {
+ top: -600%;
+ left: 0;
+}
+.flag-icon-LRD {
+ top: -600%;
+ left: -100%;
+}
+.flag-icon-LTL {
+ top: -600%;
+ left: -200%;
+}
+.flag-icon-LVL {
+ top: -600%;
+ left: -300%;
+}
+.flag-icon-MKD {
+ top: -600%;
+ left: -400%;
+}
+.flag-icon-MNT {
+ top: -600%;
+ left: -500%;
+}
+.flag-icon-MUR {
+ top: -600%;
+ left: -600%;
+}
+.flag-icon-MXN {
+ top: -600%;
+ left: -700%;
+}
+.flag-icon-MYR {
+ top: -600%;
+ left: -800%;
+}
+.flag-icon-MZN {
+ top: -600%;
+ left: -900%;
+}
+.flag-icon-NAD {
+ top: -700%;
+ left: 0;
+}
+.flag-icon-NGN {
+ top: -700%;
+ left: -100%;
+}
+.flag-icon-NIO {
+ top: -700%;
+ left: -200%;
+}
+.flag-icon-NOK {
+ top: -700%;
+ left: -300%;
+}
+.flag-icon-NPR {
+ top: -700%;
+ left: -400%;
+}
+.flag-icon-NZD {
+ top: -700%;
+ left: -500%;
+}
+.flag-icon-OMR {
+ top: -700%;
+ left: -600%;
+}
+.flag-icon-PAB {
+ top: -700%;
+ left: -700%;
+}
+.flag-icon-PEN {
+ top: -700%;
+ left: -800%;
+}
+.flag-icon-PHP {
+ top: -700%;
+ left: -900%;
+}
+.flag-icon-PKR {
+ top: -800%;
+ left: 0;
+}
+.flag-icon-PLN {
+ top: -800%;
+ left: -100%;
+}
+.flag-icon-PYG {
+ top: -800%;
+ left: -200%;
+}
+.flag-icon-QAR {
+ top: -800%;
+ left: -300%;
+}
+.flag-icon-RON {
+ top: -800%;
+ left: -400%;
+}
+.flag-icon-RSD {
+ top: -800%;
+ left: -500%;
+}
+.flag-icon-RUB {
+ top: -800%;
+ left: -600%;
+}
+.flag-icon-SAR {
+ top: -800%;
+ left: -700%;
+}
+.flag-icon-SBD {
+ top: -800%;
+ left: -800%;
+}
+.flag-icon-SCR {
+ top: -800%;
+ left: -900%;
+}
+.flag-icon-SEK {
+ top: -900%;
+ left: 0;
+}
+.flag-icon-SGD {
+ top: -900%;
+ left: -100%;
+}
+.flag-icon-SHP {
+ top: -900%;
+ left: -200%;
+}
+.flag-icon-SOS {
+ top: -900%;
+ left: -300%;
+}
+.flag-icon-SRD {
+ top: -900%;
+ left: -400%;
+}
+.flag-icon-SVC {
+ top: -900%;
+ left: -500%;
+}
+.flag-icon-SYP {
+ top: -900%;
+ left: -600%;
+}
+.flag-icon-THB {
+ top: -900%;
+ left: -700%;
+}
+.flag-icon-TRL {
+ top: -900%;
+ left: -800%;
+}
+.flag-icon-TTD {
+ top: -900%;
+ left: -900%;
+}
+.flag-icon-TVD {
+ top: -1000%;
+ left: 0;
+}
+.flag-icon-TWD {
+ top: -1000%;
+ left: -100%;
+}
+.flag-icon-UAH {
+ top: -1000%;
+ left: -200%;
+}
+.flag-icon-USD {
+ top: -1000%;
+ left: -300%;
+}
+.flag-icon-UYU {
+ top: -1000%;
+ left: -400%;
+}
+.flag-icon-UZS {
+ top: -1000%;
+ left: -500%;
+}
+.flag-icon-VEF {
+ top: -1000%;
+ left: -600%;
+}
+.flag-icon-VND {
+ top: -1000%;
+ left: -700%;
+}
+.flag-icon-YER {
+ top: -1000%;
+ left: -800%;
+}
+.flag-icon-ZAR {
+ top: -1000%;
+ left: -900%;
+}
+.flag-icon-ZWD {
+ top: -1100%;
+ left: 0;
+}
\ No newline at end of file
diff --git a/modules/assets/currency/flags.webp b/modules/assets/currency/flags.webp
new file mode 100644
index 0000000..c0cde4f
Binary files /dev/null and b/modules/assets/currency/flags.webp differ
diff --git a/modules/assets/flipclock/flipclock.css b/modules/assets/flipclock/flipclock.css
new file mode 100644
index 0000000..22b0e09
--- /dev/null
+++ b/modules/assets/flipclock/flipclock.css
@@ -0,0 +1,431 @@
+/* Get the bourbon mixin from http://bourbon.io */
+/* Reset */
+.flip-clock-wrapper * {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ -o-box-sizing: border-box;
+ box-sizing: border-box;
+ -webkit-backface-visibility: hidden;
+ -moz-backface-visibility: hidden;
+ -ms-backface-visibility: hidden;
+ -o-backface-visibility: hidden;
+ backface-visibility: hidden;
+}
+
+.flip-clock-wrapper a {
+ cursor: pointer;
+ text-decoration: none;
+ color: #ccc; }
+
+.flip-clock-wrapper a:hover {
+ color: #fff; }
+
+.flip-clock-wrapper ul {
+ list-style: none; }
+
+.flip-clock-wrapper.clearfix:before,
+.flip-clock-wrapper.clearfix:after {
+ content: " ";
+ display: table; }
+
+.flip-clock-wrapper.clearfix:after {
+ clear: both; }
+
+.flip-clock-wrapper.clearfix {
+ *zoom: 1; }
+
+/* Main */
+.flip-clock-wrapper {
+ font: normal 11px "Helvetica Neue", Helvetica, sans-serif;
+ -webkit-user-select: none; }
+
+.flip-clock-meridium {
+ background: none !important;
+ box-shadow: 0 0 0 !important;
+ font-size: 36px !important; }
+
+.flip-clock-meridium a { color: #313333; }
+
+.flip-clock-wrapper {
+ text-align: center;
+ position: relative;
+ width: 100%;
+ margin: 1em;
+}
+
+.flip-clock-wrapper:before,
+.flip-clock-wrapper:after {
+ content: " "; /* 1 */
+ display: table; /* 2 */
+}
+.flip-clock-wrapper:after {
+ clear: both;
+}
+
+/* Skeleton */
+.flip-clock-wrapper ul {
+ position: relative;
+ float: left;
+ margin: 5px;
+ width: 60px;
+ height: 90px;
+ font-size: 80px;
+ font-weight: bold;
+ line-height: 87px;
+ border-radius: 6px;
+ background: #000;
+}
+
+.flip-clock-wrapper ul li {
+ z-index: 1;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ line-height: 87px;
+ text-decoration: none !important;
+}
+
+.flip-clock-wrapper ul li:first-child {
+ z-index: 2; }
+
+.flip-clock-wrapper ul li a {
+ display: block;
+ height: 100%;
+ -webkit-perspective: 200px;
+ -moz-perspective: 200px;
+ perspective: 200px;
+ margin: 0 !important;
+ overflow: visible !important;
+ cursor: default !important; }
+
+.flip-clock-wrapper ul li a div {
+ z-index: 1;
+ position: absolute;
+ left: 0;
+ width: 100%;
+ height: 50%;
+ font-size: 80px;
+ overflow: hidden;
+ outline: 1px solid transparent; }
+
+.flip-clock-wrapper ul li a div .shadow {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ z-index: 2; }
+
+.flip-clock-wrapper ul li a div.up {
+ -webkit-transform-origin: 50% 100%;
+ -moz-transform-origin: 50% 100%;
+ -ms-transform-origin: 50% 100%;
+ -o-transform-origin: 50% 100%;
+ transform-origin: 50% 100%;
+ top: 0; }
+
+.flip-clock-wrapper ul li a div.up:after {
+ content: "";
+ position: absolute;
+ top: 44px;
+ left: 0;
+ z-index: 5;
+ width: 100%;
+ height: 3px;
+ background-color: #000;
+ background-color: rgba(0, 0, 0, 0.4); }
+
+.flip-clock-wrapper ul li a div.down {
+ -webkit-transform-origin: 50% 0;
+ -moz-transform-origin: 50% 0;
+ -ms-transform-origin: 50% 0;
+ -o-transform-origin: 50% 0;
+ transform-origin: 50% 0;
+ bottom: 0;
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+}
+
+.flip-clock-wrapper ul li a div div.inn {
+ position: absolute;
+ left: 0;
+ z-index: 1;
+ width: 100%;
+ height: 200%;
+ color: #ccc;
+ text-shadow: 0 1px 2px #000;
+ text-align: center;
+ background-color: #333;
+ border-radius: 6px;
+ font-size: 70px; }
+
+.flip-clock-wrapper ul li a div.up div.inn {
+ top: 0; }
+
+.flip-clock-wrapper ul li a div.down div.inn {
+ bottom: 0; }
+
+/* PLAY */
+.flip-clock-wrapper ul.play li.flip-clock-before {
+ z-index: 3; }
+
+.flip-clock-wrapper .flip { box-shadow: 0 2px 5px rgba(0, 0, 0, 0.7); }
+
+.flip-clock-wrapper ul.play li.flip-clock-active {
+ -webkit-animation: asd 0.5s 0.5s linear both;
+ -moz-animation: asd 0.5s 0.5s linear both;
+ animation: asd 0.5s 0.5s linear both;
+ z-index: 5; }
+
+.flip-clock-divider {
+ float: left;
+ display: inline-block;
+ position: relative;
+ width: 20px;
+ height: 100px; }
+
+.flip-clock-divider:first-child {
+ width: 0; }
+
+.flip-clock-dot {
+ display: block;
+ background: #323434;
+ width: 10px;
+ height: 10px;
+ position: absolute;
+ border-radius: 50%;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
+ left: 5px; }
+
+.flip-clock-divider .flip-clock-label {
+ position: absolute;
+ top: -1.5em;
+ right: -86px;
+ color: black;
+ text-shadow: none; }
+
+.flip-clock-divider.minutes .flip-clock-label {
+ right: -88px; }
+
+.flip-clock-divider.seconds .flip-clock-label {
+ right: -91px; }
+
+.flip-clock-dot.top {
+ top: 30px; }
+
+.flip-clock-dot.bottom {
+ bottom: 30px; }
+
+@-webkit-keyframes asd {
+ 0% {
+ z-index: 2; }
+
+ 20% {
+ z-index: 4; }
+
+ 100% {
+ z-index: 4; } }
+
+@-moz-keyframes asd {
+ 0% {
+ z-index: 2; }
+
+ 20% {
+ z-index: 4; }
+
+ 100% {
+ z-index: 4; } }
+
+@-o-keyframes asd {
+ 0% {
+ z-index: 2; }
+
+ 20% {
+ z-index: 4; }
+
+ 100% {
+ z-index: 4; } }
+
+@keyframes asd {
+ 0% {
+ z-index: 2; }
+
+ 20% {
+ z-index: 4; }
+
+ 100% {
+ z-index: 4; } }
+
+.flip-clock-wrapper ul.play li.flip-clock-active .down {
+ z-index: 2;
+ -webkit-animation: turn 0.5s 0.5s linear both;
+ -moz-animation: turn 0.5s 0.5s linear both;
+ animation: turn 0.5s 0.5s linear both; }
+
+@-webkit-keyframes turn {
+ 0% {
+ -webkit-transform: rotateX(90deg); }
+
+ 100% {
+ -webkit-transform: rotateX(0deg); } }
+
+@-moz-keyframes turn {
+ 0% {
+ -moz-transform: rotateX(90deg); }
+
+ 100% {
+ -moz-transform: rotateX(0deg); } }
+
+@-o-keyframes turn {
+ 0% {
+ -o-transform: rotateX(90deg); }
+
+ 100% {
+ -o-transform: rotateX(0deg); } }
+
+@keyframes turn {
+ 0% {
+ transform: rotateX(90deg); }
+
+ 100% {
+ transform: rotateX(0deg); } }
+
+.flip-clock-wrapper ul.play li.flip-clock-before .up {
+ z-index: 2;
+ -webkit-animation: turn2 0.5s linear both;
+ -moz-animation: turn2 0.5s linear both;
+ animation: turn2 0.5s linear both; }
+
+@-webkit-keyframes turn2 {
+ 0% {
+ -webkit-transform: rotateX(0deg); }
+
+ 100% {
+ -webkit-transform: rotateX(-90deg); } }
+
+@-moz-keyframes turn2 {
+ 0% {
+ -moz-transform: rotateX(0deg); }
+
+ 100% {
+ -moz-transform: rotateX(-90deg); } }
+
+@-o-keyframes turn2 {
+ 0% {
+ -o-transform: rotateX(0deg); }
+
+ 100% {
+ -o-transform: rotateX(-90deg); } }
+
+@keyframes turn2 {
+ 0% {
+ transform: rotateX(0deg); }
+
+ 100% {
+ transform: rotateX(-90deg); } }
+
+.flip-clock-wrapper ul li.flip-clock-active {
+ z-index: 3; }
+
+/* SHADOW */
+.flip-clock-wrapper ul.play li.flip-clock-before .up .shadow {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0.1) 0%, black 100%);
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(0, 0, 0, 0.1)), color-stop(100%, black));
+ background: linear, top, rgba(0, 0, 0, 0.1) 0%, black 100%;
+ background: -o-linear-gradient(top, rgba(0, 0, 0, 0.1) 0%, black 100%);
+ background: -ms-linear-gradient(top, rgba(0, 0, 0, 0.1) 0%, black 100%);
+ background: linear, to bottom, rgba(0, 0, 0, 0.1) 0%, black 100%;
+ -webkit-animation: show 0.5s linear both;
+ -moz-animation: show 0.5s linear both;
+ animation: show 0.5s linear both; }
+
+.flip-clock-wrapper ul.play li.flip-clock-active .up .shadow {
+ background: -moz-linear-gradient(top, rgba(0, 0, 0, 0.1) 0%, black 100%);
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(0, 0, 0, 0.1)), color-stop(100%, black));
+ background: linear, top, rgba(0, 0, 0, 0.1) 0%, black 100%;
+ background: -o-linear-gradient(top, rgba(0, 0, 0, 0.1) 0%, black 100%);
+ background: -ms-linear-gradient(top, rgba(0, 0, 0, 0.1) 0%, black 100%);
+ background: linear, to bottom, rgba(0, 0, 0, 0.1) 0%, black 100%;
+ -webkit-animation: hide 0.5s 0.3s linear both;
+ -moz-animation: hide 0.5s 0.3s linear both;
+ animation: hide 0.5s 0.3s linear both; }
+
+/*DOWN*/
+.flip-clock-wrapper ul.play li.flip-clock-before .down .shadow {
+ background: -moz-linear-gradient(top, black 0%, rgba(0, 0, 0, 0.1) 100%);
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, black), color-stop(100%, rgba(0, 0, 0, 0.1)));
+ background: linear, top, black 0%, rgba(0, 0, 0, 0.1) 100%;
+ background: -o-linear-gradient(top, black 0%, rgba(0, 0, 0, 0.1) 100%);
+ background: -ms-linear-gradient(top, black 0%, rgba(0, 0, 0, 0.1) 100%);
+ background: linear, to bottom, black 0%, rgba(0, 0, 0, 0.1) 100%;
+ -webkit-animation: show 0.5s linear both;
+ -moz-animation: show 0.5s linear both;
+ animation: show 0.5s linear both; }
+
+.flip-clock-wrapper ul.play li.flip-clock-active .down .shadow {
+ background: -moz-linear-gradient(top, black 0%, rgba(0, 0, 0, 0.1) 100%);
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, black), color-stop(100%, rgba(0, 0, 0, 0.1)));
+ background: linear, top, black 0%, rgba(0, 0, 0, 0.1) 100%;
+ background: -o-linear-gradient(top, black 0%, rgba(0, 0, 0, 0.1) 100%);
+ background: -ms-linear-gradient(top, black 0%, rgba(0, 0, 0, 0.1) 100%);
+ background: linear, to bottom, black 0%, rgba(0, 0, 0, 0.1) 100%;
+ -webkit-animation: hide 0.5s 0.3s linear both;
+ -moz-animation: hide 0.5s 0.3s linear both;
+ animation: hide 0.5s 0.2s linear both; }
+
+@-webkit-keyframes show {
+ 0% {
+ opacity: 0; }
+
+ 100% {
+ opacity: 1; } }
+
+@-moz-keyframes show {
+ 0% {
+ opacity: 0; }
+
+ 100% {
+ opacity: 1; } }
+
+@-o-keyframes show {
+ 0% {
+ opacity: 0; }
+
+ 100% {
+ opacity: 1; } }
+
+@keyframes show {
+ 0% {
+ opacity: 0; }
+
+ 100% {
+ opacity: 1; } }
+
+@-webkit-keyframes hide {
+ 0% {
+ opacity: 1; }
+
+ 100% {
+ opacity: 0; } }
+
+@-moz-keyframes hide {
+ 0% {
+ opacity: 1; }
+
+ 100% {
+ opacity: 0; } }
+
+@-o-keyframes hide {
+ 0% {
+ opacity: 1; }
+
+ 100% {
+ opacity: 0; } }
+
+@keyframes hide {
+ 0% {
+ opacity: 1; }
+
+ 100% {
+ opacity: 0; } }
diff --git a/modules/assets/flipclock/flipclock.min.js b/modules/assets/flipclock/flipclock.min.js
new file mode 100644
index 0000000..da52cf4
--- /dev/null
+++ b/modules/assets/flipclock/flipclock.min.js
@@ -0,0 +1,2 @@
+/*! flipclock 2015-01-19 */
+var Base=function(){};Base.extend=function(a,b){"use strict";var c=Base.prototype.extend;Base._prototyping=!0;var d=new this;c.call(d,a),d.base=function(){},delete Base._prototyping;var e=d.constructor,f=d.constructor=function(){if(!Base._prototyping)if(this._constructing||this.constructor==f)this._constructing=!0,e.apply(this,arguments),delete this._constructing;else if(null!==arguments[0])return(arguments[0].extend||c).call(arguments[0],d)};return f.ancestor=this,f.extend=this.extend,f.forEach=this.forEach,f.implement=this.implement,f.prototype=d,f.toString=this.toString,f.valueOf=function(a){return"object"==a?f:e.valueOf()},c.call(f,b),"function"==typeof f.init&&f.init(),f},Base.prototype={extend:function(a,b){if(arguments.length>1){var c=this[a];if(c&&"function"==typeof b&&(!c.valueOf||c.valueOf()!=b.valueOf())&&/\bbase\b/.test(b)){var d=b.valueOf();b=function(){var a=this.base||Base.prototype.base;this.base=c;var b=d.apply(this,arguments);return this.base=a,b},b.valueOf=function(a){return"object"==a?b:d},b.toString=Base.toString}this[a]=b}else if(a){var e=Base.prototype.extend;Base._prototyping||"function"==typeof this||(e=this.extend||e);for(var f={toSource:null},g=["constructor","toString","valueOf"],h=Base._prototyping?0:1;i=g[h++];)a[i]!=f[i]&&e.call(this,i,a[i]);for(var i in a)f[i]||e.call(this,i,a[i])}return this}},Base=Base.extend({constructor:function(){this.extend(arguments[0])}},{ancestor:Object,version:"1.1",forEach:function(a,b,c){for(var d in a)void 0===this.prototype[d]&&b.call(c,a[d],d,a)},implement:function(){for(var a=0;a',''].join("");d&&(e=""),b=this.factory.localize(b);var f=['',''+(b?b:"")+"",e,""],g=a(f.join(""));return this.dividers.push(g),g},createList:function(a,b){"object"==typeof a&&(b=a,a=0);var c=new FlipClock.List(this.factory,a,b);return this.lists.push(c),c},reset:function(){this.factory.time=new FlipClock.Time(this.factory,this.factory.original?Math.round(this.factory.original):0,{minimumDigits:this.factory.minimumDigits}),this.flip(this.factory.original,!1)},appendDigitToClock:function(a){a.$el.append(!1)},addDigit:function(a){var b=this.createList(a,{classes:{active:this.factory.classes.active,before:this.factory.classes.before,flip:this.factory.classes.flip}});this.appendDigitToClock(b)},start:function(){},stop:function(){},autoIncrement:function(){this.factory.countdown?this.decrement():this.increment()},increment:function(){this.factory.time.addSecond()},decrement:function(){0==this.factory.time.getTimeSeconds()?this.factory.stop():this.factory.time.subSecond()},flip:function(b,c){var d=this;a.each(b,function(a,b){var e=d.lists[a];e?(c||b==e.digit||e.play(),e.select(b)):d.addDigit(b)})}})}(jQuery),function(a){"use strict";FlipClock.Factory=FlipClock.Base.extend({animationRate:1e3,autoStart:!0,callbacks:{destroy:!1,create:!1,init:!1,interval:!1,start:!1,stop:!1,reset:!1},classes:{active:"flip-clock-active",before:"flip-clock-before",divider:"flip-clock-divider",dot:"flip-clock-dot",label:"flip-clock-label",flip:"flip",play:"play",wrapper:"flip-clock-wrapper"},clockFace:"HourlyCounter",countdown:!1,defaultClockFace:"HourlyCounter",defaultLanguage:"english",$el:!1,face:!0,lang:!1,language:"english",minimumDigits:0,original:!1,running:!1,time:!1,timer:!1,$wrapper:!1,constructor:function(b,c,d){d||(d={}),this.lists=[],this.running=!1,this.base(d),this.$el=a(b).addClass(this.classes.wrapper),this.$wrapper=this.$el,this.original=c instanceof Date?c:c?Math.round(c):0,this.time=new FlipClock.Time(this,this.original,{minimumDigits:this.minimumDigits,animationRate:this.animationRate}),this.timer=new FlipClock.Timer(this,d),this.loadLanguage(this.language),this.loadClockFace(this.clockFace,d),this.autoStart&&this.start()},loadClockFace:function(a,b){var c,d="Face",e=!1;return a=a.ucfirst()+d,this.face.stop&&(this.stop(),e=!0),this.$el.html(""),this.time.minimumDigits=this.minimumDigits,c=FlipClock[a]?new FlipClock[a](this,b):new FlipClock[this.defaultClockFace+d](this,b),c.build(),this.face=c,e&&this.start(),this.face},loadLanguage:function(a){var b;return b=FlipClock.Lang[a.ucfirst()]?FlipClock.Lang[a.ucfirst()]:FlipClock.Lang[a]?FlipClock.Lang[a]:FlipClock.Lang[this.defaultLanguage],this.lang=b},localize:function(a,b){var c=this.lang;if(!a)return null;var d=a.toLowerCase();return"object"==typeof b&&(c=b),c&&c[d]?c[d]:a},start:function(a){var b=this;b.running||b.countdown&&!(b.countdown&&b.time.time>0)?b.log("Trying to start timer when countdown already at 0"):(b.face.start(b.time),b.timer.start(function(){b.flip(),"function"==typeof a&&a()}))},stop:function(a){this.face.stop(),this.timer.stop(a);for(var b in this.lists)this.lists.hasOwnProperty(b)&&this.lists[b].stop()},reset:function(a){this.timer.reset(a),this.face.reset()},setTime:function(a){this.time.time=a,this.flip(!0)},getTime:function(){return this.time},setCountdown:function(a){var b=this.running;this.countdown=a?!0:!1,b&&(this.stop(),this.start())},flip:function(a){this.face.flip(!1,a)}})}(jQuery),function(a){"use strict";FlipClock.List=FlipClock.Base.extend({digit:0,classes:{active:"flip-clock-active",before:"flip-clock-before",flip:"flip"},factory:!1,$el:!1,$obj:!1,items:[],lastDigit:0,constructor:function(a,b){this.factory=a,this.digit=b,this.lastDigit=b,this.$el=this.createList(),this.$obj=this.$el,b>0&&this.select(b),this.factory.$el.append(this.$el)},select:function(a){if("undefined"==typeof a?a=this.digit:this.digit=a,this.digit!=this.lastDigit){var b=this.$el.find("."+this.classes.before).removeClass(this.classes.before);this.$el.find("."+this.classes.active).removeClass(this.classes.active).addClass(this.classes.before),this.appendListItem(this.classes.active,this.digit),b.remove(),this.lastDigit=this.digit}},play:function(){this.$el.addClass(this.factory.classes.play)},stop:function(){var a=this;setTimeout(function(){a.$el.removeClass(a.factory.classes.play)},this.factory.timer.interval)},createListItem:function(a,b){return['
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0.
+ -->
+
+ core-calendar
+ Calendar
+ Core
+ A module for displaying a calendar based on an iCal feed
+ fa fa-calendar-alt
+ Xibo\Widget\IcsProvider
+ \Xibo\Widget\Compatibility\CalendarWidgetCompatibility
+ ics-calendar
+ calendaradvanced
+ calendar
+ event
+ %uri%_%useDateRange%_%startIntervalFrom%_%customInterval%_%rangeStart%_%rangeEnd%_%useEventTimezone%_%useCalendarTimezone%_%windowsFormatCalendar%_%excludeAllDay%
+ 1
+ 2
+ 1
+ 1
+ html
+ 60
+
+
+ Cache Period (mins)
+ Please enter the number of minutes you would like to cache ICS feeds.
+ 1440
+
+
+
+
+ Feed URL
+ The Link for the iCal Feed.
+
+
+
+
+
+
+
+
+
+ Events to show
+
+
+ Get events using a preset date range?
+ Use the checkbox to return events within defined start and end dates.
+ 0
+
+
+ Events from the start of the
+ When should events be returned from?
+
+
+
+
+
+
+
+ 0
+
+
+
+
+ for an interval of
+ Using natural language enter a string representing the period for which events should be returned, for example 2 days or 1 week.
+
+
+
+
+
+
+
+ 0
+
+
+
+
+ Start Date
+
+
+ 1
+
+
+
+
+ End Date
+
+
+ 1
+
+
+
+
+ Exclude all day events?
+ When all day events are excluded they are removed from the list of events in the feed and wont be shown
+
+
+
+ Exclude past events?
+ When past events are excluded they are removed from the list of events in the feed and wont be shown.
+
+
+
+ Show only current events?
+ Show current events and hide all other events from the feed.
+
+
+
+ Exclude current events?
+ When current events are excluded they are removed from the list of events in the feed and wont be shown.
+
+
+
+ Use event timezone?
+ If an event specifies a timezone, should it be used. Deselection means the CMS timezone will be used.
+ 1
+
+
+ Use calendar timezone?
+ If your calendar feed specifies its own time zone, should this be used for events without their own timezone? Deselecting means the CMS timezone will be used.
+ 1
+
+
+ Windows format Calendar?
+ Does the calendar feed come from Windows - if unsure leave unselected.
+ 0
+
+
+ Duration is per item
+ The duration specified is per item otherwise it is per feed.
+ 0
+
+
+ Number of items
+ The number of items you want to display.
+
+
+ 1
+ 0
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 60
+
+
+ Web Hook triggers
+
+
+ Web Hook triggers can be executed when certain conditions are detected. If you would like to execute a trigger, enter the trigger code below against each event.
+
+
+ Current Event
+ Code to be triggered when a event is currently ongoing.
+
+
+
+ No Event
+ Code to be triggered when no events are ongoing at the moment.
+
+
+
+
+
+
+
+// Event triggers
+if (properties.currentEventTrigger && window.ongoingEvent) {
+ // If there is an event now, send the Current Event trigger (if exists)
+ xiboIC.trigger(properties.currentEventTrigger, {targetId: 0});
+} else if (properties.noEventTrigger) {
+ // If there is no event now, send the No Event trigger
+ xiboIC.trigger(properties.noEventTrigger, {targetId: 0});
+}
+ ]]>
+
+
+
diff --git a/modules/canvas.xml b/modules/canvas.xml
new file mode 100644
index 0000000..80d11d3
--- /dev/null
+++ b/modules/canvas.xml
@@ -0,0 +1,37 @@
+
+
+ core-canvas
+ Canvas
+ Core
+ Canvas module
+
+ none
+ global
+
+ 1
+ 1
+ 1
+ html
+ 1
+
+
+
\ No newline at end of file
diff --git a/modules/clock-analogue.xml b/modules/clock-analogue.xml
new file mode 100644
index 0000000..633a4bb
--- /dev/null
+++ b/modules/clock-analogue.xml
@@ -0,0 +1,245 @@
+
+
+ core-clock-analogue
+ Clock - Analogue
+ Core
+ Analogue Clock
+ fa fa-clock-o
+
+ \Xibo\Widget\Compatibility\ClockWidgetCompatibility
+ clock-analogue
+ Clock
+ clock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ clock-analogue-thumb
+
+
+
+ Theme
+ Please select a theme for the clock.
+ 1
+
+
+
+
+
+
+ Offset
+ The offset in minutes that should be applied to the current time, or if a counter then date/time to run from in the format Y-m-d H:i:s.
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+
+ 250
+ 250
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/clock-digital.xml b/modules/clock-digital.xml
new file mode 100644
index 0000000..ffb0756
--- /dev/null
+++ b/modules/clock-digital.xml
@@ -0,0 +1,123 @@
+
+
+ core-clock-digital
+ Clock - Digital
+ Core
+ Digital Clock
+ fa fa-clock-o
+
+ \Xibo\Widget\Compatibility\ClockWidgetCompatibility
+ clock-digital
+ Clock
+ clock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ clock-digital-thumb
+ 300
+ 150
+
+
+
+ Enter a format for the Digital Clock e.g. [HH:mm] or [DD/MM/YYYY]. See the manual for more information.
+
+
+ Enter text in the box below.
+ [HH:mm:ss]
+ ]]>
+
+
+ Date Formats
+ Choose from a preset date format
+
+
+
+
+
+
+
+
+
+
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Offset
+ The offset in minutes that should be applied to the current time.
+
+
+
+
+ ';
+});
+
+// Replace content with the parsed text
+$(target).find('#content').html(properties.format);
+
+// Create updateClock method and save to the interactive controller
+xiboIC.set(
+ id,
+ 'updateClock',
+ function updateClock() {
+ var offset = properties.offset || 0;
+ $(".clock").each(function() {
+ $(this).html(moment().format($(this).attr("format")));
+ $(this).html(moment().add(offset, "m").format($(this).attr("format")));
+ });
+ }
+);
+ ]]>
+
+
+
+
+
+
diff --git a/modules/clock-flip.xml b/modules/clock-flip.xml
new file mode 100644
index 0000000..254e8c8
--- /dev/null
+++ b/modules/clock-flip.xml
@@ -0,0 +1,236 @@
+
+
+ core-clock-flip
+ Clock - Flip
+ Core
+ Flip Clock
+ fa fa-clock-o
+
+ \Xibo\Widget\Compatibility\ClockWidgetCompatibility
+ clock-flip
+ Clock
+ clock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ clock-flip-thumb
+ 300
+ 150
+
+
+
+ Theme
+ Please select a clock face.
+ TwelveHourClock
+
+
+
+
+
+
+
+
+
+ Show Seconds?
+ Should the clock show seconds or not?
+
+
+
+ TwelveHourClock
+ TwentyFourHourClock
+ DailyCounter
+
+
+
+
+ Offset
+ The offset in minutes that should be applied to the current time, or if a counter then date/time to run from in the format Y-m-d H:i:s.
+
+
+
+ Background Colour
+
+
+ Flip Card Text Colour
+ #ccc
+
+
+ Flip Card Background Colour
+ #333
+
+
+ Divider Colour
+ #323434
+
+
+ AM/PM Colour
+ #313333
+
+
+ TwelveHourClock
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/countdown-clock.xml b/modules/countdown-clock.xml
new file mode 100755
index 0000000..a96435d
--- /dev/null
+++ b/modules/countdown-clock.xml
@@ -0,0 +1,320 @@
+
+
+ core-countdown-clock
+ Countdown - Clock
+ Core
+ A module for displaying a Countdown timer as a clock
+ fa fa-hourglass-o
+
+ \Xibo\Widget\Compatibility\CountDownWidgetCompatibility
+ countdown-clock
+ Countdown
+ countdown
+
+ 2
+ 1
+ 1
+ html
+ 60
+ countdown-clock-thumb
+ 300
+ 180
+
+
+
+ Countdown Type
+ Please select the type of countdown.
+ 1
+
+
+
+
+
+
+
+ Countdown Duration
+ The duration in seconds.
+
+
+
+ 2
+
+
+
+
+ 0
+ 2
+
+
+
+
+ Countdown Date
+ Select the target date and time.
+ 10/10/2010 10:10:10
+
+
+ 3
+
+
+
+
+ Warning Duration
+ The countdown will show in a warning mode from the end duration entered.
+
+
+
+ 1
+ 2
+
+
+
+
+
+ 2
+
+
+
+ 1
+
+
+
+
+ 0
+
+
+
+
+
+ Warning Date
+ The countdown will show in a warning mode from the warning date entered.
+
+
+
+ 3
+
+
+
+
+ 3
+
+
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Inner Background Colour
+ #00816A
+
+
+ Inner Text Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Inner Text Colour
+ #fff
+
+
+ Outer Background Colour
+ #00BF96
+
+
+ Label Text Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Label Text Colour
+ #fff
+
+
+ Warning Background Colour 1
+ darkorange
+
+
+ Warning Background Colour 2
+ orange
+
+
+ Finished Background Colour 1
+ darkred
+
+
+ Finished Background Colour 2
+ red
+
+
+
+
+ 640
+ 360
+
+
+
[mm]
+
||Minutes||
+
+
[ss]
+
||Seconds||
+
+
+
+ ]]>
+
+
+ ';
+ break;
+ case 'ssa':
+ replacement = '';
+ break;
+ case 'mm':
+ replacement = '';
+ break;
+ case 'mma':
+ replacement = '';
+ break;
+ case 'hh':
+ replacement = '';
+ break;
+ case 'hha':
+ replacement = '';
+ break;
+ case 'DD':
+ replacement = '';
+ break;
+ case 'WW':
+ replacement = '';
+ break;
+ case 'MM':
+ replacement = '';
+ break;
+ case 'YY':
+ replacement = '';
+ break;
+ default:
+ replacement = 'NULL';
+ break;
+ }
+
+ return replacement;
+});
+
+// Attach html back to container
+$countdownContainer.html(countdownHTML);
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Render the countdown
+var $contentContainer = $(target).find('#content');
+$contentContainer.xiboCountdownRender(properties,$contentContainer.html());
+]]>
+
+
+
+
\ No newline at end of file
diff --git a/modules/countdown-custom.xml b/modules/countdown-custom.xml
new file mode 100755
index 0000000..778e726
--- /dev/null
+++ b/modules/countdown-custom.xml
@@ -0,0 +1,247 @@
+
+
+ core-countdown-custom
+ Countdown - Custom
+ Core
+ A module for displaying a custom Countdown timer
+ fa fa-hourglass-o
+
+ none
+ countdown-custom
+ Countdown
+
+ 2
+ 1
+ 1
+ html
+ 60
+ countdown-custom-thumb
+ 400
+ 150
+
+
+
+ Countdown Type
+ Please select the type of countdown.
+ 1
+
+
+
+
+
+
+
+ Countdown Duration
+ The duration in seconds.
+
+
+
+ 2
+
+
+
+
+ 0
+ 2
+
+
+
+
+ Countdown Date
+ Select the target date and time.
+ 10/10/2010 10:10:10
+
+
+ 3
+
+
+
+
+
+
+
+
+
+ Warning Duration
+ The countdown will show in a warning mode from the end duration entered.
+
+
+
+ 1
+ 2
+
+
+
+
+
+ 2
+
+
+
+ 1
+
+
+
+
+ 0
+
+
+
+
+
+ Warning Date
+ The countdown will show in a warning mode from the warning date entered.
+
+
+
+ 3
+
+
+
+
+ 3
+
+
+
+
+
+
+ 1
+
+
+ countdown
+
+
+ Original Width
+ This is the intended width of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Original Height
+ This is the intended height of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ mainTemplate
+
+
+ styleSheet
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+
+
+
+{{mainTemplate|raw}}
+
+ ]]>
+
+
+ ';
+ break;
+ case 'ssa':
+ replacement = '';
+ break;
+ case 'mm':
+ replacement = '';
+ break;
+ case 'mma':
+ replacement = '';
+ break;
+ case 'hh':
+ replacement = '';
+ break;
+ case 'hha':
+ replacement = '';
+ break;
+ case 'DD':
+ replacement = '';
+ break;
+ case 'WW':
+ replacement = '';
+ break;
+ case 'MM':
+ replacement = '';
+ break;
+ case 'YY':
+ replacement = '';
+ break;
+ default:
+ replacement = 'NULL';
+ break;
+ }
+
+ return replacement;
+});
+
+// Attach html back to container
+$countdownContainer.html(countdownHTML);
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Render the countdown
+var $contentContainer = $(target).find('#content');
+$contentContainer.xiboCountdownRender(properties, $contentContainer.html());
+]]>
+
\ No newline at end of file
diff --git a/modules/countdown-days.xml b/modules/countdown-days.xml
new file mode 100755
index 0000000..6c3f501
--- /dev/null
+++ b/modules/countdown-days.xml
@@ -0,0 +1,317 @@
+
+
+ core-countdown-days
+ Countdown - Days
+ Core
+ A module for displaying a Countdown timer for days
+ fa fa-hourglass-o
+
+ \Xibo\Widget\Compatibility\CountDownWidgetCompatibility
+ countdown-days
+ Countdown
+ countdown
+
+ 2
+ 1
+ 1
+ html
+ 60
+ countdown-days-thumb
+ 400
+ 150
+
+
+
+ Countdown Type
+ Please select the type of countdown.
+ 1
+
+
+
+
+
+
+
+ Countdown Duration
+ The duration in seconds.
+
+
+
+ 2
+
+
+
+
+ 0
+ 2
+
+
+
+
+ Countdown Date
+ Select the target date and time.
+ 10/10/2010 10:10:10
+
+
+ 3
+
+
+
+
+
+
+
+
+
+ Warning Duration
+ The countdown will show in a warning mode from the end duration entered.
+
+
+
+ 1
+ 2
+
+
+
+
+
+ 2
+
+
+
+ 1
+
+
+
+
+ 0
+
+
+
+
+
+ Warning Date
+ The countdown will show in a warning mode from the warning date entered.
+
+
+
+ 3
+
+
+
+
+ 3
+
+
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Text Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Text Colour
+ #1b1e56
+
+
+ Text Card Background Colour
+
+
+ Text Card Border Colour
+ #1b1e56
+
+
+ Label Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Label Colour
+ #333
+
+
+ Warning Background Colour
+ darkgoldenrod
+
+
+ Finished Background Colour
+ darkred
+
+
+
+
+ 600
+ 170
+
+
+
[DD]
+
||Days||
+
+
[hh]
+
||Hours||
+
+
[mm]
+
||Minutes||
+
+
[ss]
+
||Seconds||
+
+
+
+ ]]>
+
+
+ ';
+ break;
+ case 'ssa':
+ replacement = '';
+ break;
+ case 'mm':
+ replacement = '';
+ break;
+ case 'mma':
+ replacement = '';
+ break;
+ case 'hh':
+ replacement = '';
+ break;
+ case 'hha':
+ replacement = '';
+ break;
+ case 'DD':
+ replacement = '';
+ break;
+ case 'WW':
+ replacement = '';
+ break;
+ case 'MM':
+ replacement = '';
+ break;
+ case 'YY':
+ replacement = '';
+ break;
+ default:
+ replacement = 'NULL';
+ break;
+ }
+
+ return replacement;
+});
+
+// Attach html back to container
+$countdownContainer.html(countdownHTML);
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Render the countdown
+var $contentContainer = $(target).find('#content');
+$contentContainer.xiboCountdownRender(properties, $contentContainer.html());
+]]>
+
+
+
+
\ No newline at end of file
diff --git a/modules/countdown-table.xml b/modules/countdown-table.xml
new file mode 100755
index 0000000..87881c6
--- /dev/null
+++ b/modules/countdown-table.xml
@@ -0,0 +1,290 @@
+
+
+ core-countdown-table
+ Countdown - Table
+ Core
+ A module for displaying a Countdown timer in a table
+ fa fa-hourglass-o
+
+ \Xibo\Widget\Compatibility\CountDownWidgetCompatibility
+ countdown-table
+ Countdown
+ countdown
+
+ 2
+ 1
+ 1
+ html
+ 60
+ countdown-table-thumb
+ 500
+ 350
+
+
+
+ Countdown Type
+ Please select the type of countdown.
+ 1
+
+
+
+
+
+
+
+ Countdown Duration
+ The duration in seconds.
+
+
+
+ 2
+
+
+
+
+ 0
+ 2
+
+
+
+
+ Countdown Date
+ Select the target date and time.
+ 10/10/2010 10:10:10
+
+
+ 3
+
+
+
+
+
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Header Text Colour
+ #f1f1f1
+
+
+ Header Background Colour
+ #747474
+
+
+ Even Row Text Colour
+ #333
+
+
+ Even Row Background Colour
+ #d7d7d7
+
+
+ Odd Row Text Colour
+ #333
+
+
+ Odd Row Background Colour
+ #ececec
+
+
+ Border Colour
+ #ccc
+
+
+
+
+ 600
+ 400
+
+
+
+
||Years||
+
+
+
[YY]
+
+
+
||Months||
+
||Weeks||
+
+
+
[MM]
+
[WW]
+
+
+
||Days||
+
||Total Hours||
+
+
+
[DD]
+
[hha]
+
+
+
||Total Minutes||
+
||Total Seconds||
+
+
+
[mma]
+
[ssa]
+
+
+
+ ]]>
+
+
+ ';
+ break;
+ case 'ssa':
+ replacement = '';
+ break;
+ case 'mm':
+ replacement = '';
+ break;
+ case 'mma':
+ replacement = '';
+ break;
+ case 'hh':
+ replacement = '';
+ break;
+ case 'hha':
+ replacement = '';
+ break;
+ case 'DD':
+ replacement = '';
+ break;
+ case 'WW':
+ replacement = '';
+ break;
+ case 'MM':
+ replacement = '';
+ break;
+ case 'YY':
+ replacement = '';
+ break;
+ default:
+ replacement = 'NULL';
+ break;
+ }
+
+ return replacement;
+});
+
+// Attach html back to container
+$countdownContainer.html(countdownHTML);
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Render the countdown
+var $contentContainer = $(target).find('#content');
+$contentContainer.xiboCountdownRender(properties, $contentContainer.html());
+]]>
+
+
+
+
\ No newline at end of file
diff --git a/modules/countdown-text.xml b/modules/countdown-text.xml
new file mode 100755
index 0000000..fa35d72
--- /dev/null
+++ b/modules/countdown-text.xml
@@ -0,0 +1,266 @@
+
+
+ core-countdown-text
+ Countdown - Simple Text
+ Core
+ A module for displaying a Countdown timer with Simple Text
+ fa fa-hourglass-o
+
+ \Xibo\Widget\Compatibility\CountDownWidgetCompatibility
+ countdown-text
+ Countdown
+ countdown
+
+ 2
+ 1
+ 1
+ html
+ 60
+ countdown-text-thumb
+ 300
+ 180
+
+
+
+ Countdown Type
+ Please select the type of countdown.
+ 1
+
+
+
+
+
+
+
+ Countdown Duration
+ The duration in seconds.
+
+
+
+ 2
+
+
+
+
+ 0
+ 2
+
+
+
+
+ Countdown Date
+ Select the target date and time.
+ 10/10/2010 10:10:10
+
+
+ 3
+
+
+
+
+
+
+
+
+
+ Warning Duration
+ The countdown will show in a warning mode from the end duration entered.
+
+
+
+ 1
+ 2
+
+
+
+
+
+ 2
+
+
+
+ 1
+
+
+
+
+ 0
+
+
+
+
+
+ Warning Date
+ The countdown will show in a warning mode from the warning date entered.
+
+
+
+ 3
+
+
+
+
+ 3
+
+
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Text Colour
+ #222
+
+
+ Divider Colour
+ #222
+
+
+ Warning Colour
+ orange
+
+
+ Finished Colour
+ red
+
+
+
+
+ 280
+ 100
+
+
+ [hha]:[mm]:[ss]
+
+
+ ]]>
+
+
+ ';
+ break;
+ case 'ssa':
+ replacement = '';
+ break;
+ case 'mm':
+ replacement = '';
+ break;
+ case 'mma':
+ replacement = '';
+ break;
+ case 'hh':
+ replacement = '';
+ break;
+ case 'hha':
+ replacement = '';
+ break;
+ case 'DD':
+ replacement = '';
+ break;
+ case 'WW':
+ replacement = '';
+ break;
+ case 'MM':
+ replacement = '';
+ break;
+ case 'YY':
+ replacement = '';
+ break;
+ default:
+ replacement = 'NULL';
+ break;
+ }
+
+ return replacement;
+});
+
+// Attach html back to container
+$countdownContainer.html(countdownHTML);
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Render the countdown
+var $contentContainer = $(target).find('#content');
+$contentContainer.xiboCountdownRender(properties, $contentContainer.html());
+]]>
+
+
+
+
\ No newline at end of file
diff --git a/modules/currencies.xml b/modules/currencies.xml
new file mode 100755
index 0000000..56a2303
--- /dev/null
+++ b/modules/currencies.xml
@@ -0,0 +1,117 @@
+
+
+ core-currencies
+ Currencies
+ Core
+ A module for showing Currency pairs and exchange rates
+ fa fa-line-chart
+ \Xibo\Widget\CurrenciesAndStocksProvider
+ \Xibo\Widget\Compatibility\CurrenciesWidgetCompatibility
+ currencies
+ currency
+ %items%_%base%_%reverseConversion%
+ 1
+ 2
+ 1
+ 1
+ html
+ 30
+
+
+
+ Configuration
+
+
+ Currencies
+ A comma separated list of Currency Acronyms/Abbreviations, e.g. GBP,USD,EUR. For the best results enter no more than 5 items. Do not include the Base currency in this list.
+
+
+
+
+
+
+
+ Base
+ The base currency.
+
+
+
+
+
+
+
+ Reverse conversion?
+ Tick if you would like your base currency to be used as the comparison currency for each currency you've entered. For example base/compare becomes compare/base - USD/GBP becomes GBP/USD.
+ 0
+
+
+ Duration is per item
+ The duration specified is per page/item otherwise the widget duration is divided between the number of pages/items.
+ 0
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 60
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/dashboard.xml b/modules/dashboard.xml
new file mode 100644
index 0000000..89e73ae
--- /dev/null
+++ b/modules/dashboard.xml
@@ -0,0 +1,136 @@
+
+
+ core-dashboard
+ Dashboards
+ Core
+ Securely connect to services like Microsoft PowerBI and display dashboards
+ fa fa-file-image
+ \Xibo\Widget\DashboardProvider
+ %widgetId%_%displayId%
+ dashboard
+ xibo-dashboard-service
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+
+ Type
+ Select the dashboards type below
+
+
+ Link
+ The Location (URL) of the dashboard webpage
+
+
+
+
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 60
+
+
+
+
+
+
+
+ ]]>
+
+
+ "));
+ $("#loader").hide();
+
+ if (interval) {
+ clearInterval(interval)
+ }
+ interval = setInterval(loadImage, properties.updateInterval * 60 * 1000);
+ }
+
+ image.onerror = function() {
+ $("#loader").show();
+ if (interval) {
+ clearInterval(interval)
+ }
+ interval = setInterval(loadImage, 60 * 1000);
+ }
+
+ if (item.url) {
+ image.src = item.url;
+ }
+}
+ ]]>
+
diff --git a/modules/dataset.xml b/modules/dataset.xml
new file mode 100755
index 0000000..5a26cea
--- /dev/null
+++ b/modules/dataset.xml
@@ -0,0 +1,355 @@
+
+
+ core-dataset
+ DataSet
+ Core
+ Display DataSet content
+ fa fa-table
+ \Xibo\Widget\DataSetProvider
+ \Xibo\Widget\Compatibility\DatasetWidgetCompatibility
+ %dataSetId%_%lowerLimit%_%upperLimit%_%numItems%_%orderClauses%_%useOrderingClause%_%ordering%_%filterClauses%_%useFilteringClause%_%filter%_%displayId%
+ dataset
+ dataset
+ datasetticker
+ datasetview
+ 2
+ 1
+ 1
+ html
+ 10
+
+
+
+ DataSet
+ Please select the DataSet to use as a source of data for this template.
+
+
+
+
+
+
+
+ Configuration
+
+
+
+
+
+
+
+ Lower Row Limit
+ Please enter the Lower Row Limit for this DataSet (enter 0 for no limit).
+ 0
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+ Upper Row Limit
+ Please enter the Upper Row Limit for this DataSet (enter 0 for no limit).
+ 0
+
+
+
+
+
+
+
+ 0
+
+
+
+
+ Randomise?
+ Should the order of the feed be randomised? When enabled each time the Widget is shown the items will be randomly shuffled and displayed in a random order.
+ 0
+
+
+
+
+
+
+
+ Number of Items
+ The Number of items you want to display
+ 0
+
+
+
+
+
+
+
+ 1
+ 0
+
+
+
+
+ Duration is per item
+ The duration specified is per item otherwise it is per feed.
+ 0
+
+
+
+
+
+
+
+ Order
+
+
+
+
+
+
+
+ The DataSet results can be ordered by any column and set below. New fields can be added by selecting the plus icon at the end of the current row. Should a more complicated order be required the advanced checkbox can be selected to provide custom SQL syntax.
+
+
+
+
+
+
+
+ []
+ dataSetId
+
+
+
+ 0
+
+
+
+
+ Use advanced order clause?
+ Provide a custom clause instead of using the clause builder above.
+ 0
+
+
+
+
+
+
+
+ Order
+ Please enter a SQL clause for how this dataset should be ordered
+
+
+
+
+ 1
+
+
+
+
+ Filter
+
+
+
+
+
+
+
+ The DataSet results can be filtered by any column and set below. New fields can be added by selecting the plus icon at the end of the current row. Should a more complicated filter be required the advanced checkbox can be selected to provide custom SQL syntax. The substitution [DisplayId] can be used in filter clauses and will be substituted at run time with the Display ID. When shown in the CMS it will be substituted with 0.
+
+
+
+
+
+
+
+ The substitution [Tag:tagName:defaultValue] can also be used in filter clauses. Replace tagName with the actual display tag name you want to use and defaultValue with the value to be used if the tag value is not found (e.g., [Tag:region:unknown]). At runtime, it will be substituted with the Display's tag value or defaultValue if the tag value is not found. When shown in the CMS, it will be substituted with an empty string if the tag is not found at all.
+
+
+
+
+
+
+
+
+ []
+ dataSetId
+
+
+
+ 0
+
+
+
+
+ Use advanced filter clause?
+ Provide a custom clause instead of using the clause builder above.
+ 0
+
+
+
+
+
+
+
+ Filter
+ Please enter a SQL clause to filter this DataSet.
+
+
+
+
+ 1
+
+
+
+
+ DESC
+
+
+
+
+ Caching
+
+
+
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 5
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+ Freshness (mins)
+ If the Player is offline it will switch to the No Data Template after this freshness time. Set this to 0 to never switch.
+ 0
+
+
+
+
+
+
+
+
+ 0
+ && moment(meta.cacheDt).add(properties.freshnessTimeout, 'minutes').isBefore(moment())
+ ) {
+ return {dataItems: []};
+}
+
+// Filter the items array we have been given
+if (parseInt(properties.randomiseItems) === 1) {
+ // Sort the items in a random order (considering the entire list)
+ // Durstenfeld shuffle
+ // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
+ // https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
+ for (var i = items.length - 1; i > 0; i--) {
+ var j = Math.floor(Math.random() * (i + 1));
+ var temp = items[i];
+ items[i] = items[j];
+ items[j] = temp;
+ }
+}
+
+if (properties.takeItemsFrom === 'end') {
+ // If it's an array, reverse it
+ if (Array.isArray(items)) {
+ items.reverse();
+ } else {
+ // If it's an object, reverse the keys
+ var newItems = {};
+ Object.keys(items).reverse().forEach(function(key) {
+ newItems[key] = items[key];
+ });
+ items = $(newItems);
+ }
+}
+
+// Make sure the num items is not greater than the actual number of items
+if (properties.numItems > items.length || properties.numItems === 0) {
+ properties.numItems = items.length;
+}
+
+// Get a new array with only the first N elements
+if (properties.numItems && properties.numItems > 0) {
+ items = items.slice(0, properties.numItems);
+}
+
+// Reverse the items again (so they are in the correct order)
+if ((properties.takeItemsFrom === 'end' && properties.reverseOrder === 0) ||
+ (properties.takeItemsFrom === 'start' && properties.reverseOrder === 1)
+ ) {
+ // console.log("[Xibo] Reversing items");
+ // If it's an array, reverse it
+ if (Array.isArray(items)) {
+ items.reverse();
+ } else {
+ // If it's an object, reverse the keys
+ var newItems = {};
+ Object.keys(items).reverse().forEach(function(key) {
+ newItems[key] = items[key];
+ });
+ items = $(newItems);
+ }
+}
+return {dataItems: items};
+ ]]>
+ 0) {
+ // Set up an interval to check whether we have exceeded our freshness
+ if (window.freshnessTimer) {
+ clearInterval(window.freshnessTimer);
+ }
+ window.freshnessTimer = setInterval(function() {
+ if (moment(meta.cacheDt).add(properties.freshnessTimeout, 'minutes').isBefore(moment())) {
+ // Reload the widget data.
+ XiboPlayer.playerWidgets[id].render();
+ }
+ }, 10000);
+}
+ ]]>
+
\ No newline at end of file
diff --git a/modules/datatypes/currency.xml b/modules/datatypes/currency.xml
new file mode 100644
index 0000000..630fa0e
--- /dev/null
+++ b/modules/datatypes/currency.xml
@@ -0,0 +1,73 @@
+
+
+
+ currency
+ Exchange Rate
+
+
+ The time this quote was last refreshed
+
+
+ Currency code from
+
+
+ Currency code to
+
+
+ The Exchange Rate (if real time trading then this is the Bid)
+
+
+ The Exchange Rate (if real time trading then this is the Ask)
+
+
+ Current Rate (4dp)
+
+
+ Current Rate
+
+
+ Time zone for the rate
+
+
+ From Name / To Name
+
+
+ To Name
+
+
+ Prior Day Rate
+
+
+ Difference between today and prior day
+
+
+ The percentage change between the current and prior value
+
+
+ Icon representing the direction of change, up-arrow or down-arrow
+
+
+ Style representing the direction of change, value-down or value-up
+
+
+
+
diff --git a/modules/datatypes/emergency-alert.xml b/modules/datatypes/emergency-alert.xml
new file mode 100644
index 0000000..57fcdd4
--- /dev/null
+++ b/modules/datatypes/emergency-alert.xml
@@ -0,0 +1,73 @@
+
+
+
+ emergency-alert
+ Emergency Alert
+
+
+ Type of emergency event being reported
+
+
+ Level of urgency indicating how soon action is needed
+
+
+ Indicates the severity of the emergency situation
+
+
+ Certainty level of the reported event's occurrence
+
+
+ Date and time when the alert becomes effective
+
+
+ Expected starting time of the emergency event
+
+
+ Time when the emergency alert expires
+
+
+ Name of the entity issuing the alert
+
+
+ Brief headline summarizing the emergency alert
+
+
+ Detailed explanation of the emergency situation
+
+
+ Guidance or instructions for those affected by the alert
+
+
+ Contact details for more information about the alert
+
+
+ Origin or source from which the alert information is derived
+
+
+ Supplementary notes providing additional context
+
+
+ Description of the geographical area affected by the alert
+
+
+
+
diff --git a/modules/datatypes/message.xml b/modules/datatypes/message.xml
new file mode 100644
index 0000000..ef62fb1
--- /dev/null
+++ b/modules/datatypes/message.xml
@@ -0,0 +1,40 @@
+
+
+
+ message
+ Message
+
+
+ The message subject
+
+
+ The message body
+
+
+ The release date of this message
+
+
+ The created date of this message
+
+
+
+
diff --git a/modules/datatypes/stock.xml b/modules/datatypes/stock.xml
new file mode 100644
index 0000000..c5eef2c
--- /dev/null
+++ b/modules/datatypes/stock.xml
@@ -0,0 +1,70 @@
+
+
+
+ stock
+ Stock Quote
+
+
+ Name of the Widget
+
+
+ Stock Symbol
+
+
+ The time this quote was last refreshed
+
+
+ Closing rate (4dp)
+
+
+ Closing rate (4dp)
+
+
+ Opening rate
+
+
+ Opening rate
+
+
+ The timezone the stock is listed in
+
+
+ The currency the stock is listed in
+
+
+ The value change between the current and prior value
+
+
+ The percentage change between the current and prior value
+
+
+ Icon representing the direction of change, up-arrow or down-arrow
+
+
+ Style representing the direction of change, value-down or value-up
+
+
+ If the symbol has a . return the first part before the .
+
+
+
+
diff --git a/modules/embedded.xml b/modules/embedded.xml
new file mode 100644
index 0000000..b993d7b
--- /dev/null
+++ b/modules/embedded.xml
@@ -0,0 +1,101 @@
+
+
+ core-embedded
+ Embedded
+ Core
+ Embed HTML and JavaScript
+ fa fa-code
+
+ embedded
+
+ 1
+ 1
+ 1
+ html
+ 60
+ 400
+ 600
+
+
+
+ Background transparent?
+ Should the Widget be shown with a transparent background? Also requires the embedded content to have a transparent background.
+ 0
+
+
+ Scale Content?
+ Should the embedded content be scaled along with the layout?
+ 0
+
+
+ Preload?
+ Should this Widget be loaded entirely off-screen so that it is ready when shown? Dynamic content will start running off screen.
+ 0
+
+
+ HTML
+ Add HTML to be included between the BODY tag.
+
+
+ Style Sheet
+ Add CSS to be included immediately before the closing body tag. Please do not include style tags.
+
+
+ JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+ HEAD
+ Add additional tags to appear immediately before the closing head tag, such as meta, link, etc. If your JavaScript uses the [] array notation add it inside script tags here.
+
+
+
+
+
+
+ {{embedJavaScript|raw}}
+
+ ]]>
+
+
+
+
+
diff --git a/modules/emergency-alert.xml b/modules/emergency-alert.xml
new file mode 100644
index 0000000..91a2cf8
--- /dev/null
+++ b/modules/emergency-alert.xml
@@ -0,0 +1,185 @@
+
+
+ core-emergency-alert
+ Emergency Alert
+ Core
+ A module for displaying emergency alert elements based on an CAP feed
+ fa fa-exclamation-circle
+
+ %emergencyAlertUri%_%isAreaSpecific%_%status%_%msgType%_%scope%_%category%_%responseType%_%urgency%_%severity%_%certainty%_%displayId%
+ emergency-alert
+ emergency-alert
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+
+ CAP URL
+ The Link for the CAP feed
+
+
+
+
+
+
+
+
+ Area-Specific Alert Delivery
+ Send this alert only to displays matching the alert's area?
+ 0
+
+
+ Filter by Status
+ Only show Emergency Alerts in this layout if the status matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Message Type
+ Only show Emergency Alerts in this layout if the message type matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Scope
+ Only show Emergency Alerts in this layout if the scope matches the selected option.
+
+
+
+
+
+
+
+
+
+ Filter by Event Category
+ Only show Emergency Alerts in this layout if the category matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Response Type
+ Only show Emergency Alerts in this layout if the response type matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Urgency
+ Only show Emergency Alerts in this layout if the urgency matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Severity
+ Only show Emergency Alerts in this layout if the severity matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by certainty
+ Only show Emergency Alerts in this layout if the certainty matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/flash.xml b/modules/flash.xml
new file mode 100644
index 0000000..e432d7a
--- /dev/null
+++ b/modules/flash.xml
@@ -0,0 +1,47 @@
+
+
+ core-flash
+ Flash
+ Core
+ Upload SWF files to assign to Layouts
+ fa fa-flash
+
+ flash
+
+ 1
+ 1
+ 0
+ native
+ 10
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ swf
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/font-form-settings.twig b/modules/font-form-settings.twig
new file mode 100644
index 0000000..bfefdcd
--- /dev/null
+++ b/modules/font-form-settings.twig
@@ -0,0 +1,33 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "module-form-settings.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block moduleFormFields %}
+
+ {% set title %}{% trans "Rebuild Fonts?" %}{% endset %}
+ {% set helpText %}{% trans "Will rebuild all fonts in the CMS and generate new font files for the Players to download. Do this if there are problems showing fonts." %}{% endset %}
+ {{ forms.checkbox("rebuildFonts", title, 0, helpText) }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/modules/forecastio-css-generator.twig b/modules/forecastio-css-generator.twig
new file mode 100644
index 0000000..54ae08e
--- /dev/null
+++ b/modules/forecastio-css-generator.twig
@@ -0,0 +1,132 @@
+{#
+/**
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% macro backgrounds(backgroundImage, cloudyImage, dayCloudyImage, dayClearImage, fogImage, hailImage, nightClearImage, nightPartlyCloudyImage, rainImage, snowImage, windyImage) %}
+ {% if backgroundImage != 'none' %}
+ .bg-cloudy {
+ background-image: url("{% if cloudyImage != "" %}{{ cloudyImage }}{% else %}[[assetId=wi-cloudy]]{% endif %}");
+ }
+
+ .bg-partly-cloudy-day {
+ background-image: url("{% if dayCloudyImage != "" %}{{ dayCloudyImage }}{% else %}[[assetId=wi-day-cloudy]]{% endif %}");
+ }
+
+ .bg-clear-day {
+ background-image: url("{% if dayClearImage %}{{ dayClearImage }}{% else %}[[assetId=wi-day-sunny]]{% endif %}");
+ }
+
+ .bg-fog {
+ background-image: url("{% if fogImage %}{{ fogImage }}{% else %}[[assetId=wi-fog]]{% endif %}");
+ }
+
+ .bg-sleet {
+ background-image: url("{% if hailImage %}{{ hailImage }}{% else %}[[assetId=wi-hail]]{% endif %}");
+ }
+
+ .bg-clear-night {
+ background-image: url("{% if nightClearImage %}{{ nightClearImage }}{% else %}[[assetId=wi-night-clear]]{% endif %}");
+ }
+
+ .bg-partly-cloudy-night {
+ background-image: url("{% if nightPartlyCloudyImage %}{{ nightPartlyCloudyImage }}{% else %}[[assetId=wi-night-partly-cloudy]]{% endif %}");
+ }
+
+ .bg-rain {
+ background-image: url("{% if rainImage %}{{ rainImage }}{% else %}[[assetId=wi-rain]]{% endif %}");
+ }
+
+ .bg-snow {
+ background-image: url("{% if snowImage %}{{ snowImage }}{% else %}[[assetId=wi-snow]]{% endif %}");
+ }
+
+ .bg-wind {
+ background-image: url("{% if windyImage %}{{ windyImage }}{% else %}[[assetId=wi-windy]]{% endif %}");
+ }
+
+ .bg-cloudy, .bg-partly-cloudy-day, .bg-clear-day, .bg-fog, .bg-sleet, .bg-clear-night, .bg-partly-cloudy-night, .bg-rain, .bg-snow, .bg-wind {
+ background-position: center;
+ {% if backgroundImage == 'center' %}
+ background-size: contain;
+ {% elseif backgroundImage == 'stretch' %}
+ background-size: 100% 100%;
+ {% else %}
+ background-size: cover;
+ {% endif %}
+ background-repeat: no-repeat;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ left: 0;
+ top: 0;
+ z-index: -1;
+ }
+ {% else %}
+ .bg-cloudy, .bg-partly-cloudy-day, .bg-clear-day, .bg-fog, .bg-sleet, .bg-clear-night, .bg-partly-cloudy-night, .bg-rain, .bg-snow, .bg-wind {
+ background-image: none;
+ }
+ {% endif %}
+{% endmacro %}
+
+{% macro colors(backgroundColor, textColor, iconsColor, footerColor, footerTextColor, footerIconsColor, shadowColor) %}
+ {% if backgroundColor != '' %}
+ #content > div {
+ background-color: {{ backgroundColor }} !important;
+ }
+ {% endif %}
+
+ {% if textColor != '' %}
+ #content > div {
+ color: {{ textColor }} !important;
+ }
+ {% endif %}
+
+ {% if iconsColor != '' %}
+ .wi {
+ color: {{ iconsColor }} !important;
+ }
+ {% endif %}
+
+ {% if footerColor != '' %}
+ .bg-footer {
+ background-color: {{ footerColor }} !important;
+ }
+ {% endif %}
+
+ {% if footerTextColor != '' %}
+ .bg-footer {
+ color: {{ footerTextColor }} !important;
+ }
+ {% endif %}
+
+ {% if footerIconsColor != '' %}
+ .bg-footer .wi {
+ color: {{ footerIconsColor }} !important;
+ }
+ {% endif %}
+
+ {% if shadowColor != '' %}
+ .shadowed {
+ text-shadow: 0 0 2px {{ shadowColor }};
+ filter: dropshadow(color={{ shadowColor }}, offx=2, offy=2);
+ }
+ {% endif %}
+{% endmacro %}
diff --git a/modules/forecastio.xml b/modules/forecastio.xml
new file mode 100755
index 0000000..e62ec5e
--- /dev/null
+++ b/modules/forecastio.xml
@@ -0,0 +1,217 @@
+
+
+ core-forecastio
+ Weather
+ Core
+ A module for displaying weather information. Uses the Forecast API
+ fa fa-cloud
+
+ \Xibo\Widget\Compatibility\WeatherWidgetCompatibility
+ \Xibo\Widget\Validator\DisplayOrGeoValidator
+ forecastio
+ forecast
+ %useDisplayLocation%_%latitude%_%longitude%_%units%_%lang%
+ 2
+ 1
+ 1
+ html
+ 60
+
+ weather_attribution
+
+
+ Use the Display Location
+ Use the location configured on the display
+ 1
+
+
+ Latitude
+ The Latitude for this widget
+ #DEFAULT_LAT#
+
+
+ 0
+
+
+
+
+ Longitude
+ The Longitude for this widget
+ #DEFAULT_LONG#
+
+
+ 0
+
+
+
+
+ Units
+ Select the units you would like to use.
+ auto
+
+
+ Language
+ Select the language you would like to use.
+ en
+
+
+ Only show Daytime weather conditions
+ Tick if you would like to only show the Daytime weather conditions.
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 0) {
+ moment.locale(properties.lang);
+} else {
+ moment.locale(globalOptions.locale);
+}
+
+// Make replacements [] with target data
+var makeReplacement = function(html, item) {
+ // Make replacements [] with item data
+ var replacer = function(match, key) {
+ if (key === 'time' || key.indexOf('time|') === 0) {
+ if (key.indexOf('time|') === 0) {
+ return moment.unix(item.time).format(key.split('|')[1]);
+ }
+ // Else return the time
+ return moment.unix(item.time).format('h:mm a');
+ }
+
+ // If its [icon] or [wicon] and we have properties.dayConditionsOnly then return the day icon
+ if(properties.dayConditionsOnly == 1 && (key === 'icon' || key === 'wicon')) {
+ return item[key].replace('-night', '');
+ }
+
+ // If its [poweredBy] then return the powered by html
+ if (key === 'Attribution') {
+ return meta.Attribution;
+ }
+ return item[key];
+ };
+ return html.replace(/\[([^\]]+)\]/g, replacer);
+}
+
+// Get content container
+var $content = $(target).find('#content');
+
+// Clear content container
+$content.empty();
+
+// Get current day (main) template and clone its contents
+var currentContainerHTML = $(target).find('.current-day-template').html();
+if (currentContainerHTML) {
+ // Make replacements [] with current day data
+ currentContainerHTML = makeReplacement(currentContainerHTML, items[0]);
+
+ // Add current day (main) template to content container
+ $content.append(currentContainerHTML);
+}
+
+// Make replacements if we have bg-div
+var bgDivHTML = $(target).find('.bg-div').prop('outerHTML');
+if (bgDivHTML) {
+ // Make replacements [] with current day data
+ bgDivHTML = makeReplacement(bgDivHTML, items[0]);
+
+ // Replace bg-div with current day data
+ $(target).find('.bg-div').replaceWith(bgDivHTML);
+}
+
+// Get the forecast container from current day template
+var $forecastContainer = $content.find('.forecast-container');
+
+// Get the forecast template
+var forecastTemplateHTML = $(target).find('.forecast-day-template').html();
+
+// If we have a forecast container
+// Get the number of days to show for the forecast
+var daysNum = $forecastContainer.data('days-num');
+
+// Check if we have forecast days to add
+if (daysNum > 0) {
+ // Empty container
+ $forecastContainer.empty();
+
+ var slicer = function(day, index) {
+ // Check if we are within the number of days
+ if (index < daysNum) {
+ // Make replacements [] with forecast day data
+ var forecastDayHTML = makeReplacement(forecastTemplateHTML, day);
+
+ // Add forecast day to forecast container
+ $forecastContainer.append(forecastDayHTML);
+ }
+ };
+
+ // Add the forecast days
+ items.slice(1).forEach(slicer);
+}
+
+// Handle images and scaling
+$content.xiboLayoutScaler(properties);
+$(target).find("img").xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/genericfile.xml b/modules/genericfile.xml
new file mode 100644
index 0000000..f9e8e84
--- /dev/null
+++ b/modules/genericfile.xml
@@ -0,0 +1,47 @@
+
+
+ core-genericfile
+ Generic File
+ Core
+ A generic file to be stored in the library
+
+ genericfile
+
+ 1
+ 0
+ 0
+
+ 10
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ apk,ipk,js,html,htm
+
+
+ 0
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/get-resource.twig b/modules/get-resource.twig
new file mode 100644
index 0000000..6b840f6
--- /dev/null
+++ b/modules/get-resource.twig
@@ -0,0 +1,59 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+
+
+ Xibo Open Source Digital Signage
+
+
+
+
+
+ {{ styleSheet|raw }}
+ {{ head|raw }}
+
+
+
+
+
+
+
{{ body|raw }}
+
+{{ javaScript|raw }}
+
+{{ controlMeta|raw }}
diff --git a/modules/googletraffic.xml b/modules/googletraffic.xml
new file mode 100755
index 0000000..c798179
--- /dev/null
+++ b/modules/googletraffic.xml
@@ -0,0 +1,172 @@
+
+
+ core-googletraffic
+ Google Traffic
+ Core
+ A module for displaying traffic information using Google Maps
+ fa fa-car
+
+ googletraffic
+
+ 1
+ 1
+ 1
+ html
+ 600
+ 600
+ 400
+
+
+ API Key
+ Enter your API Key from Google Maps.
+
+
+
+ Minimum recommended duration
+ Please enter a minimum recommended duration in seconds for this Module.
+ 600
+
+
+ This module uses the Google Traffic JavaScript API which is a paid-for API from Google. Charges will apply each time the map is loaded in the CMS preview and on each Player, therefore we recommend setting a high widget duration.
+
+
+
+
+
+ Use the Display Location
+ Use the location configured on the display
+
+
+
+ Latitude
+ The Latitude for this widget
+ #DEFAULT_LAT#
+
+
+ 0
+
+
+
+
+ Longitude
+ The Longitude for this widget
+ #DEFAULT_LONG#
+
+
+ 0
+
+
+
+
+ Zoom
+ How far should the map be zoomed in? The higher the number the closer, 1 represents the entire globe.
+ 15
+
+
+ This module is rendered on the Player which means the Player must have an internet connection.
+
+
+ The Traffic Widget has not been configured yet, please ask your CMS Administrator to look at it for you.
+
+
+ %apiKey%
+
+
+
+
+ You have entered a duration lower than the recommended minimum, this could cause significant API charges.
+
+
+ %minDuration%
+
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
\ No newline at end of file
diff --git a/modules/hls.xml b/modules/hls.xml
new file mode 100644
index 0000000..a3b2524
--- /dev/null
+++ b/modules/hls.xml
@@ -0,0 +1,125 @@
+
+
+ core-hls
+ HLS
+ Core
+ A module for displaying HLS video streams
+ fa fa-video-camera
+
+ hls
+
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+ Default Mute?
+ Should new widgets default to Muted?
+ 1
+
+
+ Default Subtitle?
+ Should new widgets default to Enabled Subtitles?
+ 1
+
+
+
+
+ Video Path
+ A URL to the HLS video stream. Requires Player running Windows 8.1 or later, or Android 6 or later. Earlier Android devices may play HLS via the LocalVideo widget.
+
+
+
+
+
+
+
+
+ Mute?
+ Should the video be muted?
+ %defaultMute%
+
+
+ Enable Subtitles?
+ Show subtitles if available in the HLS stream. Note that not all streams include captions, and some may have them permanently embedded in the video.
+ %defaultSubtitle%
+
+
+
+
+
+
+ ]]>
+
+
+
\ No newline at end of file
diff --git a/modules/htmlpackage.xml b/modules/htmlpackage.xml
new file mode 100644
index 0000000..3ce266a
--- /dev/null
+++ b/modules/htmlpackage.xml
@@ -0,0 +1,53 @@
+
+
+ core-htmlpackage
+ HTML Package
+ Core
+ Upload a complete package to distribute to Players
+ fa fa-file-code-o
+
+ htmlpackage
+
+ 1
+ 1
+ 0
+ native
+ 60
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ htz
+
+
+
+
+ Nominated File
+ Enter a nominated file name that player will attempt to open after extracting the .htz archive
+ index.html
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/image.xml b/modules/image.xml
new file mode 100644
index 0000000..f3dad90
--- /dev/null
+++ b/modules/image.xml
@@ -0,0 +1,114 @@
+
+
+ core-image
+ Image
+ Core
+ Upload Image files to assign to Layouts
+ fa fa-file-image-o
+
+ image
+
+ 1
+ 1
+ 0
+ native
+ 10
+ 1
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ jpg,jpeg,png,bmp,gif
+
+
+ Default Scale type
+ How should images be scaled by default?
+ center
+
+
+
+
+
+
+
+
+
+ Scale type
+ How should this image be scaled?
+ %defaultScaleTypeId%
+
+
+
+
+
+
+
+ Horizontal Align
+ How should this image be aligned?
+ center
+
+
+
+
+
+
+
+ center
+
+
+
+
+ Vertical Align
+ How should this image be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ center
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
diff --git a/modules/interactive-button.xml b/modules/interactive-button.xml
new file mode 100755
index 0000000..0841085
--- /dev/null
+++ b/modules/interactive-button.xml
@@ -0,0 +1,368 @@
+
+
+ core-interactive-button
+ Button
+ Core
+ A module for a button to be used as Trigger for Interactive
+ fa fa-hourglass-o
+
+ interactive-button
+ Interactive
+
+ 2
+ 1
+ 1
+ html
+ 60
+ interactive-button-thumb
+ 142
+ 63
+
+
+
+ Text
+ Button
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Font Size
+ 40
+
+
+ 1
+
+
+
+
+ Padding
+ 8
+
+
+ 1
+
+
+
+
+ Line Height
+ 1.2
+
+
+ 1
+
+
+
+
+ Bold
+ Should the text be bold?
+ 0
+
+
+ 0
+
+
+
+
+ Italics
+ Should the text be italicised?
+ 0
+
+
+ 0
+
+
+
+
+ Underline
+ Should the text be underlined?
+ 0
+
+
+ 0
+
+
+
+
+ Text Wrap
+ Should the text wrap to the next line?
+ 0
+
+
+ 1
+
+
+
+
+ Font Colour
+ #fff
+
+
+ 0
+
+
+
+
+ Use gradient for the text?
+ Gradients work well with most fonts. If you use a custom font please ensure you test the Layout on your player.
+ 0
+
+
+ Font Gradient
+
+
+
+ 1
+
+
+
+
+ Background Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient as background?
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Use Shadow?
+ 0
+ Should the background have a shadow?
+
+
+ Shadow Colour
+ rgba(0, 0, 0, 0.40)
+
+
+ 1
+
+
+
+
+ Shadow X Offset
+ 1
+
+
+ 1
+
+
+
+
+ Shadow Y Offset
+ 1
+
+
+ 1
+
+
+
+
+ Shadow Blur
+ 2
+
+
+ 1
+
+
+
+
+ Round Border
+ 1
+ Should the rectangle have rounded corners?
+
+
+ Border Radius
+ 6
+
+
+ 1
+
+
+
+
+ Show Outline
+ 0
+ Should the rectangle have an outline?
+
+
+ Outline Colour
+ #0f59bd
+
+
+ 1
+
+
+
+
+ Outline Width
+ 8
+
+
+ 1
+
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+ 300
+ 180
+
+
+ {{text}}
+
+
+ ]]>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/interactive-link.xml b/modules/interactive-link.xml
new file mode 100755
index 0000000..d8f895d
--- /dev/null
+++ b/modules/interactive-link.xml
@@ -0,0 +1,304 @@
+
+
+ core-interactive-link
+ Link
+ Core
+ A module for a link to be used as Trigger for Interactive
+ fa fa-hourglass-o
+
+ interactive-link
+ Interactive
+
+ 2
+ 1
+ 1
+ html
+ 60
+ interactive-link-thumb
+ 104
+ 66
+
+
+
+ Text
+ Link
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Font Size
+ 56
+
+
+ 1
+
+
+
+
+ Padding
+ 8
+
+
+ 1
+
+
+
+
+ Line Height
+ 1.2
+
+
+ 1
+
+
+
+
+ Bold
+ Should the text be bold?
+ 0
+
+
+ 0
+
+
+
+
+ Italics
+ Should the text be italicised?
+ 0
+
+
+ 0
+
+
+
+
+ Underline
+ Should the text be underlined?
+ 0
+
+
+ 0
+
+
+
+
+ Text Wrap
+ Should the text wrap to the next line?
+ 0
+
+
+ 1
+
+
+
+
+ Font Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient for the text?
+ Gradients work well with most fonts. If you use a custom font please ensure you test the Layout on your player.
+ 0
+
+
+ Font Gradient
+
+
+
+ 1
+
+
+
+
+ Use Shadow?
+ 1
+ Should the background have a shadow?
+
+
+ Text Shadow Colour
+ rgba(0, 0, 0, 0.40)
+
+
+ 1
+
+
+
+
+ Shadow X Offset
+ 2
+
+
+ 1
+
+
+
+
+ Shadow Y Offset
+ 3
+
+
+ 1
+
+
+
+
+ Shadow Blur
+ 4
+
+
+ 1
+
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+ 300
+ 180
+
+
+ {{text}}
+
+
+ ]]>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/interactive-zone.xml b/modules/interactive-zone.xml
new file mode 100755
index 0000000..e45e62d
--- /dev/null
+++ b/modules/interactive-zone.xml
@@ -0,0 +1,50 @@
+
+
+ core-interactive-zone
+ Interactive Zone
+ Core
+ A module for a zone to be used as Target or Trigger for Interactive
+ fas fa-border-none
+
+ interactive-zone
+ Interactive
+
+ 2
+ 1
+ 1
+ html
+ 60
+ interactive-zone-thumb
+ 200
+ 200
+
+
+
+
+
+ ]]>
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/layout-renderer.twig b/modules/layout-renderer.twig
new file mode 100644
index 0000000..e953700
--- /dev/null
+++ b/modules/layout-renderer.twig
@@ -0,0 +1,68 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+
+
+ {% trans "Preview for Layout" %} {{ layout.layoutId }}
+
+
+
+
+
+
+
+
+
+ {# Import JS bundle from dist #}
+
+
+
+
diff --git a/modules/localvideo.xml b/modules/localvideo.xml
new file mode 100644
index 0000000..eb742da
--- /dev/null
+++ b/modules/localvideo.xml
@@ -0,0 +1,79 @@
+
+
+ core-localvideo
+ Local Video
+ Core
+ Display Video that only exists on the Display by providing a local file path or URL
+ fa fa-file-video-o
+ \Xibo\Widget\Validator\RemoteUrlsZeroDurationValidator
+ localvideo
+
+ 1
+ 1
+ 1
+ native
+ 60
+
+
+ Default Mute?
+ Should new widgets default to Muted?
+ 1
+
+
+
+
+ Video Path
+ A local file path or URL to the video. This can be a RTSP stream.
+
+
+
+
+
+
+
+
+
+
+
+ Scale type
+ How should this video be scaled?
+ aspect
+
+
+
+
+
+
+ Mute?
+ Should the video be muted?
+ %defaultMute%
+
+
+ Show Full Screen?
+ Should the video expand over the top of existing content and show in full screen?
+ 0
+
+
+ Please note that video scaling and video streaming via RTSP is only supported by Android, webOS and Linux players at the current time. The HLS streaming Widget can be used to show compatible video streams on Windows.
+
+
+
\ No newline at end of file
diff --git a/modules/mastodon.xml b/modules/mastodon.xml
new file mode 100644
index 0000000..01f8136
--- /dev/null
+++ b/modules/mastodon.xml
@@ -0,0 +1,201 @@
+
+
+ core-mastodon
+ Mastodon
+ Core
+ Mastodon
+ fab fa-mastodon
+ \Xibo\Widget\MastodonProvider
+ mastodon
+ social-media
+ %hashtag%_%numItems%_%searchOn%_%onlyMedia%_%serverUrl%_%userName%
+ 1
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+ Default Server URL
+ The default URL for the mastodon instance.
+ https://mastodon.social
+
+
+ Cache Period for Images
+ Please enter the number of hours you would like to cache mastodon images.
+ 24
+
+
+ Cache Period
+ Please enter the number of seconds you would like to cache mastodon search results.
+ 3600
+
+
+
+
+ Hashtag
+ Test your search by using a Hashtag to return results from the mastodon URL provided in the module settings.
+
+
+
+ Search on
+ Show only local/remote server posts.
+ all
+
+
+
+
+
+
+
+ Server
+ Leave empty to use the one from settings.
+
+
+
+ Username
+ Provide Mastodon username to get public statuses from the account.
+
+
+
+ Count
+ The number of posts to return (default = 15).
+ 15
+
+
+ 1
+ 0
+
+
+
+
+ Only posts with attached media?
+ Return only posts which included attached media.
+ 0
+
+
+ Duration is per item
+ The duration specified is per item otherwise it is per feed.
+ 0
+
+
+ Remove Mentions?
+ Should mentions (@someone) be removed from the Mastodon Post?
+ 0
+
+
+ Remove Hashtags?
+ Should Hashtags (#something) be removed from the Mastodon Post?
+ 0
+
+
+ Remove URLs?
+ Should URLs be removed from the Mastodon Post? Most URLs do not compliment digital signage.
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/menuboard-category.xml b/modules/menuboard-category.xml
new file mode 100644
index 0000000..5860f77
--- /dev/null
+++ b/modules/menuboard-category.xml
@@ -0,0 +1,60 @@
+
+
+ core-menuboard-category
+ Menu Board: Category
+ Core
+ Display categories from a Menu Board
+ fa fa-list-alt
+ \Xibo\Widget\MenuBoardCategoryProvider
+ %menuId%_%categoryId%
+ menuboard-category
+ product-category
+ layout
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+
+ Menu
+ Please select the Menu to use as a source of data for this template.
+
+
+
+
+
+
+
+ Category
+ Please select the Category to use as a source of data for this template.
+ menuId
+
+
+
+
+
+
+
+
+
diff --git a/modules/menuboard-product.xml b/modules/menuboard-product.xml
new file mode 100644
index 0000000..24c3709
--- /dev/null
+++ b/modules/menuboard-product.xml
@@ -0,0 +1,115 @@
+
+
+ core-menuboard-product
+ Menu Board: Products
+ Core
+ Display products from a Menu Board
+ fas fa-hamburger
+ \Xibo\Widget\MenuBoardProductProvider
+ %menuId%_%categoryId%_%showUnavailable%_%sortField%_%sortDescending%_%lowerLimit%_%upperLimit%_%showUnavailable%
+ menuboard-product
+ product
+ layout
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+
+ Menu
+ Please select the Menu to use as a source of data for this template.
+
+
+
+
+
+
+
+ Category
+ Please select the Category to use as a source of data for this template.
+ menuId
+
+
+
+
+
+
+
+ Duration is per item
+ The duration specified is per item otherwise it is per menu.
+ 0
+
+
+ Sort by
+ How should we sort the menu items?
+ displayOrder
+
+
+
+
+
+
+
+
+ Sort descending?
+ 0
+
+
+ Show Unavailable Products?
+ Should the currently unavailable products appear in the menu?
+ 0
+
+
+ Row limits can be used to return a subset of menu items. For example if you wanted the 10th to the 20th item you could put 10 and 20.
+
+
+ Lower Row Limit
+ Provide a Lower Row Limit.
+ 0
+
+
+ 0
+
+
+
+
+
+
+
+ Upper Row Limit
+ Provide an Upper Row Limit.
+ 15
+
+
+ 0
+
+
+ 1
+ 0
+
+
+
+
+
+
diff --git a/modules/national-weather-service.xml b/modules/national-weather-service.xml
new file mode 100644
index 0000000..3a1e7d3
--- /dev/null
+++ b/modules/national-weather-service.xml
@@ -0,0 +1,187 @@
+
+
+ core-national-weather-service
+ National Weather Service
+ Core
+ A module for displaying weather alert elements based on National Weather Service's Atom feed
+ fa fa-exclamation-circle
+
+ %area%_%status%_%msgType%_%urgency%_%severity%_%certainty%_%displayId%
+ national-weather-service
+ emergency-alert
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+
+ Filter by Area
+ Only show Emergency Alerts in this layout if the status matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Status
+ Only show Emergency Alerts in this layout if the status matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Message Type
+ Only show Emergency Alerts in this layout if the message type matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Urgency
+ Only show Emergency Alerts in this layout if the urgency matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by Severity
+ Only show Emergency Alerts in this layout if the severity matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Filter by certainty
+ Only show Emergency Alerts in this layout if the certainty matches the selected option.
+
+
+
+
+
+
+
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/notificationview.xml b/modules/notificationview.xml
new file mode 100644
index 0000000..752465d
--- /dev/null
+++ b/modules/notificationview.xml
@@ -0,0 +1,63 @@
+
+
+ core-notificationview
+ Notification
+ Core
+ Display messages created in the Notification Drawer of the CMS
+ fa fa-bell-o
+ \Xibo\Widget\NotificationProvider
+ \Xibo\Widget\Compatibility\NotificationViewCompatibility
+ notificationview
+ message
+ layout
+ %age%_%displayId%
+ 1
+ 2
+ 1
+ 1
+ html
+ 10
+
+
+
+ Age
+ What is the maximum notification age in minutes, 0 for no restrictions.
+ 0
+
+
+ Duration is per item
+ The duration specified is per item otherwise it is per feed.
+ 0
+
+
+ Number of items
+ The number of items you want to display.
+
+
+ 1
+ 0
+
+
+
+
+
+
diff --git a/modules/pdf.xml b/modules/pdf.xml
new file mode 100755
index 0000000..fbaad27
--- /dev/null
+++ b/modules/pdf.xml
@@ -0,0 +1,218 @@
+
+
+ core-pdf
+ PDF
+ Core
+ Upload PDF files to assign to Layouts
+ fa fa-file-pdf-o
+ \Xibo\Widget\PdfProvider
+ pdf
+
+ 1
+ 1
+ 0
+ html
+ 60
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ pdf
+
+
+
+
+ Duration is per page
+ The duration specified is per page otherwise it is per document.
+
+
+ Not supported on Tizen and webOS Displays.
+
+
+
+
+
+
+ ]]>
+
+
+= pdfDoc.numPages) {
+ pageNum = 0;
+ }
+ pageNum++;
+ queueRenderPage(pageNum);
+}
+
+/**
+* Back to first page
+*/
+function onFirstPage() {
+ pageNum = 1;
+ queueRenderPage(pageNum);
+}
+
+/**
+* Asynchronously downloads PDF.
+*/
+pdfjsLib.getDocument({url: url, isEvalSupported: false}).promise.then(function(pdfDoc_) {
+ pdfDoc = pdfDoc_;
+ pdfLoaded = true;
+
+ var startInterval = function(interval) {
+ // Set a timer
+ setInterval(function() {
+ onNextPage();
+ }, interval * 1000);
+ };
+
+ // Initial/first page rendering
+ renderPage(pageNum);
+
+ if (properties.durationIsPerItem) {
+ // Set new widget duration by number of pages
+ xiboIC.setWidgetDuration(
+ (properties.duration * pdfDoc.numPages),
+ {
+ done: function() { // Callback after the request
+ // Start interval ( the defined duration )
+ startInterval(properties.duration);
+ },
+ error: function() {
+ // If the call fails, keep the defalt behaviour
+ startInterval(properties.duration / pdfDoc.numPages);
+ }
+ }
+ );
+ } else {
+ // Start interval ( total duration divided by the number of pages )
+ startInterval(properties.duration / pdfDoc.numPages);
+ }
+});
+
+// Render page on window resize
+window.addEventListener('resize', function(event) {
+ if(pdfLoaded) {
+ onFirstPage();
+ }
+}, true);
+ ]]>
+
+
+
+
\ No newline at end of file
diff --git a/modules/powerpoint.xml b/modules/powerpoint.xml
new file mode 100644
index 0000000..5a030a7
--- /dev/null
+++ b/modules/powerpoint.xml
@@ -0,0 +1,47 @@
+
+
+ core-powerpoint
+ PowerPoint
+ Core
+ Upload a PowerPoint file to assign to Layouts
+ fa fa-file-powerpoint-o
+
+ powerpoint
+
+ 1
+ 1
+ 0
+ native
+ 10
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ ppt,pps,pptx
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/rss-ticker.xml b/modules/rss-ticker.xml
new file mode 100644
index 0000000..7fe640a
--- /dev/null
+++ b/modules/rss-ticker.xml
@@ -0,0 +1,280 @@
+
+
+ core-rss-ticker
+ RSS Ticker
+ Core
+ Display articles from an RSS feed
+ fa fa-rss
+ \Xibo\Widget\RssProvider
+ \Xibo\Widget\Compatibility\RssWidgetCompatibility
+ rss-ticker
+ ticker
+ article
+ %uri%_%numItems%_%stripTags%_%imageSource%_%imageSourceTag%
+ 1
+ 2
+ 1
+ 1
+ html
+ 60
+
+
+ Update Interval Images (mins)
+ Please enter the update interval for images in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 240
+
+
+
+ 0
+
+
+
+
+ Cache Period (mins)
+ Please enter the number of minutes you would like to cache RSS feeds.
+ 1440
+
+
+
+
+ Feed URL
+ The Link for the RSS feed
+
+
+
+
+
+
+
+
+ Number of Items
+ The Number of RSS items you want to display
+ 15
+
+
+ 1
+ 0
+
+
+
+
+ Duration is per item
+ The duration specified is per item otherwise it is per feed.
+ 0
+
+
+ Take items from the
+ Take the items from the beginning or the end of the list
+ start
+
+
+
+
+
+
+ Reverse Order
+ Should we reverse the order of the feed items?
+ 0
+
+
+ Randomise
+ Should the order of the feed be randomised? When enabled each time the Widget is shown the items will be randomly shuffled and displayed in a random order.
+ 0
+
+
+ Image Tag
+ Choose the tag in the feed to get an image URL
+ enclosure
+
+
+
+
+
+
+
+
+ Custom Tag
+ A valid tag name which appears in this feed and will be used to get an image URL.
+
+
+
+ custom
+
+
+
+
+ Custom Tag Attribute
+ If the image URL is on an attribute of the custom tag, provide the attribute name.
+
+
+
+ custom
+
+
+
+
+ Strip Tags
+ A comma separated list of HTML tags that should be stripped from the feed in addition to the default ones.
+
+
+
+ User Agent
+ Optionally set specific User Agent for this request, provide only the value, relevant header will be added automatically.
+
+
+
+ Decode HTML
+ Should we decode the HTML entities in this feed before parsing it?
+ 0
+
+
+ Disable Date Sort
+ Should the date sort applied to the feed be disabled?
+ 0
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 60
+
+
+ Update Interval Images (mins)
+ Override the update interval for images. This should be kept as high as possible and can be set for all Tickers in Module Settings.
+ %updateIntervalImages%
+
+
+ 0; i--) {
+ var j = Math.floor(Math.random() * (i + 1));
+ var temp = items[i];
+ items[i] = items[j];
+ items[j] = temp;
+ }
+}
+
+if (properties.takeItemsFrom === 'end') {
+ // If it's an array, reverse it
+ if (Array.isArray(items)) {
+ items.reverse();
+ } else {
+ // If it's an object, reverse the keys
+ var newItems = {};
+ Object.keys(items).reverse().forEach(function(key) {
+ newItems[key] = items[key];
+ });
+ items = $(newItems);
+ }
+}
+
+// Make sure the num items is not greater than the actual number of items
+if (properties.numItems > items.length || properties.numItems === 0) {
+ properties.numItems = items.length;
+}
+
+// Get a new array with only the first N elements
+if (properties.numItems && properties.numItems > 0) {
+ items = items.slice(0, properties.numItems);
+}
+
+// Reverse the items again (so they are in the correct order)
+if ((properties.takeItemsFrom === 'end' && properties.reverseOrder === 0) ||
+ (properties.takeItemsFrom === 'start' && properties.reverseOrder === 1)
+ ) {
+ // console.log("[Xibo] Reversing items");
+ // If it's an array, reverse it
+ if (Array.isArray(items)) {
+ items.reverse();
+ } else {
+ // If it's an object, reverse the keys
+ var newItems = {};
+ Object.keys(items).reverse().forEach(function(key) {
+ newItems[key] = items[key];
+ });
+ items = $(newItems);
+ }
+}
+return {dataItems: items};
+ ]]>
+ Sample Content 1
",
+ "date": "2000-01-01T01:00:30+00:00",
+ "image": "",
+ "link": "https://www.example.com",
+ "permalink": null,
+ "publishedDate": "2000-01-01T01:00:30+00:00",
+ "summary": "Sample Summary 5",
+ "title": "Sample Title 5"
+}]
+]]>
+
\ No newline at end of file
diff --git a/modules/shellcommand.xml b/modules/shellcommand.xml
new file mode 100755
index 0000000..9644877
--- /dev/null
+++ b/modules/shellcommand.xml
@@ -0,0 +1,183 @@
+
+
+ core-shellcommand
+ Shell Command
+ Core
+ Instruct a Display to execute a command using the operating system shell
+ fa fa-terminal
+
+ \Xibo\Widget\Validator\ShellCommandValidator
+ shellcommand
+
+ 1
+ 1
+ 1
+ native
+ 10
+
+
+
+ Command Type
+ Pick a command type
+ storedCommand
+
+
+
+
+
+
+ Stored Command
+ Pick a stored command
+
+
+
+ storedCommand
+
+
+
+
+ Use global command?
+ Use a global command to work with all the player types.
+
+
+
+ createCommand
+
+
+
+
+ Global Command
+ Enter a global (Android/Linux/Tizen/webOS/Windows) Command Line compatible command
+
+
+
+ createCommand
+ 1
+
+
+
+
+ Android Command
+ Enter an Android Command Line compatible command
+
+
+
+ createCommand
+ 0
+
+
+
+
+ Linux Command
+ Enter a Linux Command Line compatible command
+
+
+
+ createCommand
+ 0
+
+
+
+
+ Tizen Command
+ Enter a Tizen Command Line compatible command
+
+
+
+ createCommand
+ 0
+
+
+
+
+ webOS Command
+ Enter a webOS Command Line compatible command
+
+
+
+ createCommand
+ 0
+
+
+
+
+ Windows Command
+ Enter a Windows Command Line compatible command
+
+
+
+ createCommand
+ 0
+
+
+
+
+ Launch the command via Windows Command Line
+ On Windows, should the player launch this command through the windows command line (cmd.exe)? This is useful for batch files. If you try to terminate this command only the command line will be terminated.
+ 1
+
+
+ createCommand
+
+
+
+
+ If you set a duration in the advanced tab additional options for how the command is terminated will become available below this message.
+
+
+ Terminate the command once the duration elapses?
+ Should the player forcefully terminate the command after the duration specified. Leave unchecked to let the command terminate naturally.
+
+
+
+ 1
+
+
+
+
+ Use taskkill to terminate commands?
+ On Windows, should the player use taskkill to terminate commands.
+
+
+
+ 1
+
+
+
+
+
+
+ {% trans "Stored Command:" %} {{commandCode}}
+{% elseif useGlobalCommand == 1 and globalCommand != '' %}
+
{% trans "Global Command:" %} {{globalCommand}}
+{% else %}
+
{% trans "Android Command:" %} {{androidCommand}}
+
{% trans "Windows Command:" %} {{windowsCommand}}
+
{% trans "Linux Command:" %} {{linuxCommand}}
+
{% trans "webOS Command:" %} {{webosCommand}}
+
{% trans "Tizen Command:" %} {{tizenCommand}}
+{% endif %}
+ ]]>
+
+
\ No newline at end of file
diff --git a/modules/spacer.xml b/modules/spacer.xml
new file mode 100644
index 0000000..f2a5491
--- /dev/null
+++ b/modules/spacer.xml
@@ -0,0 +1,42 @@
+
+
+ core-spacer
+ Spacer
+ Core
+ A module for making region empty for the duration
+ fa fa-hourglass-o
+
+ playlist
+ spacer
+
+ 1
+ 1
+ 1
+ html
+ 60
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/src/README.md b/modules/src/README.md
new file mode 100644
index 0000000..eaa5d94
--- /dev/null
+++ b/modules/src/README.md
@@ -0,0 +1,5 @@
+# /modules/src
+
+This folder contains JavaScript source files.
+
+By running build/publish, the files will be processed and placed in /modules.
diff --git a/modules/src/editor-render.js b/modules/src/editor-render.js
new file mode 100644
index 0000000..d5da8e9
--- /dev/null
+++ b/modules/src/editor-render.js
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+$(function() {
+ // Get a message from the parent window
+ // RUN ON IFRAME
+ window.onmessage = function(e) {
+ if (
+ e.data.method == 'renderContent'
+ ) {
+ // Update global options for the widget
+ globalOptions.originalWidth = e.data.options.originalWidth;
+ globalOptions.originalHeight = e.data.options.originalHeight;
+
+ // Set the pause state for animation to false
+ // To start right after the render effects are generated
+ globalOptions.pauseEffectOnStart =
+ e.data.options.pauseEffectOnStart ?? false;
+
+ // Arguments for both reRender
+ const args = (typeof widget != 'undefined') ? [
+ e.data.options.id, // id
+ $('body'), // target
+ widget.items, // items
+ Object.assign(widget.properties, globalOptions), // properties
+ widget.meta, // meta
+ ] : [];
+
+ // Call render array of functions if exists and it's an array
+ if (window.renders && Array.isArray(window.renders)) {
+ window.renders.forEach((render) => {
+ render(...args);
+ });
+ }
+ }
+ };
+});
diff --git a/modules/src/handlebars-helpers.js b/modules/src/handlebars-helpers.js
new file mode 100644
index 0000000..39aee2c
--- /dev/null
+++ b/modules/src/handlebars-helpers.js
@@ -0,0 +1,132 @@
+/* eslint-disable no-invalid-this */
+Handlebars.registerHelper('eq', function(v1, v2, opts) {
+ if (v1 === v2) {
+ return opts.fn(this);
+ } else {
+ return opts.inverse(this);
+ }
+});
+
+Handlebars.registerHelper('neq', function(v1, v2, opts) {
+ if (v1 !== v2) {
+ return opts.fn(this);
+ } else {
+ return opts.inverse(this);
+ }
+});
+
+Handlebars.registerHelper('set', function(varName, varValue, opts) {
+ if (!opts.data.root) {
+ opts.data.root = {};
+ }
+
+ opts.data.root[varName] = varValue;
+});
+
+Handlebars.registerHelper('parseJSON', function(varName, varValue, opts) {
+ if (!opts.data.root) {
+ opts.data.root = {};
+ }
+
+ try {
+ opts.data.root[varName] = JSON.parse(varValue);
+ } catch (error) {
+ console.warn(error);
+ opts.data.root = {};
+ }
+});
+
+Handlebars.registerHelper('createGradientInSVG', function(
+ gradient,
+ uniqueId,
+) {
+ if (gradient == '') {
+ return '';
+ }
+
+ const gradientObj = JSON.parse(gradient);
+
+ if (gradientObj.type === 'linear') {
+ // Convert angle to radians
+ const radians = (gradientObj.angle - 90) * Math.PI / 180;
+
+ // Calculate x and y components
+ const x = Math.cos(radians);
+ const y = Math.sin(radians);
+
+ // Determine x1, x2, y1, y2 points
+ const x1 = 0.5 - 0.5 * x;
+ const x2 = 0.5 + 0.5 * x;
+ const y1 = 0.5 - 0.5 * y;
+ const y2 = 0.5 + 0.5 * y;
+
+ return `
+
+
+ `;
+ } else {
+ // Radial
+ return `
+
+
+ `;
+ }
+});
+
+Handlebars.registerHelper('weatherBackgroundImage', function(
+ icon,
+ cloudyImage,
+ dayCloudyImage,
+ dayClearImage,
+ fogImage,
+ hailImage,
+ nightClearImage,
+ nightPartlyCloudyImage,
+ rainImage,
+ snowImage,
+ windImage,
+ opts,
+) {
+ let bgImage = false;
+
+ if ((typeof cloudyImage !== 'undefined' && cloudyImage !== '') &&
+ icon === 'cloudy') {
+ bgImage = cloudyImage;
+ } else if ((typeof dayCloudyImage !== 'undefined' && dayCloudyImage !== '') &&
+ icon === 'partly-cloudy-day') {
+ bgImage = dayCloudyImage;
+ } else if ((typeof dayClearImage !== 'undefined' && dayClearImage !== '') &&
+ icon === 'clear-day') {
+ bgImage = dayClearImage;
+ } else if ((typeof fogImage !== 'undefined' && fogImage !== '') &&
+ icon === 'fog') {
+ bgImage = fogImage;
+ } else if ((typeof hailImage !== 'undefined' && hailImage !== '') &&
+ icon === 'sleet') {
+ bgImage = hailImage;
+ } else if ((typeof nightClearImage !== 'undefined' &&
+ nightClearImage !== '') && icon === 'clear-night') {
+ bgImage = nightClearImage;
+ } else if ((typeof nightPartlyCloudyImage !== 'undefined' &&
+ nightPartlyCloudyImage !== '') && icon === 'partly-cloudy-night') {
+ bgImage = nightPartlyCloudyImage;
+ } else if ((typeof rainImage !== 'undefined' && rainImage !== '') &&
+ icon === 'rain') {
+ bgImage = rainImage;
+ } else if ((typeof snowImage !== 'undefined' && snowImage !== '') &&
+ icon === 'snow') {
+ bgImage = snowImage;
+ } else if ((typeof windImage !== 'undefined' && windImage !== '') &&
+ icon === 'wind') {
+ bgImage = windImage;
+ }
+
+ // If it's the media id, replace with path to be rendered
+ if (bgImage && !isNaN(bgImage) && imageDownloadUrl) {
+ bgImage = imageDownloadUrl.replace(':id', bgImage);
+ }
+
+ return bgImage;
+});
diff --git a/modules/src/player_bundle.js b/modules/src/player_bundle.js
new file mode 100644
index 0000000..5ee1c5b
--- /dev/null
+++ b/modules/src/player_bundle.js
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+/* eslint-disable no-unused-vars */
+const globalThis = require('globalthis/polyfill')();
+import 'core-js/stable/url-search-params';
+// Our own imports
+import 'xibo-interactive-control/dist/xibo-interactive-control.min.js';
+import './xibo-calendar-render';
+import './xibo-countdown-render';
+import './xibo-finance-render';
+import './xibo-image-render';
+import './xibo-legacy-template-render';
+import './xibo-layout-animate.js';
+import './xibo-layout-scaler';
+import './xibo-menuboard-render';
+import './xibo-metro-render';
+import './xibo-substitutes-parser';
+import './xibo-text-render';
+import './xibo-text-scaler';
+import './xibo-dataset-render';
+import './xibo-webpage-render';
+import './xibo-worldclock-render';
+import './xibo-elements-render';
+import './editor-render';
+
+window.PlayerHelper = require('../../ui/src/helpers/player-helper.js');
+window.XiboPlayer = require('./xibo-player.js');
+
+window.jQuery = window.$ = require('jquery');
+require('babel-polyfill');
+window.moment = require('moment');
+require('moment-timezone');
+window.Handlebars = require('handlebars/dist/handlebars.min.js');
+require('./handlebars-helpers.js');
+
+// Include HLS.js
+window.Hls = require('hls.js');
+
+// Include PDFjs
+window.pdfjsLib = require('pdfjs-dist/es5/build/pdf.js');
+
+// Include common helpers/transformer
+window.transformer = require('../../ui/src/helpers/transformer.js');
+window.ArrayHelper = require('../../ui/src/helpers/array.js');
+window.DateFormatHelper = require('../../ui/src/helpers/date-format-helper.js');
+
+// Plugins
+require('../vendor/jquery-cycle-2.1.6.min.js');
+require('../vendor/jquery.marquee.min.js');
diff --git a/modules/src/xibo-calendar-render.js b/modules/src/xibo-calendar-render.js
new file mode 100644
index 0000000..e80e6d0
--- /dev/null
+++ b/modules/src/xibo-calendar-render.js
@@ -0,0 +1,1412 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboCalendarRender: function(options, events) {
+ // Default options
+ const defaults = {
+ duration: '30',
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ startAtCurrentTime: 1,
+ };
+
+ options = $.extend({}, defaults, options);
+
+ // Global constants
+ const TODAY = moment();
+ const START_DATE = options.startAtCurrentTime || events.length <= 0 ?
+ TODAY.clone() :
+ moment(events[0].startDate);
+
+ const START_DATE_DAY_START = START_DATE.clone().startOf('day');
+ const START_DATE_DAY_END = START_DATE.clone().endOf('day');
+ const START_DATE_WEEK_START = START_DATE.clone().startOf('week');
+ const START_DATE_WEEK_END = START_DATE.clone().endOf('week');
+ const START_DATE_MONTH_START = START_DATE.clone().startOf('month');
+ const START_DATE_MONTH_END = START_DATE.clone().endOf('month');
+
+ const INITIAL_YEAR = START_DATE.year();
+
+ // NOTE: month format for momentjs is 1-12 and month value is zero indexed
+ const INITIAL_MONTH = START_DATE.month();
+ const INITIAL_DATE = START_DATE.date();
+
+ const TIME_FORMAT = options.timeFormat || 'HH:mm';
+
+ const DEFAULT_DAY_START_TIME =
+ START_DATE.startOf('day').format(TIME_FORMAT);
+ const DEFAULT_DAY_END_TIME =
+ START_DATE.endOf('day').format(TIME_FORMAT);
+
+ const GRID_STEP = options.gridStep &&
+ options.gridStep > 0 ? options.gridStep : 60;
+
+ const DEFAULT_FONT_SIZE = 16;
+ const DEFAULT_FONT_SCALE = options.textScale || 1;
+
+ // Global vars for all calendar types
+ let maxEventPerDay;
+ let maxEventPerDayWithExtra;
+
+ let weekdaysNames = moment.weekdays(true);
+ if (options.weekdayNameLength == 'short') {
+ weekdaysNames = moment.weekdaysMin(true);
+ } else if (options.weekdayNameLength == 'medium') {
+ weekdaysNames = moment.weekdaysShort(true);
+ }
+
+ let monthsNames = moment.months();
+ if (options.monthNameLength == 'short') {
+ monthsNames = moment.monthsShort();
+ }
+
+ // Filter events by calendar type.
+ // -------------------------------
+ const filteredEvents = [];
+ $.each(events, function(i, event) {
+ // Per calendar type, check that this event fits inside the view.
+ if (options.calendarType === 2) {
+ // Daily
+ if (moment(event.startDate) <= START_DATE_DAY_END &&
+ moment(event.endDate) >= START_DATE_DAY_START
+ ) {
+ filteredEvents.push(event);
+ }
+ } else if (options.calendarType === 3) {
+ // Weekly
+ if (moment(event.startDate) <= START_DATE_WEEK_END &&
+ moment(event.endDate) >= START_DATE_WEEK_START
+ ) {
+ filteredEvents.push(event);
+ }
+ } else if (options.calendarType === 4) {
+ // Monthly
+ if (moment(event.startDate) <= START_DATE_MONTH_END &&
+ moment(event.endDate) >= START_DATE_MONTH_START
+ ) {
+ filteredEvents.push(event);
+ }
+ } else {
+ filteredEvents.push(event);
+ }
+ });
+
+ // Main functions to be overriden
+ let createCalendar = () => {};
+ let addEventsToCalendar = () => {};
+
+ /**
+ * Apply style based on options
+ */
+ function applyStyleOptions() {
+ $('body').toggleClass('hide-header', options.showHeader != '1');
+ $('body')
+ .toggleClass('hide-weekend', options.excludeWeekendDays == '1');
+
+ $(':root').css('font-size', DEFAULT_FONT_SIZE * DEFAULT_FONT_SCALE);
+
+ options.mainBackgroundColor &&
+ $(':root').css('--main-background-color', options.mainBackgroundColor);
+
+ options.gridColor && $(':root').css('--grid-color', options.gridColor);
+ options.gridTextColor &&
+ $(':root').css('--grid-text-color', options.gridTextColor);
+
+ options.dayBgColor &&
+ $(':root').css('--day-bg-color', options.dayBgColor);
+ options.dayTextColor &&
+ $(':root').css('--day-text-color', options.dayTextColor);
+
+ options.todayTextColor &&
+ $(':root').css('--today-text-color', options.todayTextColor);
+
+ options.nowMarkerColor &&
+ $(':root').css('--now-marker-color', options.nowMarkerColor);
+
+ options.dayOtherMonthBgColor &&
+ $(':root').css(
+ '--day-other-month-bg-color',
+ options.dayOtherMonthBgColor,
+ );
+ options.dayOtherMonthTextColor &&
+ $(':root').css(
+ '--day-other-month-text-color',
+ options.dayOtherMonthTextColor,
+ );
+
+ options.headerBgColor &&
+ $(':root').css('--header-bg-color', options.headerBgColor);
+ options.headerTextColor &&
+ $(':root').css('--header-text-color', options.headerTextColor);
+
+ options.weekDaysHeaderBgColor &&
+ $(':root').css('--weekdays-bg-color', options.weekDaysHeaderBgColor);
+ options.weekDaysHeaderTextColor &&
+ $(':root').css(
+ '--weekdays-text-color',
+ options.weekDaysHeaderTextColor,
+ );
+
+ options.eventBgColor &&
+ $(':root').css('--event-bg-color', options.eventBgColor);
+ options.eventTextColor &&
+ $(':root').css('--event-text-color', options.eventTextColor);
+
+ options.dailyEventBgColor &&
+ $(':root').css('--daily-event-bg-color', options.dailyEventBgColor);
+ options.dailyEventTextColor &&
+ $(':root').css('--daily-event-text-color', options.dailyEventTextColor);
+
+ options.multiDayEventBgColor &&
+ $(':root').css(
+ '--multi-day-event-bg-color',
+ options.multiDayEventBgColor,
+ );
+ options.multiDayEventTextColor &&
+ $(':root').css(
+ '--multi-day-event-text-color',
+ options.multiDayEventTextColor,
+ );
+
+ options.aditionalEventsBgColor &&
+ $(':root').css(
+ '--aditional-events-bg-color',
+ options.aditionalEventsBgColor,
+ );
+ options.aditionalEventsTextColor &&
+ $(':root').css(
+ '--aditional-events-text-color',
+ options.aditionalEventsTextColor,
+ );
+
+ options.noEventsBgColor &&
+ $(':root').css(
+ '--no-events-bg-color',
+ options.noEventsBgColor,
+ );
+ options.noEventsTextColor &&
+ $(':root').css(
+ '--no-events-text-color',
+ options.noEventsTextColor,
+ );
+ }
+
+ /**
+ * Get week day number by date
+ * @param {string} date date string
+ * @return {number} week day number
+ */
+ function getWeekday(date) {
+ return moment(date).weekday() + 1;
+ }
+
+ /**
+ * Create a marker showing current time
+ * @param {object} $container target container
+ * @param {object} timeData data with start and end dates for the view
+ */
+ function createNowMarker($container, timeData) {
+ const dayViewDuration = timeData.end - timeData.start;
+ const $nowMarker = $('
');
+
+ const nowTimeInMinutes = moment
+ .duration(
+ moment(TODAY).diff(
+ moment(TODAY).startOf('day'),
+ ),
+ )
+ .as('minutes');
+
+ // Skip if it's not included in the selected delta time view
+ if (
+ nowTimeInMinutes >= timeData.end ||
+ nowTimeInMinutes <= timeData.start
+ ) {
+ return;
+ }
+
+ // Calculate position
+ const eventPositionPerc = (
+ nowTimeInMinutes / dayViewDuration -
+ timeData.start / dayViewDuration
+ ) * 100;
+
+ $nowMarker.css(
+ 'top',
+ eventPositionPerc + '%',
+ );
+
+ // Append marker to container
+ $nowMarker.appendTo($container);
+ }
+
+ /**
+ * Add events to calendar
+ */
+ function addEventsToCalendarBase() {
+ filteredEvents.forEach((event) => {
+ const startDate = moment(event.startDate).startOf('date');
+
+ const endDate = event.isAllDay ?
+ moment(event.endDate).startOf('date').subtract(1, 'd') :
+ moment(event.endDate).startOf('date');
+
+ const eventTotalDays = endDate.diff(startDate, 'days') + 1;
+ let currentDayOfEvent = 1;
+
+ // Days loop
+ const momentAux = moment(startDate);
+ while (momentAux <= endDate) {
+ addEventToDay(momentAux, event, eventTotalDays, currentDayOfEvent);
+ currentDayOfEvent++;
+ momentAux.add(1, 'd');
+ }
+ });
+ }
+
+ /**
+ * Add event to specific day
+ * @param {object} date momentjs date
+ * @param {object} event
+ * @param {number} eventTotalDays
+ * @param {number} currentDayOfEvent
+ */
+ function addEventToDay(date, event, eventTotalDays, currentDayOfEvent) {
+ /**
+ * Get container by date
+ * @param {object} date
+ * @return {object} Jquery container
+ */
+ function getEventContainer(date) {
+ return (options.calendarType == 2) ?
+ $('.calendar-day .calendar-events-container') :
+ $('#day_' + date.date()).find('.calendar-events-container');
+ }
+
+ /**
+ * Get all days container by date
+ * @param {object} date
+ * @return {object} Jquery container
+ */
+ function getAllDayEventsContainer(date) {
+ return (options.calendarType == 2) ?
+ $('.calendar-day .calendar-all-day-events-container') :
+ $('#day_' + date.date()).find('.calendar-all-day-events-container');
+ }
+
+ const $newEvent = $('
');
+ const weekDay = getWeekday(date);
+ let eventDuration = 1;
+
+ // Mark event as an all day
+ if (event.isAllDay) {
+ $newEvent.addClass('all-day');
+ }
+
+ if (eventTotalDays > 1) {
+ // Multiple day event
+ let htmlToAdd =
+ '' + event.summary + '';
+
+ // Mark as multi event
+ $newEvent.addClass('multi-day');
+
+ // Draw only on the first day of the event
+ // or at the beggining of the weeks when it breaks
+ if (currentDayOfEvent == 1 || weekDay == 1) {
+ if (currentDayOfEvent == 1 && !event.isAllDay) {
+ htmlToAdd =
+ '
');
+ const $eventsContainer = getEventContainer(date);
+ const weekDay = getWeekday(date);
+ let eventDuration = 1;
+
+ // Mark event as an all day
+ if (event.isAllDay) {
+ $newEvent.addClass('all-day');
+ }
+
+ if (eventTotalDays > 1) {
+ // Multiple day event
+ let htmlToAdd =
+ '' + event.summary + '';
+
+ // Mark as multi event
+ $newEvent.addClass('multi-day');
+
+ // Draw only on the first day of the event
+ // or at the beggining of the weeks when it breaks
+ if (currentDayOfEvent == 1 || weekDay == 1) {
+ if (currentDayOfEvent == 1 && !event.isAllDay) {
+ htmlToAdd =
+ '' +
+ moment(event.startDate).format(TIME_FORMAT) +
+ '' +
+ htmlToAdd;
+ }
+
+ // Show event content in multiple days
+ $newEvent.html(htmlToAdd);
+
+ // Update element duration based on event duration
+ eventDuration = eventTotalDays - (currentDayOfEvent - 1);
+
+ const remainingDays = 8 - weekDay;
+ if (eventDuration > remainingDays) {
+ eventDuration = remainingDays;
+ $newEvent.addClass('cropped-event-end');
+ }
+
+ if (currentDayOfEvent > 1) {
+ $newEvent.addClass('cropped-event-start');
+ }
+ $newEvent.css(
+ 'width',
+ 'calc(' +
+ eventDuration * 100 +
+ '% + ' +
+ eventDuration * 2 +
+ 'px)',
+ );
+ } else {
+ // Multiple event that was extended, no need to be rendered
+ return;
+ }
+ } else {
+ // Single day event
+ let htmlToAdd =
+ '' + event.summary + '';
+
+ // Mark event as an all day
+ if (event.isAllDay) {
+ $newEvent.addClass('all-day');
+ } else {
+ htmlToAdd =
+ '' +
+ moment(event.startDate).format(TIME_FORMAT) +
+ '' +
+ htmlToAdd;
+ }
+
+ // Add inner html
+ $newEvent.html(htmlToAdd);
+ }
+
+ // Calculate event slot
+ let slots = $eventsContainer.data('slots');
+ let daySlot;
+ if (slots != undefined) {
+ for (let index = 0; index < slots.length; index++) {
+ const slot = slots[index];
+ if (slot === undefined) {
+ daySlot = index;
+ slots[index] = 1;
+ break;
+ }
+ }
+
+ if (daySlot === undefined) {
+ daySlot = slots.length;
+ slots.push(1);
+ }
+ } else {
+ daySlot = 0;
+ slots = [1];
+ }
+
+ $eventsContainer.data('slots', slots);
+
+ // Extend event to the remaining days
+ if (eventDuration > 1) {
+ for (let dayAfter = 1; dayAfter < eventDuration; dayAfter++) {
+ const $newContainer = getEventContainer(
+ moment(date).add(dayAfter, 'd'),
+ );
+ let dataSlots = $newContainer.data('slots');
+
+ if (dataSlots === undefined) {
+ dataSlots = [];
+ }
+
+ dataSlots[daySlot] = 2;
+ $newContainer.data('slots', dataSlots);
+ }
+ }
+
+ $newEvent.css('top', 2 + 1.875 * daySlot + 'rem');
+
+ // Append event to container
+ $newEvent.appendTo($eventsContainer);
+
+ // Check container height and slots to show number of extra events
+ updateContainerExtraEvents($eventsContainer, slots);
+ }
+ }
+
+ // Create calendar
+ applyStyleOptions(options);
+ createCalendar();
+ addEventsToCalendar(filteredEvents);
+
+ return true;
+ },
+});
diff --git a/modules/src/xibo-countdown-render.js b/modules/src/xibo-countdown-render.js
new file mode 100644
index 0000000..dee98d3
--- /dev/null
+++ b/modules/src/xibo-countdown-render.js
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboCountdownRender: function(options, body) {
+ // Check if the given input is a number/offset
+ // or a date, and return the object
+ const getDate = function(inputDate) {
+ if (!isNaN(inputDate)) {
+ return moment().add(inputDate, 's');
+ } else if (moment(inputDate).isValid()) {
+ return moment(inputDate);
+ } else {
+ console.error('Invalid Date/Time!!!');
+ }
+ };
+
+ // Ge remaining time
+ const getTimeRemaining = function(endtime) {
+ const timeNow = moment().startOf('seconds');
+ const duration =
+ moment.duration(endtime.startOf('seconds').diff(timeNow));
+
+ return {
+ now: timeNow,
+ total: Math.floor(duration.asMilliseconds()),
+ seconds: Math.floor(duration.seconds()),
+ secondsAll: Math.floor(duration.asSeconds()),
+ minutes: Math.floor(duration.minutes()),
+ minutesAll: Math.floor(duration.asMinutes()),
+ hours: Math.floor(duration.hours()),
+ hoursAll: Math.floor(duration.asHours()),
+ days: Math.floor(duration.asDays()),
+ weeks: Math.floor(duration.asWeeks()),
+ months: Math.floor(duration.asMonths()),
+ years: Math.floor(duration.asYears()),
+ };
+ };
+
+ // Initialize clock
+ const initialiseClock = function(clock, deadlineDate, warningDate) {
+ const yearsSpan = clock.find('.years');
+ const monthsSpan = clock.find('.months');
+ const weeksSpan = clock.find('.weeks');
+ const daysSpan = clock.find('.days');
+ const hoursSpan = clock.find('.hours');
+ const hoursAllSpan = clock.find('.hoursAll');
+ const minutesSpan = clock.find('.minutes');
+ const minutesAllSpan = clock.find('.minutesAll');
+ const secondsSpan = clock.find('.seconds');
+ const secondsAllSpan = clock.find('.secondsAll');
+
+ // Remove warning and finished classes on init
+ $(clock).removeClass('warning finished');
+
+ // Clear interval if exists
+ if (window.timeinterval) {
+ clearInterval(window.timeinterval);
+ }
+
+ /**
+ * Update clock
+ */
+ function updateClock() {
+ const t = getTimeRemaining(deadlineDate);
+ yearsSpan.html(t.years);
+ monthsSpan.html(t.months);
+ weeksSpan.html(t.weeks);
+ daysSpan.html(t.days);
+ hoursSpan.html(('0' + t.hours).slice(-2));
+ hoursAllSpan.html(t.hoursAll);
+ minutesSpan.html(('0' + t.minutes).slice(-2));
+ minutesAllSpan.html(t.minutesAll);
+ secondsSpan.html(('0' + t.seconds).slice(-2));
+ secondsAllSpan.html(t.secondsAll);
+
+ if (
+ warningDate && deadlineDate.diff(warningDate) != 0 &&
+ warningDate.diff(t.now) <= 0
+ ) {
+ $(clock).addClass('warning');
+ }
+
+ if (t.total <= 0) {
+ $(clock).removeClass('warning').addClass('finished');
+ clearInterval(window.timeinterval);
+ yearsSpan.html('0');
+ monthsSpan.html('0');
+ daysSpan.html('0');
+ hoursSpan.html('00');
+ minutesSpan.html('00');
+ secondsSpan.html('00');
+ hoursAllSpan.html('0');
+ minutesAllSpan.html('0');
+ secondsAllSpan.html('0');
+ }
+ }
+
+ updateClock(); // run function once at first to avoid delay
+
+ // Update every second
+ window.timeinterval = setInterval(updateClock, 1000);
+ };
+
+ // Default options
+ const defaults = {
+ duration: '30',
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ };
+
+ options = $.extend({}, defaults, options);
+
+ // For each matched element
+ this.each(function(_idx, _el) {
+ // Calculate duration (use widget or given)
+ let initDuration = options.duration;
+ if (options.countdownType == 2) {
+ initDuration = options.countdownDuration;
+ } else if (options.countdownType == 3) {
+ initDuration = options.countdownDate;
+ }
+
+ // Get deadline date
+ const deadlineDate = getDate(initDuration);
+
+ // Calculate warning duration ( use widget or given)
+ let warningDuration = 0;
+ if (options.countdownType == 1 || options.countdownType == 2) {
+ warningDuration = options.countdownWarningDuration;
+ } else if (options.countdownType == 3) {
+ warningDuration = options.countdownWarningDate;
+ }
+ // Get warning date
+ const warningDate =
+ (
+ warningDuration == 0 ||
+ warningDuration == '' ||
+ warningDuration == null
+ ) ? false : getDate(warningDuration);
+
+ // Initialise clock
+ initialiseClock($(_el), deadlineDate, warningDate);
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-dataset-render.js b/modules/src/xibo-dataset-render.js
new file mode 100644
index 0000000..141e70c
--- /dev/null
+++ b/modules/src/xibo-dataset-render.js
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ dataSetRender: function(options) {
+ // Any options?
+ if (options === undefined || options === null) {
+ options = {
+ duration: 5,
+ transition: 'fade',
+ rowsPerPage: 0,
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ };
+ }
+
+ $(this).each(function(_idx, el) {
+ const numberItems = $(el).data('totalPages');
+ const duration =
+ (options.durationIsPerItem) ?
+ options.duration : options.duration / numberItems;
+
+ if (options.rowsPerPage > 0) {
+ // Cycle handles this for us
+ if ($(el).prop('isCycle')) {
+ $(el).cycle('destroy');
+ }
+
+ $(el).prop('isCycle', true).cycle({
+ fx: options.transition,
+ timeout: duration * 1000,
+ slides: '> table',
+ });
+ }
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-elements-render.js b/modules/src/xibo-elements-render.js
new file mode 100644
index 0000000..7755dac
--- /dev/null
+++ b/modules/src/xibo-elements-render.js
@@ -0,0 +1,212 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboElementsRender: function(options, items) {
+ const $this = $(this);
+ const defaults = {
+ selector: null,
+ effect: 'none',
+ pauseEffectOnStart: true,
+ duration: 50,
+ durationIsPerItem: false,
+ numItems: 1,
+ itemsPerPage: 1,
+ speed: 2,
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ marqueeInlineSelector: '.elements-render-item',
+ alignmentV: 'top',
+ displayDirection: 0,
+ parentId: '',
+ layer: 0,
+ seamless: true,
+ gap: 50,
+ };
+ const $content = $('#content');
+ const isAndroid = navigator.userAgent.indexOf('Android') > -1;
+
+ // Is marquee effect
+ const isMarquee =
+ options.effect === 'marqueeLeft' ||
+ options.effect === 'marqueeRight' ||
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown';
+
+ const isUseNewMarquee = options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown' ||
+ !isAndroid;
+
+ options = $.extend({}, defaults, options);
+
+ if (!isMarquee) {
+ options.speed = 1000;
+ } else {
+ options.speed = 1;
+ }
+
+ const cycleElement = `.${options.id}`;
+
+ if (isMarquee && isUseNewMarquee) {
+ $this.marquee('destroy');
+ } else if ($content.find(cycleElement + '.cycle-slideshow').length) {
+ $(cycleElement + '.cycle-slideshow').cycle('destroy');
+ }
+
+ let marquee = false;
+
+ if (options.effect === 'none') {
+ // Do nothing
+ } else if (!isMarquee && $content.find(cycleElement).length) {
+ const numberOfSlides = items?.length || 1;
+ const duration = (options.durationIsPerItem) ?
+ options.duration :
+ options.duration / numberOfSlides;
+ const timeout = duration * 1000;
+ const noTransitionSpeed = 200;
+ let cycle2Config = {
+ 'data-cycle-fx': (options.effect === 'noTransition' ||
+ options.effect === 'none') ? 'none' : options.effect,
+ 'data-cycle-speed': (
+ options.effect === 'noTransition' || options.effect === 'none'
+ ) ? noTransitionSpeed : options.speed,
+ 'data-cycle-timeout': timeout,
+ 'data-cycle-slides': `> .${options.id}--item`,
+ 'data-cycle-auto-height': false,
+ 'data-cycle-paused': options.pauseEffectOnStart,
+ };
+
+ if (options.effect === 'scrollHorz') {
+ $(cycleElement).find(`> .${options.id}--item`)
+ .each(function(idx, elem) {
+ $(elem).css({width: '-webkit-fill-available'});
+ });
+ } else {
+ cycle2Config = {
+ ...cycle2Config,
+ 'data-cycle-sync': false,
+ };
+ }
+
+ $(cycleElement).addClass('cycle-slideshow anim-cycle')
+ .attr(cycle2Config).cycle();
+
+ // Add some margin for each slide when options.effect === scrollHorz
+ if (options.effect === 'scrollHorz') {
+ $(cycleElement).css({width: options.width + (options.gap / 2)});
+ $(cycleElement).find('.cycle-slide').css({
+ marginLeft: options.gap / 4,
+ marginRight: options.gap / 4,
+ });
+ }
+ } else if (
+ options.effect === 'marqueeLeft' ||
+ options.effect === 'marqueeRight'
+ ) {
+ marquee = true;
+ options.direction =
+ ((options.effect === 'marqueeLeft') ? 'left' : 'right');
+
+ // Make sure the speed is something sensible
+ // This speed calculation gives as 80 pixels per second
+ options.speed = (options.speed === 0) ? 1 : options.speed;
+
+ // Add gap between
+ if ($this.find('.scroll').length > 0) {
+ $this.find('.scroll').css({
+ paddingLeft: !options.seamless ? options.gap : 0,
+ paddingRight: !options.seamless ? options.gap : 0,
+ columnGap: options.gap,
+ });
+ }
+ } else if (
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown'
+ ) {
+ // We want a marquee
+ marquee = true;
+ options.direction = ((options.effect === 'marqueeUp') ? 'up' : 'down');
+
+ // Make sure the speed is something sensible
+ // This speed calculation gives as 80 pixels per second
+ options.speed = (options.speed === 0) ?
+ 1 : options.speed;
+
+ if ($this.find('.scroll').length > 0) {
+ $this.find('.scroll').css({
+ flexDirection: 'column',
+ height: 'auto',
+ });
+ }
+ }
+
+ if (marquee) {
+ if (isUseNewMarquee) {
+ // in old marquee scroll delay is 85 milliseconds
+ // options.speed is the scrollamount
+ // which is the number of pixels per 85 milliseconds
+ // our new plugin speed is pixels per second
+ $this.attr({
+ 'data-is-legacy': false,
+ 'data-speed': options.speed / 25 * 1000,
+ 'data-direction': options.direction,
+ 'data-duplicated': options.seamless,
+ 'data-gap': options.gap,
+ }).marquee().addClass('animating');
+ } else {
+ let $scroller = $this.find('.scroll:not(.animating)');
+
+ if ($scroller.length !== 0) {
+ $scroller.attr({
+ 'data-is-legacy': true,
+ scrollamount: options.speed,
+ behaviour: 'scroll',
+ direction: options.direction,
+ height: options.height,
+ width: options.width,
+ }).overflowMarquee().addClass('animating scroll');
+
+ $scroller = $this.find('.scroll.animating');
+ // Correct items alignment as $scroller styles are overridden
+ // after initializing overflowMarquee
+ if (options.effect === 'marqueeLeft' ||
+ options.effect === 'marqueeRight'
+ ) {
+ $scroller.find('> div').css({
+ display: 'flex',
+ flexDirection: 'row',
+ });
+ }
+ }
+ }
+
+ // Correct for up / down
+ if (
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown'
+ ) {
+ $this.find('.js-marquee').css({marginBottom: 0});
+ }
+ }
+
+ return $this;
+ },
+});
diff --git a/modules/src/xibo-finance-render.js b/modules/src/xibo-finance-render.js
new file mode 100644
index 0000000..43247d1
--- /dev/null
+++ b/modules/src/xibo-finance-render.js
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboFinanceRender: function(options, items, body) {
+ // Default options
+ const defaults = {
+ effect: 'none',
+ pauseEffectOnStart: true,
+ speed: '2',
+ duration: '30',
+ durationIsPerItem: false,
+ numItems: items.length,
+ itemsPerPage: 5,
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ };
+
+ options = $.extend({}, defaults, options);
+
+ if (!options.itemsPerPage) {
+ options.itemsPerPage = 1;
+ }
+
+ // Calculate the dimensions of this itemoptions.numItems
+ // based on the preview/original dimensions
+ let width = height = 0;
+ if (options.previewWidth === 0 || options.previewHeight === 0) {
+ width = options.originalWidth;
+ height = options.originalHeight;
+ } else {
+ width = options.previewWidth;
+ height = options.previewHeight;
+ }
+
+ if (options.scaleOverride !== 0) {
+ width = width / options.scaleOverride;
+ height = height / options.scaleOverride;
+ }
+
+ if (options.widgetDesignWidth > 0 && options.widgetDesignHeight > 0) {
+ options.widgetDesignWidth = options.widgetDesignWidth;
+ options.widgetDesignHeight = options.widgetDesignHeight;
+ width = options.widgetDesignWidth;
+ height = options.widgetDesignHeight;
+ }
+
+ const isEditor = xiboIC.checkIsEditor();
+
+ // For each matched element
+ this.each(function(_idx, _elem) {
+ // How many pages to we need?
+ const numberOfPages =
+ (options.numItems > options.itemsPerPage) ?
+ Math.ceil(options.numItems / options.itemsPerPage) : 1;
+ const $mainContainer = $(_elem);
+
+ // Destroy any existing cycle
+ $mainContainer.find('.anim-cycle')
+ .cycle('destroy');
+
+ // Remove previous content
+ $mainContainer.find('.container-main:not(.template-container)').remove();
+
+ // Clone the main HTML
+ // and remove template-container class when we are on the editor
+ const $mainHTML = isEditor ? $(body).clone()
+ .removeClass('template-container')
+ .show() : $(body);
+
+ // Hide main HTML if isEditor = true
+ if (isEditor) {
+ $(body).hide();
+ }
+
+ // Create the pages
+ for (let i = 0; i < numberOfPages; i++) {
+ // Create a page
+ const $itemsHTML = $('').addClass('page');
+ for (let j = 0; j < options.itemsPerPage; j++) {
+ if (((i * options.itemsPerPage) + j) < options.numItems) {
+ const $item = $(items[(i * options.itemsPerPage) + j]);
+ // Clone and append the item to the page
+ // and remove template-item class when isEditor = true
+ (isEditor ? $item.clone() : $item).appendTo($itemsHTML)
+ .show().removeClass('template-item');
+
+ // Hide the original item when isEditor = true
+ if (isEditor) {
+ $item.hide();
+ }
+ }
+ }
+
+ // Append the page to the item container
+ $mainHTML.find('.items-container').append($itemsHTML);
+ }
+
+ // Append the main HTML to the container
+ $mainContainer.append($mainHTML);
+
+ const duration =
+ (options.durationIsPerItem) ?
+ options.duration :
+ options.duration / numberOfPages;
+
+ // Make sure the speed is something sensible
+ options.speed = (options.speed <= 200) ? 1000 : options.speed;
+
+ // Timeout is the duration in ms
+ const timeout = (duration * 1000) - (options.speed * 0.7);
+
+ const slides = (numberOfPages > 1) ? '.page' : '.item';
+
+ const $cycleContainer = $mainContainer.find('#cycle-container');
+
+ // Set the content div to the height of the original window
+ $cycleContainer.css('height', height);
+
+ // Set the width on the cycled slides
+ $cycleContainer.find(slides).css({
+ width: width,
+ height: height,
+ });
+
+ // Cycle handles this for us
+ $cycleContainer.addClass('anim-cycle')
+ .cycle({
+ fx: options.effect,
+ speed: options.speed,
+ timeout: timeout,
+ slides: '> ' + slides,
+ paused: options.pauseEffectOnStart,
+ log: false,
+ });
+
+ // Protect against images that don't load
+ $mainContainer.find('img').on('error', function(ev) {
+ $(ev.currentTarget).off('error')
+ // eslint-disable-next-line max-len
+ .attr('src', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNiYAAAAAkAAxkR2eQAAAAASUVORK5CYII=');
+ });
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-image-render.js b/modules/src/xibo-image-render.js
new file mode 100644
index 0000000..403aaa8
--- /dev/null
+++ b/modules/src/xibo-image-render.js
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboImageRender: function(options) {
+ // Default options
+ const defaults = {
+ reloadTime: 5000,
+ maxTries: -1, // -1: Infinite # times
+ };
+
+ // Extend options
+ options = $.extend({}, defaults, options);
+
+ const $self = $(this);
+
+ // Run all the selected elements individually
+ if ($self.length > 1) {
+ $self.each(function(i, el) {
+ $(el).xiboImageRender(options);
+ });
+ return $self;
+ }
+
+ // Handle the image error by replacing the original image
+ // with a transparent pixel and try to reload the original source again
+ const handleImageError = function() {
+ // Replace image with a single transparent pixel
+ $self.off('error')
+ .attr(
+ 'src',
+ // eslint-disable-next-line max-len
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNiYAAAAAkAAxkR2eQAAAAASUVORK5CYII=');
+
+ let reloadTimes = $self.data('reload-times');
+
+ // Loop an infinite number of times ( maxTries == -1 )
+ // or until the loop reach options.maxTries times
+ if (reloadTimes < options.maxTries || options.maxTries == -1) {
+ // Create a timeout using the options reload time
+ setTimeout(function() {
+ // Try to change source to the original
+ $self.attr('src', $self.data('original-src'))
+ .on('error', handleImageError);
+
+ // Increase the control var and set it to the element
+ reloadTimes++;
+ $self.data('reload-times', reloadTimes);
+ }, options.reloadTime);
+ }
+ };
+
+ // Original image source
+ $self.data('original-src', $self.attr('src'));
+
+ // Initialise reload times var
+ $self.data('reload-times', 0);
+
+ // Bind handle image funtion to a error event
+ if ($self.data('original-src') != undefined) {
+ $self.bind('error', handleImageError);
+ }
+
+ return $self;
+ },
+});
diff --git a/modules/src/xibo-layout-animate.js b/modules/src/xibo-layout-animate.js
new file mode 100644
index 0000000..ccd53a6
--- /dev/null
+++ b/modules/src/xibo-layout-animate.js
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboLayoutAnimate: function(options) {
+ // Default options
+ const defaults = {
+ effect: 'none',
+ };
+ options = $.extend({}, defaults, options);
+
+ this.each(function(_key, element) {
+ const isAndroid = navigator.userAgent.indexOf('Android') > -1;
+ const $contentDiv = $(element);
+ // Marquee effect
+ if (
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown'
+ ) {
+ $contentDiv.find('.scroll:not(.animating)').marquee();
+ } else if (
+ options.effect === 'marqueeLeft' ||
+ options.effect === 'marqueeRight'
+ ) {
+ if (isAndroid) {
+ $contentDiv.find('.scroll:not(.animating)').overflowMarquee();
+ } else {
+ $contentDiv.find('.scroll:not(.animating)').marquee();
+ }
+ } else if (options.effect !== 'none' ||
+ options.effect === 'noTransition'
+ ) { // Cycle effect
+ // Resume effect
+ const $target = $contentDiv.is('.anim-cycle') ?
+ $contentDiv : $contentDiv.find('.anim-cycle');
+
+ $target.cycle('resume');
+ }
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-layout-scaler.js b/modules/src/xibo-layout-scaler.js
new file mode 100644
index 0000000..23a584e
--- /dev/null
+++ b/modules/src/xibo-layout-scaler.js
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboLayoutScaler: function(options) {
+ // Default options
+ const defaults = {
+ originalWidth: 0,
+ originalHeight: 0,
+ widgetDesignWidth: 0,
+ widgetDesignHeight: 0,
+ widgetDesignGap: 0,
+ itemsPerPage: 0,
+ alignmentH: 'center',
+ alignmentV: 'middle',
+ displayDirection: 0,
+ // 0 = undefined (default), 1 = horizontal, 2 = vertical
+ };
+
+ options = $.extend({}, defaults, options);
+
+ // Width and Height of the window we're in
+ const width = $(window).width();
+ const height = $(window).height();
+
+ // Calculate the ratio to apply as a scale transform
+ let ratio =
+ Math.min(width / options.originalWidth, height / options.originalHeight);
+
+ // Calculate a new width/height based on the ratio
+ let newWidth = width / ratio;
+ let newHeight = height / ratio;
+
+ // Does the widget have an original design width/height
+ // if so, we need to further scale the widget
+ if (options.widgetDesignWidth > 0 && options.widgetDesignHeight > 0) {
+ if (options.itemsPerPage > 0) {
+ if (
+ (newWidth >= newHeight && options.displayDirection == '0') ||
+ (options.displayDirection == '1')
+ ) {
+ // Landscape or square size plus padding
+ // display direction is horizontal
+ options.widgetDesignWidth =
+ (options.itemsPerPage * options.widgetDesignWidth) +
+ (options.widgetDesignGap * (options.itemsPerPage - 1));
+ options.widgetDesignHeight = options.widgetDesignHeight;
+ } else if (
+ (newWidth < newHeight && options.displayDirection == '0') ||
+ (options.displayDirection == '2')
+ ) {
+ // Portrait size plus padding
+ // display direction is vertical
+ options.widgetDesignHeight =
+ (options.itemsPerPage * options.widgetDesignHeight) +
+ (options.widgetDesignGap * (options.itemsPerPage - 1));
+ options.widgetDesignWidth = options.widgetDesignWidth;
+ }
+ }
+
+ // Calculate the ratio between the new
+ const widgetRatio =
+ Math.min(
+ newWidth / options.widgetDesignWidth,
+ newHeight / options.widgetDesignHeight);
+
+ ratio = ratio * widgetRatio;
+ newWidth = options.widgetDesignWidth;
+ newHeight = options.widgetDesignHeight;
+ }
+
+ // Multiple element options
+ const mElOptions = {};
+
+ // Multiple elements per page
+ if (options.numCols != undefined || options.numRows != undefined) {
+ // Content dimensions and scale ( to create
+ // multiple elements based on the body scale fomr the xibo scaler )
+ mElOptions.contentWidth =
+ (options.numCols > 1) ?
+ (options.widgetDesignWidth * options.numCols) :
+ options.widgetDesignWidth;
+ mElOptions.contentHeight =
+ (options.numRows > 1) ?
+ (options.widgetDesignHeight * options.numRows) :
+ options.widgetDesignHeight;
+
+ mElOptions.contentScaleX = width / mElOptions.contentWidth;
+ mElOptions.contentScaleY = height / mElOptions.contentHeight;
+
+ // calculate/update ratio
+ ratio = Math.min(mElOptions.contentScaleX, mElOptions.contentScaleY);
+ }
+
+ // Do nothing and return $(this) when ratio = 1
+ if (ratio == 1) {
+ return $(this);
+ }
+
+ // Apply these details
+ $(this).each(function(_idx, el) {
+ if (!$.isEmptyObject(mElOptions)) {
+ $(el).css('transform-origin', '0 0');
+ $(el).css('transform', 'scale(' + ratio + ')');
+ $(el).width(mElOptions.contentWidth);
+ $(el).height(mElOptions.contentHeight);
+
+ $(el).find('.multi-element').css({
+ overflow: 'hidden',
+ float: 'left',
+ width: options.widgetDesignWidth,
+ height: options.widgetDesignHeight,
+ });
+ } else {
+ $(el).css({
+ width: newWidth,
+ height: newHeight,
+ });
+
+ // Handle the scaling
+ // What IE are we?
+ if ($('body').hasClass('ie7') || $('body').hasClass('ie8')) {
+ $(el).css({
+ filter: 'progid:DXImageTransform.Microsoft.Matrix(M11=' +
+ ratio +
+ ', M12=0, M21=0, M22=' +
+ ratio +
+ ', SizingMethod=\'auto expand\'',
+ });
+ } else {
+ $(el).css({
+ transform: 'scale(' + ratio + ')',
+ 'transform-origin': '0 0',
+ });
+ }
+ }
+
+ // Set ratio on the body incase we want to get it easily
+ $(el).attr('data-ratio', ratio);
+
+ // Handle alignment (do not add position absolute unless needed)
+ if (!options.type || options.type !== 'text') {
+ $(el).css('position', 'absolute');
+
+ // Horizontal alignment
+ if (options.alignmentH === 'right') {
+ $(el).css('left', width - ($(el).width() * ratio));
+ } else if (options.alignmentH === 'center') {
+ $(el).css('left', (width / 2) - ($(el).width() * ratio) / 2);
+ }
+
+ // Vertical alignment
+ if (options.alignmentV === 'bottom') {
+ $(el).css('top', height - ($(el).height() * ratio));
+ } else if (options.alignmentV === 'middle') {
+ $(el).css('top', (height / 2) - ($(el).height() * ratio) / 2);
+ }
+ }
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-legacy-template-render.js b/modules/src/xibo-legacy-template-render.js
new file mode 100644
index 0000000..e8fac75
--- /dev/null
+++ b/modules/src/xibo-legacy-template-render.js
@@ -0,0 +1,302 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// Based on https://github.com/octalmage/phptomoment/tree/master
+const PHP_TO_MOMENT = {
+ d: 'DD',
+ D: 'ddd',
+ j: 'D',
+ l: 'dddd',
+ N: 'E',
+ S: 'o',
+ w: 'e',
+ z: 'DDD',
+ W: 'W',
+ F: 'MMMM',
+ m: 'MM',
+ M: 'MMM',
+ n: 'M',
+ t: '',
+ L: '',
+ o: 'YYYY',
+ Y: 'YYYY',
+ y: 'YY',
+ a: 'a',
+ A: 'A',
+ B: '',
+ g: 'h',
+ G: 'H',
+ h: 'hh',
+ H: 'HH',
+ i: 'mm',
+ s: 'ss',
+ u: 'SSS',
+ e: 'zz',
+ I: '',
+ O: '',
+ P: '',
+ T: '',
+ Z: '',
+ c: '',
+ r: '',
+ U: 'X',
+ '\\': '',
+};
+
+jQuery.fn.extend({
+ xiboLegacyTemplateRender: function(options, widget) {
+ // Default options
+ const defaults = {
+ moduleType: 'none',
+ };
+
+ const newOptions = {};
+
+ options = $.extend({}, defaults, options);
+
+ // For each matched element
+ this.each(function(_idx, element) {
+ // Forecast
+ if (options.moduleType == 'forecast') {
+ // Check if we have a dailyForecast placeholder
+ const elementHTML = $(element).html();
+ const match = elementHTML.match(/\[dailyForecast.*?\]/g);
+
+ if (match) {
+ // Get the number of days
+ const numDays = match[0].split('|')[1];
+ const offset = match[0].split('|')[2].replace(']', '');
+
+ // Replace HTML on the element
+ $(element).html(elementHTML.replace(
+ match[0],
+ '',
+ ));
+ }
+
+ // Check if we have a time placeholder
+ $(element).html(
+ $(element).html().replace(/\[time\|.*?\]/g, function(match) {
+ const oldFormat = match.split('|')[1].replace(']', '');
+ const newFormat = PHP_TO_MOMENT[oldFormat];
+
+ return '[time|' + newFormat + ']';
+ }),
+ );
+ }
+
+ // Social Media
+ if (options.moduleType == 'social-media') {
+ // Template HTML
+ let templateHTML = $(element).find('.item-template').html();
+
+ // If we have NameTrimmed, replace it with a trimmed Name
+ const matches = templateHTML.match(/\[(.*?)\]/g);
+
+ if (Array.isArray(matches)) {
+ for (let index = 0; index < matches.length; index++) {
+ const match = matches[index];
+ const matchCropped = match.substring(1, match.length - 1);
+ let replacement = '';
+
+ switch (matchCropped) {
+ case 'Tweet':
+ replacement = '{{text}}';
+ break;
+ case 'User':
+ replacement = '{{user}}';
+ break;
+ case 'ScreenName':
+ replacement = '{{screenName}}';
+ break;
+ case 'Date':
+ replacement = '{{date}}';
+ break;
+ case 'Location':
+ replacement = '{{location}}';
+ break;
+ case 'ProfileImage':
+ replacement = '';
+ break;
+ case 'ProfileImage|normal':
+ replacement = '';
+ break;
+ case 'ProfileImage|mini':
+ replacement = '';
+ break;
+ case 'ProfileImage|bigger':
+ replacement = '';
+ break;
+ case 'Photo':
+ replacement = '';
+ break;
+ case 'TwitterLogoWhite':
+ replacement =
+ $(element).find('.twitter-blue-logo').data('url');
+ break;
+ case 'TwitterLogoBlue':
+ replacement =
+ $(element).find('.twitter-white-logo').data('url');
+ default:
+ break;
+ }
+
+ // Replace HTML on the element
+ templateHTML = templateHTML.replace(
+ match,
+ replacement,
+ );
+ }
+ }
+
+ // Compile template for item
+ const itemTemplate = Handlebars.compile(
+ templateHTML,
+ );
+
+ // Apply template to items and add them to content
+ for (let i = 0; i < widget.items.length; i++) {
+ const item = widget.items[i];
+
+ // Create new media item
+ const $mediaItem =
+ $('');
+
+ // Add template content to media item
+ $mediaItem.html(itemTemplate(item));
+
+ // Add to content
+ $mediaItem.appendTo($(element).find('#content'));
+ }
+ }
+
+ // Currencies and stocks
+ if (
+ options.moduleType == 'currencies' ||
+ options.moduleType == 'stocks'
+ ) {
+ // Property to trim names
+ const trimmedNames = [];
+
+ const makeTemplateReplacements = function($template) {
+ let templateHTML = $template.html();
+ // Replace [itemsTemplate] with a new div element
+ templateHTML = templateHTML.replace(
+ '[itemsTemplate]',
+ '',
+ );
+
+ // If we have NameTrimmed, replace it with a trimmed Name
+ const matches = templateHTML.match(/\[NameTrimmed.*?\]/g);
+
+ if (Array.isArray(matches)) {
+ for (let index = 0; index < matches.length; index++) {
+ const match = matches[index];
+
+ // Get the string length
+ trimmedNames.push(match.split('|')[1].replace(']', ''));
+
+ // Replace HTML on the element
+ templateHTML = templateHTML.replace(
+ match,
+ '[NameTrimmed' + (trimmedNames.length - 1) + ']',
+ );
+ }
+ }
+
+ // Add html back to container
+ $template.html(templateHTML);
+
+ // Change new element parent to be the
+ // item-container class and clear it
+ $template.find('.items-container-helper')
+ .parent().addClass('items-container').empty();
+
+ // Replace image
+ let $templateImage = $template.find('img[src="[CurrencyFlag]"]');
+ if ($templateImage.length > 0) {
+ const imageTemplate = $(element).find('.sample-image').html();
+
+ // Replace HTML with the image template
+ $templateImage[0].outerHTML = imageTemplate;
+
+ // Get new image object
+ $templateImage = $($templateImage[0]);
+ }
+
+ // Replace curly brackets with double brackets
+ $template.html(
+ $template.html().replaceAll('[', '{{').replaceAll(']', '}}'),
+ );
+
+ // Return template
+ return $template;
+ };
+
+ // Make replacements for item template
+ $(element).find('.item-template').replaceWith(
+ makeTemplateReplacements(
+ $(element).find('.item-template'),
+ ),
+ );
+
+ // Make replacements for container template
+ $(element).find('.template-container').replaceWith(
+ makeTemplateReplacements(
+ $(element).find('.template-container'),
+ ),
+ );
+
+ // Compile template for item
+ const itemTemplate = Handlebars.compile(
+ $(element).find('.item-template').html(),
+ );
+
+ // Apply template to items and add them to content
+ for (let i = 0; i < widget.items.length; i++) {
+ const item = widget.items[i];
+ // if we have trimmedNames, add those proterties to each item
+ for (let index = 0; index < trimmedNames.length; index++) {
+ const trimmedLength = trimmedNames[index];
+
+ item['NameTrimmed' + index] = item.Name.substring(0, trimmedLength);
+ }
+
+ $(itemTemplate(item)).addClass('template-item')
+ .appendTo($(element).find('#content'));
+ }
+ }
+
+ // Article
+ if (options.moduleType == 'article') {
+ widget.properties.template = widget.properties.template
+ .replaceAll('[Link|image]', '
');
+ }
+ });
+
+ return {
+ target: $(this),
+ options: newOptions,
+ };
+ },
+});
diff --git a/modules/src/xibo-menuboard-render.js b/modules/src/xibo-menuboard-render.js
new file mode 100644
index 0000000..f873cc3
--- /dev/null
+++ b/modules/src/xibo-menuboard-render.js
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ menuBoardRender: function(options) {
+ function createPage(pageNum, container) {
+ var $newPage = $('
').addClass('menu-board-product-page').attr('data-page', pageNum);
+ $(container).append($newPage);
+ return $newPage;
+ }
+
+ $(this).each(function() {
+ var deltaDuration;
+ var maxPages = 0;
+
+ // Hide all elements
+ $('.menu-board-product').css('opacity', 0);
+
+ // Get height of each products container
+ $('.menu-board-products-container').each(function() {
+ var pageNum = 1;
+ var containerHeight = $(this).height();
+ var elementsTotalHeight = 0;
+ var $productContainer = $(this);
+
+ // Create first page
+ var $currentPage = createPage(pageNum, $productContainer);
+
+ // Create pages dynamically
+ $(this).find('.menu-board-product').each(function() {
+ var $product = $(this);
+ var productHeight = $product.outerHeight();
+
+ // If the current page is full, create a new page
+ if (productHeight + elementsTotalHeight > containerHeight) {
+ pageNum++;
+ elementsTotalHeight = 0;
+
+ // Create a new page
+ $currentPage = createPage(pageNum, $productContainer);
+ }
+
+ // Increase the total height
+ elementsTotalHeight += productHeight;
+
+ // Add element to the current page
+ $currentPage.append($product);
+ });
+
+ // Fill the last page with first elements
+ if (pageNum > 1 && elementsTotalHeight < containerHeight) {
+ $(this).find('.menu-board-product').each(function() {
+ var $product = $(this);
+ var productHeight = $product.outerHeight();
+
+ // If the current page is full, stop adding elements
+ if (productHeight + elementsTotalHeight > containerHeight) {
+ return false;
+ } else {
+ // Add cloned element to the current page
+ $currentPage.append($product.clone());
+
+ // Increase the total height
+ elementsTotalHeight += productHeight;
+ }
+ });
+ }
+
+ // Save maxPages if pageNum is higher
+ if (pageNum > maxPages) {
+ maxPages = pageNum;
+ }
+ });
+
+ // Calculate the delta duration ( duration / number of max pages )
+ deltaDuration = options.duration / maxPages;
+
+ // Cycle handles this for us
+ $('.menu-board-products-container').cycle({
+ fx: "fade",
+ timeout: deltaDuration * 1000,
+ "slides": "> div.menu-board-product-page"
+ });
+
+ // Re-show elements
+ $('.menu-board-product').css('opacity', 1);
+
+ return $(this);
+ });
+ }
+});
diff --git a/modules/src/xibo-metro-render.js b/modules/src/xibo-metro-render.js
new file mode 100644
index 0000000..d78e1d4
--- /dev/null
+++ b/modules/src/xibo-metro-render.js
@@ -0,0 +1,454 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// register hbs template
+
+jQuery.fn.extend({
+ xiboMetroRender: function(options, items, colors) {
+ // Default options
+ const defaults = {
+ effect: 'none',
+ duration: '60',
+ numItems: 0,
+ speed: '2',
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ cellsPerRow: 6,
+ cellsPerPage: 18,
+ numberItemsLarge: 1,
+ numberItemsMedium: 2,
+ maxItemsLarge: 3,
+ maxItemsMedium: 4,
+ smallItemSize: 1,
+ mediumItemSize: 2,
+ largeItemSize: 3,
+ randomizeSizeRatio: false,
+ orientation: 'landscape',
+ };
+
+ options = $.extend({}, defaults, options);
+
+ options.randomizeSizeRatio = false;
+
+ // Set the cells per row according to the widgets original orientation
+ options.cellsPerRow =
+ (options.widgetDesignWidth < options.widgetDesignHeight) ? 3 : 6;
+
+ const resetRenderElements = function($contentDiv) {
+ // Destroy cycle plugin
+ $contentDiv.find('.anim-cycle').cycle('destroy');
+
+ // Empty container
+ $contentDiv.empty();
+ };
+
+ // For each matched element
+ this.each(function(_idx, element) {
+ // 1st objective - create an array that defines the
+ // positions of the items on the layout
+ // settings involved:
+ // positionsArray (the array that stores the positions
+ // of the items according to size)
+ // largeItems (number of large items to appear on the layout)
+ // mediumItems (number of medium items to appear on the layout)
+ // cellsPerPage (number of cells for each page)
+ // cellsPerRow (number of cells for each row)
+
+ // Reset the animation elements
+ resetRenderElements($(element));
+
+ // Create the positions array with size equal to the number
+ // of cells per page, and each positions starts as undefined
+ const positionsArray = new Array(options.cellsPerPage);
+
+ // Get the page small/medium/large Ratio ( by random or percentage )
+ let largeItems = 0;
+ let mediumItems = 0;
+ if (options.randomizeSizeRatio) {
+ // START OPTION 1 - RANDOM
+ // Randomize values so each one can
+ // have values from default to default+X
+ largeItems = options.numberItemsLarge + Math.floor(Math.random() * 2);
+ mediumItems = options.numberItemsMedium + Math.floor(Math.random() * 3);
+ } else {
+ // OPTION 2 - PERCENTAGE
+ // Count image tweets ratio
+ let tweetsWithImageCount = 0;
+ for (let i = 0; i < items.length; i++) {
+ if (checkBackgroundImage(items, i)) {
+ tweetsWithImageCount++;
+ }
+ }
+ const imageTweetsRatio = tweetsWithImageCount / items.length;
+ const imageTweetsCellsPerPage =
+ Math.floor(options.cellsPerPage * imageTweetsRatio);
+
+ // Calculate the large/medium quantity according
+ // to the ratio of withImage/all tweets
+ // Try to get a number of large items that fit
+ // on the calculated cells per page
+ largeItems =
+ Math.floor(imageTweetsCellsPerPage / options.largeItemSize);
+
+ // Get the number of medium items by the remaining cells
+ // per page "space" left by the large items
+ mediumItems =
+ Math.floor(
+ (imageTweetsCellsPerPage - (largeItems * options.largeItemSize)) /
+ options.mediumItemSize);
+
+ // If the reulting medium/large values are 0
+ // give them the default option values
+ if (largeItems == 0) {
+ largeItems = options.numberItemsLarge;
+ }
+
+ if (mediumItems == 0) {
+ mediumItems = options.numberItemsMedium;
+ }
+
+ // If the reulting medium/large values are
+ // over the maximum values set them to max
+ if (largeItems > options.maxItemsLarge) {
+ largeItems = options.maxItemsLarge;
+ }
+
+ if (mediumItems > options.maxItemsMedium) {
+ mediumItems = options.maxItemsMedium;
+ }
+ }
+
+ // Number of items displayed in each page
+ let numberOfItems = 0;
+
+ // Var to prevent the placement loop to run indefinitley
+ let loopMaxValue = 100;
+
+ // Try to place the large and medium items until theres none of those left
+ while (mediumItems + largeItems > 0 && loopMaxValue > 0) {
+ // Calculate a random position inside the array
+ const positionRandom = Math.floor(Math.random() * options.cellsPerPage);
+
+ // I f we still have large items to place
+ if (largeItems > 0) {
+ if (
+ checkFitPosition(
+ positionsArray,
+ positionRandom,
+ options.largeItemSize,
+ options.cellsPerRow,
+ ) &&
+ checkCellEmpty(
+ positionsArray,
+ positionRandom,
+ options.largeItemSize,
+ )
+ ) {
+ // Set the array positions to the pretended item type
+ for (let i = 0; i < options.largeItemSize; i++) {
+ positionsArray[positionRandom + i] = options.largeItemSize;
+ }
+ numberOfItems++;
+ // Decrease the items to place var
+ largeItems--;
+ }
+ } else if (mediumItems > 0) {
+ if (
+ checkFitPosition(positionsArray,
+ positionRandom,
+ options.mediumItemSize,
+ options.cellsPerRow,
+ ) &&
+ checkCellEmpty(positionsArray,
+ positionRandom,
+ options.mediumItemSize,
+ )
+ ) {
+ // Set the array positions to the pretended item type
+ for (let i = 0; i < options.mediumItemSize; i++) {
+ positionsArray[positionRandom + i] = options.mediumItemSize;
+ }
+
+ // Decrease the items to place var
+ numberOfItems++;
+ mediumItems--;
+ }
+ }
+
+ loopMaxValue--;
+ }
+
+ // Fill the rest of the array with small size items
+ for (let i = 0; i < positionsArray.length; i++) {
+ if (positionsArray[i] == undefined) {
+ numberOfItems++;
+ positionsArray[i] = options.smallItemSize;
+ }
+ }
+
+ // 2nd objective - put the items on the respective rows,
+ // add the rows to each page and build the resulting html
+ // settings involved:
+ // positionsArray (the array that stores the positions of
+ // the items according to size)
+
+ // How many pages to we need?
+ const numberOfPages =
+ (options.numItems > numberOfItems) ?
+ Math.floor(options.numItems / numberOfItems) : 1;
+
+ let rowNumber = 0;
+ let itemId = 0;
+ let pageId = 0;
+
+ // If we dont have enough items to fill a page,
+ // change the items array to have dummy position between items
+ if (items.length < numberOfItems) {
+ // Create a new array
+ const newItems = [];
+
+ // Distance between items so they can be spread in the page
+ const distance = Math.round(numberOfItems / items.length);
+
+ let idAux = 0;
+ for (let i = 0; i < numberOfItems; i++) {
+ if (i % distance == 0) {
+ // Place a real item
+ newItems.push(items[idAux]);
+ idAux++;
+ } else {
+ // Place a dummy item
+ newItems.push(undefined);
+ }
+ }
+ items = newItems;
+ }
+
+ // Create an auxiliary items array, so we can
+ // place the tweets at the same time we remove them from the new array
+ const itemsAux = items;
+
+ // Cycle through all the positions on the positionsArray
+ for (let i = 0; i < positionsArray.length; i++) {
+ // If we are on the first cell position, create a row
+ if (i % options.cellsPerRow == 0) {
+ rowNumber += 1;
+ $(element).append(
+ '');
+ }
+
+ // Create a page and add it to the content div
+ $(element).append(
+ '');
+
+ for (let j = 0; j < numberOfPages; j++) {
+ // Pass the item to a variable and replace some tags
+ // if there's no item we create a dummy item
+ let stringHTML = '';
+
+ // Search for the item to remove regarding the
+ // type of the tweet (with/without image)
+ const indexToRemove =
+ checkImageTweet(itemsAux, (positionsArray[i] > 1));
+
+ // Get a random color
+ const randomColor = colors[Math.floor(Math.random() * colors.length)];
+
+ if (itemsAux[indexToRemove] != undefined) {
+ // Get the item and replace the color tag
+ stringHTML = itemsAux[indexToRemove]
+ .replace('[Color]', randomColor);
+ } else {
+ stringHTML =
+ '
' +
+ '
' +
+ '
';
+ }
+
+ // Remove the element that we used to create the new html
+ itemsAux.splice(indexToRemove, 1);
+
+ // Increase the item ID
+ itemId++;
+
+ // Replace the item ID and Type on its html
+ stringHTML = stringHTML.replace('[itemId]', itemId);
+ stringHTML = stringHTML.replace('[itemType]', positionsArray[i]);
+
+ // Add animate class to item
+ const $newItem = $(stringHTML).addClass('metro-render-anim-item');
+
+ // Append item to the current page
+ $newItem.appendTo(
+ $(element).find('#page-' + pageId),
+ );
+ }
+
+ // Move the created page into the respective row
+ $(element).find('#idrow-' + rowNumber).append(
+ $(element).find('#page-' + pageId),
+ );
+
+ // Increase the page ID var
+ pageId++;
+
+ // Increase the iterator so it can move forward
+ // the number of cells that the current item occupies
+ i += positionsArray[i] - 1;
+ }
+
+
+ // 3rd objective - move the items around, start the timer
+ // settings involved:
+ // effect (the way we are moving effects the HTML required)
+ // speed (how fast we need to move
+
+ // Make sure the speed is something sensible
+ options.speed = (options.speed <= 200) ? 1000 : options.speed;
+
+ const slides = '.cell';
+
+ // Duration of each page
+ const pageDuration = options.duration / numberOfPages;
+
+ // Use cycle in all pages of items ( to cycle individually )
+ // only if we have an effect
+ if (options.effect !== 'none') {
+ for (let i = 0; i < numberOfItems; i++) {
+ // Timeout is the duration in ms
+ const timeout = (pageDuration * 1000);
+ const noTransitionSpeed = 10;
+
+ // The delay is calulated usign the distance between items
+ // ( random from 1 to 5 )
+ // that animate almost at the same time
+ // and a part of the timeout duration
+ const delayDistance = 1 + Math.random() * 4;
+ const delay = (timeout / delayDistance) * ((i + 1) % delayDistance);
+
+ // Get page element and start cycle
+ const $currentPage = $(element).find('#page-' + i)
+ .addClass('anim-cycle');
+
+ $currentPage.cycle({
+ fx: (options.effect === 'noTransition') ? 'none' : options.effect,
+ speed: (options.effect === 'noTransition') ?
+ noTransitionSpeed : options.speed,
+ delay: -delay,
+ timeout: timeout,
+ slides: '> ' + slides,
+ log: false,
+ });
+ }
+ }
+
+ // Protect against images that don't load
+ $(element).find('img').on('error', function() {
+ $(element).off('error')
+ .attr(
+ 'src',
+ // eslint-disable-next-line max-len
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNiYAAAAAkAAxkR2eQAAAAASUVORK5CYII=');
+ });
+ });
+
+ return $(this);
+ },
+});
+
+/**
+ * Check if a set of given cells of an array are empty (undefined)
+ * @param {array} array - Array of items
+ * @param {int} index - Index of the item to check
+ * @param {int} size - Size of the item to check
+ * @return {boolean} - True if the cells are empty, false otherwise
+ */
+function checkCellEmpty(array, index, size) {
+ let check = true;
+ for (let i = 0; i < size; i++) {
+ if (array[index + i] != undefined) {
+ check = false;
+ }
+ }
+ return check;
+}
+
+/**
+ * Check if a given position of an array is good to
+ * fit an item given it's size and position
+ * @param {array} array - Array of items
+ * @param {int} index - Index of the item to check
+ * @param {int} size - Size of the item to check
+ * @param {int} cellsPerRow - Number of cells per row
+ * @return {boolean} - True if the item fits, false otherwise
+ */
+function checkFitPosition(array, index, size, cellsPerRow) {
+ return (index % cellsPerRow <= cellsPerRow - size);
+}
+
+/**
+ * Check if a given item has background image
+ * @param {array} array - Array of items
+ * @param {int} index - Index of the item to check
+ * @return {boolean} - True if the item has background image, false otherwise
+ */
+function checkBackgroundImage(array, index) {
+ // Prevent check if the item is undefined
+ if (array[index] == undefined) {
+ return false;
+ }
+
+ return (array[index].indexOf('background-image') >= 0);
+}
+
+/**
+ * Find a tweet with image (or one without image), if not return 0
+ * @param {array} array - Array of items
+ * @param {boolean} withImage - True if we are looking for a tweet with image
+ * false otherwise
+ * @return {int} - Index of the item found, 0 if not found
+ */
+function checkImageTweet(array, withImage) {
+ // Default return var
+ let returnVar = 0;
+
+ for (let i = 0; i < array.length; i++) {
+ // Find a tweet with image
+ if (withImage && checkBackgroundImage(array, i)) {
+ returnVar = i;
+ break;
+ }
+
+ // Find a tweet without image
+ if (!withImage && !checkBackgroundImage(array, i)) {
+ returnVar = i;
+ break;
+ }
+ }
+ return returnVar;
+}
diff --git a/modules/src/xibo-player.js b/modules/src/xibo-player.js
new file mode 100644
index 0000000..a67d9b2
--- /dev/null
+++ b/modules/src/xibo-player.js
@@ -0,0 +1,1968 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+const XiboPlayer = function() {
+ this.playerWidgets = {};
+ this.countWidgetElements = 0;
+ this.countWidgetStatic = 0;
+ this.countGlobalElements = 0;
+ this.urlParams = new URLSearchParams(window.location.search);
+
+ /**
+ * Get widget data
+ * @param {Object} currentWidget Widget object
+ * @return {Promise}
+ */
+ this.getWidgetData = function(currentWidget) {
+ // if we are a dataset type, then first check to see if there
+ // is realtime data.
+ console.debug('getWidgetData: ' + currentWidget.widgetId);
+
+ let localData;
+
+ // wait for dataset fetch (resolve when its "done" or "error" fires)
+ let chain = Promise.resolve();
+ if (currentWidget.properties?.dataSetId) {
+ chain = new Promise(function(resolve) {
+ xiboIC.getData(currentWidget.properties?.dataSetId, {
+ done: (status, data) => {
+ localData = data ? JSON.parse(data) : null;
+ resolve();
+ },
+ error: () => {
+ resolve();
+ },
+ });
+ });
+ }
+
+ return chain.then(function() {
+ return new Promise(function(resolve) {
+ // if we have data on the widget (for older players),
+ // or if we are not in preview and have empty data on Widget (like text)
+ // do not run ajax use that data instead
+ if (String(currentWidget.url) !== 'null') {
+ const ajaxOptions = {
+ method: 'GET',
+ url: currentWidget.url,
+ };
+
+ // We include dataType for ChromeOS player consumer
+ if (window.location && window.location.pathname === '/pwa/') {
+ ajaxOptions.dataType = 'json';
+ }
+
+ // else get data from widget.url,
+ // this will be either getData for preview
+ // or new json file for v4 players
+ $.ajax(ajaxOptions).done(function(data) {
+ // The contents of the JSON file will be
+ // an object with data and meta
+ // add in local data.
+ if (localData) {
+ data.data = localData;
+ }
+
+ let widgetData = data;
+
+ if (typeof widgetData === 'string') {
+ widgetData = JSON.parse(data);
+ }
+
+ resolve({
+ ...widgetData,
+ isDataReady: true,
+ });
+ }).fail(function(jqXHR, textStatus, errorThrown) {
+ console.error(jqXHR, textStatus, errorThrown);
+ resolve({
+ isDataReady: false,
+ error: jqXHR.status,
+ success: false,
+ data: jqXHR.responseJSON,
+ });
+ });
+ } else if (currentWidget.data?.data !== undefined) {
+ // This happens for v3 players where the data is already
+ // added to the HTML
+ if (localData) {
+ currentWidget.data.data = localData;
+ }
+ resolve({
+ ...currentWidget.data,
+ isDataReady: true,
+ });
+ } else {
+ // This should be impossible.
+ resolve(null);
+ }
+ });
+ });
+ };
+
+ /**
+ * Compose Player Widget
+ * @param {Object} inputWidget Widget object
+ * @param {Object|null} data Widget data
+ * @param {Boolean} isDataWidget
+ * @return {Object} playerWidget Composed widget object
+ */
+ this.playerWidget = function(inputWidget, data, isDataWidget) {
+ const self = this;
+ const playerWidget = inputWidget;
+ const isStaticWidget = this.isStaticWidget(playerWidget);
+ let widgetDataItems = [];
+ let shouldShowError = false;
+ let withErrorMessage = null;
+
+ if (isDataWidget) {
+ const {dataItems, showError, errorMessage} =
+ this.loadData(playerWidget, data);
+ widgetDataItems = dataItems;
+ shouldShowError = showError;
+ withErrorMessage = errorMessage;
+ }
+
+ playerWidget.isDataReady = data?.isDataReady || false;
+ playerWidget.meta = data !== null ? data?.meta : {};
+ playerWidget.items = [];
+
+ // Decorate this widget with all applicable functions
+ this.loadWidgetFunctions(playerWidget);
+
+ if (isDataWidget) {
+ const dataLoadState = playerWidget.onDataLoad(widgetDataItems);
+ console.debug('onDataLoad::handled = ', dataLoadState.handled);
+
+ widgetDataItems = dataLoadState.dataItems;
+
+ if (!dataLoadState.handled) {
+ widgetDataItems = playerWidget.onParseData(widgetDataItems);
+ console.debug('onParseData::widgetDataItems ', widgetDataItems);
+ }
+ }
+
+ playerWidget.data = widgetDataItems;
+ playerWidget.showError = shouldShowError;
+ playerWidget.errorMessage = withErrorMessage;
+ playerWidget.isPreview = this.isPreview();
+ playerWidget.isEditor = this.isEditor();
+
+ // Only add below props for widget with elements
+ if (!isStaticWidget && !self.isModule(playerWidget)) {
+ const tempElements = this.getElementsByWidgetId(
+ playerWidget.widgetId,
+ );
+
+ this.prepareWidgetElements(tempElements, playerWidget);
+ }
+
+ // Useful when re-rendering the widget through the web console
+ // parameter "shouldRefresh" defaults to =true to refresh widget data
+ playerWidget.render = function(shouldRefresh = true) {
+ if (playerWidget.isDataExpected) {
+ self.renderWidget(playerWidget, shouldRefresh);
+ } else if (self.isModule(playerWidget)) {
+ self.renderModule(playerWidget);
+ } else {
+ self.renderGlobalElements(playerWidget);
+ }
+ };
+
+ return playerWidget;
+ };
+
+ /**
+ * Prepare widget elements (data and global)
+ * @param {Array} widgetElements
+ * @param {Object} currentWidget
+ * @return {Object} currentWidget
+ */
+ this.prepareWidgetElements = function(widgetElements, currentWidget) {
+ const transformedElems =
+ this.composeElements(widgetElements, currentWidget);
+
+ if (currentWidget.isDataExpected && widgetElements.length > 0) {
+ const {minSlot, maxSlot} =
+ PlayerHelper.getMinAndMaxSlot(Object.values(transformedElems));
+ // Compose data elements slots
+ currentWidget.maxSlot = maxSlot;
+ currentWidget.elements = transformedElems;
+ currentWidget.metaElements = this.composeMetaElements(transformedElems);
+ currentWidget.dataElements =
+ this.initSlots(transformedElems, minSlot, maxSlot);
+ currentWidget.pinnedSlots =
+ PlayerHelper.getPinnedSlots(currentWidget.dataElements);
+
+ this.composeDataSlots(currentWidget);
+ this.composeRNRData(currentWidget);
+ } else {
+ // These are global elements
+ currentWidget.globalElements = transformedElems;
+ }
+
+ return currentWidget;
+ };
+
+ /**
+ * Define widget functions used for render flow
+ * @param {Object} playerWidget Widget object
+ */
+ this.loadWidgetFunctions = function(playerWidget) {
+ const self = this;
+ const params = this.getRenderParams(
+ playerWidget,
+ {target: $('body')},
+ globalOptions,
+ );
+
+ playerWidget.onDataLoad = function(widgetDataItems) {
+ return self.onDataLoad({
+ widgetId: playerWidget.widgetId,
+ dataItems: widgetDataItems,
+ meta: playerWidget.meta,
+ properties: playerWidget.properties,
+ isDataReady: playerWidget.isDataReady,
+ });
+ };
+ playerWidget.onParseData = function(widgetDataItems) {
+ return self.onParseData(playerWidget, widgetDataItems);
+ };
+ playerWidget.onTemplateRender = function(currentWidget, options) {
+ return self.onTemplateRender(
+ options ? {...params, ...options} : params,
+ currentWidget,
+ );
+ };
+ playerWidget.onRender = function(staticWidget, options) {
+ // We use staticWidget and options parameter to get updated parameters
+ // after loading these functions
+ const onRenderParams = options ? {...params, ...options} : params;
+
+ return self.onRender({
+ ...onRenderParams,
+ items: staticWidget ? staticWidget.items : params.items,
+ });
+ };
+ playerWidget.onTemplateVisible = function(options) {
+ return self.onTemplateVisible(options ? {...params, ...options} : params);
+ };
+ playerWidget.onVisible = function(options) {
+ return self.onVisible(options ? {...params, ...options} : params);
+ };
+ };
+
+ /**
+ * Compose widget elements
+ * @param {Array} widgetElements Widget elements
+ * @param {Object} currentWidget Widget object
+ * @return {Object}
+ */
+ this.composeElements = function(widgetElements, currentWidget) {
+ const self = this;
+ return widgetElements.reduce(function(collection, widgetElement) {
+ const grpId = widgetElement.groupId;
+ const hasGroup = Boolean(grpId);
+
+ // If element isn't visible, skip
+ if (widgetElement.isVisible === false) {
+ return collection;
+ }
+
+ // Check for group
+ if (hasGroup) {
+ const grpWidgetId = grpId + '_' + currentWidget.widgetId;
+ if (!Boolean(collection[grpWidgetId])) {
+ const groupProps = {
+ ...widgetElement.groupProperties,
+ groupId: widgetElement.groupId,
+ groupScale: widgetElement.groupScale,
+ slot: widgetElement.slot ?? undefined,
+ dataKeys: [],
+ items: [],
+ duration: currentWidget.duration,
+ durationIsPerItem:
+ Boolean(currentWidget.properties.durationIsPerItem),
+ };
+ collection[grpWidgetId] = groupProps;
+ collection[grpWidgetId].onTemplateVisible = function($target) {
+ self.runLayoutAnimate($target, groupProps);
+ console.debug('Called onTemplateVisible for group > ', grpWidgetId);
+ };
+ }
+
+ if (Boolean(collection[grpWidgetId])) {
+ collection[grpWidgetId].items.push(
+ self.decorateElement(widgetElement, currentWidget),
+ );
+ }
+ } else {
+ const elemWidgetId =
+ widgetElement.elementId + '_' + currentWidget.widgetId;
+
+ if (!Boolean(collection[elemWidgetId])) {
+ collection[elemWidgetId] =
+ self.decorateElement({...widgetElement}, currentWidget);
+ }
+ }
+
+ return collection;
+ }, {});
+ };
+
+ /**
+ * Compose elements that has data from meta
+ * @param {Object} transformedElems Elements collection
+ * @return {Object} metaElements
+ * */
+ this.composeMetaElements = function(transformedElems) {
+ let metaElements = {};
+
+ if (Object.entries(transformedElems).length > 0) {
+ metaElements = Object.keys(transformedElems).reduce((a, b) => {
+ const metaItem = transformedElems[b];
+ if (metaItem.dataInMeta) {
+ a[b] = metaItem;
+ }
+
+ return a;
+ }, {});
+ }
+
+ return metaElements;
+ };
+
+ /**
+ * Initialize slots
+ * @param {Object} collection Data elements
+ * @param {Number} minSlot
+ * @param {Number} maxSlot
+ * @return {*}
+ */
+ this.initSlots = function(collection, minSlot, maxSlot) {
+ if (minSlot === 0) {
+ return minSlot;
+ }
+
+ const dataSlots =
+ [...Array(maxSlot).keys()].reduce(function(slots, slot) {
+ slots[slot + 1] = {
+ items: {},
+ hasPinnedSlot: false,
+ dataKeys: [],
+ slot: slot + 1,
+ };
+
+ return slots;
+ }, {});
+
+ if (Object.values(dataSlots).length > 0 &&
+ Object.values(collection).length > 0
+ ) {
+ for (const [itemKey, currentItem] of Object.entries(collection)) {
+ // Skip item if dataInMeta = true
+ if (currentItem.dataInMeta) {
+ continue;
+ }
+
+ const currentSlot = currentItem.slot + 1;
+ if (Boolean(dataSlots[currentSlot])) {
+ dataSlots[currentSlot].items[itemKey] = currentItem;
+ dataSlots[currentSlot].hasGroup = Boolean(currentItem.groupId);
+ dataSlots[currentSlot].hasPinnedSlot =
+ Object.keys(dataSlots[currentSlot].items).filter(function(k) {
+ return dataSlots[currentSlot].items[k].pinSlot === true;
+ }).length > 0;
+ dataSlots[currentSlot].pinnedItems =
+ PlayerHelper.getPinnedItems(dataSlots[currentSlot].items);
+ }
+ }
+ }
+
+ return dataSlots;
+ };
+
+ /**
+ * Compose widget data slots
+ * @param {Object} currentWidget
+ */
+ this.composeDataSlots = function(currentWidget) {
+ const {
+ data,
+ maxSlot,
+ dataElements,
+ pinnedSlots,
+ } = currentWidget;
+
+ if (data.length > 0) {
+ let lastSlotFilled = null;
+ const filledPinnedSlot = [];
+
+ dataLoop: for (const [dataItemKey] of Object.entries(data)) {
+ let hasSlotFilled = false;
+ const currentKey = parseInt(dataItemKey) + 1;
+ const currCollection = Object.keys(dataElements);
+
+ // Stop iteration through data when all pinned slots are filled
+ // and maxSlot = pinnedSlots.length
+ if (lastSlotFilled === null &&
+ pinnedSlots.length === maxSlot &&
+ currentKey > maxSlot
+ ) {
+ break;
+ }
+
+ // Slots loop
+ for (const [, itemValue] of Object.entries(currCollection)) {
+ const itemObj = dataElements[itemValue];
+ const slotItems = itemObj.items;
+ const pinnedItems = itemObj.pinnedItems;
+ const currentSlot = itemObj.slot;
+ let nextSlot = currentSlot + 1;
+
+ if (nextSlot > maxSlot) {
+ nextSlot = currentSlot;
+ }
+
+ // Skip if currentKey is less than the currentSlot
+ // This occurs when a data slot has been skipped
+ // E.g. dataSlots = [2, 3]
+ if (currentKey < currentSlot) {
+ continue dataLoop;
+ }
+
+ // If lastSlotFilled is filled and is <= to currentSlot
+ // Then, move to next slot
+ if (lastSlotFilled !== null &&
+ currentSlot <= lastSlotFilled
+ ) {
+ continue;
+ }
+
+ // Skip slot if all slot items are pinned and
+ // currentKey is more than the maxSlot and
+ // currentSlot is a pinned slot
+ if (lastSlotFilled === null &&
+ currentKey > maxSlot && itemObj.hasPinnedSlot &&
+ Object.keys(pinnedItems).length === Object.keys(slotItems).length
+ ) {
+ continue;
+ }
+
+ // Loop through data slot items (elements or groups)
+ for (const [dataSlotItemKey] of Object.entries(slotItems)) {
+ const dataSlotItem = itemObj.items[dataSlotItemKey];
+ const isPinnedSlot = dataSlotItem.pinSlot;
+
+ if (isPinnedSlot) {
+ if (currentKey !== currentSlot) {
+ hasSlotFilled = true;
+ lastSlotFilled = currentSlot;
+ continue;
+ }
+
+ if (!dataSlotItem.dataKeys.includes(currentKey)) {
+ dataSlotItem.dataKeys = [
+ ...dataSlotItem.dataKeys,
+ currentKey,
+ ];
+ }
+ } else {
+ dataSlotItem.dataKeys = [
+ ...dataSlotItem.dataKeys,
+ currentKey,
+ ];
+ }
+
+ hasSlotFilled = true;
+ lastSlotFilled = currentSlot;
+ }
+
+ if (pinnedSlots.includes(currentSlot) &&
+ lastSlotFilled === currentSlot &&
+ !filledPinnedSlot.includes(currentSlot)
+ ) {
+ filledPinnedSlot.push(currentSlot);
+ }
+
+ itemObj.dataKeys = [
+ ...itemObj.dataKeys,
+ currentKey,
+ ];
+
+ if (hasSlotFilled) {
+ hasSlotFilled = false;
+ if (lastSlotFilled % maxSlot === 0) {
+ lastSlotFilled = null;
+ } else if (currentKey > maxSlot &&
+ nextSlot !== currentSlot &&
+ pinnedSlots.includes(nextSlot) &&
+ filledPinnedSlot.includes(nextSlot)
+ ) {
+ // Next slot is a pinned slot and has been filled
+ // So, current item must be passed to next non-pinned slot
+ if (nextSlot === maxSlot) {
+ lastSlotFilled = null;
+ } else {
+ lastSlotFilled = nextSlot;
+ }
+ }
+
+ break;
+ }
+ }
+ }
+ }
+ };
+
+ /**
+ * Compose repeat and non-repeat data
+ * @param {Object} currentWidget
+ */
+ this.composeRNRData = function(currentWidget) {
+ const {dataElements, pinnedSlots, isRepeatData} = currentWidget;
+ // Copy data elements slots
+ const groupSlotsData = {...dataElements};
+
+ const dataCounts = Object.keys(groupSlotsData).reduce((a, b) => {
+ a[b] = groupSlotsData[b].dataKeys.length;
+ return a;
+ }, {});
+ const maxCount = Math.max(
+ ...(Object.values(dataCounts).map((count) => Number(count))));
+ const minCount = Math.min(
+ ...(Object.values(dataCounts).map((count) => Number(count))));
+
+ if (minCount < maxCount) {
+ const nonPinnedDataKeys =
+ Object.values(groupSlotsData).reduce((a, b) => {
+ if (!b.hasPinnedSlot) {
+ a = [...a, ...(b.dataKeys)];
+ } else {
+ if (b.dataKeys.length > 1) {
+ b.dataKeys.forEach(function(dataKey) {
+ if (!pinnedSlots.includes(dataKey)) {
+ a = [...a, dataKey];
+ }
+ });
+ }
+ }
+
+ return a;
+ }, []).sort((a, b) => {
+ if (a < b) return -1;
+ if (a > b) return 1;
+ return 0;
+ });
+
+ Object.keys(groupSlotsData).forEach(function(slotIndex, slotKey) {
+ const dataCount = dataCounts[slotIndex];
+ if (dataCount < maxCount) {
+ const countDiff = maxCount - dataCount;
+ if (countDiff === 1) {
+ const poppedKey = nonPinnedDataKeys.shift();
+ dataElements[slotIndex].dataKeys.push(
+ isRepeatData ? poppedKey : 'empty');
+
+ // Update data keys of each data slot items
+ if (Object.keys(dataElements[slotIndex].items).length > 0) {
+ Object.keys(dataElements[slotIndex].items).forEach(function(k) {
+ if (!dataElements[slotIndex].items[k].pinSlot) {
+ dataElements[slotIndex].items[k].dataKeys.push(
+ isRepeatData ? poppedKey : 'empty');
+ }
+ });
+ }
+ }
+ }
+ });
+ }
+
+ currentWidget.dataElements = dataElements;
+ };
+
+ /**
+ * Parse single element for extended properties
+ * @param {Object} element Element object
+ * @param {Object} currentWidget Widget object
+ * @return {Object} element
+ */
+ this.decorateElement = function(element, currentWidget) {
+ const self = this;
+ const elemCopy = JSON.parse(JSON.stringify(element));
+ const elemProps = elemCopy?.properties || {};
+
+ // Initialize element data keys
+ elemCopy.dataKeys = [];
+
+ if (Object.keys(elemCopy).length > 0 &&
+ elemCopy.hasOwnProperty('properties')) {
+ delete elemCopy.properties;
+ }
+
+ // Check if we have template from templateId or module
+ // and set it as the template
+ let $template = null;
+ const templateSelector = `#hbs-${elemCopy.id}`;
+ if ($(templateSelector).length > 0) {
+ $template = $(templateSelector);
+ }
+
+ elemCopy.hbs = null;
+ elemCopy.dataOverride = null;
+ elemCopy.dataOverrideWith = null;
+ elemCopy.escapeHtml = null;
+ elemCopy.isExtended = false;
+ elemCopy.withData = false;
+ elemCopy.widgetId = currentWidget.widgetId;
+ elemCopy.dataInMeta = false;
+
+ // Compile the template if it exists
+ if ($template && $template.length > 0) {
+ elemCopy.dataOverride =
+ $template?.data('extends-override');
+ elemCopy.dataOverrideWith =
+ $template?.data('extends-with');
+ elemCopy.escapeHtml = ($template?.data('escape-html') === 1);
+
+ if (String(elemCopy.dataOverride).length > 0 &&
+ String(elemCopy.dataOverrideWith).length > 0
+ ) {
+ elemCopy.isExtended = true;
+ }
+
+ elemCopy.hbs = Handlebars.compile($template.html());
+ }
+
+ elemCopy.templateData = Object.assign(
+ {}, elemCopy, elemProps, globalOptions,
+ {uniqueID: elemCopy.elementId, prop: {...elemCopy, ...elemProps}},
+ );
+
+ // Get widget info if exists.
+ if (currentWidget.templateId !== null &&
+ String(currentWidget.url) !== 'null'
+ ) {
+ elemCopy.renderData = Object.assign(
+ {},
+ currentWidget.properties,
+ elemCopy,
+ globalOptions,
+ {
+ duration: currentWidget.duration,
+ marqueeInlineSelector: `.${elemCopy.templateData.id}--item`,
+ parentId: elemCopy.elementId,
+ },
+ );
+ elemCopy.withData = true;
+ } else {
+ // Elements with no data can be extended.
+ // Thus, we have to decorate the element with extended params
+ if (elemCopy.dataOverride !== null &&
+ elemCopy.dataOverrideWith !== null
+ ) {
+ const extendWith =
+ transformer.getExtendedDataKey(elemCopy.dataOverrideWith);
+
+ // Check if extendWith exist in elemProps and templateData
+ if (elemProps.hasOwnProperty(extendWith)) {
+ elemCopy[elemCopy.dataOverride] = elemProps[extendWith];
+ elemCopy.templateData[elemCopy.dataOverride] =
+ elemProps[extendWith];
+ }
+ }
+ }
+
+ // Duration
+ elemCopy.duration = currentWidget.duration;
+ elemCopy.durationIsPerItem =
+ Boolean(currentWidget.properties.durationIsPerItem);
+
+ // Check if element is extended and data is coming from meta
+ if (elemCopy.isExtended && elemCopy.dataOverrideWith !== null &&
+ elemCopy.dataOverrideWith.includes('meta')) {
+ elemCopy.dataInMeta = true;
+ }
+
+ // Add onTemplateVisible if element does not belong to a group
+ if (!self.isGroup(elemCopy)) {
+ elemCopy.onTemplateVisible = function($target) {
+ self.runLayoutAnimate($target, elemCopy);
+ console.debug('Called onTemplateVisible for element > ',
+ elemCopy.elementId);
+ };
+ }
+
+ return elemCopy;
+ };
+
+ this.isGroup = function(element) {
+ return Boolean(element.groupId);
+ };
+};
+
+/**
+ * Initializes player widgets, accepting inputs from HTML output
+ */
+XiboPlayer.prototype.init = function() {
+ const self = this;
+ let calledXiboScaler = false;
+
+ // Create global render array of functions
+ window.renders = [];
+
+ // If we have scoped styles for elements
+ // convert the CSS rules to use it
+ $(
+ 'style[data-style-scope][data-style-target="element"]',
+ ).each((_idx, styleEl) => {
+ const scopeName = $(styleEl).data('style-scope');
+ const styleContent = $(styleEl).html();
+
+ function scopeCSS(css, scope) {
+ return css
+ .split('}')
+ .map((rule) => rule.trim() ? `${scope} ${rule.trim()}}` : '')
+ .join('\n')
+ .trim();
+ }
+
+ $(styleEl).html(
+ scopeCSS(
+ styleContent,
+ '[data-style-scope="' + scopeName + '"]',
+ ),
+ );
+ });
+
+ // Loop through each widget from widgetData
+ if (widgetData.length > 0) {
+ widgetData.forEach(function(inputWidget, widgetIndex) {
+ // Save widgetData to xic
+ xiboIC.set(inputWidget.widgetId, 'widgetData', inputWidget);
+
+ // Run the onInitialize function if it exists
+ if (typeof window['onInitialize_' + inputWidget.widgetId] ===
+ 'function') {
+ window['onInitialize_' + inputWidget.widgetId](
+ inputWidget.widgetId,
+ $('body'),
+ inputWidget.properties,
+ inputWidget.meta,
+ );
+ console.debug(
+ 'Called onInitialize for widget > ',
+ inputWidget.widgetId,
+ );
+ }
+
+ // Set default isDataExpected value if it does not exist
+ if (!inputWidget.hasOwnProperty('isDataExpected')) {
+ inputWidget.isDataExpected = String(inputWidget.url) !== 'null';
+ }
+
+ // Check if inputWidget is a data widget
+ if (inputWidget.isDataExpected) {
+ // Load data
+ self.getWidgetData(inputWidget).then(function(response) {
+ if (self.isStaticWidget(inputWidget)) {
+ console.debug('Data Widget::Static Template');
+ self.countWidgetStatic++;
+ } else {
+ console.debug('Data Widget::Elements');
+ self.countWidgetElements++;
+ }
+
+ const currentWidget = self.playerWidget(
+ inputWidget,
+ response,
+ true,
+ );
+ self.playerWidgets[inputWidget.widgetId] = currentWidget;
+
+ self.renderWidget(currentWidget);
+
+ if (self.countWidgetElements > 0 && calledXiboScaler === false) {
+ self.runLayoutScaler(currentWidget);
+ calledXiboScaler = true;
+ }
+ });
+
+ // Handle real-time data/dataset
+ if (inputWidget.properties?.dataSetId) {
+ xiboIC.registerNotifyDataListener((dataKey) => {
+ // Loose match.
+ if (dataKey == inputWidget.properties?.dataSetId) {
+ inputWidget.render();
+ }
+ });
+ }
+ } else if (self.isModule(inputWidget)) { // It's a module
+ console.debug('Non-data Widget::Module');
+ const currentWidget = self.playerWidget(
+ inputWidget,
+ [],
+ false,
+ );
+ self.playerWidgets[inputWidget.widgetId] = currentWidget;
+
+ self.renderModule(currentWidget);
+ } else { // All global elements goes here
+ console.debug('Non-data Widget::Global Elements');
+ const currentWidget = self.playerWidget(
+ inputWidget,
+ [],
+ false,
+ );
+ self.playerWidgets[inputWidget.widgetId] = currentWidget;
+ self.countGlobalElements++;
+
+ self.renderGlobalElements(currentWidget);
+
+ if (self.countGlobalElements > 0 && calledXiboScaler === false) {
+ self.runLayoutScaler(currentWidget);
+ calledXiboScaler = true;
+ }
+ }
+ });
+
+ // Lock all interactions
+ xiboIC.lockAllInteractions();
+ }
+};
+
+XiboPlayer.prototype.isPreview = function() {
+ return this.urlParams.get('preview') === '1';
+};
+
+XiboPlayer.prototype.isEditor = function() {
+ return this.urlParams.get('isEditor') === '1';
+};
+
+/**
+ * Show sample data or an error if in the editor.
+ * @param {Object} currentWidget Widget object
+ * @param {Object|Array} data Widget data from data provider
+ * @return {Object} widgetLoadedData
+ */
+XiboPlayer.prototype.loadData = function(currentWidget, data) {
+ const self = this;
+ const widgetLoadedData = {
+ isSampleData: false,
+ dataItems: [],
+ isArray: Array.isArray(data?.data),
+ showError: false,
+ errorMessage: null,
+ };
+ const composeSampleData = () => {
+ widgetLoadedData.isSampleData = true;
+
+ if (currentWidget.sample === null) {
+ widgetLoadedData.dataItems = [];
+ return [];
+ }
+
+ // If data is empty, use sample data instead
+ // Add single element or array of elements
+ widgetLoadedData.dataItems = (Array.isArray(currentWidget.sample)) ?
+ currentWidget.sample.slice(0) :
+ [currentWidget.sample];
+
+ return widgetLoadedData.dataItems.reduce(function(data, item) {
+ Object.keys(item).forEach(function(itemKey) {
+ if (String(item[itemKey]).match(DateFormatHelper.macroRegex) !== null) {
+ item[itemKey] =
+ DateFormatHelper.composeUTCDateFromMacro(item[itemKey]);
+ }
+ });
+
+ return [...data, {...item}];
+ }, []);
+ };
+
+ if (currentWidget.isDataExpected) {
+ if (widgetLoadedData.isArray && data?.data?.length > 0) {
+ widgetLoadedData.dataItems = data?.data;
+ } else {
+ widgetLoadedData.dataItems = self.isEditor() ? composeSampleData() : [];
+ if (data?.success === false || !currentWidget.isValid) {
+ widgetLoadedData.showError = self.isEditor();
+ }
+ }
+ }
+
+ if (widgetLoadedData.showError && data?.message) {
+ widgetLoadedData.errorMessage = data?.message;
+ }
+
+ return widgetLoadedData;
+};
+
+XiboPlayer.prototype.getElementsByWidgetId = function(widgetId) {
+ let widgetElements = [];
+ const _inputElements = elements;
+
+ if (_inputElements !== undefined && _inputElements?.length > 0) {
+ _inputElements.forEach(function(elemVal) {
+ if (elemVal?.length > 0) {
+ elemVal.forEach(function(elemObj) {
+ if (elemObj.widgetId === widgetId) {
+ widgetElements = elemObj?.elements ?? [];
+ }
+ });
+ }
+ });
+ }
+
+ return widgetElements;
+};
+
+XiboPlayer.prototype.getWidgetById = function(widgetId) {
+ const playerWidgets = this.playerWidgets;
+
+ if (!widgetId || Object.keys(playerWidgets).length === 0) {
+ return null;
+ }
+
+ if (!playerWidgets.hasOwnProperty(widgetId)) {
+ return null;
+ }
+
+ return playerWidgets[widgetId];
+};
+
+/**
+ * Gets new widget data from data provider and calls callback parameter
+ * to re-render the widget
+ * @param {Object} currentWidget Widget object
+ * @param {Function} callback Callback function to call after getting new widget
+ * data
+ */
+XiboPlayer.prototype.getFreshWidgetData = function(currentWidget, callback) {
+ if (!currentWidget) {
+ return;
+ }
+
+ const self = this;
+ if (typeof callback === 'function') {
+ this.getWidgetData(currentWidget).then(function(response) {
+ const freshWidget = self.playerWidget(currentWidget, response, true);
+
+ if (self.playerWidgets.hasOwnProperty(freshWidget.widgetId)) {
+ self.playerWidgets[freshWidget.widgetId] = freshWidget;
+ }
+
+ callback.apply(self, [freshWidget, false]);
+ });
+ }
+};
+
+/**
+ * Renders data widgets (static template/elements)
+ * @param {Object} widget
+ * @param {Boolean?} shouldRefresh Optional parameter to get fresh widget data
+ * @param {Number?} widgetId Optional parameter to get widget object
+ */
+XiboPlayer.prototype.renderWidget = function(widget, shouldRefresh, widgetId) {
+ let currentWidget = widget;
+
+ if (widgetId) {
+ currentWidget = this.getWidgetById(widgetId);
+ }
+
+ // Render widgets by kind: static OR elements
+ if (this.isStaticWidget(currentWidget)) {
+ // Render static widget template
+ if (shouldRefresh) {
+ this.getFreshWidgetData(currentWidget, this.renderStaticWidget);
+ } else {
+ this.renderStaticWidget(currentWidget);
+ }
+ } else {
+ // Render widget elements
+ if (shouldRefresh) {
+ this.getFreshWidgetData(currentWidget, this.renderDataElements);
+ } else {
+ this.renderDataElements(currentWidget);
+ }
+ }
+};
+
+/**
+ * Renders widget with static templates
+ * @param {Object} staticWidget Widget object
+ */
+XiboPlayer.prototype.renderStaticWidget = function(staticWidget) {
+ const $target = $('body');
+ const $content = $('#content');
+ const {data, showError, errorMessage} = staticWidget;
+
+ staticWidget.items = [];
+
+ if (this.isEditor() && showError && errorMessage !== null) {
+ const $errMsg = $('');
+
+ $errMsg.css({
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ textAlign: 'center',
+ width: '100%',
+ padding: '12px 0',
+ backgroundColor: '#d05454',
+ color: 'white',
+ zIndex: 2,
+ fontWeight: 'bold',
+ fontSize: '1.1rem',
+ opacity: 0.85,
+ }).html(errorMessage);
+
+ $target.append($errMsg);
+ }
+
+ // Expire if the data is not ready
+ // TODO: once we have a mechanism to refresh widget data in 4.1,
+ // we won't need this anymore
+ if (!staticWidget.isDataReady) {
+ // eslint-disable-next-line max-len
+ console.error('renderStaticWidget: static widget where isDataReady:false, expiring in 1500ms');
+ setTimeout(() => xiboIC.expireNow({targetId: xiboICTargetId}), 1500);
+ }
+
+ // Add meta to the widget if it exists
+ if (data?.meta) {
+ staticWidget.meta = data.meta;
+ }
+
+ // Check if we have template from templateId or module
+ // and set it as the template
+ let $template = null;
+ if ($('#hbs-' + staticWidget.templateId).length > 0) {
+ $template = $('#hbs-' + staticWidget.templateId);
+ } else if ($('#hbs-module').length > 0) {
+ // Dashboard module is using this template
+ $template = $('#hbs-module');
+ }
+
+ let hbs = null;
+ // Compile the template if it exists
+ if ($template && $template.length > 0) {
+ hbs = Handlebars.compile($template.html());
+ }
+
+ // For each data item, parse it and add it to the content
+ $.each(data, function(_key, item) {
+ // Add the item to the content
+ if (hbs) {
+ $content.append(hbs(item));
+ }
+
+ // Add item to the widget object
+ (item) && staticWidget.items.push(item);
+ });
+
+ // Save template height and width if exists to global options
+ this.saveTemplateDimensions($template);
+
+ // Save template properties to widget properties
+ for (const key in staticWidget.templateProperties) {
+ if (staticWidget.templateProperties.hasOwnProperty(key)) {
+ staticWidget.properties[key] = staticWidget.templateProperties[key];
+ }
+ }
+
+ // Check if we have a custom template
+ let customTemplate = false;
+ if (
+ staticWidget.properties['customTemplate'] &&
+ staticWidget.properties['customTemplate'] == 1
+ ) {
+ customTemplate = true;
+ }
+
+ // If we have a custom template, run the legacy template render first
+ if (customTemplate) {
+ const newOptions =
+ $('body').xiboLegacyTemplateRender(
+ Object.assign(
+ staticWidget.properties,
+ globalOptions,
+ ),
+ staticWidget,
+ ).options;
+
+ // Merge new options with globalOptions
+ globalOptions = Object.assign(globalOptions, newOptions);
+ }
+ // Save widget as global variable
+ window.widget = staticWidget;
+
+ // Updated params for rendering
+ const optionsForRendering = {
+ rendering: this.renderOptions(staticWidget, globalOptions),
+ };
+
+ const templateRenderState = staticWidget.onTemplateRender(
+ staticWidget,
+ optionsForRendering,
+ );
+
+ if (!templateRenderState.handled) {
+ // Run module onRender function
+ staticWidget.onRender(staticWidget, optionsForRendering);
+ }
+
+ const onVisibleMethods = function() {
+ const templateVisibleState =
+ staticWidget.onTemplateVisible(optionsForRendering);
+
+ if (!templateVisibleState.handled) {
+ staticWidget.onVisible(optionsForRendering);
+ }
+ };
+
+ // Check for visibility
+ if (xiboIC.checkVisible()) {
+ onVisibleMethods();
+ } else {
+ xiboIC.addToQueue(onVisibleMethods);
+ }
+
+ console.debug(
+ '<<>> renderStaticWidget for widget >', staticWidget.widgetId);
+};
+
+/**
+ * Renders widget elements
+ * @param {Object} currentWidget Widget object
+ */
+XiboPlayer.prototype.renderDataElements = function(currentWidget) {
+ const self = this;
+ const {
+ data,
+ meta,
+ } = currentWidget;
+ const $content = $('#content');
+
+ // Check if data is expected, and we have elements but with no data
+ // Then expire
+ if (currentWidget.isDataExpected && data.length === 0) {
+ xiboIC.expireNow({targetId: xiboICTargetId});
+ xiboIC.reportFault({
+ code: '5001',
+ reason: 'No Data',
+ }, {targetId: xiboICTargetId});
+ return;
+ }
+
+ // New implementation of widget elements rendering
+ if (currentWidget.dataElements && Object.values(currentWidget.dataElements)) {
+ // Loop through data slot of elements
+ Object.keys(currentWidget.dataElements).forEach(function(slotKey) {
+ const slotObj = currentWidget.dataElements[slotKey];
+ const dataKeys = slotObj.dataKeys;
+
+ if (Object.keys(slotObj.items).length > 0) {
+ Object.keys(slotObj.items).forEach(function(itemKey) {
+ const slotObjItem = slotObj.items[itemKey];
+ const isGroup = Boolean(slotObjItem.groupId);
+ const $slotItemContent = $(``);
+ const isMarquee = PlayerHelper.isMarquee(slotObjItem?.efffect);
+
+ for (const [, dataKey] of Object.entries(dataKeys)) {
+ // If currentKey(dataKey) does not belong to current item
+ // Then, skip it
+ if (!slotObjItem.dataKeys.includes(dataKey)) {
+ continue;
+ }
+
+ if (isGroup) {
+ // Check group items
+ if (slotObjItem.items.length > 0) {
+ // Loop through group items
+ slotObjItem.items.forEach(function(groupItem) {
+ // Load element functions
+ self.loadElementFunctions(groupItem, dataKey === 'empty' ?
+ dataKey : {...(data[dataKey - 1] || {})});
+
+ PlayerHelper.renderDataItem(
+ isGroup,
+ dataKey,
+ groupItem.onElementParseData(dataKey === 'empty' ?
+ dataKey : {...(data[dataKey - 1] || {})},
+ ),
+ groupItem,
+ slotKey,
+ currentWidget.maxSlot,
+ groupItem.pinSlot,
+ currentWidget.pinnedSlots,
+ itemKey,
+ $slotItemContent,
+ {...slotObjItem, isMarquee},
+ meta,
+ $content,
+ );
+ });
+ }
+ } else {
+ // Load element functions
+ self.loadElementFunctions(slotObjItem, dataKey === 'empty' ?
+ dataKey : {...(data[dataKey - 1] || {})});
+
+ PlayerHelper.renderDataItem(
+ isGroup,
+ dataKey,
+ slotObjItem.onElementParseData(dataKey === 'empty' ?
+ dataKey : {...(data[dataKey - 1] || {})},
+ ),
+ slotObjItem,
+ slotKey,
+ currentWidget.maxSlot,
+ slotObjItem.pinSlot,
+ currentWidget.pinnedSlots,
+ itemKey,
+ $slotItemContent,
+ {...slotObjItem, isMarquee},
+ meta,
+ $content,
+ );
+ }
+ }
+
+ self.postRenderDataElements(
+ $slotItemContent,
+ slotObjItem,
+ isMarquee,
+ itemKey,
+ isGroup,
+ $content,
+ currentWidget,
+ data,
+ );
+ });
+ }
+ });
+ }
+
+ // Render data elements from meta data
+ if (currentWidget.metaElements &&
+ Object.entries(currentWidget.metaElements).length > 0
+ ) {
+ Object.keys(currentWidget.metaElements).forEach(function(itemKey, slotKey) {
+ const slotObjItem = currentWidget.metaElements[itemKey];
+ const $slotItemContent = $(``);
+ const isMarquee = PlayerHelper.isMarquee(slotObjItem?.efffect);
+
+ // Load element functions
+ self.loadElementFunctions(slotObjItem, meta);
+
+ PlayerHelper.renderDataItem(
+ false,
+ slotObjItem.id,
+ slotObjItem.onElementParseData(meta),
+ slotObjItem,
+ slotKey,
+ currentWidget.maxSlot,
+ slotObjItem.pinSlot,
+ currentWidget.pinnedSlots,
+ itemKey,
+ $slotItemContent,
+ {...slotObjItem, isMarquee},
+ meta,
+ $content,
+ );
+
+ self.postRenderDataElements(
+ $slotItemContent,
+ slotObjItem,
+ isMarquee,
+ itemKey,
+ false,
+ $content,
+ currentWidget,
+ data,
+ );
+ });
+ }
+ // Find and handle any images
+ $content.find('img').xiboImageRender();
+
+ // Check if we are visible
+ if (xiboIC.checkVisible()) {
+ currentWidget.onVisible();
+ } else {
+ xiboIC.addToQueue(currentWidget.onVisible);
+ }
+
+ console.debug(
+ '<<>> of renderDataElements for widget >', currentWidget.widgetId);
+};
+
+XiboPlayer.prototype.postRenderDataElements = function(
+ $slotItemContent,
+ slotObjItem,
+ isMarquee,
+ itemKey,
+ isGroup,
+ $content,
+ currentWidget,
+ data,
+) {
+ $slotItemContent.css({
+ width: slotObjItem.width,
+ height: slotObjItem.height,
+ position: 'absolute',
+ top: slotObjItem.top,
+ left: slotObjItem.left,
+ zIndex: slotObjItem.layer,
+ });
+
+ if (isMarquee) {
+ const $scroller =
+ $(``);
+
+ $scroller.css({
+ display: 'flex',
+ height: slotObjItem.height,
+ });
+
+ if (slotObjItem?.templateData?.verticalAlign) {
+ $scroller.css({
+ alignItems: slotObjItem?.templateData?.verticalAlign,
+ });
+ }
+
+ $slotItemContent.wrapInner($scroller.prop('outerHTML'));
+ } else {
+ if (!isGroup) {
+ $slotItemContent.css({
+ position: 'absolute',
+ top: slotObjItem.top,
+ left: slotObjItem.left,
+ width: slotObjItem.width,
+ height: slotObjItem.height,
+ zIndex: slotObjItem.layer,
+ });
+ }
+ }
+
+ // Remove data group element if exists to avoid duplicate
+ if ($content.find('.' +
+ itemKey + '.cycle-slideshow').length === 1) {
+ $content.find('.' +
+ itemKey + '.cycle-slideshow').cycle('destroy');
+ }
+ if ($content.find('.' + itemKey).length === 1) {
+ $content.find('.' + itemKey).remove();
+ }
+
+ $content.append($slotItemContent);
+
+ $slotItemContent.promise().done(function() {
+ $slotItemContent.xiboElementsRender(
+ {
+ ...slotObjItem,
+ itemsPerPage: currentWidget?.maxSlot,
+ numItems: data?.length || 0,
+ id: itemKey,
+ selector: `.${itemKey}`,
+ },
+ $slotItemContent.find(`.${itemKey}--item`),
+ );
+
+ const runOnTemplateVisible = function() {
+ slotObjItem.onTemplateVisible($slotItemContent);
+ };
+
+ // Run onTemplateVisible by default if visible
+ if (xiboIC.checkVisible()) {
+ runOnTemplateVisible();
+ } else {
+ xiboIC.addToQueue(runOnTemplateVisible);
+ }
+
+ currentWidget.items.push($slotItemContent);
+ });
+};
+
+/**
+ * Renders widget with global elements
+ * @param {Object} currentWidget Widget object
+ */
+XiboPlayer.prototype.renderGlobalElements = function(currentWidget) {
+ const self = this;
+ const {globalElements, meta} = currentWidget;
+ const $content = $('#content');
+
+ // New implementation for global elements
+ if (globalElements && Object.values(globalElements).length > 0) {
+ Object.keys(globalElements).forEach(function(itemKey) {
+ const elemObj = globalElements[itemKey];
+ const isGroup = Boolean(elemObj.groupId);
+
+ if (isGroup) {
+ // Grouped elements
+ if (elemObj.items.length > 0) {
+ // Check if group element exists
+ // If not, then create
+ let $groupContent;
+ if ($content.find(`.${itemKey}`).length === 0) {
+ $groupContent = $(``);
+
+ $groupContent.css({
+ width: elemObj.width,
+ height: elemObj.height,
+ position: 'absolute',
+ top: elemObj.top,
+ left: elemObj.left,
+ zIndex: elemObj.layer,
+ });
+ }
+
+ // Loop through group items
+ elemObj.items.forEach(function(groupItem) {
+ // Load element functions
+ self.loadElementFunctions(groupItem, {});
+
+ if (groupItem.hbs && $groupContent) {
+ const $elementContent = $(PlayerHelper.renderElement(
+ groupItem.hbs,
+ groupItem.templateData,
+ true,
+ ));
+
+ // Add style scope to container
+ const $elementContentContainer = $('
');
+ $elementContentContainer.append($elementContent).attr(
+ 'data-style-scope',
+ 'element_' +
+ groupItem.templateData.type + '__' +
+ groupItem.templateData.id,
+ );
+
+ // Append to main container
+ $groupContent.append(
+ $elementContentContainer,
+ );
+ }
+ });
+
+ // If there's a group content element
+ // Append it to the page
+ if ($groupContent) {
+ $content.append($groupContent);
+ }
+
+ // Invoke groupItem onTemplateRender if present
+ Promise.all(Array.from(elemObj.items).map(function(groupItem) {
+ const itemID =
+ groupItem.uniqueID || groupItem.templateData?.uniqueID;
+
+ // Call onTemplateRender
+ // Handle the rendering of the template
+ (groupItem.onTemplateRender() !== undefined) &&
+ groupItem.onTemplateRender()(
+ groupItem.elementId,
+ $content.find(`#${itemID}`).parent(),
+ {},
+ {groupItem, ...groupItem.templateData, data: {}},
+ meta,
+ );
+ }));
+ }
+ } else {
+ // Single elements
+ // Load element functions
+ self.loadElementFunctions(elemObj, {});
+
+ if (elemObj.hbs) {
+ const $elementContent = $(PlayerHelper.renderElement(
+ elemObj.hbs,
+ elemObj.templateData,
+ true,
+ ));
+
+ // Add style scope to container
+ const $elementContentContainer = $('
');
+ $elementContentContainer.append($elementContent).attr(
+ 'data-style-scope',
+ `element_${elemObj.templateData.type}__${elemObj.templateData.id}`,
+ );
+
+ // Append to main container
+ $content.append(
+ $elementContentContainer,
+ );
+ }
+
+ const itemID =
+ elemObj.uniqueID || elemObj.templateData?.uniqueID;
+
+ // Call onTemplateRender
+ // Handle the rendering of the template
+ (elemObj.onTemplateRender() !== undefined) &&
+ elemObj.onTemplateRender()(
+ elemObj.elementId,
+ $content.find(`#${itemID}`).parent(),
+ {},
+ {elemObj, ...elemObj.templateData, data: {}},
+ meta,
+ );
+ }
+ });
+ }
+
+ // Find and handle any images
+ $content.find('img').xiboImageRender();
+
+ // Check if we are visible
+ if (xiboIC.checkVisible()) {
+ currentWidget.onVisible();
+ } else {
+ xiboIC.addToQueue(currentWidget.onVisible);
+ }
+
+ console.debug(
+ '<<>> of renderGlobalElements for widget >', currentWidget.widgetId);
+};
+
+/**
+ * Renders widget module
+ * @param {Object} currentWidget Widget object
+ */
+XiboPlayer.prototype.renderModule = function(currentWidget) {
+ let $template = null;
+ if ($('#hbs-module').length > 0) {
+ $template = $('#hbs-module');
+ }
+
+ let hbs = null;
+ // Compile the template if it exists
+ if ($template && $template.length > 0) {
+ hbs = Handlebars.compile($template.html());
+ }
+
+ // If we don't have dataType, or we have a module template
+ // add it to the content with widget properties and global options
+ if (hbs) {
+ $('#content').append(hbs(
+ Object.assign(currentWidget.properties, globalOptions),
+ ));
+ }
+
+ // Save template height and width if exists to global options
+ this.saveTemplateDimensions($template);
+
+ // Save widget as global variable
+ window.widget = currentWidget;
+
+ // Updated params for rendering
+ const optionsForRendering = {
+ rendering: this.renderOptions(currentWidget, globalOptions),
+ };
+
+ // Run onRender
+ currentWidget.onRender(currentWidget, optionsForRendering);
+
+ if (xiboIC.checkVisible()) {
+ // Run onVisible
+ currentWidget.onVisible(optionsForRendering);
+ } else {
+ xiboIC.addToQueue(currentWidget.onVisible);
+ }
+
+ console.debug(
+ '<<>> of renderModule for widget >', currentWidget.widgetId);
+};
+
+/**
+ * Define element functions
+ * @param {Object} element Element
+ * @param {Object} dataItem Data item
+ */
+XiboPlayer.prototype.loadElementFunctions = function(element, dataItem) {
+ element.onElementParseData = function(elemData) {
+ const newDataItem = elemData ?? dataItem;
+ const extendDataWith = transformer
+ .getExtendedDataKey(element.dataOverrideWith);
+
+ if (extendDataWith !== null &&
+ newDataItem.hasOwnProperty(extendDataWith)
+ ) {
+ newDataItem[element.dataOverride] = newDataItem[extendDataWith];
+ }
+
+ // Handle special case for setting data for the player
+ if (element.type === 'dataset' && Object.keys(newDataItem).length > 0) {
+ if (element.dataOverride !== null &&
+ element.templateData?.datasetField !== undefined
+ ) {
+ const datasetField = element.templateData.datasetField;
+ // Check if there are dates that needs formatting
+ // before assigning value
+ let tempVal = newDataItem[datasetField];
+
+ if (element.dataOverride === 'date') {
+ const dateFormat = element.templateData.dateFormat;
+ tempVal = DateFormatHelper.formatDate(tempVal, dateFormat);
+ }
+
+ element[element.dataOverride] = tempVal;
+
+ // Change value in templateData if exists
+ if (element.templateData.hasOwnProperty(element.dataOverride)) {
+ element.templateData[element.dataOverride] = tempVal;
+ }
+ }
+ }
+
+ if (typeof window[
+ `onElementParseData_${element.templateData.id}`
+ ] === 'function') {
+ newDataItem[element.dataOverride] =
+ window[`onElementParseData_${element.templateData.id}`](
+ newDataItem[extendDataWith],
+ {...element.templateData, data: newDataItem},
+ );
+ }
+
+ console.debug('Called onElementParseData for element >', element.elementId);
+ return newDataItem;
+ };
+ element.onTemplateRender = function() {
+ let onTemplateRender;
+
+ // Check if onTemplateRender for child template is isExtended
+ // And onTemplateRender is defined on child, then use it
+ // Else, use parent onTemplateRender
+ if (element.isExtended && typeof window[
+ `onTemplateRender_${element.templateData.id}`
+ ] === 'function') {
+ onTemplateRender = window[`onTemplateRender_${element.templateData.id}`];
+ } else if (element.isExtended && typeof window[
+ `onTemplateRender_${element.dataOverride}`
+ ] === 'function') {
+ onTemplateRender = window[`onTemplateRender_${element.dataOverride}`];
+ } else if (!element.isExtended) {
+ onTemplateRender = window[`onTemplateRender_${element.templateData.id}`];
+ }
+
+ console.debug('Called onTemplateRender for element >', element.elementId);
+
+ return onTemplateRender;
+ };
+};
+
+XiboPlayer.prototype.isStaticWidget = function(playerWidget) {
+ return playerWidget !== undefined && playerWidget !== null &&
+ playerWidget.templateId !== 'elements' &&
+ elements.length === 0;
+};
+
+XiboPlayer.prototype.isModule = function(currentWidget) {
+ return (!currentWidget.isDataExpected && $('#hbs-module').length > 0) ||
+ (!currentWidget.isDataExpected && elements.length === 0);
+};
+
+/**
+ * Caller function for onDataLoad
+ * @param {Object} params
+ * @return {Object} State to determine next step.
+ * E.g. {handled: false, dataItems: []}
+ */
+XiboPlayer.prototype.onDataLoad = function(params) {
+ let onDataLoad = null;
+ if (typeof window['onDataLoad_' + params.widgetId] === 'function') {
+ // onDataLoad callback function is currently not returning any state
+ // that can be used to identify what to do next
+ onDataLoad = window['onDataLoad_' + params.widgetId];
+ }
+
+ let onDataLoadResponse = {handled: false, dataItems: params.dataItems ?? []};
+
+ if (onDataLoad) {
+ const onDataLoadResult = onDataLoad(
+ params.dataItems,
+ params.meta,
+ params.properties,
+ params.isDataReady,
+ );
+
+ if (onDataLoadResult !== undefined &&
+ Object.keys(onDataLoadResult).length > 0
+ ) {
+ if ((onDataLoadResult ?? {}).hasOwnProperty('handled')) {
+ onDataLoadResponse = {
+ ...onDataLoadResponse,
+ handled: onDataLoadResult.handled,
+ };
+ }
+
+ if ((onDataLoadResult ?? {}).hasOwnProperty('dataItems')) {
+ onDataLoadResponse = {
+ ...onDataLoadResponse,
+ dataItems: onDataLoadResult.dataItems,
+ };
+ }
+ }
+ }
+
+ return onDataLoadResponse;
+};
+
+/**
+ * Caller function for onParseData
+ * @param {Object} currentWidget Widget object
+ * @param {Array} widgetDataItems Widget data items
+ * @return {Array} Widget data items
+ */
+XiboPlayer.prototype.onParseData = function(
+ currentWidget,
+ widgetDataItems,
+) {
+ const dataItems = widgetDataItems ?? [];
+ // Parse the widgetDataItems if there is a parser function for the module
+ if (typeof window['onParseData_' + currentWidget.widgetId] === 'function') {
+ widgetDataItems.forEach(function(dataItem, _dataKey) {
+ dataItems[_dataKey] =
+ window['onParseData_' + currentWidget.widgetId](
+ dataItem,
+ currentWidget.properties,
+ currentWidget.meta,
+ );
+ });
+ }
+
+ return dataItems;
+};
+
+/**
+ * Caller function for onTemplateRender method
+ * @param {Object} params onTemplateRender parameters
+ * @param {Object?} currentWidget Optional widget object parameter
+ * to get updated widget
+ * @return {Object} State to determine next step. E.g. {handled: false}
+ */
+XiboPlayer.prototype.onTemplateRender = function(params, currentWidget) {
+ // Handle the rendering of the template
+ if (
+ typeof window['onTemplateRender_' + params.templateId] === 'function'
+ ) { // Custom scaler
+ window.onTemplateRender =
+ window['onTemplateRender_' + params.templateId];
+ }
+
+ let onTemplateRender = null;
+ // Template render function
+ if (window.onTemplateRender) {
+ onTemplateRender = window.onTemplateRender;
+ // Save the render method in renders
+ window.renders.push(window.onTemplateRender);
+ }
+
+ let onTemplateRenderResponse = {handled: false};
+ if (onTemplateRender) {
+ const onTemplateRenderResult = onTemplateRender(
+ params.widgetId,
+ params.target,
+ currentWidget ? currentWidget.items : params.items,
+ params.rendering,
+ params.meta,
+ );
+ console.debug('Called onTemplateRender for widget > ', params.widgetId);
+
+ if (onTemplateRenderResult !== undefined &&
+ Object.keys(onTemplateRenderResult).length > 0
+ ) {
+ if ((onTemplateRenderResult ?? {}).hasOwnProperty('handled')) {
+ onTemplateRenderResponse = {
+ ...onTemplateRenderResponse,
+ handled: onTemplateRenderResult.handled,
+ };
+ }
+ }
+ }
+
+ return onTemplateRenderResponse;
+};
+
+/**
+ * Caller function for onRender method
+ * @param {Object} params
+ */
+XiboPlayer.prototype.onRender = function(params) {
+ // Run the onRender function if it exists
+ if (typeof window['onRender_' + params.widgetId] === 'function') {
+ window.onRender = window['onRender_' + params.widgetId];
+ }
+
+ if (window.onRender) {
+ // Save the render method in renders
+ window.renders.push(window.onRender);
+
+ // Run render function
+ window.onRender(
+ params.widgetId,
+ params.target,
+ params.items,
+ params.rendering,
+ params.meta,
+ );
+ console.debug('Called onRender for widget > ', params.widgetId);
+ }
+};
+
+/**
+ * Caller function for onTemplateVisible
+ * @param {Object} params
+ * @return {Object} State to determine next step. E.g. {handled: false}
+ */
+XiboPlayer.prototype.onTemplateVisible = function(params) {
+ let templateVisibleResponse = {handled: false};
+ // Call the run on template visible function if it exists
+ if (
+ typeof window['onTemplateVisible_' + params.templateId] === 'function'
+ ) {
+ const onTemplateVisible = window['onTemplateVisible_' + params.templateId];
+ window.runOnTemplateVisible = function() {
+ const onTemplateVisibleResult = onTemplateVisible(
+ params.widgetId,
+ params.target,
+ params.items,
+ params.rendering,
+ params.meta,
+ );
+ console.debug('Called onTemplateVisible for widget > ', params.widgetId);
+
+ if (onTemplateVisibleResult !== undefined &&
+ Object.keys(onTemplateVisibleResult).length > 0
+ ) {
+ if ((onTemplateVisibleResult ?? {}).hasOwnProperty('handled')) {
+ templateVisibleResponse = {
+ ...templateVisibleResponse,
+ handled: onTemplateVisibleResult.handled,
+ };
+ }
+ }
+
+ return templateVisibleResponse;
+ };
+
+ return window.runOnTemplateVisible();
+ }
+
+ return templateVisibleResponse;
+};
+
+/**
+ * Caller function for onVisible
+ * @param {Object} params
+ */
+XiboPlayer.prototype.onVisible = function(params) {
+ // Call the run on visible function if it exists
+ if (
+ typeof window['onVisible_' + params.widgetId] === 'function'
+ ) {
+ window.runOnVisible = function() {
+ window['onVisible_' + params.widgetId](
+ params.widgetId,
+ params.target,
+ params.items,
+ params.rendering,
+ params.meta,
+ );
+ console.debug('Called onVisible for widget > ', params.widgetId);
+ };
+
+ window.runOnVisible();
+ }
+};
+
+XiboPlayer.prototype.saveTemplateDimensions = function($template) {
+ if ($template && $template.length > 0) {
+ $template.data('width') &&
+ (globalOptions.widgetDesignWidth = $template.data('width'));
+ $template.data('height') &&
+ (globalOptions.widgetDesignHeight = $template.data('height'));
+ $template.data('gap') &&
+ (globalOptions.widgetDesignGap = $template.data('gap'));
+ }
+};
+
+XiboPlayer.prototype.renderOptions = function(currentWidget, globalOptions) {
+ // Options for the render functions
+ return Object.assign(
+ currentWidget.properties,
+ globalOptions,
+ {
+ duration: currentWidget.duration,
+ pauseEffectOnStart:
+ globalOptions.pauseEffectOnStart ?? true,
+ isPreview: currentWidget.isPreview,
+ isEditor: currentWidget.isEditor,
+ },
+ );
+};
+
+XiboPlayer.prototype.getRenderParams = function(
+ currentWidget,
+ options,
+ globalOptions,
+) {
+ return {
+ templateId: currentWidget.templateId,
+ widgetId: currentWidget.widgetId,
+ target: options.target,
+ items: currentWidget.items,
+ rendering: this.renderOptions(currentWidget, globalOptions),
+ properties: currentWidget.properties,
+ meta: currentWidget.meta,
+ };
+};
+
+/**
+ * Runs xiboLayoutScaler
+ * @param {Object} currentWidget Widget object
+ */
+XiboPlayer.prototype.runLayoutScaler = function(currentWidget) {
+ // Run xiboLayoutScaler once to scale the content
+ $('#content').xiboLayoutScaler(Object.assign(
+ currentWidget.properties,
+ globalOptions,
+ {duration: currentWidget.duration},
+ ));
+};
+
+/**
+ * Run xiboLayoutAnimate to start animations
+ * @param {Object} $target HTML target element
+ * @param {Object} properties Widget or Group or Element properties
+ */
+XiboPlayer.prototype.runLayoutAnimate = function($target, properties) {
+ $target.xiboLayoutAnimate(properties);
+};
+
+const xiboPlayer = new XiboPlayer();
+
+module.exports = xiboPlayer;
+
+$(function() {
+ xiboPlayer.init();
+});
diff --git a/modules/src/xibo-substitutes-parser.js b/modules/src/xibo-substitutes-parser.js
new file mode 100644
index 0000000..daad664
--- /dev/null
+++ b/modules/src/xibo-substitutes-parser.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboSubstitutesParser: function(
+ template,
+ dateFormat,
+ dateFields = [],
+ mapping = {},
+ ) {
+ const items = [];
+ const parser = new RegExp('\\[.*?\\]', 'g');
+ const pipeParser = new RegExp('\\|{1}', 'g');
+ this.each(function(_idx, data) {
+ // Parse the template for a list of things to substitute, and match those
+ // with content from items.
+ let replacement = template;
+ let match = parser.exec(template);
+ while (match != null) {
+ // Matched text: match[0], match start: match.index,
+ // capturing group n: match[n]
+ // Remove the [] from the match
+ let variable = match[0]
+ .replace('[', '')
+ .replace(']', '');
+ variable = variable.charAt(0).toLowerCase() + variable.substring(1);
+
+ // Check if variable has its own formatting
+ // Then, parse it and use later as dateFormat
+ let formatFromTemplate = null;
+
+ if (variable.match(pipeParser) !== null &&
+ variable.match(pipeParser).length === 1) {
+ const variableWithFormat = variable.split('|');
+ formatFromTemplate = variableWithFormat[1];
+ variable = variableWithFormat[0];
+ }
+
+ if (mapping[variable]) {
+ variable = mapping[variable];
+ }
+ let value = '';
+
+ // Does this variable exist? or is it one of the ones in our map
+ if (data.hasOwnProperty(variable)) {
+ // Use it
+ value = data[variable];
+
+ // Is it a date field?
+ dateFields.forEach((field) => {
+ if (field === variable) {
+ value = moment(value).format(formatFromTemplate !== null ?
+ formatFromTemplate : dateFormat);
+ }
+ });
+ }
+
+ // If value is null, set it as empty string
+ (value === null) && (value = '');
+
+ // Finally set the replacement in the template
+ replacement = replacement.replace(match[0], value);
+
+ // Get the next match
+ match = parser.exec(template);
+ }
+
+ // Add to our items
+ items.push(replacement);
+ });
+ return items;
+ },
+});
diff --git a/modules/src/xibo-text-render.js b/modules/src/xibo-text-render.js
new file mode 100644
index 0000000..be70f08
--- /dev/null
+++ b/modules/src/xibo-text-render.js
@@ -0,0 +1,430 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboTextRender: function(options, items) {
+ // Default options
+ const defaults = {
+ effect: 'none',
+ pauseEffectOnStart: true,
+ duration: '50',
+ durationIsPerItem: false,
+ numItems: 1,
+ itemsPerPage: 0,
+ speed: '2',
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ randomiseItems: 0,
+ marqueeInlineSelector: '.text-render-item, .text-render-item p',
+ alignmentV: 'top',
+ widgetDesignWidth: 0,
+ widgetDesignHeight: 0,
+ widgetDesignGap: 0,
+ displayDirection: 0,
+ seamless: true,
+ };
+
+ options = $.extend({}, defaults, options);
+
+ const resetRenderElements = function($contentDiv) {
+ // Remove item classes
+ $contentDiv.find('.text-render-item').removeClass('text-render-item');
+
+ // Remove animation items
+ $contentDiv.find('.text-render-anim-item').remove();
+
+ // If options is seamless, remove second .scroll marquee div
+ // so we don't have duplicated elements
+ if (
+ options.seamless &&
+ $contentDiv.find('.scroll .js-marquee').length > 1
+ ) {
+ $contentDiv.find('.scroll .js-marquee')[1].remove();
+ }
+
+ // Show and reset the hidden elements
+ const $originalElements =
+ $contentDiv.find('.text-render-hidden-element');
+ $originalElements.removeClass('text-render-hidden-element').show();
+
+ // If we have a scroll container, move elements
+ // to content and destroy container
+ if ($contentDiv.find('.scroll').length > 0) {
+ $originalElements.appendTo($contentDiv);
+ $contentDiv.find('.scroll').remove();
+ }
+ };
+
+ // If number of items is not defined, get it from the item count
+ options.numItems = options.numItems ? options.numItems : items.length;
+
+ // Calculate the dimensions of this item
+ // based on the preview/original dimensions
+ let width = height = 0;
+ if (options.previewWidth === 0 || options.previewHeight === 0) {
+ width = options.originalWidth;
+ height = options.originalHeight;
+ } else {
+ width = options.previewWidth;
+ height = options.previewHeight;
+ }
+
+ if (options.scaleOverride !== 0) {
+ width = width / options.scaleOverride;
+ height = height / options.scaleOverride;
+ }
+
+ let paddingBottom = paddingRight = 0;
+ if (options.widgetDesignWidth > 0 && options.widgetDesignHeight > 0) {
+ if (options.itemsPerPage > 0) {
+ if (
+ (
+ $(window).width() >= $(window).height() &&
+ options.displayDirection == '0'
+ ) ||
+ (options.displayDirection == '1')
+ ) {
+ // Landscape or square size plus padding
+ options.widgetDesignWidth =
+ (options.itemsPerPage * options.widgetDesignWidth) +
+ (options.widgetDesignGap * (options.itemsPerPage - 1));
+ options.widgetDesignHeight = options.widgetDesignHeight;
+ width = options.widgetDesignWidth;
+ height = options.widgetDesignHeight;
+ paddingRight = options.widgetDesignGap;
+ } else if (
+ (
+ $(window).width() < $(window).height() &&
+ options.displayDirection == '0'
+ ) ||
+ (options.displayDirection == '2')
+ ) {
+ // Portrait size plus padding
+ options.widgetDesignHeight =
+ (options.itemsPerPage * options.widgetDesignHeight) +
+ (options.widgetDesignGap * (options.itemsPerPage - 1));
+ options.widgetDesignWidth = options.widgetDesignWidth;
+ width = options.widgetDesignWidth;
+ height = options.widgetDesignHeight;
+ paddingBottom = options.widgetDesignGap;
+ }
+ }
+ }
+
+ const isAndroid = navigator.userAgent.indexOf('Android') > -1;
+
+ // For each matched element
+ this.each(function(_key, element) {
+ // console.log("[Xibo] Selected: " + this.tagName.toLowerCase());
+ // console.log("[Xibo] Options: " + JSON.stringify(options));
+
+ const $contentDiv = $(element).find('#content');
+
+ // Is marquee effect
+ const isMarquee =
+ options.effect === 'marqueeLeft' ||
+ options.effect === 'marqueeRight' ||
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown';
+
+ const isUseNewMarquee = options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown' ||
+ !isAndroid;
+
+ // Reset the animation elements
+ resetRenderElements($contentDiv);
+
+ // Store the number of items (we might change this to number of pages)
+ let numberOfItems = options.numItems;
+
+ // How many pages to we need?
+ // if there's no effect, we don't need any pages
+ const numberOfPages =
+ (options.itemsPerPage > 0 && options.effect !== 'none') ?
+ Math.ceil(options.numItems / options.itemsPerPage) :
+ options.numItems;
+
+ let itemsThisPage = 1;
+
+ // console.log("[Xibo] We need to have " + numberOfPages + " pages");
+ let appendTo = $contentDiv;
+
+ // Clear previous animation elements
+ if (isMarquee && isUseNewMarquee) {
+ $contentDiv.marquee('destroy');
+ } else {
+ // Destroy cycle plugin
+ $(element).find('.anim-cycle').cycle('destroy');
+ }
+
+ // If we have animations
+ // Loop around each of the items we have been given
+ // and append them to this element (in a div)
+ if (options.effect !== 'none') {
+ for (let i = 0; i < items.length; i++) {
+ // We don't add any pages for marquee
+ if (!isMarquee) {
+ // If we need to set pages, have we switched over to a new page?
+ if (
+ options.itemsPerPage > 1 &&
+ (itemsThisPage >= options.itemsPerPage || i === 0)
+ ) {
+ // Append a new page to the body
+ appendTo = $('')
+ .addClass('text-render-page text-render-anim-item')
+ .appendTo($contentDiv);
+
+ // Reset the row count on this page
+ itemsThisPage = 0;
+ }
+ }
+
+ // For each item, create a DIV if element doesn't exist on the DOM
+ // Or clone the element if it does
+ // hide the original and show the clone
+ let $newItem;
+ let $oldItem;
+ if ($.contains(element, items[i])) {
+ $oldItem = $(items[i]);
+ $newItem = $oldItem.clone();
+ } else {
+ $oldItem = null;
+ $newItem = $('').html(items[i]);
+ }
+
+ // Hide and mark as hidden the original element
+ ($oldItem) && $oldItem.hide().addClass('text-render-hidden-element');
+
+ // Append the item to the page
+ $newItem
+ .addClass('text-render-item text-render-anim-item')
+ .appendTo(appendTo);
+
+ itemsThisPage++;
+ }
+ }
+
+ // 4th objective - move the items around, start the timer
+ // settings involved:
+ // fx (the way we are moving effects the HTML required)
+ // speed (how fast we need to move)
+ let marquee = false;
+
+ if (options.effect === 'none') {
+ // Do nothing
+ } else if (!isMarquee) {
+ // Make sure the speed is something sensible
+ options.speed = (options.speed <= 200) ? 1000 : options.speed;
+
+ // Cycle slides are either page or item
+ let slides =
+ (options.itemsPerPage > 1) ?
+ '.text-render-page' :
+ '.text-render-item';
+
+ // If we only have 1 item, then
+ // we are in trouble and need to duplicate it.
+ if ($(slides).length <= 1 && options.type === 'text') {
+ // Change our slide tag to be the paragraphs inside
+ slides = slides + ' p';
+
+ // Change the number of items
+ numberOfItems = $(slides).length;
+ } else if (options.type === 'text') {
+ numberOfItems = $(slides).length;
+ }
+
+ const numberOfSlides = (options.itemsPerPage > 1) ?
+ numberOfPages :
+ numberOfItems;
+ const duration = (options.durationIsPerItem) ?
+ options.duration :
+ options.duration / numberOfSlides;
+
+ // console.log("[Xibo] initialising the cycle2 plugin with "
+ // + numberOfSlides + " slides and selector " + slides +
+ // ". Duration per slide is " + duration + " seconds.");
+
+ // Set the content div to the height of the original window
+ $contentDiv.css('height', height);
+
+ // Set the width on the cycled slides
+ $(slides, $contentDiv).css({
+ width: width,
+ height: height,
+ });
+
+ let timeout = duration * 1000;
+ const noTransitionSpeed = 10;
+
+ if (options.effect !== 'noTransition') {
+ timeout = timeout - options.speed;
+ } else {
+ timeout = timeout - noTransitionSpeed;
+ }
+
+ // Cycle handles this for us
+ $contentDiv.addClass('anim-cycle').cycle({
+ fx: (options.effect === 'noTransition') ? 'none' : options.effect,
+ speed: (options.effect === 'noTransition') ?
+ noTransitionSpeed : options.speed,
+ timeout: timeout,
+ slides: '> ' + slides,
+ autoHeight: false, // To fix the rogue sentinel issue
+ paused: options.pauseEffectOnStart,
+ log: false,
+ });
+ } else if (
+ options.effect === 'marqueeLeft' ||
+ options.effect === 'marqueeRight'
+ ) {
+ marquee = true;
+ options.direction =
+ ((options.effect === 'marqueeLeft') ? 'left' : 'right');
+
+ // Make sure the speed is something sensible
+ options.speed = (options.speed === 0) ? 1 : options.speed;
+
+ // Stack the articles up and move them across the screen
+ $(
+ options.marqueeInlineSelector + ':not(.text-render-hidden-element)',
+ $contentDiv,
+ ).each(function(_idx, _el) {
+ if (!$(_el).hasClass('text-render-hidden-element')) {
+ $(_el).css({
+ display: 'inline-block',
+ 'padding-left': '10px',
+ });
+ }
+ });
+ } else if (
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown'
+ ) {
+ // We want a marquee
+ marquee = true;
+ options.direction = ((options.effect === 'marqueeUp') ? 'up' : 'down');
+
+ // Make sure the speed is something sensible
+ options.speed = (options.speed === 0) ? 1 : options.speed;
+
+ // Set the content div height, if we don't do this when the marquee
+ // plugin floats the content inside, this goes to 0 and up/down
+ // marquees don't work
+ $contentDiv.css('height', height);
+ }
+
+ if (marquee) {
+ // Create a DIV to scroll, and put this inside the body
+ const scroller = $('')
+ .addClass('scroll');
+
+ if (isUseNewMarquee) {
+ // in old marquee scroll delay is 85 milliseconds
+ // options.speed is the scrollamount
+ // which is the number of pixels per 85 milliseconds
+ // our new plugin speed is pixels per second
+ scroller.attr({
+ 'data-is-legacy': false,
+ 'data-speed': options.speed / 25 * 1000,
+ 'data-direction': options.direction,
+ 'data-duplicated': options.seamless,
+ scaleFactor: options.scaleFactor,
+ });
+ } else {
+ scroller.attr({
+ 'data-is-legacy': true,
+ scrollamount: options.speed,
+ scaleFactor: options.scaleFactor,
+ behaviour: 'scroll',
+ direction: options.direction,
+ height: height,
+ width: width,
+ });
+ }
+
+ $contentDiv.wrapInner(scroller);
+
+ // Correct for up / down
+ if (
+ options.effect === 'marqueeUp' ||
+ options.effect === 'marqueeDown'
+ ) {
+ // Set the height of the scroller to 100%
+ $contentDiv.find('.scroll')
+ .css('height', '100%')
+ .children()
+ .css({'white-space': 'normal', float: 'none'});
+ }
+
+ if (!options.pauseEffectOnStart) {
+ // Set some options on the extra DIV and make it a marquee
+ if (isUseNewMarquee) {
+ $contentDiv.find('.scroll').marquee();
+ } else {
+ $contentDiv.find('.scroll').overflowMarquee();
+ }
+
+ // Add animating class to prevent multiple inits
+ $contentDiv.find('.scroll').addClass('animating');
+ }
+ }
+
+ // Add aditional padding to the items
+ if (paddingRight > 0 || paddingBottom > 0) {
+ // Add padding to all item elements
+ $('.text-render-item').css(
+ 'padding',
+ '0px ' + paddingRight + 'px ' + paddingBottom + 'px 0px',
+ );
+
+ // Exclude the last item on the page and
+ // the last on the content ( if there are no pages )
+ $('.text-render-page .text-render-item:last-child').css('padding', 0);
+ $('#content .text-render-item:last').css('padding', 0);
+ }
+
+ // Align the whole thing according to vAlignment
+ if (options.type && options.type === 'text') {
+ // The timeout just yields a bit to let our content get rendered
+ setTimeout(function() {
+ if (options.alignmentV === 'bottom') {
+ $contentDiv.css(
+ 'margin-top',
+ $(window).height() -
+ ($contentDiv.height() * $('body').data().ratio),
+ );
+ } else if (options.alignmentV === 'middle') {
+ $contentDiv.css(
+ 'margin-top',
+ (
+ $(window).height() -
+ ($contentDiv.height() * $('body').data().ratio)
+ ) / 2,
+ );
+ }
+ }, 500);
+ }
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-text-scaler.js b/modules/src/xibo-text-scaler.js
new file mode 100644
index 0000000..1b81949
--- /dev/null
+++ b/modules/src/xibo-text-scaler.js
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboTextScaler: function(options) {
+ // Default options
+ const defaults = {
+ fitTarget: '',
+ fontFamily: '',
+ fitScaleAxis: 'x',
+ isIcon: false,
+ };
+
+ options = $.extend({}, defaults, options);
+
+ // For each matched element
+ this.each(function(_key, el) {
+ const elWidth = $(el).width();
+ const elHeight = $(el).height();
+
+ // Continue only if we have a valid element
+ if (elWidth == 0 || elHeight == 0) {
+ return $(el);
+ }
+
+ const $fitTarget = (options.fitTarget != '') ?
+ $(el).find(options.fitTarget) :
+ $(el);
+
+ const waitForFontToLoad = function(font, callback) {
+ // Wait for font to load
+ // but also make sure target has any dimensions
+ if (
+ $fitTarget.width() > 0 &&
+ $fitTarget.height() > 0 &&
+ document.fonts.check(font)
+ ) {
+ callback();
+ } else {
+ setTimeout(function() {
+ waitForFontToLoad(font, callback);
+ }, 100);
+ }
+ };
+
+ if (options.isIcon) {
+ const fontFamily = (options.fontFamily) ?
+ options.fontFamily : $fitTarget.css('font-family');
+ const maxFontSize = 1000;
+ let fontSize = 1;
+
+ $fitTarget.css('font-size', fontSize);
+
+ // Wait for font to load, then run resize
+ waitForFontToLoad(fontSize + 'px ' + fontFamily, function() {
+ while (fontSize < maxFontSize) {
+ const auxFontSize = fontSize + 2;
+
+ // Increase font
+ $fitTarget.css('font-size', fontSize);
+
+ const doesItBreak = (options.fitScaleAxis === 'y') ?
+ $fitTarget.height() > elHeight :
+ $fitTarget.width() > elWidth;
+
+ // When it breaks, use previous fontSize
+ if (doesItBreak) {
+ break;
+ } else {
+ // Increase font size and continue
+ fontSize = auxFontSize;
+ }
+ }
+
+ // Set font size to element
+ $fitTarget.css('font-size', fontSize);
+ });
+ } else {
+ const maxFontSize = 1000;
+ let fontSize = 1;
+ // Text
+ const fontFamily = (options.fontFamily) ?
+ options.fontFamily : 'sans-serif';
+
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ const text = $fitTarget.html().trim();
+ const fontStyle = $fitTarget.css('font-style');
+ const fontWeight = $fitTarget.css('font-weight');
+
+ // If text is empty, dont resize
+ if (text.length === 0) {
+ return $(el);
+ }
+
+ // Set a low font size to begin with
+ $fitTarget.css('font-size', fontSize);
+ $fitTarget.hide();
+
+ // Wait for font to load, then run resize
+ waitForFontToLoad(fontWeight + ' ' + fontStyle + ' ' +
+ fontSize + 'px ' + fontFamily, function() {
+ context.font =
+ fontWeight + ' ' + fontStyle + ' ' +
+ fontSize + 'px ' + fontFamily;
+
+ while (fontSize < maxFontSize) {
+ const auxFontSize = fontSize + 1;
+
+ // Increase font
+ context.font =
+ fontWeight + ' ' + fontStyle + ' ' +
+ auxFontSize + 'px ' + fontFamily;
+
+ const doesItBreak = (options.fitScaleAxis === 'y') ?
+ context.measureText(text).height > elHeight :
+ context.measureText(text).width > elWidth;
+
+ // When it breaks, use previous fontSize
+ if (doesItBreak) {
+ break;
+ } else {
+ // Increase font size and continue
+ fontSize = auxFontSize;
+ }
+ }
+
+ // Set font size to element
+ $fitTarget.css('font-size', fontSize);
+ $fitTarget.show();
+ });
+ }
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/src/xibo-webpage-render.js b/modules/src/xibo-webpage-render.js
new file mode 100644
index 0000000..49e3fe1
--- /dev/null
+++ b/modules/src/xibo-webpage-render.js
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+jQuery.fn.extend({
+ xiboIframeScaler: function(options) {
+ let width;
+ let height;
+ const iframeWidth = parseInt(options.iframeWidth);
+ const iframeHeight = parseInt(options.iframeHeight);
+
+ // All we worry about is the item we have been working on ($(this))
+ $(this).each(function(_idx, el) {
+ // Mode
+ if (options.modeid == 1) {
+ // Open Natively
+ // We shouldn't ever get here, because the
+ // Layout Designer will not show a preview for mode 1, and
+ // the client will not call GetResource at all for mode 1
+ $(el).css({
+ width: options.originalWidth,
+ height: options.originalHeight,
+ });
+ } else if (options.modeid == 3) {
+ // Best fit, set the scale so that the web-page fits inside the region
+
+ // If there is a preview width and height
+ // then we want to reset the original width and height in the
+ // ratio calculation so that it represents the
+ // preview width/height * the scale override
+ let originalWidth = options.originalWidth;
+ let originalHeight = options.originalHeight;
+
+ if (options.scaleOverride !== 0) {
+ // console.log("Iframe: Scale Override is set,
+ // meaning we want to scale according to the provided
+ // scale of " + options.scaleOverride + ". Provided Width is " +
+ // options.previewWidth + ". Provided Height is " +
+ // options.previewHeight + ".");
+ ratio = options.scaleOverride;
+ originalWidth = options.previewWidth / ratio;
+ originalHeight = options.previewHeight / ratio;
+ }
+
+ options.scale = Math.min(
+ originalWidth / iframeWidth,
+ originalHeight / iframeHeight,
+ );
+
+ // Remove the offsets
+ options.offsetTop = 0;
+ options.offsetLeft = 0;
+
+ // Set frame to the full size and scale it back to fit inside the window
+ if ($('body').hasClass('ie7') || $('body').hasClass('ie8')) {
+ $(el).css({
+ filter: 'progid:DXImageTransform.Microsoft.Matrix(M11=' +
+ options.scale + ', M12=0, M21=0, M22=' + options.scale +
+ ', SizingMethod=\'auto expand\'',
+ });
+ } else {
+ $(el).css({
+ transform: 'scale(' + options.scale + ')',
+ 'transform-origin': '0 0',
+ width: iframeWidth,
+ height: iframeHeight,
+ });
+ }
+ } else {
+ // Manual Position. This is the default.
+ // We want to set its margins and scale
+ // according to the provided options.
+
+ // Offsets
+ const offsetTop = parseInt(options.offsetTop) ?
+ parseInt(options.offsetTop) : 0;
+ const offsetLeft = parseInt(options.offsetLeft) ?
+ parseInt(options.offsetLeft) : 0;
+
+ // Dimensions
+ width = iframeWidth + offsetLeft;
+ height = iframeHeight + offsetTop;
+
+ // Margins on frame
+ $(el).css({
+ 'margin-top': -1 * offsetTop,
+ 'margin-left': -1 * offsetLeft,
+ width: width,
+ height: height,
+ });
+
+ // Do we need to scale?
+ if (options.scale !== 1 && options.scale !== 0) {
+ if ($('body').hasClass('ie7') || $('body').hasClass('ie8')) {
+ $(el).css({
+ filter: 'progid:DXImageTransform.Microsoft.Matrix(M11=' +
+ options.scale + ', M12=0, M21=0, M22=' +
+ options.scale + ', SizingMethod=\'auto expand\'',
+ });
+ } else {
+ $(el).css({
+ transform: 'scale(' + options.scale + ')',
+ 'transform-origin': '0 0',
+ width: width / options.scale,
+ height: height / options.scale,
+ });
+ }
+ }
+ }
+ });
+ },
+});
diff --git a/modules/src/xibo-worldclock-render.js b/modules/src/xibo-worldclock-render.js
new file mode 100644
index 0000000..bbcd964
--- /dev/null
+++ b/modules/src/xibo-worldclock-render.js
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+*/
+jQuery.fn.extend({
+ xiboWorldClockRender: function(options, template) {
+ const worldClocks =
+ (options.worldClocks != '' && options.worldClocks != null) ?
+ JSON.parse(options.worldClocks) : [];
+ const self = this;
+
+ // Make replacements to the template
+ const $templateHTML = $(template).html();
+ $(template).html($templateHTML.replace(/\[.*?\]/g, function(match) {
+ const matchContent = match.replace(/[\[\]]/g, '');
+ if (matchContent == 'label') {
+ return '';
+ } else {
+ return '';
+ }
+ }));
+
+ // Update clocks
+ const updateClocks = function() {
+ const timeNow = moment();
+
+ for (let index = 0; index < worldClocks.length; index++) {
+ // Get time according to timezone
+ const t = timeNow.tz(worldClocks[index].clockTimezone);
+ const $clockContainer = $(self).find('#clock' + index);
+
+ $clockContainer.find('.year').html(t.year());
+ $clockContainer.find('.month').html(t.month() + 1);
+ $clockContainer.find('.week').html(t.week());
+ $clockContainer.find('.day').html(t.date());
+ $clockContainer.find('.hour').html(t.hours());
+ $clockContainer.find('.minutes').html(t.minutes());
+ $clockContainer.find('.seconds').html(t.seconds());
+
+ $clockContainer.find('.momentClockTag').each(function(_k, el) {
+ $(el).html(t.format($(el).attr('format')));
+ });
+
+ const secondAnalog = t.seconds() * 6;
+ const minuteAnalog = t.minutes() * 6 + secondAnalog / 60;
+ const hourAnalog =
+ ((t.hours() % 12) / 12) * 360 + 90 + minuteAnalog / 12;
+
+ $clockContainer.find('.analogue-clock-hour')
+ .css('transform', 'rotate(' + hourAnalog + 'deg)');
+ $clockContainer.find('.analogue-clock-minute')
+ .css('transform', 'rotate(' + minuteAnalog + 'deg)');
+ $clockContainer.find('.analogue-clock-second')
+ .css('transform', 'rotate(' + secondAnalog + 'deg)');
+ }
+ };
+
+ // Default options
+ const defaults = {
+ duration: '30',
+ previewWidth: 0,
+ previewHeight: 0,
+ scaleOverride: 0,
+ };
+
+ options = $.extend({}, defaults, options);
+
+ // For each matched element
+ this.each(function() {
+ for (let index = 0; index < worldClocks.length; index++) {
+ // Create new item and add a copy of the template html
+ const $newItem =
+ $('
').attr('id', 'clock' + index)
+ .addClass('world-clock multi-element')
+ .html($(template).html());
+
+ // Add label or timezone name
+ $newItem.find('.world-clock-label')
+ .html(
+ (worldClocks[index].clockLabel != '') ?
+ worldClocks[index].clockLabel :
+ worldClocks[index].clockTimezone,
+ );
+
+ // Check if clock has highlighted class
+ if (worldClocks[index].clockHighlight) {
+ $newItem.addClass('highlighted');
+ }
+
+ // Check if the element is outside the drawing area ( cols * rows )
+ if ((index + 1) > (options.numCols * options.numRows)) {
+ $newItem.css('display', 'none');
+ }
+
+ // Add content to the main container
+ $(self).find('#content').append($newItem);
+ }
+
+ // Update clocks
+ updateClocks(); // run function once at first to avoid delay
+
+ // Update every second
+ setInterval(updateClocks, 1000);
+ });
+
+ return $(this);
+ },
+});
diff --git a/modules/ssp.xml b/modules/ssp.xml
new file mode 100644
index 0000000..6f0a3de
--- /dev/null
+++ b/modules/ssp.xml
@@ -0,0 +1,42 @@
+
+
+ core-ssp
+ SSP
+ Core
+ Manually schedule SSP content from the SSP connector via select partners.
+ fa fa-tv
+ ssp
+
+ 1
+ 1
+ 1
+ native
+ 10
+
+
+
+ Partner
+ Choose from the partners that support this type of scheduling.
+
+
+
+
\ No newline at end of file
diff --git a/modules/stocks.xml b/modules/stocks.xml
new file mode 100755
index 0000000..9fc046a
--- /dev/null
+++ b/modules/stocks.xml
@@ -0,0 +1,110 @@
+
+
+ core-stocks
+ Stocks
+ Core
+ A module for showing Stock quotes
+ fa fa-bar-chart
+ \Xibo\Widget\CurrenciesAndStocksProvider
+ \Xibo\Widget\Compatibility\StocksWidgetCompatibility
+ stocks
+ stock
+ %items%
+ 1
+ 2
+ 1
+ 1
+ html
+ 30
+
+
+
+ Configuration
+
+
+ Stock Symbols
+ A comma separated list of Stock Ticker Symbols, e.g. GOOGL,NVDA,AMZN. For the best results enter no more than 5 items.
+
+
+
+
+
+
+
+
+ Duration is per item
+ The duration specified is per page/item otherwise the widget duration is divided between the number of pages/items.
+ 0
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Update Interval (mins)
+ Please enter the update interval in minutes. This should be kept as high as possible. For example, if the data will only change once per hour this could be set to 60.
+ 60
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/subplaylist.xml b/modules/subplaylist.xml
new file mode 100644
index 0000000..79e617f
--- /dev/null
+++ b/modules/subplaylist.xml
@@ -0,0 +1,114 @@
+
+
+ core-subplaylist
+ Playlist
+ Core
+ Display widgets from one or more Playlists
+ fa fa-list-ol
+
+ \Xibo\Widget\Compatibility\SubPlaylistWidgetCompatibility
+ none
+
+ subplaylist
+
+ 2
+ 1
+ 1
+ html
+ 0
+
+
+
+ Please select one or more Playlists to embed. If selecting more than one use the Configuration tab to adjust how each Playlist is combined.
+
+
+ Optionally set Spot options to expand or shrink each Playlist to a particular size or duration. Leave the Spot options empty to use the count of Widgets in each Playlist.
+
+
+ Setting Spots to 0 will omit the first Playlist from the play order, and will be used as a Spot Fill option.
+
+
+ []
+
+
+ Playlist Ordering
+ How would you like the Widgets on these Playlists to be ordered?
+ none
+
+
+
+
+
+
+
+ Remaining Widgets
+ If there are Widgets left unordered at the end, what should be done with these Widgets?
+ none
+
+
+
+
+
+
+
+ Enable cycle based playback?
+ When cycle based playback is enabled only 1 Widget from this Sub-Playlist will be played each time the Layout is shown. The same Widget will be shown until the 'Play count' is achieved.
+ 0
+
+
+ 1
+
+
+
+
+ Play count
+ In cycle based playback, how many plays should each Widget have before moving on?
+ 1
+
+
+ 1
+ 1
+
+
+
+
+ Random Widget each cycle?
+ When enabled the next Widget to play will be chosen at random from the available Widgets.
+ 0
+
+
+ 1
+ 1
+
+
+
+
+
+
+
+
+ {% trans %}{{ countSubPlaylistWidgets }} Widgets / {{ calculatedDuration }} seconds{% endtrans %}
+
+ ]]>
+
+
diff --git a/modules/templates/article-elements.xml b/modules/templates/article-elements.xml
new file mode 100644
index 0000000..e46a315
--- /dev/null
+++ b/modules/templates/article-elements.xml
@@ -0,0 +1,143 @@
+
+
+
+ article_title
+ text
+ Title
+ element
+ article
+ fas fa-font
+ true
+ 500
+ 100
+
+
+ article_summary
+ text
+ Summary
+ element
+ article
+ fas fa-font
+ true
+ 500
+ 250
+
+
+ article_content
+ text
+ Content
+ element
+ article
+ fas fa-font
+ true
+ 500
+ 500
+
+
+ Remove new lines?
+ 0
+ Should new lines (\n) be removed from content?
+
+
+
+
+
+ article_author
+ text
+ Author
+ element
+ article
+ fas fa-user
+ true
+ 400
+ 100
+
+
+ article_date
+ date
+ Date
+ element
+ article
+ fas fa-calendar-week
+ true
+ 400
+ 100
+
+
+ article_publishedDate
+ date
+ Published Date
+ element
+ article
+ fas fa-calendar-week
+ true
+ 400
+ 100
+
+
+ article_image
+ global_image
+ Image
+ element
+ article
+ fas fa-image
+ true
+ 200
+ 200
+
+
+ article_link
+ text
+ Link
+ element
+ article
+ fas fa-link
+ true
+ 480
+ 100
+
+
+ article_permalink
+ text
+ Permalink
+ element
+ article
+ fas fa-link
+ true
+ 480
+ 100
+
+
diff --git a/modules/templates/article-static.xml b/modules/templates/article-static.xml
new file mode 100644
index 0000000..3b9a1e1
--- /dev/null
+++ b/modules/templates/article-static.xml
@@ -0,0 +1,1454 @@
+
+
+
+ article_custom_html
+ static
+ article
+ none
+ Articles shown with custom HTML
+
+
+ 1
+
+
+ article
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1)
+ 1000
+
+
+ Show items side by side?
+ Should items be shown side by side?
+ 0
+
+
+ Items per page
+ If an effect has been selected from the General tab, how many pages should we split the items across? If you don't enter anything here 1 item will be put on each page.
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Text direction
+ Which direction does the text in the feed use?
+ ltr
+
+
+
+
+
+
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+ Snippets
+ Choose element to add to template
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+ Optional Stylesheet Template
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+
+ {{javaScript|raw}}{% endif %}
+ ]]>
+
+
+ 0) {
+ items = $(items).xiboSubstitutesParser(properties.template, properties.dateFormat, ['date', 'publishedDate'], {
+ description: 'summary',
+ copyright: 'author',
+ });
+}
+
+// No data message
+if (items.length <= 0 && properties.noDataMessage && properties.noDataMessage !== '') {
+ items.push(properties.noDataMessage);
+}
+
+// Copyright
+if (properties.copyright) {
+ items.push({
+ title: properties.copyright
+ });
+}
+
+// Clear container
+$(target).find('#content').empty();
+
+// Add items to container
+for (var index = 0; index < items.length; index++) {
+ var $newItem = $('
').addClass('item').html(items[index]);
+ $(target).find('#content').append($newItem);
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > *'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+ article_image_only
+ static
+ article
+ Image only
+ article_image_only
+ 600
+ 400
+
+
+ Background
+
+
+
+ Image Fit
+ How should images be scaled by default?
+ contain
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+
+
+ {{#if image}}{{/if}}
+
')
+ );
+
+ // Increase numItems to include the copyright
+ properties.numItems = parseInt(properties.numItems) + 1;
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .image, #content > .copyright'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ article_with_left_hand_text
+ static
+ article
+ Image overlaid with the Feed Content on the Left
+ article_with_left_hand_text
+ 600
+ 400
+
+
+ Background
+
+
+
+ Background (content)
+ black
+
+
+ Background opacity (content)
+ 0.6
+
+
+ Title
+ white
+
+
+ Description
+ white
+
+
+ Font Size
+ 16
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Image Fit
+ How should images be scaled by default?
+ contain
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+
+
+ {{#if image}}{{/if}}
+
')
+ );
+
+ // Increase numItems to include the copyright
+ properties.numItems = parseInt(properties.numItems) + 1;
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .image, #content > .copyright'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ article_with_title
+ static
+ article
+ Image overlaid with the Title
+ article_with_title
+ 600
+ 400
+
+
+ Background
+
+
+
+ Background (content)
+ black
+
+
+ Background opacity (content)
+ 0.6
+
+
+ Title
+ white
+
+
+ Font Size
+ 16
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Image Fit
+ How should images be scaled by default?
+ contain
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+
+
+ {{#if image}}{{/if}}
+
')
+ );
+
+ // Increase numItems to include the copyright
+ properties.numItems = parseInt(properties.numItems) + 1;
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .image, #content > .copyright'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ article_with_desc_and_name_separator
+ static
+ article
+ Prominent title with description and name separator
+ article_with_desc_and_name_separator
+ 600
+ 600
+
+
+ Background
+
+
+
+ Name
+ #000
+
+
+ Title
+ #000
+
+
+ Description
+ #000
+
+
+ Font Size
+ 16
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1)
+ 1000
+
+
+ Show a separator between items?
+ 0
+
+
+ marqueeLeft
+ marqueeRight
+ marqueeUp
+ marqueeDown
+
+
+
+
+ Show items side by side?
+ 0
+
+
+ marqueeLeft
+ marqueeRight
+ marqueeUp
+ marqueeDown
+
+
+
+
+ Separator
+ A separator to show between marquee items
+
+ /
+ ]]>
+
+
+ none
+ noTransition
+ fade
+ fadeout
+ scrollHorz
+ scrollVert
+ flipHorz
+ flipVert
+ shuffle
+ tileSlide
+ tileBlind
+ 1
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+
+
+
')
+ );
+
+ // Increase numItems to include the copyright
+ properties.numItems = parseInt(properties.numItems) + 1;
+}
+
+// Add separator
+if (
+ (
+ properties.effect == 'marqueeLeft' ||
+ properties.effect == 'marqueeRight' ||
+ properties.effect == 'marqueeUp' ||
+ properties.effect == 'marqueeDown'
+ ) && properties.showSeparator == 1 &&
+ properties.separator != ''
+) {
+ var $separator = $(properties.separator);
+ $separator.addClass('separator');
+ $(target).find('#content .article, #content .copyright').after($separator);
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content .article, #content .copyright, #content .separator'));
+ ]]>
+
+
+
+
+
+
+ article_marquee
+ static
+ article
+ Articles shown in a marquee
+ 1200
+ 80
+
+
+ Selected Tags
+ Select tags to be displayed.
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ marqueeLeft
+
+
+ Speed
+ Marquee Speed in a low to high scale (normal = 1)
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Gap between tags
+ Value (in pixels) to set a gap between each item's tags.
+ 6
+
+
+ Gap between items
+ Value (in pixels) to set a gap between each item.
+ 6
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Text direction
+ Which direction does the text in the feed use?
+ ltr
+
+
+
+
+
+
+ Show a separator between items?
+ 0
+
+
+ marqueeLeft
+ marqueeRight
+ marqueeUp
+ marqueeDown
+
+
+
+
+ Separator
+ A separator to show between marquee items
+
+ /
+ ]]>
+
+
+ none
+ noTransition
+ fade
+ fadeout
+ scrollHorz
+ scrollVert
+ flipHorz
+ flipVert
+ shuffle
+ tileSlide
+ tileBlind
+ 1
+
+
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+ Copyright Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Copyright Font Colour
+
+
+
+
+
+
+
+
+ Copyright Font Size
+
+
+
+
+
+
+
+
+ Bold
+ Should the copyright text be bold?
+ 0
+
+
+
+
+
+
+
+ Italics
+ Should the copyright text be italicised?
+ 0
+
+
+
+
+
+
+
+ Underline
+ Should the copyright text be underlined?
+ 0
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+ {{javaScript|raw}}{% endif %}
+ ]]>
+
+
+ ';
+ } else {
+ properties.template += '">[' + tag + ']';
+ }
+}
+
+if (items.length > 0) {
+ items = $(items).xiboSubstitutesParser(properties.template, properties.dateFormat, ['date', 'publishedDate'], {
+ description: 'summary',
+ copyright: 'author',
+ });
+}
+
+// No data message
+if (items.length <= 0 && properties.noDataMessage && properties.noDataMessage !== '') {
+ // Add message to the target content
+ $(target).find('#content').after(
+ $('
+ ]]>
+
+
+ div');
+
+// Get items
+var templateItems = $(target).find('#content > .template-item');
+
+// Run only if there are items
+if (templateItems.length > 0) {
+ // Render items
+ $(target).find('#content').xiboFinanceRender(properties, templateItems, body);
+} else {
+ // Hide container
+ $(body).hide();
+}
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+
+ currencies1
+ static
+ currency
+ Currencies 1
+ currencies1
+ 500
+ 300
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Item Colour
+ Background colour for each currency item.
+ rgba(0, 0, 0, 0.87)
+
+
+ Item Font Colour
+ Font colour for each currency item.
+ #fff
+
+
+ Header Font Colour
+ Font colour for the header.
+ #fff
+
+
+ Up Arrow Colour
+ Colour for the up change arrow.
+ green
+
+
+ Down Arrow Colour
+ Colour for the down change arrow.
+ red
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ 4
+
+
+
+ 820
+ 420
+
+
+
+
+
+
+
{{NameShort}}
+
{{RawLastTradePriceOnly}}
+
+ ]]>
+
+
+
+
||RATE||
+
+
+
+
+
+ ]]>
+
+
+ 0) {
+ // Render items
+ $(target).find("#content").xiboFinanceRender(properties, templateItems, body);
+} else {
+ // Hide container
+ $(body).hide();
+}
+$(target).find("img").xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+
+
+ currencies2
+ static
+ currency
+ Currencies 2
+ currencies2
+ 500
+ 300
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Item Colour
+ Background colour for each currency item.
+ #e0e0e0
+
+
+ Item Font Colour
+ Font colour for each currency item.
+ #000
+
+
+ Item Border Colour
+ Border colour for each currency item.
+ #264a88
+
+
+ Up Arrow Colour
+ Colour for the up change arrow.
+ green
+
+
+ Down Arrow Colour
+ Colour for the down change arrow.
+ red
+
+
+ Equal Arrow Colour
+ Colour for the equal change arrow.
+ gray
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ 5
+
+
+
+ 820
+ 420
+
+
+
+
+
+
+
{{NameShort}}
+
{{RawLastTradePriceOnly}}
+
{{ChangePercentage}}%
+
+
+ ]]>
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/templates/dashboard-static.xml b/modules/templates/dashboard-static.xml
new file mode 100644
index 0000000..b09cc12
--- /dev/null
+++ b/modules/templates/dashboard-static.xml
@@ -0,0 +1,30 @@
+
+
+
+
+ dashboard_image
+ static
+ xibo-dashboard-service
+ Dashboard Image
+ fa fa-file-image
+
+
\ No newline at end of file
diff --git a/modules/templates/dataset-elements.xml b/modules/templates/dataset-elements.xml
new file mode 100644
index 0000000..c1e3e56
--- /dev/null
+++ b/modules/templates/dataset-elements.xml
@@ -0,0 +1,238 @@
+
+
+
+ dataset_data_string
+ text
+ String
+ element
+ dataset
+ true
+ fa fa-font
+ 360
+ 100
+
+
+ 1
+
+
+ Please choose a Dataset from the Configure tab to be able to customise this element.
+
+
+
+
+
+
+
+ No field is available for that type of DataSet element.
+
+
+
+
+
+
+
+
+ dataSetId
+ Select DataSet Field
+ Please choose a DataSet field for this element.
+
+
+
+
+
+
+
+
+
+
+ dataset_data_number
+ text
+ Number
+ element
+ dataset
+ true
+ fas fa-sort-numeric-up
+ 360
+ 100
+
+
+ 2
+
+
+ Please choose a Dataset from the Configure tab to be able to customise this element.
+
+
+
+
+
+
+
+ No field is available for that type of DataSet element.
+
+
+
+
+
+
+
+
+ dataSetId
+ Select DataSet Field
+ Please choose a DataSet field for this element.
+
+
+
+
+
+
+
+
+
+ dataset_data_date
+ date
+ Date
+ element
+ dataset
+ true
+ fas fa-calendar-week
+ 360
+ 100
+
+
+ 3
+
+
+ Please choose a Dataset from the Configure tab to be able to customise this element.
+
+
+
+
+
+
+
+ No field is available for that type of DataSet element.
+
+
+
+
+
+
+
+
+ dataSetId
+ Select DataSet Field
+ Please choose a DataSet field for this element.
+
+
+
+
+
+
+
+
+
+ dataset_data_img
+ global_image
+ Image
+ element
+ dataset
+ true
+ fas fa-image
+ 150
+ 150
+
+
+ 4,5
+
+
+ Please choose a Dataset from the Configure tab to be able to customise this element.
+
+
+
+
+
+
+
+ No field is available for that type of DataSet element.
+
+
+
+
+
+
+
+
+ dataSetId
+ Select DataSet Field
+ Please choose a DataSet field for this element.
+
+
+
+
+
+
+
+
+
+ dataset_data_html
+ text
+ HTML
+ element
+ dataset
+ true
+ fas fa-file-code
+ 360
+ 100
+
+
+ 6
+
+
+ Please choose a Dataset from the Configure tab to be able to customise this element.
+
+
+
+
+
+
+
+ No field is available for that type of DataSet element.
+
+
+
+
+
+
+
+
+ dataSetId
+ Select DataSet Field
+ Please choose a DataSet field for this element.
+
+
+
+
+
+
+
+
+
diff --git a/modules/templates/dataset-static.xml b/modules/templates/dataset-static.xml
new file mode 100644
index 0000000..f5aed28
--- /dev/null
+++ b/modules/templates/dataset-static.xml
@@ -0,0 +1,3686 @@
+
+
+
+ dataset_table_custom_html
+ static
+ dataset
+ none
+
+
+ Optional Stylesheet Template
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+ Colours
+
+
+
+
+
+
+
+ Background Colour
+ Use the colour picker to select the background colour
+
+
+
+
+
+
+
+ Border Colour
+ Use the colour picker to select the border colour
+
+
+
+
+
+
+
+ Font Colour
+ Use the colour picker to select the font colour
+
+
+
+
+
+
+
+
+ {{javaScript|raw}}{% endif %}
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('#DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings == 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_custom_html
+ static
+ dataset
+ none
+ Dataset Custom HTML
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ Snippets
+ Choose data set snippet
+ dataSetId
+
+
+
+
+
+
+
+ Optional Stylesheet Template
+
+
+
+
+
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+
+
+
+
+
+ Show items side by side?
+ Should items be shown side by side?
+ 0
+
+
+
+
+
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+
+
+
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1)
+ 1000
+
+
+ none
+ noTransition
+
+
+
+
+
+ Items per page
+ If an effect has been selected, how many pages should we split the items across? If you don't enter anything here 1 item will be put on each page.
+ 1
+
+
+ none
+ marqueeLeft
+ marqueeRight
+ marqueeUp
+ marqueeDown
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+
+
+
+ {{javaScript|raw}}
+
+{% endif %}
+ ]]>
+
+
+ ' : '';
+ }
+
+ return itemValue ? itemValue : '';
+ });
+
+ // Add the content to the target
+ $(target).find('#content').append(
+ $('
')
+ .addClass('item')
+ .append(content)
+ );
+}
+
+// Scale the layout
+$('body').xiboLayoutScaler(properties);
+
+$(target).xiboTextRender(Object.assign(properties, globalOptions), $(target).find('#content > *'));
+
+// Image render
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+ dataset_table_1
+ static
+ dataset
+ Plain Table (Customisable)
+ empty
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+ Colours
+
+
+
+
+
+
+
+ Font Colour
+ Use the colour picker to select the font colour
+ #111
+
+
+
+
+
+
+
+ Background Colour
+ Use the colour picker to select the background colour
+
+
+
+
+
+
+
+ Border Colour
+ Use the colour picker to select the border colour
+
+
+
+
+
+
+
+ Header Font Colour
+ Use the colour picker to select the header font colour
+ #111
+
+
+
+
+
+
+
+ Header Background Colour
+ Use the colour picker to select the header background colour
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_2
+ static
+ dataset
+ A light green background with darker green borders. White heading text.
+ light-green
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_3
+ static
+ dataset
+ Simple white table with rounded rows.
+ simple-round-table
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_4
+ static
+ dataset
+ Striped blue table with darker blue header.
+ transparent-blue
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_5
+ static
+ dataset
+ White striped table with orange header.
+ orange-grey-striped
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_6
+ static
+ dataset
+ White and grey table with split rows.
+ split-rows-
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_7
+ static
+ dataset
+ A dark table with round borders and yellow heading text.
+ dark-round
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_table_8
+ static
+ dataset
+ Round cells with multi colours and a full coloured header.
+ pill-colored
+ 600
+ 350
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Show the table headings?
+ Should the Table headings be shown?
+ 1
+
+
+
+
+
+
+
+ Rows per page
+ Please enter the number of rows per page. 0 for no pages.
+ 0
+
+
+
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+ Font Size
+ Set the font size
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+ {
+ if (column.dataSetColumnId !== '' && columnIndex.indexOf(column.dataSetColumnId) !== -1) {
+ columns[columnIndex.indexOf(column.dataSetColumnId)] = column;
+ }
+ });
+}
+
+// Get table element
+var $datasetTableContainer = $(target).find('.DataSetTableContainer');
+
+// Clear the table container
+$datasetTableContainer.empty();
+
+// Calculate number of pages
+var totalPages = (properties.rowsPerPage > 0) ? Math.ceil(items.length / properties.rowsPerPage) : 1;
+
+// Set the number of pages to the table
+$datasetTableContainer.data('totalPages', totalPages);
+
+// Create a table for each page
+for (var i = 0; i < totalPages; i++) {
+ // Create a new table
+ var $newTable = properties.rowsPerPage > 0 ?
+ $('
') :
+ $('
');
+
+ // Show the table headings if required
+ if (properties.showHeadings === 1) {
+ // Build the headings
+ var headings = columns.map(function (column, colIdx) {
+ return '
' + column.heading + '
';
+ });
+
+ // Add the headings to the table
+ $newTable.append(
+ $('')
+ .append(
+ $('
')
+ );
+
+ // Add the table to the container
+ $datasetTableContainer.append($newTable);
+}
+
+// Add rows to the tables per page
+for (var i = 0; i < items.length; i++) {
+ // Get the table for this row
+ var $table = (properties.rowsPerPage > 0) ?
+ $datasetTableContainer.find('.DataSetTable[data-page="' + Math.floor(i / properties.rowsPerPage) + '"]') :
+ $datasetTableContainer.find('.DataSetTable');
+
+ // Build the row content based on the columns
+ var rowContent = columns.map(function (column, colIdx) {
+ var value = items[i][column.heading];
+
+ // If it's a date and we have date format
+ if (column.dataTypeId === 3 && properties.dateFormat) {
+ value = moment(value).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (column.dataTypeId === 4 || column.dataTypeId === 5) {
+ value = value ? '' : '';
+ }
+
+ // Empty string if value is null
+ if (value === null) {
+ value = '';
+ }
+
+ return '
' + value + '
';
+ }).join('');
+
+ // Add the row to the table's body
+ $table.find('tbody').append(
+ '
' +
+ rowContent +
+ '
'
+ );
+}
+
+// Move table container into content
+$datasetTableContainer.appendTo($(target).find('#content'));
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Call render
+$datasetTableContainer.dataSetRender(properties);
+
+// Image render
+$datasetTableContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+ dataset_slideshow
+ static
+ dataset
+ playlist
+ fas fa-film
+ Image Slideshow
+
+
+ 4,5
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ No image field is available for the selected DataSet.
+
+
+
+
+
+
+
+
+ dataSetId
+ Select DataSet Field
+ Please choose a DataSet field for this element.
+
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ tileBlind
+
+
+
+
+
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000)
+ 1000
+
+
+ noTransition
+
+
+
+
+
+
+
+
+
+ ';
+
+ // Add the content to the target
+ $(target).find('#content').append(
+ $('
')
+ .addClass('item')
+ .append(content)
+ );
+ }
+}
+
+// Scale the layout
+$('body').xiboLayoutScaler(properties);
+
+$(target).xiboTextRender(Object.assign(properties, globalOptions), $(target).find('#content > *'));
+
+// Image render
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+ dataset_string_template
+ static
+ dataset
+ playlist
+ fa fa-font
+ String template with placeholders
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ Snippets
+ Choose data set snippet
+ dataSetId
+
+
+
+
+
+
+
+ Show items side by side?
+ Should items be shown side by side?
+ 0
+
+
+
+
+
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+
+
+
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1)
+ 1000
+
+
+ none
+ noTransition
+
+
+
+
+
+ Items per page
+ If an effect has been selected, how many pages should we split the items across? If you don't enter anything here 1 item will be put on each page.
+ 1
+
+
+ none
+ marqueeLeft
+ marqueeRight
+ marqueeUp
+ marqueeDown
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+
+
+
+
+ ' : '';
+ }
+
+ return itemValue ? itemValue : '';
+ });
+
+ // Add the content to the target
+ $(target).find('#content').append(
+ $('
')
+ .addClass('item')
+ .append(content)
+ );
+}
+
+// Scale the layout
+$('body').xiboLayoutScaler(properties);
+
+$(target).xiboTextRender(Object.assign(properties, globalOptions), $(target).find('#content > *'));
+
+// Image render
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+ dataset_marquee
+ static
+ dataset
+ Dataset shown in a marquee
+ 1200
+ 80
+
+
+ Select a dataset to display appearance options.
+
+
+
+
+
+
+
+ Below you can select the columns to be shown in the table - drag and drop to reorder and to move between lists.
+
+
+
+
+
+
+
+ dataSetId
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ marqueeLeft
+
+
+
+
+
+
+
+ Speed
+ Marquee Speed in a low to high scale (normal = 1)
+ 1
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+
+
+
+
+
+ Gap between tags
+ Value (in pixels) to set a gap between each item's tags.
+ 6
+
+
+
+
+
+
+
+ Gap between items
+ Value (in pixels) to set a gap between each item.
+ 6
+
+
+
+
+
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+
+
+
+
+
+ Text direction
+ Which direction does the text in the feed use?
+ ltr
+
+
+
+
+
+
+
+
+
+
+
+ Show a separator between items?
+ 0
+
+
+ none
+ noTransition
+ fade
+ fadeout
+ scrollHorz
+ scrollVert
+ flipHorz
+ flipVert
+ shuffle
+ tileSlide
+ tileBlind
+
+
+
+
+
+ Separator
+ A separator to show between marquee items
+
+ /
+ ]]>
+
+
+ none
+ noTransition
+ fade
+ fadeout
+ scrollHorz
+ scrollVert
+ flipHorz
+ flipVert
+ shuffle
+ tileSlide
+ tileBlind
+ 1
+
+
+
+
+
+ Copyright
+ Copyright information to display as the last item in this feed.
+
+
+
+
+
+
+
+ Copyright Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+
+
+
+
+
+
+ Copyright Font Colour
+
+
+
+
+
+
+
+
+
+ Copyright Font Size
+
+
+
+
+
+
+
+
+
+ Bold
+ Should the copyright text be bold?
+ 0
+
+
+
+
+
+
+
+
+ Italics
+ Should the copyright text be italicised?
+ 0
+
+
+
+
+
+
+
+
+ Underline
+ Should the copyright text be underlined?
+ 0
+
+
+
+
+
+
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+
+
+
+
+
+
+ {{javaScript|raw}}{% endif %}
+ ]]>
+
+
+ [' + columnInfo[column.colId].name + '|' + column.colId + ']';
+}
+
+
+// Clear containers
+$(target).find('#content').empty();
+$(target).find('.no-data-message').remove();
+
+var hasAddedItems = false;
+if (items.length <= 0 && properties.noDataMessage && properties.noDataMessage !== '') {
+ // No data message
+ // Add message to the target content
+ $(target).find('#content').after(
+ $('
')
+ .addClass('no-data-message')
+ .append(properties.noDataMessage)
+ );
+} else {
+ // Add items to container
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+
+ // Replace the template with the item content
+ var content = properties.template.replace(/\[(.*?)\]/g, function(match, column) {
+ var itemId = column.split('|')[0];
+ var itemCol = column.split('|')[1];
+ var itemType = (itemCol) ? columnInfo[itemCol].type : '';
+ var itemValue = item[itemId];
+
+ // If it's a date and we have date format
+ if (itemType === 3 && properties.dateFormat) {
+ itemValue = moment(itemValue).format(properties.dateFormat);
+ }
+
+ // If this is an image column, wrap it in an image tag
+ if (itemType === 4 || itemType === 5) {
+ itemValue = itemValue ? '' : '';
+ }
+
+ return itemValue ? itemValue : '';
+ });
+
+ // Add the content to the target
+ if(content != "") {
+ hasAddedItems = true;
+ $(target).find('#content').append(
+ $('
')
+ .addClass('item')
+ .append(copyrightTemplate)
+ );
+}
+
+// Add separator
+if (
+ (
+ properties.effect == 'marqueeLeft' ||
+ properties.effect == 'marqueeRight' ||
+ properties.effect == 'marqueeUp' ||
+ properties.effect == 'marqueeDown'
+ ) && properties.showSeparator == 1 &&
+ properties.separator != ''
+) {
+ var $separator = $(properties.separator);
+ $separator.addClass('separator');
+ $(target).find('.item').after($separator);
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > *'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
diff --git a/modules/templates/emergency-alert-elements.xml b/modules/templates/emergency-alert-elements.xml
new file mode 100644
index 0000000..5675124
--- /dev/null
+++ b/modules/templates/emergency-alert-elements.xml
@@ -0,0 +1,154 @@
+
+
+
+ source
+ text
+ Source
+ element
+ emergency-alert
+ fas fa-external-link-alt
+ true
+ 500
+ 80
+
+
+ note
+ text
+ Note
+ element
+ emergency-alert
+ fas fa-sticky-note
+ true
+ 500
+ 80
+
+
+ event
+ text
+ Event
+ element
+ emergency-alert
+ fas fa-calendar-o
+ true
+ 500
+ 80
+
+
+ dateTimeEffective
+ date
+ Date & Time Effective
+ element
+ emergency-alert
+ fas fa-clock
+ true
+ 450
+ 80
+
+
+ dateTimeOnset
+ date
+ Date & Time Onset
+ element
+ emergency-alert
+ fas fa-clock
+ true
+ 450
+ 80
+
+
+ dateTimeExpires
+ date
+ Date & Time Expires
+ element
+ emergency-alert
+ fas fa-clock
+ true
+ 450
+ 80
+
+
+ senderName
+ text
+ Sender Name
+ element
+ emergency-alert
+ fas fa-address-card
+ true
+ 500
+ 80
+
+
+ headline
+ text
+ Headline
+ element
+ emergency-alert
+ fas fa-newspaper-o
+ true
+ 500
+ 80
+
+
+ description
+ text
+ Description
+ element
+ emergency-alert
+ fas fa-align-justify
+ true
+ 700
+ 350
+
+
+ instruction
+ text
+ Instruction
+ element
+ emergency-alert
+ fas fa-list
+ true
+ 500
+ 80
+
+
+ contact
+ text
+ Contact
+ element
+ emergency-alert
+ fas fa-phone-square
+ true
+ 500
+ 80
+
+
+ areaDesc
+ text
+ Area Description
+ element
+ emergency-alert
+ fas fa-map
+ true
+ 700
+ 80
+
+
diff --git a/modules/templates/event-elements.xml b/modules/templates/event-elements.xml
new file mode 100644
index 0000000..2425b12
--- /dev/null
+++ b/modules/templates/event-elements.xml
@@ -0,0 +1,494 @@
+
+
+
+ event_summary
+ text
+ Summary
+ element
+ event
+ true
+ fa fa-font
+ 300
+ 80
+
+
+ event_description
+ text
+ Description
+ element
+ event
+ true
+ fa fa-font
+ 650
+ 250
+
+
+ event_startDate
+ date
+ Start Date
+ element
+ event
+ true
+ fas fa-calendar-week
+ 450
+ 80
+
+
+ event_endDate
+ date
+ End Date
+ element
+ event
+ true
+ fas fa-calendar-week
+ 450
+ 80
+
+
+ event_location
+ text
+ Location
+ element
+ event
+ true
+ fas fa-map-marker-alt
+ 650
+ 250
+
+
+ calendar_event_detailed
+ element-group
+ event
+ Calendar Detailed Event
+ calendar-event-detailed
+
+
+ 300
+ 300
+
+
+ Background
+ 0
+ 0
+ 0
+ 300
+ 300
+
+ rgba(74,74,74,0.66)
+ 0
+ 1
+ 12
+
+
+
+ Separator
+ 90
+ 60
+ 230
+ 30
+ 0
+
+ #4a4a4a
+ 7
+ dotted
+
+
+
+ Day background
+ 10
+ 10
+ 110
+ 110
+ 1
+
+ #4a4a4a
+ 0
+
+
+
+ Day of the month
+ 25
+ 25
+ 80
+ 80
+ 2
+ 0
+
+ d
+ linear regular
+ 80
+ 1.25
+ 1
+ #6fedf8
+ center
+ center
+
+
+
+ Day of the week
+ 10
+ 120
+ 170
+ 45
+ 2
+ 0
+
+ l
+ railway regular
+ 30
+ #fff
+ flex-end
+ center
+
+
+
+ Hour
+ 60
+ 120
+ 170
+ 30
+ 2
+ 0
+
+ H:i
+ railway regular
+ 24
+ #fff
+ flex-end
+ center
+
+
+
+ Summary
+ 130
+ 10
+ 280
+ 35
+ 0
+ 2
+
+ aileron regular
+ 1
+ 24
+ #6fedf8
+ flex-start
+ flex-end
+
+
+
+ Description
+ 165
+ 10
+ 280
+ 125
+ 2
+ 0
+
+ aileron regular
+ 22
+ #fff
+ flex-start
+ flex-start
+ 0
+ 1
+
+
+
+
+
+
+
+
+
+ calendar_event_simple
+ element-group
+ event
+ Calendar Simple Event
+ calendar-event-simple
+
+
+ 400
+ 160
+
+
+ Background
+ 0
+ 0
+ 0
+ 400
+ 160
+
+ rgba(234,234,234,0.66)
+ 0
+
+
+
+ Header Background
+ 0
+ 0
+ 400
+ 55
+ 1
+
+ rgba(66,66,66,0.64)
+ 0
+
+
+
+ Day of the week
+ 5
+ 5
+ 390
+ 45
+ 0
+ 2
+
+ l
+ aileron regular
+ 1
+ 32
+ #ffffff
+ flex-start
+ center
+ 0
+ 1.5
+
+
+
+ Summary
+ 55
+ 5
+ 390
+ 40
+ 0
+ 2
+
+ aileron regular
+ 24
+ #2f5c7d
+ flex-start
+ center
+ 0
+ 1.5
+
+
+
+ Hour
+ 95
+ 5
+ 390
+ 35
+ 0
+ 2
+
+ M jS @ H:i
+ aileron regular
+ 18
+ #535353
+ flex-start
+ center
+ 0
+ 1.5
+
+
+
+ Location
+ 130
+ 5
+ 390
+ 25
+ 2
+ 0
+
+ aileron regular
+ 16
+ #828282
+ flex-start
+ flex-start
+ 0
+ 1
+
+
+
+
+
+
+
+
+
+ calendar_event_row
+ element-group
+ event
+ Calendar Event Row
+ calendar-event-row
+
+
+ 600
+ 100
+
+
+ Background
+ 0
+ 0
+ 0
+ 600
+ 100
+
+ rgba(25,25,25,0.7)
+ 1
+ 12
+ 0
+
+
+
+ Separator
+ 0
+ 200
+ 10
+ 100
+ 1
+
+ rgba(201,84,59,0.89)
+ 0
+
+
+
+ Day
+ 10
+ 10
+ 180
+ 40
+ 0
+ 2
+
+ d F
+ aileron regular
+ 1
+ 30
+ #e9e9e9
+ flex-end
+ center
+ 0
+
+
+
+ Week day
+ 50
+ 10
+ 180
+ 40
+ 0
+ 2
+
+ D
+ aileron regular
+ 1
+ 26
+ #cacaca
+ flex-end
+ center
+ 0
+
+
+
+ Summary
+ 10
+ 220
+ 380
+ 40
+ 0
+ 2
+
+ aileron regular
+ 1
+ 30
+ #e9e9e9
+ flex-start
+ center
+ 0
+
+
+
+ Start Date
+ 50
+ 220
+ 60
+ 40
+ 0
+ 2
+
+ H:i
+ aileron regular
+ 1
+ 20
+ #d18170
+ flex-end
+ center
+ 0
+ 2
+
+
+
+ End Date
+ 50
+ 285
+ 65
+ 40
+ 0
+ 2
+
+ - H:i
+ aileron regular
+ 1
+ 20
+ #d18170
+ flex-start
+ center
+ 0
+ 2
+
+
+
+ Location
+ 50
+ 360
+ 230
+ 40
+ 2
+ 0
+
+ aileron regular
+ 18
+ #bcbcbc
+ flex-start
+ flex-start
+ 0
+ 2
+
+
+
+
+
+
+
+
+
diff --git a/modules/templates/event-static.xml b/modules/templates/event-static.xml
new file mode 100644
index 0000000..ee691a2
--- /dev/null
+++ b/modules/templates/event-static.xml
@@ -0,0 +1,3367 @@
+
+
+
+
+ event_custom_html
+ static
+ event
+ none
+ Events shown with custom HTML
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1)
+ 1000
+
+
+ Show items side by side?
+ Should items be shown side by side?
+ 0
+
+
+ Items per page
+ If an effect has been selected, how many pages should we split the items across? If you don't enter anything here 1 item will be put on each page.
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Item Template
+ Enter text in the box below, used to display each article.
+
+
+ Snippets
+ Choose data type snippet
+
+
+ No data message
+ A message to display when no data is returned from the source
+
+
+ Optional Stylesheet Template
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+
+ {{javaScript|raw}}{% endif %}
+ ]]>
+
+
+ 0) {
+ items = $(items).xiboSubstitutesParser(properties.text, properties.dateFormat, ['startDate', 'endDate']);
+}
+
+// No data message
+if (items.length <= 0 && properties.noDataMessage && properties.noDataMessage !== '') {
+ items.push(properties.noDataMessage);
+}
+
+// Clear container
+$(target).find('#content').empty();
+
+// Add items to container
+for (var index = 0; index < items.length; index++) {
+ var $newItem = $('
').addClass('event').html(items[index]).appendTo('body');
+ $(target).find('#content').append($newItem);
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .event'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ daily_light
+ static
+ event
+ Daily Calendar - Light
+ daily_light
+ 800
+ 500
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ First hour slot
+ This view features a grid running from midnight to midnight. Use the first slot to shorten the time window shown.
+
+
+
+ Last hour slot
+ This view features a grid running from midnight to midnight. Use the last slot to shorten the time window shown.
+
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Start at the current time?
+ Should the calendar start at the current time, or at the time of the first event?
+ 1
+
+
+ Show now marker?
+ Should the calendar show a marker for the current time?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Grid step
+ Duration, in minutes, for each row in the grid.
+ 60
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #dadada
+
+
+ Grid Text Colour
+ #505050
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #292929
+
+
+ Calendar Days
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #3e4e63
+
+
+ Current day text Colour
+ #65668d
+
+
+ Now marker Colour
+ #ff2525
+
+
+ Events
+
+
+ Background Colour
+ #9264A6
+
+
+ Text Colour
+ #fff
+
+
+ All day events
+
+
+ Background Colour
+ #70497E
+
+
+ Text Colour
+ #fff
+
+
+ Multiple days events
+
+
+ Background Colour
+ #603D6B
+
+
+ Text Colour
+ #fff
+
+
+ Aditional days container
+
+
+ Background Colour
+ #dcdcdc
+
+
+ Text Colour
+ #656565
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+ daily_dark
+ static
+ event
+ Daily Calendar - Dark
+ daily_dark
+ 800
+ 500
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ First hour slot
+ This view features a grid running from midnight to midnight. Use the first slot to shorten the time window shown.
+
+
+
+ Last hour slot
+ This view features a grid running from midnight to midnight. Use the last slot to shorten the time window shown.
+
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Start at the current time?
+ Should the calendar start at the current time, or at the time of the first event?
+ 1
+
+
+ Show now marker?
+ Should the calendar show a marker for the current time?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Grid step
+ Duration, in minutes, for each row in the grid.
+ 60
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #545454
+
+
+ Grid Text Colour
+ #bbbbbb
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #1e1e1e
+
+
+ Text Colour
+ #bbbbbb
+
+
+ Calendar Days
+
+
+ Background Colour
+ #303030
+
+
+ Text Colour
+ #bbbbbb
+
+
+ Current day text Colour
+ #ef4050
+
+
+ Now marker Colour
+ #ef4050
+
+
+ Events
+
+
+ Background Colour
+ #a5daff
+
+
+ Text Colour
+ #434343
+
+
+ All day events
+
+
+ Background Colour
+ #1d6ca3
+
+
+ Text Colour
+ #e9e9e9
+
+
+ Multiple days events
+
+
+ Background Colour
+ #2d75a7
+
+
+ Text Colour
+ #e9e9e9
+
+
+ Aditional days container
+
+
+ Background Colour
+ #4b4b4b
+
+
+ Text Colour
+ #bbbbbb
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+ weekly_light
+ static
+ event
+ Weekly Calendar - Light
+ weekly_light
+ 800
+ 500
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ First hour slot
+ This view features a grid running from midnight to midnight. Use the first slot to shorten the time window shown.
+
+
+
+ Last hour slot
+ This view features a grid running from midnight to midnight. Use the last slot to shorten the time window shown.
+
+
+
+ Start at the current time?
+ Should the calendar start at the current time, or at the time of the first event?
+ 1
+
+
+ Exclude weekend days?
+ Saturdays and Sundays wont be shown.
+
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Show now marker?
+ Should the calendar show a marker for the current time?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Week name length
+ Please select the length for the week names.
+ long
+
+
+
+
+
+
+
+ Grid step
+ Duration, in minutes, for each row in the grid.
+ 60
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #dadada
+
+
+ Grid Text Colour
+ #505050
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #3e4e63
+
+
+ Calendar Days
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #3e4e63
+
+
+ Current day text Colour
+ #e37918
+
+
+ Now marker Colour
+ #e37918
+
+
+ Events
+
+
+ Background Colour
+ #506DC3
+
+
+ Text Colour
+ #fff
+
+
+ All day events
+
+
+ Background Colour
+ #4D3394
+
+
+ Text Colour
+ #fff
+
+
+ Multiple days events
+
+
+ Background Colour
+ #452E85
+
+
+ Text Colour
+ #fff
+
+
+ Aditional days container
+
+
+ Background Colour
+ #efefef
+
+
+ Text Colour
+ #3e4e63
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+ weekly_dark
+ static
+ event
+ Weekly Calendar - Dark
+ weekly_dark
+ 800
+ 500
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ First hour slot
+ This view features a grid running from midnight to midnight. Use the first slot to shorten the time window shown.
+
+
+
+ Last hour slot
+ This view features a grid running from midnight to midnight. Use the last slot to shorten the time window shown.
+
+
+
+ Exclude weekend days?
+ Saturdays and Sundays wont be shown.
+
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Start at the current time?
+ Should the calendar start at the current time, or at the time of the first event?
+ 1
+
+
+ Show now marker?
+ Should the calendar show a marker for the current time?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Week name length
+ Please select the length for the week names.
+ long
+
+
+
+
+
+
+
+ Grid step
+ Duration, in minutes, for each row in the grid.
+ 60
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #545454
+
+
+ Grid Text Colour
+ #bbbbbb
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #1e1e1e
+
+
+ Text Colour
+ #bbbbbb
+
+
+ Calendar Days
+
+
+ Background Colour
+ #303030
+
+
+ Text Colour
+ #bbbbbb
+
+
+ Current day text Colour
+ #ef4050
+
+
+ Now marker Colour
+ #ef4050
+
+
+ Events
+
+
+ Background Colour
+ #d7c9ff
+
+
+ Text Colour
+ #434343
+
+
+ All day events
+
+
+ Background Colour
+ #6639eb
+
+
+ Text Colour
+ #e9e9e9
+
+
+ Multiple days events
+
+
+ Background Colour
+ #6639eb
+
+
+ Text Colour
+ #e9e9e9
+
+
+ Aditional days container
+
+
+ Background Colour
+ #4b4b4b
+
+
+ Text Colour
+ #bbbbbb
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+ monthly_light
+ static
+ event
+ Monthly Calendar - Light
+ monthly_light
+ 800
+ 500
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ Exclude weekend days?
+ Saturdays and Sundays wont be shown.
+
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Start at the current time?
+ Should the calendar start at the current time, or at the time of the first event?
+ 1
+
+
+ Show header?
+ Should the selected template have a header?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Week name length
+ Please select the length for the week names.
+ long
+
+
+
+
+
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #dadada
+
+
+ Header (Month)
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #3e4e63
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #3e4e63
+
+
+ Calendar Days
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Text Colour
+ #3e4e63
+
+
+ Current day text Colour
+ #fb8100
+
+
+ Other Month Days
+
+
+ Background Colour
+ #efefef
+
+
+ Text Colour
+ #3e4e63
+
+
+ Events
+
+
+ Background Colour
+ transparent
+
+
+ Text Colour
+ #3e4e63
+
+
+ All day events
+
+
+ Background Colour
+ #4174B5
+
+
+ Text Colour
+ #fff
+
+
+ Multiple days events
+
+
+ Background Colour
+ #4174B5
+
+
+ Text Colour
+ #fff
+
+
+ Aditional days container
+
+
+ Background Colour
+ #efefef
+
+
+ Text Colour
+ #3e4e63
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+ monthly_dark
+ static
+ event
+ Monthly Calendar - Dark
+ monthly_dark
+ 800
+ 500
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ Exclude weekend days?
+ Saturdays and Sundays wont be shown.
+
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Start at the current time?
+ Should the calendar start at the current time, or at the time of the first event?
+ 1
+
+
+ Show header?
+ Should the selected template have a header?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Week name length
+ Please select the length for the week names.
+ long
+
+
+
+
+
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #dddddd
+
+
+ Header (Month)
+
+
+ Background Colour
+ #404040
+
+
+ Text Colour
+ #ebebeb
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #606060
+
+
+ Text Colour
+ #ebebeb
+
+
+ Calendar Days
+
+
+ Background Colour
+ #303030
+
+
+ Text Colour
+ #ebebeb
+
+
+ Current day text Colour
+ #ff9922
+
+
+ Other Month Days
+
+
+ Background Colour
+ #404040
+
+
+ Text Colour
+ #afafaf
+
+
+ Events
+
+
+ Background Colour
+ transparent
+
+
+ Text Colour
+ #edf5ff
+
+
+ All day events
+
+
+ Background Colour
+ #bcfbff
+
+
+ Text Colour
+ #454545
+
+
+ Multiple days events
+
+
+ Background Colour
+ #bcfbff
+
+
+ Text Colour
+ #454545
+
+
+ Aditional days container
+
+
+ Background Colour
+ #616161
+
+
+ Text Colour
+ #eaeaea
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+ schedule_light
+ static
+ event
+ Schedule Calendar - Light
+ schedule_light
+ 400
+ 800
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ No events message
+ Message to be shown if no events are returned.
+ No upcoming events
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Show now marker?
+ Should the calendar show a marker for the current time?
+ 1
+
+
+ Show event description?
+ Should events with descriptions display them?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #6e6e6e
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #ffffff
+
+
+ Text Colour
+ #3e3e3e
+
+
+ Calendar Days
+
+
+ Background Colour
+ #f9f9f9
+
+
+ Current day text Colour
+ #f53030
+
+
+ Now marker Colour
+ #f53030
+
+
+ Events
+
+
+ Background Colour
+ #e2ddff
+
+
+ Text Colour
+ #353535
+
+
+ All day events
+
+
+ Background Colour
+ #8174d4
+
+
+ Text Colour
+ #ffffff
+
+
+ Multiple days events
+
+
+ Background Colour
+ #695ac6
+
+
+ Text Colour
+ #ffffff
+
+
+ No events message
+
+
+ Background Colour
+ #695ac6
+
+
+ Text Colour
+ #ffffff
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+ schedule_dark
+ static
+ event
+ Schedule Calendar - Dark
+ schedule_dark
+ 400
+ 800
+
+
+ This template uses features which will not work on devices with a browser older than Chrome 57, including webOS older than 6 and Tizen older than 5.
+
+
+ No events message
+ Message to be shown if no events are returned.
+ "No upcoming events"
+
+
+ Time Format
+ The format to apply to event time (default HH:mm).
+ HH:mm
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Show now marker?
+ Should the calendar show a marker for the current time?
+ 1
+
+
+ Show event description?
+ Should events with descriptions display them?
+ 1
+
+
+ Text scale
+ Set the scale for the text element on the calendar.
+ 1.0
+
+
+ Colours
+
+
+ Use the colour pickers to override the element colours.
+
+
+ Grid Colour
+ #1b1b1b
+
+
+ Header (Weekdays)
+
+
+ Background Colour
+ #606060
+
+
+ Text Colour
+ #f0f0f0
+
+
+ Calendar Days
+
+
+ Background Colour
+ #535353
+
+
+ Current day text Colour
+ #afb1ff
+
+
+ Now marker Colour
+ #ff2525
+
+
+ Events
+
+
+ Background Colour
+ #9264A6
+
+
+ Text Colour
+ #fff
+
+
+ All day events
+
+
+ Background Colour
+ #70497E
+
+
+ Text Colour
+ #fff
+
+
+ Multiple days events
+
+
+ Background Colour
+ #603D6B
+
+
+ Text Colour
+ #fff
+
+
+ No events message
+
+
+ Background Colour
+ #695ac6
+
+
+ Text Colour
+ #ffffff
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
diff --git a/modules/templates/forecast-elements.xml b/modules/templates/forecast-elements.xml
new file mode 100644
index 0000000..cc3cca3
--- /dev/null
+++ b/modules/templates/forecast-elements.xml
@@ -0,0 +1,1335 @@
+
+
+
+ weather_summary
+ text
+ Summary
+ element
+ forecast
+ true
+ fa fa-font
+ 360
+ 100
+
+
+ weather_location
+ text
+ Location
+ element
+ forecast
+ true
+ fa fa-map
+ 360
+ 100
+
+
+ weather_temperature
+ text
+ Temperature
+ element
+ forecast
+ true
+ fas fa-thermometer-half
+ 150
+ 150
+ °' + temperatureUnit + '';
+}
+ ]]>
+
+
+ weather_min_temperature
+ text
+ Min. Temperature
+ element
+ forecast
+ true
+ fas fa-thermometer-empty
+ 150
+ 150
+ °' + temperatureUnit + '';
+}
+ ]]>
+
+
+ weather_max_temperature
+ text
+ Max. Temperature
+ element
+ forecast
+ true
+ fas fa-thermometer-full
+ 150
+ 150
+ °' + temperatureUnit + '';
+}
+ ]]>
+
+
+ weather_humidity_percent
+ text
+ Humidity Percent
+ element
+ forecast
+ true
+ fas fa-percentage
+ 150
+ 150
+
+
+
+ weather_icon
+ Icon
+ element
+ forecast
+ true
+ fas fa-sun
+ 100
+ 100
+
+
+ Font Colour
+ %THEME_COLOR%
+
+
+ Font Size
+ 40
+
+
+ 1
+
+
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ weathericons
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ weather_wind_direction
+ text
+ Wind Direction
+ element
+ forecast
+ true
+ fas fa-location-arrow
+ 100
+ 100
+
+
+ weather_wind_speed
+ text
+ Wind Speed
+ element
+ forecast
+ true
+ fas fa-tachometer-alt
+ 100
+ 100
+
+
+ weather_wind_speed_unit
+ text
+ Wind Speed Unit
+ element
+ forecast
+ true
+ fas fa-wind
+ 100
+ 100
+
+
+ Use Slash for Units
+ Use '/' instead of 'p' to represent units of measure (e.g., m/s instead of mps).
+ 0
+
+
+
+
+
+ weather_attribution
+ text
+ Attribution
+ element
+ forecast
+ true
+ fa fa-font
+ 360
+ 100
+ div:first-child' : '.global-elements-text > div:first-child';
+ $(target).find($childElem).html(meta.Attribution);
+}
+
+if(properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = 'div';
+
+ // Scale text to container
+ $(target).find('.global-elements-text').xiboTextScaler(properties);
+}
+ ]]>
+
+
+ weather_condition_background_image
+ Image
+ element
+ forecast
+ true
+ fa fa-image
+ 480
+ 600
+
+
+ Round Border
+ 0
+ Should the square have rounded corners?
+
+
+ Border Radius
+ 20
+
+
+ 1
+
+
+
+
+ Scale type
+ How should this image be scaled?
+ cover
+
+
+
+
+
+
+
+ Horizontal Align
+ How should this image be aligned?
+ center
+
+
+
+
+
+
+
+ fill
+
+
+
+
+ Vertical Align
+ How should this image be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ fill
+
+
+
+
+ Images
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather images.
+
+
+ Cloudy
+
+
+ Cloudy day
+
+
+ Clear
+
+
+ Fog
+
+
+ Hail
+
+
+ Clear night
+
+
+ Cloudy night
+
+
+ Raining
+
+
+ Snowing
+
+
+ Windy
+
+
+ bg-
+
+
+
+
+ {{set 'bgImg' (weatherBackgroundImage data.icon cloudyImage dayCloudyImage dayClearImage fogImage hailImage nightClearImage nightPartlyCloudyImage rainImage snowImage windImage)}}
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ weather_date
+ date
+ Date
+ element
+ forecast
+ true
+ fas fa-calendar-week
+ 420
+ 100
+
+
+ Date Format
+ M d
+
+
+ 0) {
+ weatherDate.locale(properties.lang);
+ }
+
+ // Format the date with the dateFormat property
+ var formattedDate = weatherDate.format(properties.dateFormat);
+
+ // Set the date div value to the formatted date
+ $(dateEl).html(formattedDate);
+
+ if (properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = '.date';
+
+ // Scale text to container
+ $(target).find('.global-elements-date').xiboTextScaler(properties);
+ }
+});
+ ]]>
+
+
+ weather_group_1
+ element-group
+ forecast
+ Forecast 1
+ forecast-group-1
+
+
+ 940
+ 400
+
+
+ Group 0
+ 0
+ 0
+ 0
+ 940
+ 400
+ 0
+ 1
+
+
+ Group 1
+ 25
+ 20
+ 0
+ 300
+ 350
+ 0
+ 1
+
+
+ Group 2
+ 120
+ 320
+ 2
+ 200
+ 160
+ 1
+ 1
+
+
+ Group 3
+ 120
+ 520
+ 2
+ 200
+ 160
+ 2
+ 1
+
+
+ Group 4
+ 120
+ 720
+ 2
+ 200
+ 160
+ 3
+ 1
+
+
+
+
+ Background
+ 100
+ 0
+ 1
+ 940
+ 200
+
+ rgba(84,103,228,0.8)
+ 0
+ 1
+ 24
+
+
+
+ Day 1 Background
+ 0
+ 20
+ 0
+ 300
+ 400
+
+ rgba(0,0,0,0.7)
+ 1
+ 24
+ 0
+
+
+
+ Date
+ 0
+ 0
+ 2
+ 300
+ 55
+
+ #f9f9f9
+ 60
+ 1
+ 1
+ flex-start
+
+
+
+ Icon
+ 100
+ 0
+ 2
+ 300
+ 80
+
+ #f9f9f9
+ 66
+
+
+
+ Temperature
+ 175
+ 0
+ 2
+ 300
+ 80
+
+ aileron regular
+ #f9f9f9
+ 60
+ 1
+ 0.8
+ flex-end
+
+
+
+ Summary
+ 295
+ 0
+ 2
+ 300
+ 30
+
+ #f9f9f9
+ 32
+ 1
+ 0.8
+ flex-start
+
+
+
+ Attribution
+ 325
+ 0
+ 2
+ 300
+ 25
+
+ #f9f9f9
+ 16
+
+
+
+ Date
+ 0
+ 0
+ 2
+ 200
+ 50
+
+ #f9f9f9
+ 32
+ 1
+ flex-start
+
+
+
+ Icon
+ 50
+ 0
+ 2
+ 200
+ 50
+
+ #f9f9f9
+ 40
+
+
+
+ Temperature
+ 100
+ 0
+ 2
+ 200
+ 60
+
+ aileron regular
+ #f9f9f9
+ 40
+ 1
+ 1
+ flex-end
+
+
+
+ Date
+ 0
+ 0
+ 2
+ 200
+ 50
+
+ #f9f9f9
+ 32
+ 1
+ flex-start
+
+
+
+ Icon
+ 50
+ 0
+ 2
+ 200
+ 50
+
+ #f9f9f9
+ 40
+
+
+
+ Temperature
+ 100
+ 0
+ 2
+ 200
+ 60
+
+ aileron regular
+ #f9f9f9
+ 40
+ 1
+ 1
+ flex-end
+
+
+
+ Date
+ 0
+ 0
+ 2
+ 200
+ 50
+
+ #f9f9f9
+ 32
+ 1
+ flex-start
+
+
+
+ Icon
+ 50
+ 0
+ 2
+ 200
+ 50
+
+ #f9f9f9
+ 40
+
+
+
+ Temperature
+ 100
+ 0
+ 2
+ 200
+ 60
+
+ aileron regular
+ #f9f9f9
+ 40
+ 1
+ 1
+ flex-end
+
+
+
+
+
+
+
+
+
+ weather_current_1
+ element-group
+ forecast
+ Daily 1
+ forecast-current-1
+
+
+ 380
+ 380
+
+
+ Background
+ 0
+ 0
+ 0
+ 380
+ 380
+
+ #5ad0ff
+ 1
+ 24
+ 0
+
+
+
+ Temp area
+ 131
+ 0
+ 1
+ 380
+ 126
+
+ rgba(255,255,255,0.5)
+ 0
+
+
+
+ Line top
+ 97
+ 0
+ 2
+ 380
+ 68
+
+ 2
+ #f9f9f9
+ solid
+
+
+
+ Line top
+ 223
+ 0
+ 2
+ 380
+ 68
+
+ 2
+ #f9f9f9
+ solid
+
+
+
+ Icon
+ 19
+ 77
+ 3
+ 225
+ 90
+
+ #f9f9f9
+ 78
+
+
+
+ Summary
+ 282
+ 20
+ 3
+ 340
+ 35
+
+ #f9f9f9
+ 32
+ 1
+
+
+
+ Attribution
+ 332
+ 20
+ 3
+ 340
+ 28
+
+ #f9f9f9
+ 16
+
+
+
+ Temperature
+ 153
+ 101
+ 3
+ 179
+ 102
+
+ aileron regular
+ #04b1f5
+ 80
+ 1
+
+
+
+
+
+
+
+
+
+ weather_current_2
+ element-group
+ forecast
+ Daily 2
+ forecast-current-2
+
+
+ 900
+ 400
+
+
+ Background
+ 0
+ 0
+ 0
+ 900
+ 400
+
+ forecast-current-2-
+ 1
+ 24
+ 0
+
+
+
+ Summary
+ 214
+ 49
+ 3
+ 360
+ 56
+
+ #ffff
+ 40
+ flex-start
+ 1
+
+
+
+ Attribution
+ 329
+ 49
+ 3
+ 351
+ 50
+
+ #f9f9f9
+ flex-start
+ 24
+
+
+
+ Temperature
+ 71
+ 49
+ 3
+ 220
+ 126
+
+ #fff
+ 120
+ flex-start
+ 1
+
+
+
+
+
+
+
+
+
+ weather_current_3
+ element-group
+ forecast
+ Daily 3
+ forecast-current-3
+
+
+ 400
+ 500
+
+
+ Background
+ 0
+ 0
+ 0
+ 400
+ 500
+
+ #865bff
+ 1
+ 24
+ 0
+
+
+
+ Temp area
+ 234
+ 0
+ 1
+ 400
+ 68
+
+ rgba(0,0,0,0.12)
+ 0
+
+
+
+ Line left
+ 354
+ 100
+ 2
+ 44
+ 59
+ 90
+
+ 2
+ #f9f9f9
+ solid
+
+
+
+ Line right
+ 354
+ 256
+ 2
+ 44
+ 59
+ 90
+
+ 2
+ #f9f9f9
+ solid
+
+
+
+ Icon
+ 100
+ 80
+ 3
+ 237
+ 140
+
+ #f9f9f9
+ 100
+
+
+
+ Summary
+ 250
+ 68
+ 3
+ 272
+ 39
+
+ #f9f9f9
+ 32
+ 1
+
+
+
+ Attribution
+ 436
+ 51
+ 3
+ 296
+ 32
+
+ #f9f9f9
+ 16
+
+
+
+ Temperature
+ 334
+ 51
+ 3
+ 50
+ 99
+
+ #f9f9f9
+ 24
+
+
+
+ Wind Speed
+ 335
+ 136
+ 3
+ 61
+ 100
+
+ #f9f9f9
+ 24
+
+
+
+ Wind Speed
+ 335
+ 206
+ 3
+ 56
+ 100
+
+ #f9f9f9
+ 24
+
+
+
+ Humidity
+ 334
+ 297
+ 3
+ 50
+ 102
+
+ #f9f9f9
+ 24
+
+
+
+ Date
+ 40
+ 68
+ 3
+ 272
+ 39
+
+ g:i A
+ #f9f9f9
+ 32
+
+
+
+ Humidity Icon
+ 320
+ 307
+ 3
+ 32
+ 33
+
+ icon-humidity
+
+
+
+ Wind Icon
+ 320
+ 180
+ 3
+ 32
+ 32
+
+ icon-wind
+
+
+
+ Weather Icon
+ 320
+ 61
+ 3
+ 32
+ 32
+
+ icon-weather
+
+
+
+
+
+
+
+
+
+
+
+
+ weather_current_4
+ element-group
+ forecast
+ Daily 4
+ forecast-current-4
+
+
+ 500
+ 440
+
+
+ Background
+ 0
+ 0
+ 0
+ 500
+ 440
+
+ #212121
+ 1
+ 24
+ 0
+
+
+
+ Background
+ 32
+ 125
+ 0
+ 250
+ 250
+
+ forecast-3d-
+ 1
+ 24
+ 0
+
+
+
+ Summary
+ 296
+ 49
+ 3
+ 407
+ 76
+
+ #ffff
+ 40
+ 1
+
+
+
+ Attribution
+ 358
+ 74
+ 3
+ 351
+ 50
+
+ rgba(249,249,249,0.34)
+ 20
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/templates/forecast-static.xml b/modules/templates/forecast-static.xml
new file mode 100644
index 0000000..3bb450f
--- /dev/null
+++ b/modules/templates/forecast-static.xml
@@ -0,0 +1,7155 @@
+
+
+
+ weather_custom_html
+ static
+ forecast
+ none
+
+
+ 1
+
+
+ forecast
+
+
+ Original Width
+ This is the intended width of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Original Height
+ This is the intended height of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Current Forecast Template
+
+
+ Daily Forecast Template
+
+
+ CSS Style Sheet
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+ Background Image
+ The background image to use
+ none
+
+
+
+
+
+
+
+
+ Backgrounds
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather backgrounds.
+
+
+ none
+
+
+
+
+ Cloudy
+
+
+ none
+
+
+
+
+ Cloudy day
+
+
+ none
+
+
+
+
+ Clear
+
+
+ none
+
+
+
+
+ Fog
+
+
+ none
+
+
+
+
+ Hail
+
+
+ none
+
+
+
+
+ Clear night
+
+
+ none
+
+
+
+
+ Cloudy night
+
+
+ none
+
+
+
+
+ Raining
+
+
+ none
+
+
+
+
+ Snowing
+
+
+ none
+
+
+
+
+ Windy
+
+
+ none
+
+
+
+
+
+
+ {{javaScript|raw}}{% endif %}
+
+{% if attribute(_context, 'background-image') != 'none' %}
+
+{% endif %}
+
+
+{{currentTemplate|raw}}
+
+
+
+{{dailyTemplate|raw}}
+
+ ]]>
+
+
+
+
+ weather_1
+ static
+ forecast
+ Landscape - Current day, 4 day forecast
+ weather_1
+ 960
+ 340
+
+
+ Text
+ The colour of the text
+ #000
+
+
+ Icons
+ The colour of the icons
+ #000
+
+
+ Background
+ The colour of the background
+
+
+
+ Shadow
+ The colour of the shadow
+ rgba(255, 255, 255, 0.5)
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Background Image
+ The background image to use
+ none
+
+
+
+
+
+
+
+
+ Backgrounds
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather backgrounds.
+
+
+ none
+
+
+
+
+ Cloudy
+
+
+ none
+
+
+
+
+ Cloudy day
+
+
+ none
+
+
+
+
+ Clear
+
+
+ none
+
+
+
+
+ Fog
+
+
+ none
+
+
+
+
+ Hail
+
+
+ none
+
+
+
+
+ Clear night
+
+
+ none
+
+
+
+
+ Cloudy night
+
+
+ none
+
+
+
+
+ Raining
+
+
+ none
+
+
+
+
+ Snowing
+
+
+ none
+
+
+
+
+ Windy
+
+
+ none
+
+
+
+
+
+ 960
+ 340
+
+
+{% endif %}
+
+
+
+
+
+
+
[time|ddd]
+
[temperatureRound]°[temperatureUnit]
+
+
+
+
+
+
+
+
+
+
+
[temperatureMaxRound] ° [temperatureUnit]
+
[time|ddd]
+
+
+ ]]>
+
+
+
+
+
+
+
+ weather_2
+ static
+ forecast
+ Landscape - Current day, summary
+ weather_2
+ 340
+ 200
+
+
+ Text
+ The colour of the text
+ #000
+
+
+ Icons
+ The colour of the icons
+ #000
+
+
+ Background
+ The colour of the background
+
+
+
+ Shadow
+ The colour of the shadow
+ rgba(255, 255, 255, 0.5)
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Background Image
+ The background image to use
+ none
+
+
+
+
+
+
+
+
+ Backgrounds
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather backgrounds.
+
+
+ none
+
+
+
+
+ Cloudy
+
+
+ none
+
+
+
+
+ Cloudy day
+
+
+ none
+
+
+
+
+ Clear
+
+
+ none
+
+
+
+
+ Fog
+
+
+ none
+
+
+
+
+ Hail
+
+
+ none
+
+
+
+
+ Clear night
+
+
+ none
+
+
+
+
+ Cloudy night
+
+
+ none
+
+
+
+
+ Raining
+
+
+ none
+
+
+
+
+ Snowing
+
+
+ none
+
+
+
+
+ Windy
+
+
+ none
+
+
+
+
+
+ 340
+ 200
+
+
+{% endif %}
+
+
+
+
+
+
+
+
+
+
+
[temperatureRound]°[temperatureUnit]
+
+
+
+
[summary]
+
+
+
+
[Attribution]
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ weather_3
+ static
+ forecast
+ Landscape - Current day
+ weather_3
+ 340
+ 200
+
+
+ Text
+ The colour of the text
+ #000
+
+
+ Icons
+ The colour of the icons
+ #000
+
+
+ Background
+ The colour of the background
+
+
+
+ Shadow
+ The colour of the shadow
+ rgba(255, 255, 255, 0.5)
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Background Image
+ The background image to use
+ none
+
+
+
+
+
+
+
+
+ Backgrounds
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather backgrounds.
+
+
+ none
+
+
+
+
+ Cloudy
+
+
+ none
+
+
+
+
+ Cloudy day
+
+
+ none
+
+
+
+
+ Clear
+
+
+ none
+
+
+
+
+ Fog
+
+
+ none
+
+
+
+
+ Hail
+
+
+ none
+
+
+
+
+ Clear night
+
+
+ none
+
+
+
+
+ Cloudy night
+
+
+ none
+
+
+
+
+ Raining
+
+
+ none
+
+
+
+
+ Snowing
+
+
+ none
+
+
+
+
+ Windy
+
+
+ none
+
+
+
+
+
+ 340
+ 200
+
+
+{% endif %}
+
+
+
+
+
+
+
+
+
+
+
[summary]
+
[temperatureRound]°[temperatureUnit]
+
+
+
[Attribution]
+
+
+
+
+
+
+
+{% import "forecastio-css-generator.twig" as generateForecastCSS %}
+ ]]>
+
+
+
+
+
+
+
+ weather_4
+ static
+ forecast
+ Landscape - Current day detailed, 4 day forecast
+ weather_4
+ 1200
+ 750
+
+
+ Text
+ The colour of the text
+ #fff
+
+
+ Icons
+ The colour of the icons
+ #fff
+
+
+ Background
+ The colour of the background
+
+
+
+ Shadow
+ The colour of the shadow
+ rgba(0, 0, 0, 0.4)
+
+
+ Footer Background
+ The colour of the footer background
+ #000
+
+
+ Footer Text
+ The colour of the footer text
+ #fff
+
+
+ Footer Icons
+ The colour of the footer icons
+ #fff
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Background Image
+ The background image to use
+ fit
+
+
+
+
+
+
+
+
+ Backgrounds
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather backgrounds.
+
+
+ none
+
+
+
+
+ Cloudy
+
+
+ none
+
+
+
+
+ Cloudy day
+
+
+ none
+
+
+
+
+ Clear
+
+
+ none
+
+
+
+
+ Fog
+
+
+ none
+
+
+
+
+ Hail
+
+
+ none
+
+
+
+
+ Clear night
+
+
+ none
+
+
+
+
+ Cloudy night
+
+
+ none
+
+
+
+
+ Raining
+
+
+ none
+
+
+
+
+ Snowing
+
+
+ none
+
+
+
+
+ Windy
+
+
+ none
+
+
+
+
+
+ 1920
+ 1080
+
+
+{% endif %}
+
+
+
+
+
+
[time|MMM] [time|D]
+
+
+
[temperatureRound]°[temperatureUnit]
+
+
+
+
+
+
+
+
+
+
[time|ddd]
+
+
[temperatureMaxRound] ° [temperatureUnit]
+
+
+ ]]>
+
+
+
+
+
+
+
+ weather_5
+ static
+ forecast
+ Portrait - Current day, 2 day forecast
+ weather_5
+ 540
+ 960
+
+
+ Text
+ The colour of the text
+ #fff
+
+
+ Icons
+ The colour of the icons
+ #fff
+
+
+ Background
+ The colour of the background
+
+
+
+ Shadow
+ The colour of the shadow
+ #111
+
+
+ Footer Background
+ The colour of the footer background
+ #fff
+
+
+ Footer Text
+ The colour of the footer text
+ #333
+
+
+ Footer Icons
+ The colour of the footer icons
+ #333
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Background Image
+ The background image to use
+ fit
+
+
+
+
+
+
+
+
+ Backgrounds
+
+
+ none
+
+
+
+
+ Select images from the media library to replace the default weather backgrounds.
+
+
+ none
+
+
+
+
+ Cloudy
+
+
+ none
+
+
+
+
+ Cloudy day
+
+
+ none
+
+
+
+
+ Clear
+
+
+ none
+
+
+
+
+ Fog
+
+
+ none
+
+
+
+
+ Hail
+
+
+ none
+
+
+
+
+ Clear night
+
+
+ none
+
+
+
+
+ Cloudy night
+
+
+ none
+
+
+
+
+ Raining
+
+
+ none
+
+
+
+
+ Snowing
+
+
+ none
+
+
+
+
+ Windy
+
+
+ none
+
+
+
+
+
+ 540
+ 960
+
+
+{% endif %}
+
+
+ ]]>
+
+
+
+
+
+
+
diff --git a/modules/templates/global-elements.xml b/modules/templates/global-elements.xml
new file mode 100644
index 0000000..bcbbfed
--- /dev/null
+++ b/modules/templates/global-elements.xml
@@ -0,0 +1,2028 @@
+
+
+
+ text
+ Text
+ element
+ global
+ true
+ text-thumb
+ 200
+ 100
+
+
+ Text
+ Text
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Font Colour
+ %THEME_COLOR%
+
+
+ 0
+
+
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Use gradient for the text?
+ Gradients work well with most fonts. If you use a custom font please ensure you test the Layout on your player.
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Font Size
+ 40
+
+
+ 1
+
+
+
+
+ Line Height
+ 1.2
+
+
+ 1
+
+
+
+
+ Bold
+ Should the text be bold?
+ 0
+
+
+ 0
+
+
+
+
+ Italics
+ Should the text be italicised?
+ 0
+
+
+ 0
+
+
+
+
+ Underline
+ Should the text be underlined?
+ 0
+
+
+ 0
+
+
+
+
+ Text Wrap
+ Should the text wrap to the next line?
+ 1
+
+
+ 1
+
+
+
+
+ Justify
+ Should the text be justified?
+ 0
+
+
+ 1
+ 1
+
+
+
+
+ Show Overflow
+ Should the widget overflow the region?
+ 1
+
+
+ 1
+
+
+
+
+ Text Shadow
+ 0
+ Should the text have a shadow?
+
+
+ 0
+
+
+
+
+ Text Shadow Colour
+
+
+ 0
+ 1
+
+
+
+
+ Shadow X Offset
+ 1
+
+
+ 0
+ 1
+
+
+
+
+ Shadow Y Offset
+ 1
+
+
+ 0
+ 1
+
+
+
+
+ Shadow Blur
+ 2
+
+
+ 0
+ 1
+
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ date
+ Date
+ element
+ global
+ none
+ true
+ date-thumb
+ 300
+ 80
+
+
+ Date
+
+
+
+ Date Format
+ d/m/Y H:i:s
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Font Colour
+ %THEME_COLOR%
+
+
+ 0
+
+
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Use gradient for the text?
+ Gradients work well with most fonts. If you use a custom font please ensure you test the Layout on your player.
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Font Size
+ 40
+
+
+ 1
+
+
+
+
+ Line Height
+ 1.2
+
+
+ 1
+
+
+
+
+ Bold
+ Should the text be bold?
+ 0
+
+
+ 0
+
+
+
+
+ Italics
+ Should the text be italicised?
+ 0
+
+
+ 0
+
+
+
+
+ Underline
+ Should the text be underlined?
+ 0
+
+
+ 0
+
+
+
+
+ Text Wrap
+ Should the text wrap to the next line?
+ 1
+
+
+ 1
+
+
+
+
+ Show Overflow
+ Should the widget overflow the region?
+ 1
+
+
+ 1
+
+
+
+
+ Text Shadow
+ 0
+ Should the text have a shadow?
+
+
+ 0
+
+
+
+
+ Text Shadow Colour
+
+
+ 0
+ 1
+
+
+
+
+ Shadow X Offset
+ 1
+
+
+ 0
+ 1
+
+
+
+
+ Shadow Y Offset
+ 1
+
+
+ 0
+ 1
+
+
+
+
+ Shadow Blur
+ 2
+
+
+ 0
+ 1
+
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+
{{date}}
+
+ ]]>
+
+ 0) {
+ globalDate.locale(properties.lang);
+ }
+
+ // Format the date with the dateFormat property
+ var formattedDate = globalDate.format(properties.dateFormat);
+
+ // Set the date div value to the formatted date
+ $(dateEl).html(formattedDate);
+
+
+ if (properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = '.date';
+
+ var $selector = $(target).is('.global-elements-date') ?
+ $(target) : $(target).find('.global-elements-date');
+
+ // Scale text to container
+ $selector.xiboTextScaler(properties);
+ }
+});
+ ]]>
+
+
+
+
+
+ date_advanced
+ Date / Time
+ element
+ global
+ true
+ date-thumb
+ 380
+ 80
+
+
+ Current date?
+ Use the current date to be displayed.
+ 1
+
+
+ Offset
+ The offset in minutes that should be applied to the current date.
+
+
+
+ 1
+
+
+
+
+ Custom Date
+ Insert date to be displayed.
+
+
+
+ 0
+
+
+
+
+ Date Format
+ d/m/Y H:i:s
+
+
+ Language
+ Select the language you would like to use.
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Font Colour
+ %THEME_COLOR%
+
+
+ 0
+
+
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Use gradient for the text?
+ Gradients work well with most fonts. If you use a custom font please ensure you test the Layout on your player.
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Font Size
+ 40
+
+
+ 1
+
+
+
+
+ Line Height
+ 1.2
+
+
+ 1
+
+
+
+
+ Bold
+ Should the text be bold?
+ 0
+
+
+ 0
+
+
+
+
+ Italics
+ Should the text be italicised?
+ 0
+
+
+ 0
+
+
+
+
+ Underline
+ Should the text be underlined?
+ 0
+
+
+ 0
+
+
+
+
+ Text Wrap
+ Should the text wrap to the next line?
+ 1
+
+
+ 1
+
+
+
+
+ Show Overflow
+ Should the widget overflow the region?
+ 1
+
+
+ Text Shadow
+ 0
+ Should the text have a shadow?
+
+
+ 0
+
+
+
+
+ Text Shadow Colour
+
+
+ 0
+ 1
+
+
+
+
+ Shadow X Offset
+ 1
+
+
+ 0
+ 1
+
+
+
+
+ Shadow Y Offset
+ 1
+
+
+ 0
+ 1
+
+
+
+
+ Shadow Blur
+ 2
+
+
+ 0
+ 1
+
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+ 0) {
+ currentDate.locale(properties.lang);
+ }
+
+ $dateEl.html(currentDate.format(properties.dateFormat));
+
+ if (firstRun && properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = '.date-advanced';
+
+ var $selector = $(target).is('.global-elements-date-advanced') ?
+ $(target) : $(target).find('.global-elements-date-advanced');
+
+ // Scale text to container
+ $(target).find('.global-elements-date-advanced').xiboTextScaler(properties);
+
+ firstRun = false;
+ }
+ }, 1000);
+} else {
+ // Use custom date
+
+ // If date is not defined, don't render
+ if (String(properties.date).length === 0) {
+ $dateEl.html('');
+ return;
+ }
+
+ var customDate = moment(properties.date);
+
+ // Check for lang config
+ if (properties.lang !== null && String(properties.lang).length > 0) {
+ customDate.locale(properties.lang);
+ }
+
+ // Format the date with the dateFormat property
+ var formattedDate = customDate.format(properties.dateFormat);
+
+ // Set the date div value to the formatted date
+ $dateEl.html(formattedDate);
+
+ if (properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = '.date-advanced';
+
+ var $selector = $(target).is('.global-elements-date-advanced') ?
+ $(target) : $(target).find('.global-elements-date-advanced');
+
+ // Scale text to container
+ $selector.xiboTextScaler(properties);
+ }
+}
+ ]]>
+
+
+
+
+
+ global_image
+ element
+ global
+ Image
+ image
+ none
+ 100
+ 100
+
+
+ Image URL
+ Enter the URL of the image you want to use.
+
+
+ Opacity
+ Should the image have some transparency? Choose from 0 to 100.
+ 100
+
+
+ Scale type
+ How should this image be scaled?
+ contain
+
+
+
+
+
+
+
+ Horizontal Align
+ How should this image be aligned?
+ center
+
+
+
+
+
+
+
+ contain
+
+
+
+
+ Vertical Align
+ How should this image be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ contain
+
+
+
+
+ Round Border
+ 0
+ Should the image have rounded corners?
+
+
+ contain
+
+
+
+
+ Border Radius
+ 20
+
+
+ 1
+ contain
+
+
+
+
+ Image Shadow
+ 0
+ Should the image have a shadow?
+
+
+ Image Shadow Colour
+
+
+ 1
+
+
+
+
+ Shadow X Offset
+ 1
+
+
+ 1
+
+
+
+
+ Shadow Y Offset
+ 1
+
+
+ 1
+
+
+
+
+ Shadow Blur
+ 2
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ global_library_image
+ global_image
+ element
+ Library Image
+ global
+ none
+ true
+ 250
+ 250
+
+
+ Replace Image
+ Select an image from the Toolbox and drop here to replace this element.
+
+
+
+
+ line
+ Line
+ element
+ global
+ true
+ line-thumb
+ 250
+ 250
+
+
+ Width
+ 5
+
+
+ Colour
+ %THEME_COLOR%
+
+
+ Style
+ solid
+
+
+
+
+
+
+
+
+ Tip1 Type
+ squared
+
+
+
+
+
+
+
+
+
+
+ Tip2 Type
+ squared
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+ rectangle
+ Rectangle
+ element
+ global
+ true
+ square-thumb
+ 250
+ 250
+
+
+ Background Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient as background?
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Round Border
+ 0
+ Should the rectangle have rounded corners?
+
+
+ Border Radius
+ 20
+
+
+ 1
+
+
+
+
+ Show Outline
+ 1
+ Should the rectangle have an outline?
+
+
+ Outline Colour
+ %THEME_COLOR%
+
+
+ 1
+
+
+
+
+ Outline Width
+ 8
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+ circle
+ Circle
+ element
+ global
+ circle-thumb
+ 250
+ 250
+
+
+ Background Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient as background?
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Fit to area
+ 0
+ Should the shape scale to fit the element area?
+
+
+ Show Outline
+ 1
+ Should the circle have an outline?
+
+
+ Outline Colour
+ %THEME_COLOR%
+
+
+ 1
+
+
+
+
+ Outline Width
+ 8
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ ellipse
+ Ellipse
+ element
+ global
+ ellipse-thumb
+ true
+ 300
+ 200
+
+
+ Background Colour
+ #1775F6
+
+
+ Show Outline
+ 1
+ Should the circle have an outline?
+
+
+ Outline Colour
+ %THEME_COLOR%
+
+
+ 1
+
+
+
+
+ Outline Width
+ 4
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+ triangle
+ Triangle
+ element
+ global
+ true
+ triangle-thumb
+ 250
+ 250
+
+
+ Background Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient as background?
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Fit to area
+ 0
+ Should the shape scale to fit the element area?
+
+
+ Show Outline
+ 1
+ Should the triangle have an outline?
+
+
+ Outline Colour
+ %THEME_COLOR%
+
+
+ 1
+
+
+
+
+ Outline Width
+ 8
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ pentagon
+ Pentagon
+ element
+ global
+ true
+ pentagon-thumb
+ 250
+ 250
+
+
+ Background Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient as background?
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Fit to area
+ 0
+ Should the shape scale to fit the element area?
+
+
+ Show Outline
+ 1
+ Should the pentagon have an outline?
+
+
+ Outline Colour
+ %THEME_COLOR%
+
+
+ 1
+
+
+
+
+ Outline Width
+ 8
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ hexagon
+ Hexagon
+ element
+ global
+ true
+ hexagon-thumb
+ 250
+ 250
+
+
+ Background Colour
+ #1775F6
+
+
+ 0
+
+
+
+
+ Use gradient as background?
+ 0
+
+
+ Gradient
+
+
+
+ 1
+
+
+
+
+ Fit to area
+ 0
+ Should the shape scale to fit the element area?
+
+
+ Show Outline
+ 1
+ Should the hexagon have an outline?
+
+
+ Outline Colour
+ %THEME_COLOR%
+
+
+ 1
+
+
+
+
+ Outline Width
+ 8
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+ placeholder
+ Placeholder
+ element
+ global
+ none
+ false
+ placeholder-thumb
+ 200
+ 100
+
+
+ Placeholder type
+ Please select the type of placeholder to use as target.
+ all
+
+
+
+
+
+
+
+
+
+Placeholder: {{placeholderType}}
+
+ ]]>
+
+
+
+
+
+
+
+ image_placeholder
+ Image Placeholder
+ true
+ global_image
+ element
+ global
+ none
+ false
+ 200
+ 100
+
+
+ Placeholder options
+
+
+
+
+
+ Placeholder message
+
+ Image Placeholder
+
+
+ Placeholder message colour
+
+ #333
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Font Size
+ 22
+
+
+ Placeholder background colour
+
+ #f9f9f9
+
+
+
+
+
+
+
+ {{placeholderMessage}}
+
+
+ ]]>
+
+
+
+
+
diff --git a/modules/templates/message-elements.xml b/modules/templates/message-elements.xml
new file mode 100644
index 0000000..463b269
--- /dev/null
+++ b/modules/templates/message-elements.xml
@@ -0,0 +1,67 @@
+
+
+
+
+ message_subject
+ text
+ Subject
+ element
+ message
+ fas fa-font
+ true
+ 500
+ 100
+
+
+ message_body
+ text
+ Body
+ element
+ message
+ fas fa-envelope
+ true
+ 500
+ 100
+
+
+ message_date
+ date
+ Date
+ element
+ message
+ fas fa-calendar-week
+ true
+ 500
+ 100
+
+
+ message_created_date
+ date
+ Created Date
+ element
+ message
+ fas fa-calendar-week
+ true
+ 500
+ 100
+
+
diff --git a/modules/templates/message-static.xml b/modules/templates/message-static.xml
new file mode 100644
index 0000000..ce729d7
--- /dev/null
+++ b/modules/templates/message-static.xml
@@ -0,0 +1,125 @@
+
+
+
+ message_custom_html
+ static
+ message
+ none
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Main Template
+ The template for formatting your notifications. Enter [Subject] and [Body] with your desired formatting. Enter text or HTML in the box below.
+
+
+ Snippets
+ Choose element to add to template
+
+
+
+
+
+
+
+ Custom Style Sheets
+
+
+ No data message
+ A message to display when there are no notifications to show. Enter text or HTML in the box below.
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ noTransition
+
+
+
+
+
+
+ {{javaScript|raw}}
+
+{% endif %}
+ ]]>
+
+
+ = 0) {
+ properties.template = properties.template.replace('[Body]', '[body]');
+}
+if (properties.template && properties.template.indexOf('[Subject]') >= 0) {
+ properties.template = properties.template.replace('[Subject]', '[subject]');
+}
+if (properties.template && properties.template.indexOf('[Date]') >= 0) {
+ properties.template = properties.template.replace('[Date]', '[date]');
+}
+
+if (items.length > 0) {
+ items = $(items).xiboSubstitutesParser(properties.template, properties.dateFormat, ['date', 'createdAt']);
+}
+
+// No data message
+if (items.length <= 0 && properties.noDataMessage && properties.noDataMessage !== '') {
+ items.push(properties.noDataMessage);
+}
+
+// Clear container
+$(target).find('#content').empty();
+
+// Add items to container
+for (var index = 0; index < items.length; index++) {
+ $(items[index]).appendTo($(target).find('#content'));
+}
+
+// Render
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > *'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
diff --git a/modules/templates/product-category-elements.xml b/modules/templates/product-category-elements.xml
new file mode 100644
index 0000000..1be943b
--- /dev/null
+++ b/modules/templates/product-category-elements.xml
@@ -0,0 +1,54 @@
+
+
+
+ product_category_name
+ text
+ Name
+ element
+ product-category
+ fas fa-font
+ true
+ 500
+ 100
+
+
+ product_category_description
+ text
+ Description
+ element
+ product-category
+ fas fa-font
+ true
+ 500
+ 100
+
+
+ product_category_photo
+ global_image
+ element
+ product-category
+ fas fa-image
+ Category Photo
+ 100
+ 100
+
+
diff --git a/modules/templates/product-elements.xml b/modules/templates/product-elements.xml
new file mode 100644
index 0000000..3fe3899
--- /dev/null
+++ b/modules/templates/product-elements.xml
@@ -0,0 +1,475 @@
+
+
+
+ product_name
+ text
+ Name
+ element
+ product
+ fas fa-font
+ true
+ 500
+ 100
+
+
+ Dim when unavailable?
+ 0
+
+
+ Dim Colour
+ #888
+
+
+ 1
+
+
+
+
+
+
+
+ product_description
+ text
+ Description
+ element
+ product
+ fas fa-font
+ true
+ 500
+ 100
+
+
+ product_price
+ text
+ Price
+ element
+ product
+ fas fa-money-bill-alt
+ true
+ 500
+ 100
+
+
+ Currency Code
+ The 3 digit currency code to apply to the price, e.g. USD/GBP/EUR
+
+
+
+ Prefix
+
+
+
+ Suffix
+
+
+
+
+
+
+ product_allergy_info
+ text
+ Allergy info
+ element
+ product
+ fas fa-allergies
+ true
+ 500
+ 100
+
+
+ product_photo
+ global_image
+ element
+ product
+ fas fa-image
+ Photo
+ 100
+ 100
+
+
+ product_options_name
+ Options: Name
+ element
+ product
+ fas fa-list
+ true
+ 500
+ 100
+
+
+ Option slot
+ 1
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Font Size
+ 24
+
+
+ 1
+
+
+
+
+ Font Colour
+ %THEME_COLOR%
+
+
+ Prefix
+
+
+
+ Suffix
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+ 0 &&
+ properties.data.productOptions[optionSlot]
+) {
+ var option = properties.data.productOptions[optionSlot];
+ var name = option.name;
+
+ if (properties.prefix && properties.prefix !== '') {
+ name = properties.prefix + '' + name;
+ }
+
+ if (properties.suffix && properties.suffix !== '') {
+ name = name + '' + properties.suffix;
+ }
+
+ $productContainer.find('div').html(name);
+}
+
+if(properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = 'div';
+
+ // Scale text to container
+ $(target).find('.product-elements-options-name').xiboTextScaler(properties);
+}
+ ]]>
+
+
+ product_options_value
+ Options: Value
+ element
+ product
+ fas fa-money-bill-alt
+ true
+ 500
+ 100
+
+
+ Option slot
+ 1
+
+
+ Fit to selection
+
+ Fit to selected area instead of using the font size?
+ 0
+
+
+ Font Family
+ Select a custom font - leave empty to use the default font.
+
+
+ Font Size
+ 24
+
+
+ 1
+
+
+
+
+ Font Colour
+ %THEME_COLOR%
+
+
+ Currency Code
+ The 3 digit currency code to apply to the price, e.g. USD/GBP/EUR
+
+
+
+ Prefix
+
+
+
+ Suffix
+
+
+
+ Horizontal Align
+ center
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ Vertical Align
+ center
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+ 0 &&
+ properties.data.productOptions[optionSlot]
+) {
+ var option = properties.data.productOptions[optionSlot];
+ var value = option.value;
+
+ var options = {};
+ if (properties.currencyCode && properties.currencyCode !== '') {
+ options.style = 'currency';
+ options.currency = properties.currencyCode;
+ }
+
+ value = new Intl.NumberFormat(undefined, options).format(value);
+
+ if (properties.prefix && properties.prefix !== '') {
+ value = properties.prefix + '' + value;
+ }
+
+ if (properties.suffix && properties.suffix !== '') {
+ value = value + '' + properties.suffix;
+ }
+
+ $productContainer.find('div').html(value);
+}
+
+if(properties.fitToArea) {
+ // Set target for the text
+ properties.fitTarget = 'div';
+
+ // Scale text to container
+ $(target).find('.product-elements-options-value').xiboTextScaler(properties);
+}
+ ]]>
+
+
+ product_calories
+ text
+ Calories
+ element
+ product
+ fas fa-weight
+ true
+ 500
+ 100
+
+
+ Units
+ kcal
+
+
+ Units margin
+ 4
+
+
+
+
+
+
+
+ Units color
+
+
+
+
+
+
+
+
+
+
+ ' + properties.units + '');
+
+ if (properties.unitMargin > 0) {
+ $(target).find('.calories-label').css('margin-left', properties.unitMargin + 'px');
+ }
+
+ if (properties.unitColor != '') {
+ $(target).find('.calories-label').css('color', properties.unitColor);
+ }
+}
+ ]]>
+
+
diff --git a/modules/templates/social-media-elements.xml b/modules/templates/social-media-elements.xml
new file mode 100644
index 0000000..5beac85
--- /dev/null
+++ b/modules/templates/social-media-elements.xml
@@ -0,0 +1,361 @@
+
+
+
+ profile_photo
+ global_image
+ element
+ social-media
+ Profile Photo
+ fas fa-user
+ 100
+ 100
+
+
+ social_media_description
+ text
+ Description
+ element
+ social-media
+ true
+ fas fa-font
+ 650
+ 250
+
+
+ social_media_screen_name
+ text
+ Screen name
+ element
+ social-media
+ true
+ fas fa-id-card
+ 650
+ 250
+
+
+ social_media_username
+ text
+ Username
+ element
+ social-media
+ true
+ fas fa-at
+ 650
+ 250
+
+
+ social_media_date
+ date
+ Date
+ element
+ social-media
+ true
+ fas fa-calendar-week
+ 450
+ 80
+
+
+ post_photo
+ global_image
+ element
+ social-media
+ Post Photo
+ fas fa-image
+ 100
+ 100
+
+
+ post_horizontal
+ element-group
+ social-media
+ Post
+ social-media-post-horizontal
+
+
+ 800
+ 200
+
+
+ Background
+ 0
+ 0
+ 0
+ 800
+ 200
+
+ #f4f8ff
+ #3c3ad3
+ 4
+ 1
+ 10
+
+
+
+ Profile Photo
+ 20
+ 20
+ 160
+ 160
+ 0
+ 1
+
+
+ Username
+ 20
+ 190
+ 1
+ 400
+ 40
+ 0
+
+ aileron regular
+ 1
+ 24
+ #3c3ad3
+ flex-start
+ flex-start
+ 0
+
+
+
+ Date
+ 20
+ 590
+ 1
+ 190
+ 40
+ 0
+
+ H:i - d/m/Y
+ aileron regular
+ 20
+ #3c3ad3
+ flex-end
+ flex-start
+
+
+
+ Description
+ 60
+ 190
+ 1
+ 590
+ 120
+ 0
+
+ linear regular
+ 22
+ #3c3ad3
+ flex-start
+ flex-start
+ 0
+
+
+
+
+
+
+
+
+
+ post_vintage_photo
+ element-group
+ social-media
+ Vintage Photo
+ social-media-post-vintage-photo
+
+
+ 300
+ 350
+
+
+ Background
+ 0
+ 0
+ 0
+ 300
+ 350
+
+ #f9f9f9
+ #333
+ 1
+
+
+
+ Photo area
+ 20
+ 20
+ 1
+ 260
+ 260
+
+ #333
+ 0
+
+
+
+ Post Photo
+ 20
+ 20
+ 2
+ 260
+ 260
+
+
+ Username
+ 280
+ 20
+ 260
+ 35
+ 2
+ 0
+
+ linear regular
+ 1
+ 18
+ #96614a
+ flex-start
+ center
+ 0
+
+
+
+ Date
+ 315
+ 20
+ 260
+ 30
+ 2
+ 0
+
+ F d, Y H:i
+ railway regular
+ 16
+ #626262
+ flex-end
+ flex-end
+ 2.5
+
+
+
+
+
+
+
+
+
+ post_dark
+ element-group
+ social-media
+ Post - Dark
+ social-media-post-dark
+
+
+ 440
+ 250
+
+
+ Background
+ 0
+ 0
+ 0
+ 440
+ 250
+
+ rgba(0,0,0,0.60)
+ 0
+
+
+
+ Profile Photo
+ 20
+ 20
+ 80
+ 80
+ 0
+ 1
+
+ fill
+ 1
+
+
+
+ Username
+ 25
+ 110
+ 300
+ 30
+ 1
+ 0
+
+ linear regular
+ 1
+ 22
+ #ffffff
+ flex-start
+ flex-start
+ 0
+
+
+
+ Date
+ 55
+ 110
+ 300
+ 30
+ 1
+ 0
+
+ M d, H:i
+ linear regular
+ 20
+ #aeaeae
+ flex-start
+ flex-start
+
+
+
+ Description
+ 105
+ 20
+ 400
+ 125
+ 1
+ 0
+
+ linear regular
+ 20
+ #fff
+ flex-start
+ flex-start
+ 0
+ 1
+ 1
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/templates/social-media-static.xml b/modules/templates/social-media-static.xml
new file mode 100644
index 0000000..da6ef8d
--- /dev/null
+++ b/modules/templates/social-media-static.xml
@@ -0,0 +1,3084 @@
+
+
+
+ social_media_custom_html
+ static
+ social-media
+ none
+
+
+ 1
+
+
+ social-media
+
+
+ Original Width
+ This is the intended width of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Original Height
+ This is the intended height of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Original Padding
+ This is the intended padding of the template and is used to position the Widget within its region when the template is applied.
+
+
+ Main Template
+
+
+ Optional Stylesheet Template
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+ 1
+ Content Type
+ This is the intended tweet content type.
+
+
+
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ {{javaScript|raw}}{% endif %}
+
+
+{{template|raw}}
+
+
+
+
+ ]]>
+
+
+ .social-media-item'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_1
+ static
+ social-media
+ Template 1 - text, profile image
+ fulltime-np
+ 400
+ 500
+
+
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Background Colour
+ The colour of the post background
+ #ecf0f1
+
+
+ Post Text Colour
+ The colour of the post text
+ #333
+
+
+ Post Header Text Colour
+ The colour of the post header text
+ #95a5a6
+
+
+ Profile Border Colour
+ The colour of the profile border
+ #ecf0f1
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 1000
+ 200
+ 24
+
+
+
+
+
+
+
+
{{user}}
+
{{date}}
+
{{text}}
+
+
+
+
+ ]]>
+
+
+ span').each((_idx, el)=> {
+ $(el).html(moment($(el).html()).format(properties.dateFormat));
+ });
+}
+
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_2
+ static
+ social-media
+ Template 2 - text, profile image, photo
+ fulltime
+ 1200
+ 300
+
+
+ 2
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Background Colour
+ The colour of the post background
+ #ecf0f1
+
+
+ Post Text Colour
+ The colour of the post text
+ #333
+
+
+ Post Header Text Colour
+ The colour of the post header text
+ #95a5a6
+
+
+ Profile Border Colour
+ The colour of the profile border
+ #ecf0f1
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 1000
+ 900
+ 24
+
+
+
+
+
+
+
+
{{user}}
+
{{date}}
+
{{text}}
+
+
+
+
+
+
+
+ ]]>
+
+
+ span').each((_idx, el)=> {
+ $(el).html(moment($(el).html()).format(properties.dateFormat));
+ });
+}
+
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_3
+ static
+ social-media
+ Template 3 - text
+ textonly
+ 400
+ 500
+
+
+ 1
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #333
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 600
+ 150
+ 12
+
+
+
+ {{text}}
+
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_4
+ static
+ social-media
+ Template 4 - text, profile image
+ profileleft
+ 400
+ 500
+
+
+ 1
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #333
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 800
+ 100
+ 12
+
+
+
+
{{text}}
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_5
+ static
+ social-media
+ Template 5 - text, profile image
+ profileright
+ 400
+ 500
+
+
+ 1
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #333
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 800
+ 100
+ 12
+
+
+
{{text}}
+
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_6
+ static
+ social-media
+ Template 6 - text, profile image
+ twitter1
+ 300
+ 550
+
+
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Background Colour
+ The colour of the post background
+ rgba(255, 255, 255, 0.6)
+
+
+ Post Text Colour
+ The colour of the post text
+ #434343
+
+
+ Date Text Colour
+ The colour of the date text
+ #6e6e6e
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 960
+ 350
+ 24
+
+
+
+
+
+
+
+
+
{{text}}
+
{{date}}
+
{{location}}
+
+
+
+ ]]>
+
+
+ {
+ $(el).html(moment($(el).html()).format(properties.dateFormat));
+ });
+}
+
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_7
+ static
+ social-media
+ Template 7 - text, profile image
+ twitter2
+ 200
+ 700
+
+
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Background Colour
+ The colour of the post background
+ rgba(255, 255, 255, 0.6)
+
+
+ Post Text Colour
+ The colour of the post text
+ #434343
+
+
+ User Name Text Colour
+ The colour of the username text
+ #434343
+
+
+ Date Text Colour
+ The colour of the date text
+ #434343
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 600
+ 460
+ 16
+
+
+
+
+
+
+
+
{{user}}
+
{{screenName}}
+
+
+
+
{{text}}
+
{{date}}
+
{{location}}
+
+
+
+ ]]>
+
+
+ {
+ $(el).html(moment($(el).html()).format(properties.dateFormat));
+ });
+}
+
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_8
+ static
+ social-media
+ Template 8 - text, profile image
+ twitter4
+ 250
+ 750
+
+
+ 1
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Background Colour
+ The colour of the post background
+ rgba(255, 255, 255, 0.6)
+
+
+ Inner Post Background Colour
+ The colour of the inner post background
+ #fff
+
+
+ Inner Post Border Colour
+ The colour of the inner post border
+ #eee
+
+
+ Post Text Colour
+ The colour of the post text
+ #434343
+
+
+ User Name Text Colour
+ The colour of the username text
+ #434343
+
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 340
+ 200
+ 0
+
+
+
+
+
+
+
+
{{user}}
+
{{screenName}}
+
+
+
+
{{text}}
+
+
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_9
+ static
+ social-media
+ Template 9 - text, logo
+ twitter6np
+ 400
+ 800
+
+
+ 1
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #434343
+
+
+ Post Background Colour
+ The colour of the post background
+ #fff
+
+
+ Footer Text Colour
+ The colour of the footer text
+ #fff
+
+
+ Footer Background Colour
+ The colour of the footer background
+ #1da1f2
+
+
+ Border Colour
+ The colour of the border
+ #ddd
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 400
+ 160
+ 8
+
+
+
+
+ {{text}}
+
+
+
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_10
+ static
+ social-media
+ Template 10 - text, photo, logo
+ twitter6pl
+ 1100
+ 250
+
+
+ 2
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #434343
+
+
+ Post Background Colour
+ The colour of the post background
+ #fff
+
+
+ Footer Text Colour
+ The colour of the footer text
+ #fff
+
+
+ Footer Background Colour
+ The colour of the footer background
+ #1da1f2
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 400
+ 450
+ 8
+
+
+
+
+
+
{{text}}
+
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_11
+ static
+ social-media
+ Template 11 - text, logo
+ twitter7
+ 400
+ 800
+
+
+ 1
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #434343
+
+
+ Post Background Colour
+ The colour of the post background
+ #fff
+
+
+ Header Text Colour
+ The colour of the header text
+ #fff
+
+
+ Header Background Colour
+ The colour of the header background
+ #1da1f2
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 400
+ 160
+ 8
+
+
+
+
{{user}}
+
+
+
+
+
+
+
{{text}}
+
+
+
+ ]]>
+
+
+ .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ social_media_static_12
+ static
+ social-media
+ Template 12 - text, profile image, logo
+ twitter8
+ 1000
+ 400
+
+
+ 1
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items Per Page
+ The number of items to show per page (default = 5).
+ 5
+
+
+ Items direction
+ The display order if there's more than one item.
+ 0
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Post Text Colour
+ The colour of the post text
+ #4d4d4d
+
+
+ Date Text Colour
+ The colour of the date text
+ #4d4d4d
+
+
+ Post Background Colour
+ The colour of the post background
+ #fff
+
+
+ Header Text Colour
+ The colour of the header text
+ #fff
+
+
+ Header Background Colour
+ The colour of the header background
+ #1da1f2
+
+
+ Profile Border Colour
+ The colour of the profile border
+ #fff
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+ 350
+ 650
+
+
+
+
+
+
+
{{user}}
+
+
+
{{text}}
+
{{date}}
+
+
+
+
+ ]]>
+
+
+ {
+ $(el).html(moment($(el).html()).format(properties.dateFormat));
+ });
+}
+
+$(target).xiboLayoutScaler(properties);
+$(target).xiboTextRender(properties, $(target).find('#content > .main-container'));
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+ metro_social
+ static
+ social-media
+ Metro Social
+ colors1
+ 1150
+ 650
+
+
+ Colors
+ Select colors to be randomly applied to the metro cells
+
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Colours Template
+ Select the template colours you would like to apply values to the colours below.
+ custom
+
+
+
+
+
+
+
+
+
+
+
+ Colour 1
+ colorTemplateId[0]
+
+
+ Colour 2
+ colorTemplateId[1]
+
+
+ Colour 3
+ colorTemplateId[2]
+
+
+ Colour 4
+ colorTemplateId[3]
+
+
+ Colour 5
+ colorTemplateId[4]
+
+
+ Colour 6
+ colorTemplateId[5]
+
+
+ Colour 7
+ colorTemplateId[6]
+
+
+ Colour 8
+ colorTemplateId[7]
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Horizontal Align
+ How should this widget be aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+
+
+
+
{{text}}
+
+
+
+
+
+
{{user}}
+ {{date}}
+
+
+
+
+
+ ]]>
+
+ ]]>
+
+
+ = $(window).height()) {
+ properties.widgetDesignWidth = 1920;
+ properties.widgetDesignHeight = 1080;
+} else {
+ properties.widgetDesignWidth = 1080;
+ properties.widgetDesignHeight = 1920;
+ orientation = 'portrait';
+}
+
+// Scale the layout
+$(target).xiboLayoutScaler(properties);
+
+// Get an array of items from the templates generated
+var itemsHTML = $(target).find('.metro-cell-template').map(function(_k, el) {
+ if (properties.dateFormat) {
+ var date = $(el).find('.post-userData > small').html();
+ $(el).find('.post-userData > small').html(moment(date).format(properties.dateFormat));
+ }
+
+ return $(el).html();
+});
+
+// Get metro render container
+var $metroRenderContainer = $(target).find('.metro-render-container');
+
+// Mark container as landscape or portrait
+$metroRenderContainer.removeClass('orientation-landscape orientation-portrait').
+ addClass('orientation-' + orientation);
+
+// Get colours array
+var colors = [];
+
+for (var i = 1; i <= 8; i++) {
+ if (properties['color' + i] !== '') {
+ colors.push(properties['color' + i]);
+ }
+}
+
+// Render the items with metro render
+$metroRenderContainer.xiboMetroRender(properties, itemsHTML, colors);
+
+// Render the images
+$metroRenderContainer.find('img').xiboImageRender(properties);
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/templates/stock-elements.xml b/modules/templates/stock-elements.xml
new file mode 100644
index 0000000..61898f8
--- /dev/null
+++ b/modules/templates/stock-elements.xml
@@ -0,0 +1,734 @@
+
+
+
+ stock_name
+ text
+ Name
+ element
+ stock
+ true
+ fas fa-font
+ 420
+ 100
+
+
+ stock_symbol
+ text
+ Symbol
+ element
+ stock
+ true
+ fas fa-font
+ 420
+ 100
+
+
+ stock_lastTradePrice
+ text
+ Last Trade Price
+ element
+ stock
+ fas fa-money-bill-alt
+ truedate
+ 200
+ 100
+
+
+ stock_changePercentage
+ text
+ Change Percentage
+ element
+ stock
+ true
+ fas fa-percentage
+ 200
+ 100
+
+
+
+
+ stock_changeIcon
+ Stock Icon
+ element
+ stock
+ true
+ fas fa-arrows-alt-v
+ 80
+ 60
+
+
+
+]]>
+
+ `);
+}
+ ]]>
+
+
+
+
+
+
+
+
+ stocks_single_1
+ element-group
+ stock
+ Stocks - Single 1
+ stocks-single-1
+
+
+ 419
+ 169
+
+
+ Background
+ 0
+ 0
+ 0
+ 419
+ 169
+
+ #001061
+ 1
+ 20
+ 0
+
+
+
+ Temp area
+ 2
+ 20
+ 28
+ 124
+ 124
+
+ #fff
+ 1
+ 10
+ 0
+
+
+
+ Stock change icon
+ 4
+ 31
+ 40
+ 100
+ 100
+
+
+ Stock name
+ 4
+ 21
+ 185
+ 208
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ 1
+ flex-start
+ center
+ 0
+ 0
+
+
+
+ Stocks symbol
+ 8
+ 85
+ 184
+ 39
+ 63
+
+ $
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 85
+ 223
+ 169
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+
+
+
+
+
+
+
+
+
+ stocks_single_2
+ element-group
+ stock
+ Stocks - Single 2
+ stocks-single-2
+
+
+ 479
+ 225
+
+
+ Background
+ 0
+ 0
+ 0
+ 479
+ 225
+
+ #171717
+ 1
+ 20
+ 0
+
+
+
+ Stock name
+ 4
+ 41
+ 44
+ 392
+ 63
+
+ open sans regular
+ #fff
+ 60
+ flex-start
+ center
+ 0
+ 0
+
+
+
+ Stock symbol
+ 8
+ 124
+ 45
+ 39
+ 64
+
+ $
+ open sans regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 124
+ 84
+ 119
+ 63
+
+ open sans regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Stock change icon
+ 10
+ 125
+ 222
+ 58
+ 63
+
+
+ Change percentage
+ 9
+ 124
+ 288
+ 148
+ 63
+
+ open sans regular
+ 40
+ flex-start
+ center
+
+
+
+
+
+
+
+
+
+ stocks_group_1
+ element-group
+ stock
+ Stocks - Group 1
+ stocks-group-1
+
+
+ 482
+ 683
+
+
+ Background group
+ 0
+ 0
+ 0
+ 482
+ 683
+ 0
+ 1
+
+
+ Group 1
+ 3
+ 38
+ 40
+ 400
+ 63
+ 0
+
+
+ Group 2
+ 3
+ 179
+ 40
+ 400
+ 63
+ 1
+
+
+ Group 3
+ 3
+ 312
+ 40
+ 400
+ 63
+ 2
+
+
+ Group 4
+ 3
+ 445
+ 40
+ 400
+ 63
+ 3
+
+
+ Group 5
+ 3
+ 582
+ 40
+ 400
+ 63
+ 4
+
+
+
+
+ Background
+ 1
+ 0
+ 0
+ 482
+ 683
+
+ #171717
+ 1
+ #b4b4b4
+ 2
+ 1
+ 20
+
+
+
+ Separator 1
+ 5
+ 116
+ 36
+ 402
+ 48
+
+ #b4b4b4
+ 2
+
+
+
+ Separator 2
+ 5
+ 253
+ 36
+ 402
+ 48
+
+ #b4b4b4
+ 2
+
+
+
+ Separator 3
+ 5
+ 386
+ 36
+ 402
+ 48
+
+ #b4b4b4
+ 2
+
+
+
+ Separator 4
+ 5
+ 523
+ 36
+ 402
+ 48
+
+ #b4b4b4
+ 2
+
+
+
+ Stock name
+ 4
+ 0
+ 0
+ 203
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+ 0
+ 0
+ 1
+
+
+
+ Stock symbol
+ 8
+ 0
+ 225
+ 39
+ 63
+
+ $
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 0
+ 264
+ 136
+ 63
+
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Stock name
+ 4
+ 0
+ 0
+ 203
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+ 0
+ 0
+ 1
+
+
+
+ Stock symbol
+ 8
+ 0
+ 225
+ 39
+ 63
+
+ $
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 0
+ 264
+ 136
+ 63
+
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Stock name
+ 4
+ 0
+ 0
+ 203
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+ 0
+ 0
+ 1
+
+
+
+ Stock symbol
+ 8
+ 0
+ 225
+ 39
+ 63
+
+ $
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 0
+ 264
+ 136
+ 63
+
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Stock name
+ 4
+ 0
+ 0
+ 203
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+ 0
+ 0
+ 1
+
+
+
+ Stock symbol
+ 8
+ 0
+ 225
+ 39
+ 63
+
+ $
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 0
+ 264
+ 136
+ 63
+
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Stock name
+ 4
+ 0
+ 0
+ 203
+ 63
+
+ Poppins Regular
+ #fff
+ 40
+ flex-start
+ center
+ 0
+ 0
+ 1
+
+
+
+ Stock symbol
+ 8
+ 0
+ 225
+ 39
+ 63
+
+ $
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+ Last trade price
+ 3
+ 0
+ 264
+ 136
+ 63
+
+ Poppins Regular
+ #b4b4b4
+ 40
+ flex-start
+ center
+
+
+
+
+
+
+
+
+
diff --git a/modules/templates/stock-static.xml b/modules/templates/stock-static.xml
new file mode 100644
index 0000000..d8e0926
--- /dev/null
+++ b/modules/templates/stock-static.xml
@@ -0,0 +1,753 @@
+
+
+
+ stocks_custom_html
+ static
+ stock
+ none
+ Stocks Custom HTML
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Date Format
+ The format to apply to all dates returned by the Widget.
+ #DATE_FORMAT#
+
+
+ Items per Page
+ This is the intended number of items on each page.
+ 7
+
+
+ 1
+
+
+ stocks
+
+
+ Original Width
+ This is the intended width of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Original Height
+ This is the intended height of the template and is used to scale the Widget within its region when the template is applied.
+
+
+ Main Template
+
+
+ Item Template
+
+
+ Optional Stylesheet
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+
+ {{javaScript|raw}}{% endif %}
+
+
+{{mainTemplate|raw}}
+
+
+
+{{itemTemplate|raw}}
+
+ ]]>
+
+
+
+
+
+
+
+
+
+ stocks1
+ static
+ stock
+ Stocks 1
+ stocks1
+ 800
+ 450
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Item Colour
+ Background colour for each stock item.
+ #e0e0e0
+
+
+ Item Font Colour
+ Font colour for each stock item.
+ #000
+
+
+ Item Label Font Colour
+ Font colour for each stock item label.
+ gray
+
+
+ Item Border Colour
+ Border colour for each stock item.
+ #264a88
+
+
+ Up Arrow Colour
+ Colour for the up change arrow.
+ green
+
+
+ Down Arrow Colour
+ Colour for the down change arrow.
+ red
+
+
+ Equal Arrow Colour
+ Colour for the equal change arrow.
+ gray
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ 7
+
+
+
+ 820
+ 420
+
+
{{SymbolTrimmed}}
+
{{Name}}
+
{{LastTradePriceOnly}} {{CurrencyUpper}}
+
{{ChangePercentage}}%
+
+
+ ]]>
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
+ stocks2
+ static
+ stock
+ Stocks 2
+ stocks2
+ 550
+ 350
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ noTransition
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000).
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+ Item Font Colour
+ Font colour for each stock item.
+ #000
+
+
+ Item Label Font Colour
+ Font colour for each stock item label.
+ gray
+
+
+ Up Arrow Colour
+ Colour for the up change arrow.
+ green
+
+
+ Down Arrow Colour
+ Colour for the down change arrow.
+ red
+
+
+ Equal Arrow Colour
+ Colour for the equal change arrow.
+ gray
+
+
+ Font
+ Select a custom font - leave empty to use the default font.
+
+
+ 1
+
+
+
+ 500
+ 380
+
+
{{Name}}
+
{{SymbolTrimmed}}
+
{{LastTradePriceOnly}} {{CurrencyUpper}}
+
+
{{ChangePercentage}}%
+
+
+
+ ]]>
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+
+
+
+
diff --git a/modules/text.xml b/modules/text.xml
new file mode 100755
index 0000000..a80d543
--- /dev/null
+++ b/modules/text.xml
@@ -0,0 +1,195 @@
+
+
+ core-text
+ Rich Text
+ Core
+ Add Text directly to a Layout
+ fa fa-font
+
+ playlist
+ text
+
+ 1
+ 1
+ 1
+ html
+ 10
+ 600
+ 400
+
+
+
+ Enter text or HTML in the box below.
+ Enter the text to display. The red rectangle reflects the size of the region you are editing. Shift+Enter will drop a single line. Enter alone starts a new paragraph.
+
+
+ Media
+ Choose media
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+ Effect
+ Please select the effect that will be used to transition between items.
+ none
+
+
+ Speed
+ The transition speed of the selected effect in milliseconds (normal = 1000) or the Marquee Speed in a low to high scale (normal = 1).
+
+
+ none
+
+
+
+
+ Marquee Selector
+ p
+ The selector to use for stacking marquee items in a line when scrolling Left/Right.
+
+
+ none
+
+
+
+
+ Show advanced controls?
+ Show Javascript and CSS controls.
+ 0
+
+
+ Optional JavaScript
+ Add JavaScript to be included immediately before the closing body tag. Do not use [] array notation as this is reserved for library references. Do not include script tags.
+
+
+ 1
+
+
+
+
+ Optional Stylesheet
+
+
+ 1
+ qa
+
+
+
+
+
+
+ {{javaScript|raw}}
+
+ ]]>
+
+
+ = 0) {
+ hasClock = true;
+ properties.text = properties.text.replace('[Clock]', '[HH:mm]');
+}
+
+if (properties.text && properties.text.indexOf('[Clock|') >= 0) {
+ hasClock = true;
+ properties.text = properties.text.replace('[Clock|', '[');
+}
+
+if (properties.text && properties.text.indexOf('[Date]') >= 0) {
+ hasClock = true;
+ properties.text = properties.text.replace('[Date]', '[DD/MM/YYYY]');
+}
+
+if (properties.text && properties.text.indexOf('[Date|') >= 0) {
+ hasClock = true;
+ properties.text = properties.text.replace('[Date|', '[');
+}
+
+if (hasClock) {
+ // Use regex to out the bit between the [] brackets and use that as the format mask for moment.
+ var text = properties.text;
+ var regex = /\[.*?\]/g;
+
+ properties.text = text.replace(regex, function (match) {
+ return '';
+ });
+
+ // Set moment locale
+ moment.locale(globalOptions.locale);
+}
+
+// Save hasClock as global variable
+xiboIC.set(id, 'hasClock', hasClock);
+ ]]>
+
+ *'));
+
+// Image render
+$(target).find('img').xiboImageRender(properties);
+ ]]>
+
+
diff --git a/modules/vendor/jquery-cycle-2.1.6.min.js b/modules/vendor/jquery-cycle-2.1.6.min.js
new file mode 100644
index 0000000..b173202
--- /dev/null
+++ b/modules/vendor/jquery-cycle-2.1.6.min.js
@@ -0,0 +1,39 @@
+/*!
+* jQuery Cycle2; version: 2.1.6 build: 20141007
+* http://jquery.malsup.com/cycle2/
+* Copyright (c) 2014 M. Alsup; Dual licensed: MIT/GPL
+*/
+!function(a){"use strict";function b(a){return(a||"").toLowerCase()}var c="2.1.6";a.fn.cycle=function(c){var d;return 0!==this.length||a.isReady?this.each(function(){var d,e,f,g,h=a(this),i=a.fn.cycle.log;if(!h.data("cycle.opts")){(h.data("cycle-log")===!1||c&&c.log===!1||e&&e.log===!1)&&(i=a.noop),d=h.data();for(var j in d)d.hasOwnProperty(j)&&/^cycle[A-Z]+/.test(j)&&(g=d[j],f=j.match(/^cycle(.*)/)[1].replace(/^[A-Z]/,b),i(f+":",g,"("+typeof g+")"),d[f]=g);e=a.extend({},a.fn.cycle.defaults,d,c||{}),e.timeoutId=0,e.paused=e.paused||!1,e.container=h,e._maxZ=e.maxZ,e.API=a.extend({_container:h},a.fn.cycle.API),e.API.log=i,e.API.trigger=function(a,b){return e.container.trigger(a,b),e.API},h.data("cycle.opts",e),h.data("cycle.API",e.API),e.API.trigger("cycle-bootstrap",[e,e.API]),e.API.addInitialSlides(),e.API.preInitSlideshow(),e.slides.length&&e.API.initSlideshow()}}):(d={s:this.selector,c:this.context},a.fn.cycle.log("requeuing slideshow (dom not ready)"),a(function(){a(d.s,d.c).cycle(c)}),this)},a.fn.cycle.API={opts:function(){return this._container.data("cycle.opts")},addInitialSlides:function(){var b=this.opts(),c=b.slides;b.slideCount=0,b.slides=a(),c=c.jquery?c:b.container.find(c),b.random&&c.sort(function(){return Math.random()-.5}),b.API.add(c)},preInitSlideshow:function(){var b=this.opts();b.API.trigger("cycle-pre-initialize",[b]);var c=a.fn.cycle.transitions[b.fx];c&&a.isFunction(c.preInit)&&c.preInit(b),b._preInitialized=!0},postInitSlideshow:function(){var b=this.opts();b.API.trigger("cycle-post-initialize",[b]);var c=a.fn.cycle.transitions[b.fx];c&&a.isFunction(c.postInit)&&c.postInit(b)},initSlideshow:function(){var b,c=this.opts(),d=c.container;c.API.calcFirstSlide(),"static"==c.container.css("position")&&c.container.css("position","relative"),a(c.slides[c.currSlide]).css({opacity:1,display:"block",visibility:"visible"}),c.API.stackSlides(c.slides[c.currSlide],c.slides[c.nextSlide],!c.reverse),c.pauseOnHover&&(c.pauseOnHover!==!0&&(d=a(c.pauseOnHover)),d.hover(function(){c.API.pause(!0)},function(){c.API.resume(!0)})),c.timeout&&(b=c.API.getSlideOpts(c.currSlide),c.API.queueTransition(b,b.timeout+c.delay)),c._initialized=!0,c.API.updateView(!0),c.API.trigger("cycle-initialized",[c]),c.API.postInitSlideshow()},pause:function(b){var c=this.opts(),d=c.API.getSlideOpts(),e=c.hoverPaused||c.paused;b?c.hoverPaused=!0:c.paused=!0,e||(c.container.addClass("cycle-paused"),c.API.trigger("cycle-paused",[c]).log("cycle-paused"),d.timeout&&(clearTimeout(c.timeoutId),c.timeoutId=0,c._remainingTimeout-=a.now()-c._lastQueue,(c._remainingTimeout<0||isNaN(c._remainingTimeout))&&(c._remainingTimeout=void 0)))},resume:function(a){var b=this.opts(),c=!b.hoverPaused&&!b.paused;a?b.hoverPaused=!1:b.paused=!1,c||(b.container.removeClass("cycle-paused"),0===b.slides.filter(":animated").length&&b.API.queueTransition(b.API.getSlideOpts(),b._remainingTimeout),b.API.trigger("cycle-resumed",[b,b._remainingTimeout]).log("cycle-resumed"))},add:function(b,c){var d,e=this.opts(),f=e.slideCount,g=!1;"string"==a.type(b)&&(b=a.trim(b)),a(b).each(function(){var b,d=a(this);c?e.container.prepend(d):e.container.append(d),e.slideCount++,b=e.API.buildSlideOpts(d),e.slides=c?a(d).add(e.slides):e.slides.add(d),e.API.initSlide(b,d,--e._maxZ),d.data("cycle.opts",b),e.API.trigger("cycle-slide-added",[e,b,d])}),e.API.updateView(!0),g=e._preInitialized&&2>f&&e.slideCount>=1,g&&(e._initialized?e.timeout&&(d=e.slides.length,e.nextSlide=e.reverse?d-1:1,e.timeoutId||e.API.queueTransition(e)):e.API.initSlideshow())},calcFirstSlide:function(){var a,b=this.opts();a=parseInt(b.startingSlide||0,10),(a>=b.slides.length||0>a)&&(a=0),b.currSlide=a,b.reverse?(b.nextSlide=a-1,b.nextSlide<0&&(b.nextSlide=b.slides.length-1)):(b.nextSlide=a+1,b.nextSlide==b.slides.length&&(b.nextSlide=0))},calcNextSlide:function(){var a,b=this.opts();b.reverse?(a=b.nextSlide-1<0,b.nextSlide=a?b.slideCount-1:b.nextSlide-1,b.currSlide=a?0:b.nextSlide+1):(a=b.nextSlide+1==b.slides.length,b.nextSlide=a?0:b.nextSlide+1,b.currSlide=a?b.slides.length-1:b.nextSlide-1)},calcTx:function(b,c){var d,e=b;return e._tempFx?d=a.fn.cycle.transitions[e._tempFx]:c&&e.manualFx&&(d=a.fn.cycle.transitions[e.manualFx]),d||(d=a.fn.cycle.transitions[e.fx]),e._tempFx=null,this.opts()._tempFx=null,d||(d=a.fn.cycle.transitions.fade,e.API.log('Transition "'+e.fx+'" not found. Using fade.')),d},prepareTx:function(a,b){var c,d,e,f,g,h=this.opts();return h.slideCount<2?void(h.timeoutId=0):(!a||h.busy&&!h.manualTrump||(h.API.stopTransition(),h.busy=!1,clearTimeout(h.timeoutId),h.timeoutId=0),void(h.busy||(0!==h.timeoutId||a)&&(d=h.slides[h.currSlide],e=h.slides[h.nextSlide],f=h.API.getSlideOpts(h.nextSlide),g=h.API.calcTx(f,a),h._tx=g,a&&void 0!==f.manualSpeed&&(f.speed=f.manualSpeed),h.nextSlide!=h.currSlide&&(a||!h.paused&&!h.hoverPaused&&h.timeout)?(h.API.trigger("cycle-before",[f,d,e,b]),g.before&&g.before(f,d,e,b),c=function(){h.busy=!1,h.container.data("cycle.opts")&&(g.after&&g.after(f,d,e,b),h.API.trigger("cycle-after",[f,d,e,b]),h.API.queueTransition(f),h.API.updateView(!0))},h.busy=!0,g.transition?g.transition(f,d,e,b,c):h.API.doTransition(f,d,e,b,c),h.API.calcNextSlide(),h.API.updateView()):h.API.queueTransition(f))))},doTransition:function(b,c,d,e,f){var g=b,h=a(c),i=a(d),j=function(){i.animate(g.animIn||{opacity:1},g.speed,g.easeIn||g.easing,f)};i.css(g.cssBefore||{}),h.animate(g.animOut||{},g.speed,g.easeOut||g.easing,function(){h.css(g.cssAfter||{}),g.sync||j()}),g.sync&&j()},queueTransition:function(b,c){var d=this.opts(),e=void 0!==c?c:b.timeout;return 0===d.nextSlide&&0===--d.loop?(d.API.log("terminating; loop=0"),d.timeout=0,e?setTimeout(function(){d.API.trigger("cycle-finished",[d])},e):d.API.trigger("cycle-finished",[d]),void(d.nextSlide=d.currSlide)):void 0!==d.continueAuto&&(d.continueAuto===!1||a.isFunction(d.continueAuto)&&d.continueAuto()===!1)?(d.API.log("terminating automatic transitions"),d.timeout=0,void(d.timeoutId&&clearTimeout(d.timeoutId))):void(e&&(d._lastQueue=a.now(),void 0===c&&(d._remainingTimeout=b.timeout),d.paused||d.hoverPaused||(d.timeoutId=setTimeout(function(){d.API.prepareTx(!1,!d.reverse)},e))))},stopTransition:function(){var a=this.opts();a.slides.filter(":animated").length&&(a.slides.stop(!1,!0),a.API.trigger("cycle-transition-stopped",[a])),a._tx&&a._tx.stopTransition&&a._tx.stopTransition(a)},advanceSlide:function(a){var b=this.opts();return clearTimeout(b.timeoutId),b.timeoutId=0,b.nextSlide=b.currSlide+a,b.nextSlide<0?b.nextSlide=b.slides.length-1:b.nextSlide>=b.slides.length&&(b.nextSlide=0),b.API.prepareTx(!0,a>=0),!1},buildSlideOpts:function(c){var d,e,f=this.opts(),g=c.data()||{};for(var h in g)g.hasOwnProperty(h)&&/^cycle[A-Z]+/.test(h)&&(d=g[h],e=h.match(/^cycle(.*)/)[1].replace(/^[A-Z]/,b),f.API.log("["+(f.slideCount-1)+"]",e+":",d,"("+typeof d+")"),g[e]=d);g=a.extend({},a.fn.cycle.defaults,f,g),g.slideNum=f.slideCount;try{delete g.API,delete g.slideCount,delete g.currSlide,delete g.nextSlide,delete g.slides}catch(i){}return g},getSlideOpts:function(b){var c=this.opts();void 0===b&&(b=c.currSlide);var d=c.slides[b],e=a(d).data("cycle.opts");return a.extend({},c,e)},initSlide:function(b,c,d){var e=this.opts();c.css(b.slideCss||{}),d>0&&c.css("zIndex",d),isNaN(b.speed)&&(b.speed=a.fx.speeds[b.speed]||a.fx.speeds._default),b.sync||(b.speed=b.speed/2),c.addClass(e.slideClass)},updateView:function(a,b){var c=this.opts();if(c._initialized){var d=c.API.getSlideOpts(),e=c.slides[c.currSlide];!a&&b!==!0&&(c.API.trigger("cycle-update-view-before",[c,d,e]),c.updateView<0)||(c.slideActiveClass&&c.slides.removeClass(c.slideActiveClass).eq(c.currSlide).addClass(c.slideActiveClass),a&&c.hideNonActive&&c.slides.filter(":not(."+c.slideActiveClass+")").css("visibility","hidden"),0===c.updateView&&setTimeout(function(){c.API.trigger("cycle-update-view",[c,d,e,a])},d.speed/(c.sync?2:1)),0!==c.updateView&&c.API.trigger("cycle-update-view",[c,d,e,a]),a&&c.API.trigger("cycle-update-view-after",[c,d,e]))}},getComponent:function(b){var c=this.opts(),d=c[b];return"string"==typeof d?/^\s*[\>|\+|~]/.test(d)?c.container.find(d):a(d):d.jquery?d:a(d)},stackSlides:function(b,c,d){var e=this.opts();b||(b=e.slides[e.currSlide],c=e.slides[e.nextSlide],d=!e.reverse),a(b).css("zIndex",e.maxZ);var f,g=e.maxZ-2,h=e.slideCount;if(d){for(f=e.currSlide+1;h>f;f++)a(e.slides[f]).css("zIndex",g--);for(f=0;f=0;f--)a(e.slides[f]).css("zIndex",g--);for(f=h-1;f>e.currSlide;f--)a(e.slides[f]).css("zIndex",g--)}a(c).css("zIndex",e.maxZ-1)},getSlideIndex:function(a){return this.opts().slides.index(a)}},a.fn.cycle.log=function(){window.console&&console.log&&console.log("[cycle2] "+Array.prototype.join.call(arguments," "))},a.fn.cycle.version=function(){return"Cycle2: "+c},a.fn.cycle.transitions={custom:{},none:{before:function(a,b,c,d){a.API.stackSlides(c,b,d),a.cssBefore={opacity:1,visibility:"visible",display:"block"}}},fade:{before:function(b,c,d,e){var f=b.API.getSlideOpts(b.nextSlide).slideCss||{};b.API.stackSlides(c,d,e),b.cssBefore=a.extend(f,{opacity:0,visibility:"visible",display:"block"}),b.animIn={opacity:1},b.animOut={opacity:0}}},fadeout:{before:function(b,c,d,e){var f=b.API.getSlideOpts(b.nextSlide).slideCss||{};b.API.stackSlides(c,d,e),b.cssBefore=a.extend(f,{opacity:1,visibility:"visible",display:"block"}),b.animOut={opacity:0}}},scrollHorz:{before:function(a,b,c,d){a.API.stackSlides(b,c,d);var e=a.container.css("overflow","hidden").width();a.cssBefore={left:d?e:-e,top:0,opacity:1,visibility:"visible",display:"block"},a.cssAfter={zIndex:a._maxZ-2,left:0},a.animIn={left:0},a.animOut={left:d?-e:e}}}},a.fn.cycle.defaults={allowWrap:!0,autoSelector:".cycle-slideshow[data-cycle-auto-init!=false]",delay:0,easing:null,fx:"fade",hideNonActive:!0,loop:0,manualFx:void 0,manualSpeed:void 0,manualTrump:!0,maxZ:100,pauseOnHover:!1,reverse:!1,slideActiveClass:"cycle-slide-active",slideClass:"cycle-slide",slideCss:{position:"absolute",top:0,left:0},slides:"> img",speed:500,startingSlide:0,sync:!0,timeout:4e3,updateView:0},a(document).ready(function(){a(a.fn.cycle.defaults.autoSelector).cycle()})}(jQuery),/*! Cycle2 autoheight plugin; Copyright (c) M.Alsup, 2012; version: 20130913 */
+function(a){"use strict";function b(b,d){var e,f,g,h=d.autoHeight;if("container"==h)f=a(d.slides[d.currSlide]).outerHeight(),d.container.height(f);else if(d._autoHeightRatio)d.container.height(d.container.width()/d._autoHeightRatio);else if("calc"===h||"number"==a.type(h)&&h>=0){if(g="calc"===h?c(b,d):h>=d.slides.length?0:h,g==d._sentinelIndex)return;d._sentinelIndex=g,d._sentinel&&d._sentinel.remove(),e=a(d.slides[g].cloneNode(!0)),e.removeAttr("id name rel").find("[id],[name],[rel]").removeAttr("id name rel"),e.css({position:"static",visibility:"hidden",display:"block"}).prependTo(d.container).addClass("cycle-sentinel cycle-slide").removeClass("cycle-slide-active"),e.find("*").css("visibility","hidden"),d._sentinel=e}}function c(b,c){var d=0,e=-1;return c.slides.each(function(b){var c=a(this).height();c>e&&(e=c,d=b)}),d}function d(b,c,d,e){var f=a(e).outerHeight();c.container.animate({height:f},c.autoHeightSpeed,c.autoHeightEasing)}function e(c,f){f._autoHeightOnResize&&(a(window).off("resize orientationchange",f._autoHeightOnResize),f._autoHeightOnResize=null),f.container.off("cycle-slide-added cycle-slide-removed",b),f.container.off("cycle-destroyed",e),f.container.off("cycle-before",d),f._sentinel&&(f._sentinel.remove(),f._sentinel=null)}a.extend(a.fn.cycle.defaults,{autoHeight:0,autoHeightSpeed:250,autoHeightEasing:null}),a(document).on("cycle-initialized",function(c,f){function g(){b(c,f)}var h,i=f.autoHeight,j=a.type(i),k=null;("string"===j||"number"===j)&&(f.container.on("cycle-slide-added cycle-slide-removed",b),f.container.on("cycle-destroyed",e),"container"==i?f.container.on("cycle-before",d):"string"===j&&/\d+\:\d+/.test(i)&&(h=i.match(/(\d+)\:(\d+)/),h=h[1]/h[2],f._autoHeightRatio=h),"number"!==j&&(f._autoHeightOnResize=function(){clearTimeout(k),k=setTimeout(g,50)},a(window).on("resize orientationchange",f._autoHeightOnResize)),setTimeout(g,30))})}(jQuery),/*! caption plugin for Cycle2; version: 20130306 */
+function(a){"use strict";a.extend(a.fn.cycle.defaults,{caption:"> .cycle-caption",captionTemplate:"{{slideNum}} / {{slideCount}}",overlay:"> .cycle-overlay",overlayTemplate:"
{{title}}
{{desc}}
",captionModule:"caption"}),a(document).on("cycle-update-view",function(b,c,d,e){if("caption"===c.captionModule){a.each(["caption","overlay"],function(){var a=this,b=d[a+"Template"],f=c.API.getComponent(a);f.length&&b?(f.html(c.API.tmpl(b,d,c,e)),f.show()):f.hide()})}}),a(document).on("cycle-destroyed",function(b,c){var d;a.each(["caption","overlay"],function(){var a=this,b=c[a+"Template"];c[a]&&b&&(d=c.API.getComponent("caption"),d.empty())})})}(jQuery),/*! command plugin for Cycle2; version: 20140415 */
+function(a){"use strict";var b=a.fn.cycle;a.fn.cycle=function(c){var d,e,f,g=a.makeArray(arguments);return"number"==a.type(c)?this.cycle("goto",c):"string"==a.type(c)?this.each(function(){var h;return d=c,f=a(this).data("cycle.opts"),void 0===f?void b.log('slideshow must be initialized before sending commands; "'+d+'" ignored'):(d="goto"==d?"jump":d,e=f.API[d],a.isFunction(e)?(h=a.makeArray(g),h.shift(),e.apply(f.API,h)):void b.log("unknown command: ",d))}):b.apply(this,arguments)},a.extend(a.fn.cycle,b),a.extend(b.API,{next:function(){var a=this.opts();if(!a.busy||a.manualTrump){var b=a.reverse?-1:1;a.allowWrap===!1&&a.currSlide+b>=a.slideCount||(a.API.advanceSlide(b),a.API.trigger("cycle-next",[a]).log("cycle-next"))}},prev:function(){var a=this.opts();if(!a.busy||a.manualTrump){var b=a.reverse?1:-1;a.allowWrap===!1&&a.currSlide+b<0||(a.API.advanceSlide(b),a.API.trigger("cycle-prev",[a]).log("cycle-prev"))}},destroy:function(){this.stop();var b=this.opts(),c=a.isFunction(a._data)?a._data:a.noop;clearTimeout(b.timeoutId),b.timeoutId=0,b.API.stop(),b.API.trigger("cycle-destroyed",[b]).log("cycle-destroyed"),b.container.removeData(),c(b.container[0],"parsedAttrs",!1),b.retainStylesOnDestroy||(b.container.removeAttr("style"),b.slides.removeAttr("style"),b.slides.removeClass(b.slideActiveClass)),b.slides.each(function(){var d=a(this);d.removeData(),d.removeClass(b.slideClass),c(this,"parsedAttrs",!1)})},jump:function(a,b){var c,d=this.opts();if(!d.busy||d.manualTrump){var e=parseInt(a,10);if(isNaN(e)||0>e||e>=d.slides.length)return void d.API.log("goto: invalid slide index: "+e);if(e==d.currSlide)return void d.API.log("goto: skipping, already on slide",e);d.nextSlide=e,clearTimeout(d.timeoutId),d.timeoutId=0,d.API.log("goto: ",e," (zero-index)"),c=d.currSlide .cycle-pager",pagerActiveClass:"cycle-pager-active",pagerEvent:"click.cycle",pagerEventBubble:void 0,pagerTemplate:"•"}),a(document).on("cycle-bootstrap",function(a,c,d){d.buildPagerLink=b}),a(document).on("cycle-slide-added",function(a,b,d,e){b.pager&&(b.API.buildPagerLink(b,d,e),b.API.page=c)}),a(document).on("cycle-slide-removed",function(b,c,d){if(c.pager){var e=c.API.getComponent("pager");e.each(function(){var b=a(this);a(b.children()[d]).remove()})}}),a(document).on("cycle-update-view",function(b,c){var d;c.pager&&(d=c.API.getComponent("pager"),d.each(function(){a(this).children().removeClass(c.pagerActiveClass).eq(c.currSlide).addClass(c.pagerActiveClass)}))}),a(document).on("cycle-destroyed",function(a,b){var c=b.API.getComponent("pager");c&&(c.children().off(b.pagerEvent),b.pagerTemplate&&c.empty())})}(jQuery),/*! prevnext plugin for Cycle2; version: 20140408 */
+function(a){"use strict";a.extend(a.fn.cycle.defaults,{next:"> .cycle-next",nextEvent:"click.cycle",disabledClass:"disabled",prev:"> .cycle-prev",prevEvent:"click.cycle",swipe:!1}),a(document).on("cycle-initialized",function(a,b){if(b.API.getComponent("next").on(b.nextEvent,function(a){a.preventDefault(),b.API.next()}),b.API.getComponent("prev").on(b.prevEvent,function(a){a.preventDefault(),b.API.prev()}),b.swipe){var c=b.swipeVert?"swipeUp.cycle":"swipeLeft.cycle swipeleft.cycle",d=b.swipeVert?"swipeDown.cycle":"swipeRight.cycle swiperight.cycle";b.container.on(c,function(){b._tempFx=b.swipeFx,b.API.next()}),b.container.on(d,function(){b._tempFx=b.swipeFx,b.API.prev()})}}),a(document).on("cycle-update-view",function(a,b){if(!b.allowWrap){var c=b.disabledClass,d=b.API.getComponent("next"),e=b.API.getComponent("prev"),f=b._prevBoundry||0,g=void 0!==b._nextBoundry?b._nextBoundry:b.slideCount-1;b.currSlide==g?d.addClass(c).prop("disabled",!0):d.removeClass(c).prop("disabled",!1),b.currSlide===f?e.addClass(c).prop("disabled",!0):e.removeClass(c).prop("disabled",!1)}}),a(document).on("cycle-destroyed",function(a,b){b.API.getComponent("prev").off(b.nextEvent),b.API.getComponent("next").off(b.prevEvent),b.container.off("swipeleft.cycle swiperight.cycle swipeLeft.cycle swipeRight.cycle swipeUp.cycle swipeDown.cycle")})}(jQuery),/*! progressive loader plugin for Cycle2; version: 20130315 */
+function(a){"use strict";a.extend(a.fn.cycle.defaults,{progressive:!1}),a(document).on("cycle-pre-initialize",function(b,c){if(c.progressive){var d,e,f=c.API,g=f.next,h=f.prev,i=f.prepareTx,j=a.type(c.progressive);if("array"==j)d=c.progressive;else if(a.isFunction(c.progressive))d=c.progressive(c);else if("string"==j){if(e=a(c.progressive),d=a.trim(e.html()),!d)return;if(/^(\[)/.test(d))try{d=a.parseJSON(d)}catch(k){return void f.log("error parsing progressive slides",k)}else d=d.split(new RegExp(e.data("cycle-split")||"\n")),d[d.length-1]||d.pop()}i&&(f.prepareTx=function(a,b){var e,f;return a||0===d.length?void i.apply(c.API,[a,b]):void(b&&c.currSlide==c.slideCount-1?(f=d[0],d=d.slice(1),c.container.one("cycle-slide-added",function(a,b){setTimeout(function(){b.API.advanceSlide(1)},50)}),c.API.add(f)):b||0!==c.currSlide?i.apply(c.API,[a,b]):(e=d.length-1,f=d[e],d=d.slice(0,e),c.container.one("cycle-slide-added",function(a,b){setTimeout(function(){b.currSlide=1,b.API.advanceSlide(-1)},50)}),c.API.add(f,!0)))}),g&&(f.next=function(){var a=this.opts();if(d.length&&a.currSlide==a.slideCount-1){var b=d[0];d=d.slice(1),a.container.one("cycle-slide-added",function(a,b){g.apply(b.API),b.container.removeClass("cycle-loading")}),a.container.addClass("cycle-loading"),a.API.add(b)}else g.apply(a.API)}),h&&(f.prev=function(){var a=this.opts();if(d.length&&0===a.currSlide){var b=d.length-1,c=d[b];d=d.slice(0,b),a.container.one("cycle-slide-added",function(a,b){b.currSlide=1,b.API.advanceSlide(-1),b.container.removeClass("cycle-loading")}),a.container.addClass("cycle-loading"),a.API.add(c,!0)}else h.apply(a.API)})}})}(jQuery),/*! tmpl plugin for Cycle2; version: 20121227 */
+function(a){"use strict";a.extend(a.fn.cycle.defaults,{tmplRegex:"{{((.)?.*?)}}"}),a.extend(a.fn.cycle.API,{tmpl:function(b,c){var d=new RegExp(c.tmplRegex||a.fn.cycle.defaults.tmplRegex,"g"),e=a.makeArray(arguments);return e.shift(),b.replace(d,function(b,c){var d,f,g,h,i=c.split(".");for(d=0;d1)for(h=g,f=0;f=0;f--)a(b.slides[f]).css("zIndex",g++);for(f=b.slideCount-1;f>b.nextSlide;f--)a(b.slides[f]).css("zIndex",g++);a(d).css("zIndex",b.maxZ),a(c).css("zIndex",b.maxZ-1)}}}}(jQuery);
+
+/* Plugin for Cycle2; Copyright (c) 2012 M. Alsup; v20141007 */
+!function(a){"use strict";a.fn.cycle.transitions.tileSlide=a.fn.cycle.transitions.tileBlind={before:function(b,c,d,e){b.API.stackSlides(c,d,e),a(c).css({display:"block",visibility:"visible"}),b.container.css("overflow","hidden"),b.tileDelay=b.tileDelay||"tileSlide"==b.fx?100:125,b.tileCount=b.tileCount||7,b.tileVertical=b.tileVertical!==!1,b.container.data("cycleTileInitialized")||(b.container.on("cycle-destroyed",a.proxy(this.onDestroy,b.API)),b.container.data("cycleTileInitialized",!0))},transition:function(b,c,d,e,f){function g(a){m.eq(a).animate(t,{duration:b.speed,easing:b.easing,complete:function(){(e?p-1===a:0===a)&&b._tileAniCallback()}}),setTimeout(function(){(e?p-1!==a:0!==a)&&g(e?a+1:a-1)},b.tileDelay)}b.slides.not(c).not(d).css("visibility","hidden");var h,i,j,k,l,m=a(),n=a(c),o=a(d),p=b.tileCount,q=b.tileVertical,r=b.container.height(),s=b.container.width();q?(i=Math.floor(s/p),k=s-i*(p-1),j=l=r):(i=k=s,j=Math.floor(r/p),l=r-j*(p-1)),b.container.find(".cycle-tiles-container").remove();var t,u={left:0,top:0,overflow:"hidden",position:"absolute",margin:0,padding:0};t=q?"tileSlide"==b.fx?{top:r}:{width:0}:"tileSlide"==b.fx?{left:s}:{height:0};var v=a('');v.css({zIndex:n.css("z-index"),overflow:"visible",position:"absolute",top:0,left:0,direction:"ltr"}),v.insertBefore(d);for(var w=0;p>w;w++)h=a("").css(u).css({width:p-1===w?k:i,height:p-1===w?l:j,marginLeft:q?w*i:0,marginTop:q?0:w*j}).append(n.clone().css({position:"relative",maxWidth:"none",width:n.width(),margin:0,padding:0,marginLeft:q?-(w*i):0,marginTop:q?0:-(w*j)})),m=m.add(h);v.append(m),n.css("visibility","hidden"),o.css({opacity:1,display:"block",visibility:"visible"}),g(e?0:p-1),b._tileAniCallback=function(){o.css({display:"block",visibility:"visible"}),n.css("visibility","hidden"),v.remove(),f()}},stopTransition:function(a){a.container.find("*").stop(!0,!0),a._tileAniCallback&&a._tileAniCallback()},onDestroy:function(){var a=this.opts();a.container.find(".cycle-tiles-container").remove()}}}(jQuery);
+
+/* Custom transitions */
+$.fn.cycle.transitions.noAnim = {
+ before: function( slideOptions, currEl, nextEl, fwdFlag ){
+ $(currEl).css('visibility','hidden');
+ $(nextEl).css('visibility','visible');
+ }
+}
\ No newline at end of file
diff --git a/modules/vendor/jquery.marquee.min.js b/modules/vendor/jquery.marquee.min.js
new file mode 100644
index 0000000..3aedb23
--- /dev/null
+++ b/modules/vendor/jquery.marquee.min.js
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2023 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+
+// We have two different marquee plugins in here, the first is the old method of using an overflow and scrolling
+// the content underneath it
+// the second is a newer (relatively speaking) plugin which will use CSS3 animations if available to translate
+// the content - this should be more efficient
+// we include both because the CSS3 one doesn't appear to work on Android 4.4
+
+/**
+* author Remy Sharp
+* url https://remysharp.com/2008/09/10/the-silky-smooth-marquee
+*/
+(function(e){e.fn.overflowMarquee=function(t){function i(e,t,n){var r=n.behavior,i=n.width,s=n.dir;var o=0;if(r=="alternate"){o=e==1?t[n.widthAxis]-i*2:i}else if(r=="slide"){if(e==-1){o=s==-1?t[n.widthAxis]:i}else{o=s==-1?t[n.widthAxis]-i*2:0}}else{o=e==-1?t[n.widthAxis]:0}return o}function s(){var t=n.length,r=null,o=null,u={},a=[],f=false;while(t--){r=n[t];o=e(r);u=o.data("marqueeState");if(o.data("paused")!==true){r[u.axis]+=u.scrollamount*u.dir;f=u.dir==-1?r[u.axis]<=i(u.dir*-1,r,u):r[u.axis]>=i(u.dir*-1,r,u);if(u.behavior=="scroll"&&u.last==r[u.axis]||u.behavior=="alternate"&&f&&u.last!=-1||u.behavior=="slide"&&f&&u.last!=-1){if(u.behavior=="alternate"){u.dir*=-1}u.last=-1;o.trigger("stop");u.loops--;if(u.loops===0){if(u.behavior!="slide"){r[u.axis]=i(u.dir,r,u)}else{r[u.axis]=i(u.dir*-1,r,u)}o.trigger("end")}else{a.push(r);o.trigger("start");r[u.axis]=i(u.dir,r,u)}}else{a.push(r)}u.last=r[u.axis];o.data("marqueeState",u)}else{a.push(r)}}n=a;if(n.length){setTimeout(s,25)}}var n=[],r=this.length;this.each(function(o){var u=e(this),a=u.attr("width")||u.width(),f=u.attr("height")||u.height(),l=u.after("
'+u.html()+"
").next(),c=l.get(0),h=0,p=(u.attr("direction")||"left").toLowerCase(),d={dir:/down|right/.test(p)?-1:1,axis:/left|right/.test(p)?"scrollLeft":"scrollTop",widthAxis:/left|right/.test(p)?"scrollWidth":"scrollHeight",last:-1,loops:u.attr("loop")||-1,scrollamount:u.attr("scrollamount")||this.scrollAmount||2,behavior:(u.attr("behavior")||"scroll").toLowerCase(),width:/left|right/.test(p)?a:f};if(u.attr("loop")==-1&&d.behavior=="slide"){d.loops=1}u.remove();if(/left|right/.test(p)){l.find("> div").css("padding","0 "+a+"px")}else{l.find("> div").css("padding",f+"px 0")}l.bind("stop",function(){l.data("paused",true)}).bind("pause",function(){l.data("paused",true)}).bind("start",function(){l.data("paused",false)}).bind("unpause",function(){l.data("paused",false)}).data("marqueeState",d);n.push(c);c[d.axis]=i(d.dir,c,d);l.trigger("start");if(o+1==r){s()}});return e(n)}})(jQuery);
+
+
+/**
+* jQuery.marquee - scrolling text like old marquee element
+* @author Aamir Afridi - aamirafridi(at)gmail(dot)com / https://github.com/aamirafridi/jQuery.Marquee
+*/
+(function(factory){"use strict";if(typeof define==="function"&&define.amd){define(["jquery"],factory)}else if(typeof exports!=="undefined"){module.exports=factory(require("jquery"))}else{factory(jQuery)}})(function($){$.fn.marquee=function(options){return this.each(function(){var o=$.extend({},$.fn.marquee.defaults,options),$this=$(this),$marqueeWrapper,containerWidth,animationCss,verticalDir,elWidth,loopCount=3,playState="animation-play-state",css3AnimationIsSupported=false,_prefixedEvent=function(element,type,callback){var pfx=["webkit","moz","MS","o",""];for(var p=0;p');var $el=$this.find(".js-marquee").css({"margin-right":o.gap,float:"left"});if(o.duplicated){$el.clone(true).appendTo($this)}$this.wrapInner('');$marqueeWrapper=$this.find(".js-marquee-wrapper");if(verticalDir){var containerHeight=$this.height();$marqueeWrapper.removeAttr("style");$this.height(containerHeight);$this.find(".js-marquee").css({float:"none","margin-bottom":o.gap,"margin-right":0});if(o.duplicated){$this.find(".js-marquee:last").css({"margin-bottom":0})}var elHeight=$this.find(".js-marquee:first").height()+o.gap;if(o.startVisible&&!o.duplicated){o._completeDuration=(parseInt(elHeight,10)+parseInt(containerHeight,10))/parseInt(containerHeight,10)*o.duration;o.duration=parseInt(elHeight,10)/parseInt(containerHeight,10)*o.duration}else{o.duration=(parseInt(elHeight,10)+parseInt(containerHeight,10))/parseInt(containerHeight,10)*o.duration}}else{elWidth=$this.find(".js-marquee:first").width()+o.gap;containerWidth=$this.width();if(o.startVisible&&!o.duplicated){o._completeDuration=(parseInt(elWidth,10)+parseInt(containerWidth,10))/parseInt(containerWidth,10)*o.duration;o.duration=parseInt(elWidth,10)/parseInt(containerWidth,10)*o.duration}else{o.duration=(parseInt(elWidth,10)+parseInt(containerWidth,10))/parseInt(containerWidth,10)*o.duration}}if(o.duplicated){o.duration=o.duration/2}if(o.allowCss3Support){var elm=document.body||document.createElement("div"),animationName="marqueeAnimation-"+Math.floor(Math.random()*1e7),domPrefixes="Webkit Moz O ms Khtml".split(" "),animationString="animation",animationCss3Str="",keyframeString="";if(elm.style.animation!==undefined){keyframeString="@keyframes "+animationName+" ";css3AnimationIsSupported=true}if(css3AnimationIsSupported===false){for(var i=0;i2){$marqueeWrapper.css("transform","translateY("+(o.direction==="up"?0:"-"+elHeight+"px")+")")}animationCss={transform:"translateY("+(o.direction==="up"?"-"+elHeight+"px":0)+")"}}else if(o.startVisible){if(loopCount===2){if(animationCss3Str){animationCss3Str=animationName+" "+o.duration/1e3+"s "+o.delayBeforeStart/1e3+"s "+o.css3easing}animationCss={transform:"translateY("+(o.direction==="up"?"-"+elHeight+"px":containerHeight+"px")+")"};loopCount++}else if(loopCount===3){o.duration=o._completeDuration;if(animationCss3Str){animationName=animationName+"0";keyframeString=$.trim(keyframeString)+"0 ";animationCss3Str=animationName+" "+o.duration/1e3+"s 0s infinite "+o.css3easing}_rePositionVertically()}}else{_rePositionVertically();animationCss={transform:"translateY("+(o.direction==="up"?"-"+$marqueeWrapper.height()+"px":containerHeight+"px")+")"}}}else{if(o.duplicated){if(loopCount>2){$marqueeWrapper.css("transform","translateX("+(o.direction==="left"?0:"-"+elWidth+"px")+")")}animationCss={transform:"translateX("+(o.direction==="left"?"-"+elWidth+"px":0)+")"}}else if(o.startVisible){if(loopCount===2){if(animationCss3Str){animationCss3Str=animationName+" "+o.duration/1e3+"s "+o.delayBeforeStart/1e3+"s "+o.css3easing}animationCss={transform:"translateX("+(o.direction==="left"?"-"+elWidth+"px":containerWidth+"px")+")"};loopCount++}else if(loopCount===3){o.duration=o._completeDuration;if(animationCss3Str){animationName=animationName+"0";keyframeString=$.trim(keyframeString)+"0 ";animationCss3Str=animationName+" "+o.duration/1e3+"s 0s infinite "+o.css3easing}_rePositionHorizontally()}}else{_rePositionHorizontally();animationCss={transform:"translateX("+(o.direction==="left"?"-"+elWidth+"px":containerWidth+"px")+")"}}}$this.trigger("beforeStarting");if(css3AnimationIsSupported){$marqueeWrapper.css(animationString,animationCss3Str);var keyframeCss=keyframeString+" { 100% "+_objToString(animationCss)+"}",$styles=$marqueeWrapper.find("style");if($styles.length!==0){$styles.filter(":last").html(keyframeCss)}else{$("head").append("")}_prefixedEvent($marqueeWrapper[0],"AnimationIteration",function(){$this.trigger("finished")});_prefixedEvent($marqueeWrapper[0],"AnimationEnd",function(){animate();$this.trigger("finished")})}else{$marqueeWrapper.animate(animationCss,o.duration,o.easing,function(){$this.trigger("finished");if(o.pauseOnCycle){_startAnimationWithDelay()}else{animate()}})}$this.data("runningStatus","resumed")};$this.on("pause",methods.pause);$this.on("resume",methods.resume);if(o.pauseOnHover){$this.on("mouseenter",methods.pause);$this.on("mouseleave",methods.resume)}if(css3AnimationIsSupported&&o.allowCss3Support){animate()}else{_startAnimationWithDelay()}})};$.fn.marquee.defaults={allowCss3Support:true,css3easing:"linear",easing:"linear",delayBeforeStart:1e3,direction:"left",duplicated:false,duration:5e3,speed:0,gap:20,pauseOnCycle:false,pauseOnHover:false,startVisible:false}});
\ No newline at end of file
diff --git a/modules/video.xml b/modules/video.xml
new file mode 100644
index 0000000..3986256
--- /dev/null
+++ b/modules/video.xml
@@ -0,0 +1,108 @@
+
+
+ core-video
+ Video
+ Core
+ Upload Video files to assign to Layouts
+ fa fa-file-video-o
+ \Xibo\Widget\VideoProvider
+ \Xibo\Widget\Validator\ZeroDurationValidator
+ video
+
+ 1
+ 1
+ 0
+ native
+ 0
+ 1
+
+
+ Valid Extensions
+ The Extensions allowed on files uploaded using this module. Comma Separated.
+ wmv,avi,mpg,mpeg,webm,mp4
+
+
+ Default Mute?
+ Should new Video Widgets default to Muted?
+ 1
+
+
+ Default Scale type
+ How should new Video Widgets be scaled by default?
+ aspect
+
+
+
+
+
+
+
+
+ This video will play for %media.duration% seconds. Cut the video short by setting a shorter duration in the Advanced tab. Wait on the last frame or set to Loop by setting a higher duration in the Advanced tab.
+
+
+ Loop?
+ Should the video loop if it finishes before the provided duration?
+ 0
+
+
+ 1
+ $media.duration$
+
+
+
+
+ Scale type
+ How should this video be scaled?
+ %defaultScaleType%
+
+
+
+
+
+
+ Mute?
+ Should the video be muted?
+ %defaultMute%
+
+
+ Show Full Screen?
+ Should the video expand over the top of existing content and show in full screen?
+ 0
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
diff --git a/modules/videoin.xml b/modules/videoin.xml
new file mode 100644
index 0000000..83226ad
--- /dev/null
+++ b/modules/videoin.xml
@@ -0,0 +1,59 @@
+
+
+ core-videoin
+ Video In
+ Core
+ Display input from an external source
+ fa fa-video-camera
+
+ videoin
+
+ 1
+ 1
+ 1
+ native
+ 60
+
+
+
+ Input
+ Which device input should be shown
+ hdmi
+
+
+
+
+
+
+
+
+
+ Show Full Screen?
+ Should the video expand over the top of existing content and show in full screen?
+ 0
+
+
+ This Module is compatible with webOS, Tizen and Philips SOC Players only
+
+
+
+
diff --git a/modules/webpage.xml b/modules/webpage.xml
new file mode 100644
index 0000000..087bff9
--- /dev/null
+++ b/modules/webpage.xml
@@ -0,0 +1,156 @@
+
+
+ core-webpage
+ Webpage
+ Core
+ Embed a Webpage
+ fa fa-sitemap
+
+ webpage
+
+ 1
+ 1
+ 1
+ native
+ 60
+
+
+
+ Link
+ The Location (URL) of the webpage
+
+
+
+
+
+
+
+
+ Background transparent?
+ Should the Widget be shown with a transparent background? Also requires the embedded content to have a transparent background.
+
+
+
+ Preload?
+ Should this Widget be loaded entirely off screen so that it is ready when shown? Dynamic content will start running off screen.
+
+
+
+ Options
+ How should this web page be embedded?
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+ Page Width
+ The width of the page. Leave empty to use the region width.
+
+
+
+ 1
+
+
+
+
+ Page Height
+ The height of the page. Leave empty to use the region height.
+
+
+
+ 1
+
+
+
+
+ Offset Top
+ The starting point from the top in pixels
+
+
+
+ 2
+
+
+
+
+ Offset Left
+ The starting point from the left in pixels
+
+
+
+ 2
+
+
+
+
+ Scale Percentage
+ The Percentage to Scale this Webpage (0 - 100)
+
+
+
+ 2
+
+
+
+
+ Trigger on page load error
+ Code to be triggered when the page to be loaded returns an error, e.g. a 404 not found.
+
+
+
+ 1
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
diff --git a/modules/widget-html-render.twig b/modules/widget-html-render.twig
new file mode 100644
index 0000000..7f444a0
--- /dev/null
+++ b/modules/widget-html-render.twig
@@ -0,0 +1,161 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+
+
+ Xibo Open Source Digital Signage
+
+
+
+
+
+
+ {% for item in head %}
+ {{ item|raw }}
+ {% endfor %}
+
+ {% if style|length > 0 %}
+ {% for item in style %}
+
+ {% endfor %}
+ {% endif %}
+
+
+
+ {% for item in twig %}
+ {{ item|raw }}
+ {% endfor %}
+ {% for asset in assets %}
+ {% if asset.isAutoInclude() and asset.mimeType == 'text/css' %}
+
+ {% elseif asset.isAutoInclude() and asset.mimeType == 'text/javascript' %}
+
+ {% endif %}
+ {% endfor %}
+
+
+{% for id, item in hbs %}
+
+{% endfor %}
+{% for widgetId, parser in onInitialize %}
+
+{% endfor %}
+{% for widgetId, parser in onParseData %}
+
+{% endfor %}
+{% for widgetId, parser in onDataLoad %}
+
+{% endfor %}
+{% for widgetId, parser in onRender %}
+
+{% endfor %}
+{% for widgetId, parser in onVisible %}
+
+{% endfor %}
+{% for templateId, renderer in onTemplateRender %}
+
+{% endfor %}
+{% for templateId, renderer in onTemplateVisible %}
+
+{% endfor %}
+{% for templateId, parser in onElementParseData %}
+
+{% endfor %}
+
+
+
diff --git a/modules/worldclock-analogue.xml b/modules/worldclock-analogue.xml
new file mode 100644
index 0000000..182c7dc
--- /dev/null
+++ b/modules/worldclock-analogue.xml
@@ -0,0 +1,561 @@
+
+
+ core-worldclock-analogue
+ World Clock - Analogue
+ Core
+ Analogue World Clock
+ fa fa-globe
+
+ \Xibo\Widget\Compatibility\WorldClockWidgetCompatibility
+ worldclock-analogue
+ World Clock
+ worldclock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ worldclock-analogue-thumb
+
+
+
+ Clocks
+
+
+
+ Clock Columns
+ Number of columns to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Clock Rows
+ Number of rows to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Analogue Clock Settings
+
+
+ Background colour
+ Use the colour picker to select the background colour
+
+
+
+ Face colour
+ Use the colour picker to select the face colour
+ #f9f9f9
+
+
+ Case colour
+ Use the colour picker to select the case colour
+ #222
+
+
+ Hour hand colour
+ Use the colour picker to select the hour hand colour
+ #333
+
+
+ Minute hand colour
+ Use the colour picker to select the minute hand colour
+ #333
+
+
+ Show seconds hand?
+ Tick if you would like to show the seconds hand
+ 1
+
+
+
+
+ 1
+
+
+
+
+ Seconds hand colour
+ Use the colour picker to select the seconds hand colour
+ #aa0202
+
+
+ 1
+
+
+
+
+
+
+ 1
+
+
+
+
+ Dial centre colour
+ Use the colour picker to select the dial centre colour
+ #222
+
+
+ Show steps?
+ Tick if you would like to show the clock steps
+ 1
+
+
+
+
+ 1
+
+
+
+
+ Steps colour
+ Use the colour picker to select the steps colour
+ #333
+
+
+ 1
+
+
+
+
+ Secondary steps colour
+ Use the colour picker to select the secondary steps colour
+ #333
+
+
+ 1
+
+
+
+
+
+
+ 1
+
+
+
+
+ Detailed look?
+ Tick if you would like to show a more detailed look for the clock ( using shadows and 3D effects )
+ 1
+
+
+ Show inner digital clock?
+ Tick if you would like to show a small inner digital clock
+ 1
+
+
+
+
+ 1
+
+
+
+
+ Digital clock text colour
+ Use the colour picker to select the digital clock text colour
+ #3b3b3b
+
+
+ 1
+
+
+
+
+ Digital clock background colour
+ Use the colour picker to select the digital clock background colour
+ #e6e6e6
+
+
+ 1
+
+
+
+
+
+
+ 1
+
+
+
+
+ Show label?
+ Tick if you would like to show the timezone label
+ 1
+
+
+
+
+ 1
+
+
+
+
+ Label text colour
+ Use the colour picker to select the label text colour
+ #f9f9f9
+
+
+ 1
+
+
+
+
+ Label background colour
+ Use the colour picker to select the label background colour
+ #222
+
+
+ 1
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+ 250
+ 250
+
+
+ {{/eq}}
+
+ ]]>
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/worldclock-digital-custom.xml b/modules/worldclock-digital-custom.xml
new file mode 100644
index 0000000..10b24bf
--- /dev/null
+++ b/modules/worldclock-digital-custom.xml
@@ -0,0 +1,166 @@
+
+
+ core-worldclock-custom
+ World Clock - Custom
+ Core
+ Custom World Clock
+ fa fa-globe
+
+ \Xibo\Widget\Compatibility\WorldClockWidgetCompatibility
+ none
+ worldclock-digital-custom
+ World Clock
+ worldclock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ worldclock-custom-thumb
+ 400
+ 250
+
+
+
+ Clocks
+
+
+
+ Template - HTML
+ Enter text or HTML in the box below. Use squared brackets for elements to be replaced (e.g. [HH:mm] for time, or [label] to show the timezone name
+
+
+ [HH:mm:ss]
+
+
[label]
+
+ ]]>
+
+
+ Template - Stylesheet
+ Enter CSS styling in the box below.
+
+
+
+ Original Width
+ This is the intended width of the template and is used to scale the Widget within its region when the template is applied.
+ 200
+
+
+ Original Height
+ This is the intended height of the template and is used to scale the Widget within its region when the template is applied.
+ 100
+
+
+ Clock Columns
+ Number of columns to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Clock Rows
+ Number of rows to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+
+
+
+ {{template_html|raw}}
+
+ ]]>
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/worldclock-digital-date.xml b/modules/worldclock-digital-date.xml
new file mode 100644
index 0000000..28dade6
--- /dev/null
+++ b/modules/worldclock-digital-date.xml
@@ -0,0 +1,180 @@
+
+
+ core-worldclock-digital-date
+ World Clock - Time and Date
+ Core
+ Time and Date World Clock
+ fa fa-globe
+
+ \Xibo\Widget\Compatibility\WorldClockWidgetCompatibility
+ worldclock-digital-date
+ World Clock
+ worldclock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ worldclock-date-thumb
+
+
+
+ Clocks
+
+
+
+ Clock Columns
+ Number of columns to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Clock Rows
+ Number of rows to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Label Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Label Colour
+ The colour of the label
+ #2993c3
+
+
+ Date/Time Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Date/Time Colour
+ The colour of the text
+ #f9f9f9
+
+
+ Background Colour
+ #222
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+
+ 200
+ 80
+
+
+
[label]
+
[DD MMMM, YYYY]
+
[HH:mm:ss]
+
+
+ ]]>
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/worldclock-digital-text.xml b/modules/worldclock-digital-text.xml
new file mode 100644
index 0000000..cf06fe3
--- /dev/null
+++ b/modules/worldclock-digital-text.xml
@@ -0,0 +1,172 @@
+
+
+ core-worldclock-digital-text
+ World Clock - Text
+ Core
+ Text World Clock
+ fa fa-globe
+
+ \Xibo\Widget\Compatibility\WorldClockWidgetCompatibility
+ worldclock-digital-text
+ World Clock
+ worldclock
+
+ 2
+ 1
+ 1
+ html
+ 10
+ worldclock-text-thumb
+
+
+
+ Clocks
+
+
+
+ Clock Columns
+ Number of columns to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Clock Rows
+ Number of rows to display
+ 1
+
+
+
+ 0
+
+
+
+
+ Horizontal Align
+ How should this widget be horizontally aligned?
+ center
+
+
+
+
+
+
+
+ Vertical Align
+ How should this widget be vertically aligned?
+ middle
+
+
+
+
+
+
+
+ Label Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Label Colour
+ The colour of the label
+ #a1a1a1
+
+
+ Date/Time Font
+ Select a custom font - leave empty to use the default font.
+
+
+ Date/Time Colour
+ The colour of the text
+ #323232
+
+
+ Background Colour
+ The selected effect works best with a background colour. Optionally add one here.
+
+
+
+
+ 200
+ 80
+
+
+
+ {{ placeholder }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/apirequests-report-form.twig b/reports/apirequests-report-form.twig
new file mode 100644
index 0000000..5e1f650
--- /dev/null
+++ b/reports/apirequests-report-form.twig
@@ -0,0 +1,390 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: API Requests History" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+ {% trans "API Requests History" %}
+
+
+ {% include "report-selector.twig" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "User Name" %}
+
{% trans "User ID" %}
+
{% trans "Application" %}
+
{% trans "Request ID" %}
+
{% trans "Method" %}
+
{% trans "Url" %}
+
{% trans "Entity" %}
+
{% trans "Entity ID" %}
+
{% trans "Message" %}
+
{% trans "Details" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "UserName" %}
+
{% trans "User ID" %}
+
{% trans "Application" %}
+
{% trans "Request ID" %}
+
{% trans "Method" %}
+
{% trans "Url" %}
+
{% trans "Level" %}
+
{% trans "Details" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "UserName" %}
+
{% trans "User ID" %}
+
{% trans "Application" %}
+
{% trans "Request ID" %}
+
{% trans "Method" %}
+
{% trans "Url" %}
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+ {% verbatim %}
+
+ {% endverbatim %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/apirequests-report-preview.twig b/reports/apirequests-report-preview.twig
new file mode 100644
index 0000000..64f528a
--- /dev/null
+++ b/reports/apirequests-report-preview.twig
@@ -0,0 +1,216 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+ {% if metadata.logType == 'audit' %}
+
+
+
+
+
{% trans "Date" %}
+
{% trans "User Name" %}
+
{% trans "User ID" %}
+
{% trans "Application" %}
+
{% trans "Request ID" %}
+
{% trans "Method" %}
+
{% trans "Url" %}
+
{% trans "Entity" %}
+
{% trans "Entity ID" %}
+
{% trans "Message" %}
+
{% trans "Details" %}
+
+
+
+
+
+
+
+ {% elseif metadata.logType == 'debug' %}
+
+
+
+
+
{% trans "Date" %}
+
{% trans "UserName" %}
+
{% trans "User ID" %}
+
{% trans "Application" %}
+
{% trans "Request ID" %}
+
{% trans "Method" %}
+
{% trans "Url" %}
+
{% trans "Level" %}
+
{% trans "Details" %}
+
+
+
+
+
+
+
+ {% else %}
+
+
+
+
+
{% trans "Date" %}
+
{% trans "UserName" %}
+
{% trans "User ID" %}
+
{% trans "Application" %}
+
{% trans "Request ID" %}
+
{% trans "Method" %}
+
{% trans "Url" %}
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
diff --git a/reports/apirequests-schedule-form-add.twig b/reports/apirequests-schedule-form-add.twig
new file mode 100644
index 0000000..add3fa0
--- /dev/null
+++ b/reports/apirequests-schedule-form-add.twig
@@ -0,0 +1,95 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/apirequests.report b/reports/apirequests.report
new file mode 100644
index 0000000..e445102
--- /dev/null
+++ b/reports/apirequests.report
@@ -0,0 +1,14 @@
+{
+ "name": "apirequests",
+ "description": "API Requests History",
+ "class": "\\Xibo\\Report\\ApiRequests",
+ "type": "Report",
+ "output_type": "table",
+ "color":"orange",
+ "fa_icon": "fa-th",
+ "sort_order": 5,
+ "hidden": 0,
+ "category": "Audit",
+ "feature": "admin",
+ "adminOnly": 1
+}
diff --git a/reports/bandwidth-email-template.twig b/reports/bandwidth-email-template.twig
new file mode 100644
index 0000000..3b901d6
--- /dev/null
+++ b/reports/bandwidth-email-template.twig
@@ -0,0 +1,48 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+ {{ placeholder }}
+
+
+
+
+
+
+
{% trans "Bandwidth" %}
+
{% trans "Unit" %}
+
+ {% for item in tableData %}
+
+
{{ item.label }}
+
{{ item.bandwidth }}
+
{{ item.unit }}
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/bandwidth-report-form.twig b/reports/bandwidth-report-form.twig
new file mode 100644
index 0000000..2e012fd
--- /dev/null
+++ b/reports/bandwidth-report-form.twig
@@ -0,0 +1,251 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Bandwidth" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/bandwidth-report-preview.twig b/reports/bandwidth-report-preview.twig
new file mode 100644
index 0000000..b752f0b
--- /dev/null
+++ b/reports/bandwidth-report-preview.twig
@@ -0,0 +1,95 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Bandwidth" %}
+
{% trans "Unit" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/bandwidth-schedule-form-add.twig b/reports/bandwidth-schedule-form-add.twig
new file mode 100644
index 0000000..7727a28
--- /dev/null
+++ b/reports/bandwidth-schedule-form-add.twig
@@ -0,0 +1,90 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/bandwidth.report b/reports/bandwidth.report
new file mode 100644
index 0000000..abb563f
--- /dev/null
+++ b/reports/bandwidth.report
@@ -0,0 +1,14 @@
+{
+ "name": "bandwidth",
+ "description": "Display Statistics: Bandwidth",
+ "class": "\\Xibo\\Report\\Bandwidth",
+ "type": "Chart",
+ "output_type": "chart",
+ "color":"green",
+ "fa_icon": "fa-bar-chart",
+ "sort_order": 3,
+ "hidden": 0,
+ "category": "Display",
+ "feature": "displays.reporting",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/reports/base-report.twig b/reports/base-report.twig
new file mode 100644
index 0000000..c20ac92
--- /dev/null
+++ b/reports/base-report.twig
@@ -0,0 +1,31 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% if logo %}
+
+
+
+{% endif %}
+
{{ header }}
+
{{ title }} ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
+{% block content %}{% endblock %}
\ No newline at end of file
diff --git a/reports/campaign-proofofplay-email-template.twig b/reports/campaign-proofofplay-email-template.twig
new file mode 100644
index 0000000..09e1ee7
--- /dev/null
+++ b/reports/campaign-proofofplay-email-template.twig
@@ -0,0 +1,48 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
{% trans "Period" %}
+
{% trans "Ad Plays" %}
+
{% trans "Ad Duration" %}
+
{% trans "Audience Impressions" %}
+
+ {% for item in tableData %}
+
+
{{ item.labelDate }}
+
{{ item.adPlays }}
+
{{ item.adDuration }}
+
{{ item.impressions }}
+
+ {% endfor %}
+
+{% endblock %}
+
diff --git a/reports/campaign-proofofplay-report-form.twig b/reports/campaign-proofofplay-report-form.twig
new file mode 100644
index 0000000..71b1ae0
--- /dev/null
+++ b/reports/campaign-proofofplay-report-form.twig
@@ -0,0 +1,529 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Campaign Proof of Play" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
diff --git a/reports/campaign-proofofplay-report-preview.twig b/reports/campaign-proofofplay-report-preview.twig
new file mode 100644
index 0000000..b5b6961
--- /dev/null
+++ b/reports/campaign-proofofplay-report-preview.twig
@@ -0,0 +1,97 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
Period
+
Ad Plays
+
Ad Duration
+
Audience Impressions
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/campaign-proofofplay-schedule-form-add.twig b/reports/campaign-proofofplay-schedule-form-add.twig
new file mode 100644
index 0000000..92e41b4
--- /dev/null
+++ b/reports/campaign-proofofplay-schedule-form-add.twig
@@ -0,0 +1,100 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#campaignProofofplayScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}campaignProofOfPlayScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/display-adplays-report-form.twig b/reports/display-adplays-report-form.twig
new file mode 100644
index 0000000..477665b
--- /dev/null
+++ b/reports/display-adplays-report-form.twig
@@ -0,0 +1,516 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Display Ad Plays" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
diff --git a/reports/display-adplays-report-preview.twig b/reports/display-adplays-report-preview.twig
new file mode 100644
index 0000000..d441bdb
--- /dev/null
+++ b/reports/display-adplays-report-preview.twig
@@ -0,0 +1,98 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Period" %}
+
{% trans "Ad Plays" %}
+
{% trans "Impressions" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/display-adplays-schedule-form-add.twig b/reports/display-adplays-schedule-form-add.twig
new file mode 100644
index 0000000..ca4f0ea
--- /dev/null
+++ b/reports/display-adplays-schedule-form-add.twig
@@ -0,0 +1,100 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}reportScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/display-percentage-report-form.twig b/reports/display-percentage-report-form.twig
new file mode 100644
index 0000000..f949971
--- /dev/null
+++ b/reports/display-percentage-report-form.twig
@@ -0,0 +1,704 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Display Played Percentage" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+
+{% endblock %}
diff --git a/reports/display-percentage-report-preview.twig b/reports/display-percentage-report-preview.twig
new file mode 100644
index 0000000..bbf453f
--- /dev/null
+++ b/reports/display-percentage-report-preview.twig
@@ -0,0 +1,101 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Display" %}
+
{% trans "Spend(%)" %}
+
{% trans "Playtime(%)" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/display-percentage-schedule-form-add.twig b/reports/display-percentage-schedule-form-add.twig
new file mode 100644
index 0000000..adc9b18
--- /dev/null
+++ b/reports/display-percentage-schedule-form-add.twig
@@ -0,0 +1,72 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}reportScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/displayalerts-email-template.twig b/reports/displayalerts-email-template.twig
new file mode 100644
index 0000000..6f03d91
--- /dev/null
+++ b/reports/displayalerts-email-template.twig
@@ -0,0 +1,56 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Event Type" %}
+
{% trans "Start" %}
+
{% trans "End" %}
+
{% trans "Reference ID" %}
+
{% trans "Detail" %}
+
+ {% for item in tableData %}
+
+
{{ item.displayId }}
+
{{ item.display }}
+
{{ item.eventType }}
+
{{ item.start }}
+
{{ item.end }}
+
{{ item.refId }}
+
{{ item.detail }}
+
+ {% endfor %}
+
+
+ {{ placeholder }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/displayalerts-report-form.twig b/reports/displayalerts-report-form.twig
new file mode 100644
index 0000000..25bdc9c
--- /dev/null
+++ b/reports/displayalerts-report-form.twig
@@ -0,0 +1,315 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Display Alerts" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+ {% trans "Display Alerts" %}
+
+
+ {% include "report-selector.twig" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Event Type" %}
+
{% trans "Start" %}
+
{% trans "End" %}
+
{% trans "Duration" %}
+
{% trans "Reference" %}
+
{% trans "Detail" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/displayalerts-report-preview.twig b/reports/displayalerts-report-preview.twig
new file mode 100644
index 0000000..4b60146
--- /dev/null
+++ b/reports/displayalerts-report-preview.twig
@@ -0,0 +1,122 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Event Type" %}
+
{% trans "Start" %}
+
{% trans "End" %}
+
{% trans "Reference" %}
+
{% trans "Detail" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/displayalerts-schedule-form-add.twig b/reports/displayalerts-schedule-form-add.twig
new file mode 100644
index 0000000..5a60f40
--- /dev/null
+++ b/reports/displayalerts-schedule-form-add.twig
@@ -0,0 +1,106 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}displayAlertsReportScheduleFormOpen{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/displayalerts.report b/reports/displayalerts.report
new file mode 100644
index 0000000..d5616cc
--- /dev/null
+++ b/reports/displayalerts.report
@@ -0,0 +1,14 @@
+{
+ "name": "displayalerts",
+ "description": "Display Alerts",
+ "class": "\\Xibo\\Report\\DisplayAlerts",
+ "type": "Report",
+ "output_type": "table",
+ "color":"orange",
+ "fa_icon": "fa-bell",
+ "sort_order": 5,
+ "hidden": 0,
+ "category": "Display",
+ "feature": "displays.reporting",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/reports/distribution-email-template.twig b/reports/distribution-email-template.twig
new file mode 100644
index 0000000..8118645
--- /dev/null
+++ b/reports/distribution-email-template.twig
@@ -0,0 +1,48 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+{{ placeholder }}
+
+
+
+
+
+
{% trans "Period" %}
+
{% trans "Duration" %}
+
{% trans "Count" %}
+
+ {% for item in tableData %}
+
+
{{ item.label }}
+
{{ item.duration }}
+
{{ item.count }}
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/distribution-report-form.twig b/reports/distribution-report-form.twig
new file mode 100644
index 0000000..ef1aad1
--- /dev/null
+++ b/reports/distribution-report-form.twig
@@ -0,0 +1,562 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2019 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Distribution by Layout, Media or Event" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+ {% trans "Distribution by Layout, Media or Event" %}
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
diff --git a/reports/distribution-report-preview.twig b/reports/distribution-report-preview.twig
new file mode 100644
index 0000000..e4aa5e8
--- /dev/null
+++ b/reports/distribution-report-preview.twig
@@ -0,0 +1,96 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2019 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Period" %}
+
{% trans "Duration" %}
+
{% trans "Count" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/distribution-schedule-form-add.twig b/reports/distribution-schedule-form-add.twig
new file mode 100644
index 0000000..1c066b6
--- /dev/null
+++ b/reports/distribution-schedule-form-add.twig
@@ -0,0 +1,119 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {{ formTitle }}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}distributionScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/distribution.report b/reports/distribution.report
new file mode 100644
index 0000000..928aae3
--- /dev/null
+++ b/reports/distribution.report
@@ -0,0 +1,14 @@
+{
+ "name": "distributionReport",
+ "description": "Chart: Distribution by Layout, Media or Event",
+ "class": "\\Xibo\\Report\\DistributionReport",
+ "type": "Chart",
+ "output_type": "both",
+ "color":"green",
+ "fa_icon": "fa-bar-chart",
+ "sort_order": 3,
+ "hidden": 0,
+ "category": "Proof of Play",
+ "feature": "proof-of-play",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/reports/libraryusage-email-template.twig b/reports/libraryusage-email-template.twig
new file mode 100644
index 0000000..bff69d2
--- /dev/null
+++ b/reports/libraryusage-email-template.twig
@@ -0,0 +1,53 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
{% trans "ID" %}
+
{% trans "User" %}
+
{% trans "Usage" %}
+
{% trans "Count Files" %}
+
+ {% for item in tableData %}
+
+
{{ item.userId }}
+
{{ item.userName }}
+
{{ item.bytesUsedFormatted }}
+
{{ item.numFiles }}
+
+ {% endfor %}
+
+
+ {% for key,item in multipleCharts %}
+
{{ key|replace({'_': " "}) }}
+
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/libraryusage-report-form.twig b/reports/libraryusage-report-form.twig
new file mode 100644
index 0000000..052790a
--- /dev/null
+++ b/reports/libraryusage-report-form.twig
@@ -0,0 +1,231 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Library Usage" %} | {% endblock %}
+
+{% block actionMenu %}
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/libraryusage-report-preview.twig b/reports/libraryusage-report-preview.twig
new file mode 100644
index 0000000..e708b0e
--- /dev/null
+++ b/reports/libraryusage-report-preview.twig
@@ -0,0 +1,133 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/libraryusage-schedule-form-add.twig b/reports/libraryusage-schedule-form-add.twig
new file mode 100644
index 0000000..3bf2059
--- /dev/null
+++ b/reports/libraryusage-schedule-form-add.twig
@@ -0,0 +1,93 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/libraryusage.report b/reports/libraryusage.report
new file mode 100644
index 0000000..9021214
--- /dev/null
+++ b/reports/libraryusage.report
@@ -0,0 +1,14 @@
+{
+ "name": "libraryusage",
+ "description": "Library Usage",
+ "class": "\\Xibo\\Report\\LibraryUsage",
+ "type": "Report",
+ "output_type": "both",
+ "color":"green",
+ "fa_icon": "fa-th",
+ "sort_order": 3,
+ "hidden": 0,
+ "category": "Library",
+ "feature": "admin",
+ "adminOnly": 1
+}
\ No newline at end of file
diff --git a/reports/mobile-proofofplay-email-template.twig b/reports/mobile-proofofplay-email-template.twig
new file mode 100644
index 0000000..5276a5f
--- /dev/null
+++ b/reports/mobile-proofofplay-email-template.twig
@@ -0,0 +1,62 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
{% trans "Start" %}
+
{% trans "End" %}
+
{% trans "Display Id" %}
+
{% trans "Display" %}
+
{% trans "Layout Id" %}
+
{% trans "Layout" %}
+
{% trans "Start Latitude" %}
+
{% trans "Start Longitude" %}
+
{% trans "End Latitude" %}
+
{% trans "End Longitude" %}
+
{% trans "Duration" %}
+
+ {% for item in tableData %}
+
+
{{ item.from }}
+
{{ item.to }}
+
{{ item.displayId }}
+
{{ item.display }}
+
{{ item.layoutId }}
+
{{ item.layout }}
+
{{ item.startLat }}
+
{{ item.startLong }}
+
{{ item.endLat }}
+
{{ item.endLong }}
+
{{ item.duration }}
+
+ {% endfor %}
+
+{% endblock %}
+
diff --git a/reports/mobile-proofofplay-report-form.twig b/reports/mobile-proofofplay-report-form.twig
new file mode 100644
index 0000000..7e26cdd
--- /dev/null
+++ b/reports/mobile-proofofplay-report-form.twig
@@ -0,0 +1,514 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Mobile Proof of Play" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
diff --git a/reports/mobile-proofofplay-report-preview.twig b/reports/mobile-proofofplay-report-preview.twig
new file mode 100644
index 0000000..a65b56f
--- /dev/null
+++ b/reports/mobile-proofofplay-report-preview.twig
@@ -0,0 +1,117 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Start" %}
+
{% trans "End" %}
+
{% trans "Display Id" %}
+
{% trans "Display" %}
+
{% trans "Layout Id" %}
+
{% trans "Layout" %}
+
{% trans "Start Latitude" %}
+
{% trans "Start Longitude" %}
+
{% trans "End Latitude" %}
+
{% trans "End Longitude" %}
+
{% trans "Duration" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/mobile-proofofplay-schedule-form-add.twig b/reports/mobile-proofofplay-schedule-form-add.twig
new file mode 100644
index 0000000..ca4f0ea
--- /dev/null
+++ b/reports/mobile-proofofplay-schedule-form-add.twig
@@ -0,0 +1,100 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}reportScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/proofofplay-email-template.twig b/reports/proofofplay-email-template.twig
new file mode 100644
index 0000000..8e3dd6e
--- /dev/null
+++ b/reports/proofofplay-email-template.twig
@@ -0,0 +1,66 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
{% trans "Type" %}
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Campaign" %}
+
{% trans "Layout ID" %}
+
{% trans "Layout" %}
+
{% trans "Widget ID" %}
+
{% trans "Media" %}
+
{% trans "Tag" %}
+
{% trans "Number of Plays" %}
+
{% trans "Total Duration (s)" %}
+
{% trans "First Shown" %}
+
{% trans "Last Shown" %}
+
+ {% for item in tableData %}
+
+
{{ item.type }}
+
{{ item.displayId }}
+
{{ item.display }}
+
{{ item.parentCampaign }}
+
{{ item.layoutId }}
+
{{ item.layout }}
+
{{ item.widgetId }}
+
{{ item.media }}
+
{{ item.tag }}
+
{{ item.numberPlays }}
+
{{ item.duration }}
+
{{ item.minStart }}
+
{{ item.maxEnd }}
+
+ {% endfor %}
+
+{% endblock %}
+
diff --git a/reports/proofofplay-report-form.twig b/reports/proofofplay-report-form.twig
new file mode 100644
index 0000000..f8be3c3
--- /dev/null
+++ b/reports/proofofplay-report-form.twig
@@ -0,0 +1,691 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2021 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Proof of Play" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/proofofplay-report-preview.twig b/reports/proofofplay-report-preview.twig
new file mode 100644
index 0000000..8572fb4
--- /dev/null
+++ b/reports/proofofplay-report-preview.twig
@@ -0,0 +1,149 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2019 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Type" %}
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Campaign" %}
+
{% trans "Layout ID" %}
+
{% trans "Layout" %}
+
{% trans "Widget ID" %}
+
{% trans "Media" %}
+
{% trans "Tag" %}
+
{% trans "Number of Plays" %}
+
{% trans "Total Duration" %}
+
{% trans "Total Duration (s)" %}
+
{% trans "First Shown" %}
+
{% trans "Last Shown" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/proofofplay-schedule-form-add.twig b/reports/proofofplay-schedule-form-add.twig
new file mode 100644
index 0000000..0840b12
--- /dev/null
+++ b/reports/proofofplay-schedule-form-add.twig
@@ -0,0 +1,189 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#proofofplayScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}proofOfPlayScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/proofofplay.report b/reports/proofofplay.report
new file mode 100644
index 0000000..c459270
--- /dev/null
+++ b/reports/proofofplay.report
@@ -0,0 +1,14 @@
+{
+ "name": "proofofplayReport",
+ "description": "Proof of Play",
+ "class": "\\Xibo\\Report\\ProofOfPlay",
+ "type": "Report",
+ "output_type": "table",
+ "color":"blue",
+ "fa_icon": "fa-th",
+ "sort_order": 1,
+ "hidden": 0,
+ "category": "Proof of Play",
+ "feature": "proof-of-play",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/reports/sessionhistory-email-template.twig b/reports/sessionhistory-email-template.twig
new file mode 100644
index 0000000..2fd1b84
--- /dev/null
+++ b/reports/sessionhistory-email-template.twig
@@ -0,0 +1,56 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "User Name" %}
+
{% trans "User ID" %}
+
{% trans "IP Address" %}
+
{% trans "Session ID" %}
+
{% trans "Message" %}
+
{% trans "Object" %}
+
+ {% for item in tableData %}
+
+
{{ item.logDate }}
+
{{ item.userName }}
+
{{ item.userId }}
+
{{ item.ipAddress }}
+
{{ item.sessionHistoryId }}
+
{{ item.message }}
+
{{ item.objectAfter }}
+
+ {% endfor %}
+
+
+ {{ placeholder }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/sessionhistory-report-form.twig b/reports/sessionhistory-report-form.twig
new file mode 100644
index 0000000..f8a18f1
--- /dev/null
+++ b/reports/sessionhistory-report-form.twig
@@ -0,0 +1,411 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Session History" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+ {% trans "Session History" %}
+
+
+ {% include "report-selector.twig" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "User Name" %}
+
{% trans "User ID" %}
+
{% trans "IP Address" %}
+
{% trans "Session ID" %}
+
{% trans "Entity" %}
+
{% trans "Entity ID" %}
+
{% trans "Message" %}
+
{% trans "Details" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "UserName" %}
+
{% trans "User ID" %}
+
{% trans "IP Address" %}
+
{% trans "Session ID" %}
+
{% trans "Channel" %}
+
{% trans "Function" %}
+
{% trans "Level" %}
+
{% trans "Page" %}
+
{% trans "Details" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Start Date" %}
+
{% trans "End Date" %}
+
{% trans "Duration" %}
+
{% trans "UserName" %}
+
{% trans "User Type" %}
+
{% trans "IP Address" %}
+
{% trans "Session ID" %}
+
{% trans "Browser" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+ {% verbatim %}
+
+ {% endverbatim %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/sessionhistory-report-preview.twig b/reports/sessionhistory-report-preview.twig
new file mode 100644
index 0000000..82f170b
--- /dev/null
+++ b/reports/sessionhistory-report-preview.twig
@@ -0,0 +1,104 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Date" %}
+
{% trans "User Name" %}
+
{% trans "User ID" %}
+
{% trans "IP Address" %}
+
{% trans "Session ID" %}
+
{% trans "Message" %}
+
{% trans "Object" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/sessionhistory-schedule-form-add.twig b/reports/sessionhistory-schedule-form-add.twig
new file mode 100644
index 0000000..b4837e2
--- /dev/null
+++ b/reports/sessionhistory-schedule-form-add.twig
@@ -0,0 +1,94 @@
+{#
+/**
+ * Copyright (C) 2024 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - https://xibosignage.com
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/sessionhistory.report b/reports/sessionhistory.report
new file mode 100644
index 0000000..1bcb37f
--- /dev/null
+++ b/reports/sessionhistory.report
@@ -0,0 +1,14 @@
+{
+ "name": "sessionhistory",
+ "description": "Session History",
+ "class": "\\Xibo\\Report\\SessionHistory",
+ "type": "Report",
+ "output_type": "table",
+ "color":"orange",
+ "fa_icon": "fa-th",
+ "sort_order": 5,
+ "hidden": 0,
+ "category": "Audit",
+ "feature": "admin",
+ "adminOnly": 1
+}
diff --git a/reports/summary-email-template.twig b/reports/summary-email-template.twig
new file mode 100644
index 0000000..68c87fe
--- /dev/null
+++ b/reports/summary-email-template.twig
@@ -0,0 +1,48 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+ {{ placeholder }}
+
+
+
+
+
+
{% trans "Period" %}
+
{% trans "Duration" %}
+
{% trans "Count" %}
+
+ {% for item in tableData %}
+
+
{{ item.label }}
+
{{ item.duration }}
+
{{ item.count }}
+
+ {% endfor %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/summary-report-form.twig b/reports/summary-report-form.twig
new file mode 100644
index 0000000..a633a1d
--- /dev/null
+++ b/reports/summary-report-form.twig
@@ -0,0 +1,621 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Summary by Layout, Media or Event" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+ {% trans "Summary by Layout, Media or Event" %}
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
diff --git a/reports/summary-report-preview.twig b/reports/summary-report-preview.twig
new file mode 100644
index 0000000..07233ad
--- /dev/null
+++ b/reports/summary-report-preview.twig
@@ -0,0 +1,97 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2019 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Period" %}
+
{% trans "Duration" %}
+
{% trans "Count" %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/summary-report-schedule-form-add.twig b/reports/summary-report-schedule-form-add.twig
new file mode 100644
index 0000000..34a30fb
--- /dev/null
+++ b/reports/summary-report-schedule-form-add.twig
@@ -0,0 +1,98 @@
+{#
+/*
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ *
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {{ formTitle }}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}summaryScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/summary.report b/reports/summary.report
new file mode 100644
index 0000000..00aa3fb
--- /dev/null
+++ b/reports/summary.report
@@ -0,0 +1,14 @@
+{
+ "name": "summaryReport",
+ "description": "Chart: Summary by Layout, Media or Event",
+ "class": "\\Xibo\\Report\\SummaryReport",
+ "type": "Chart",
+ "output_type": "both",
+ "color":"red",
+ "fa_icon": "fa-bar-chart",
+ "sort_order": 2,
+ "hidden": 0,
+ "category": "Proof of Play",
+ "feature": "proof-of-play",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/reports/timeconnected-email-template.twig b/reports/timeconnected-email-template.twig
new file mode 100644
index 0000000..e40bf18
--- /dev/null
+++ b/reports/timeconnected-email-template.twig
@@ -0,0 +1,66 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+Grey is disconnected %
+Green is connected %
+
+
+
+
+
+ {% set displays = tableData.displays %}
+ {% set timeConnected = tableData.timeConnected %}
+
+ {% for key,displayStat in timeConnected %}
+
+
+ {% for option in displays[key] %}
+
{{ option }}
+ {% endfor %}
+
+
+ {% for item in displayStat %}
+
+ {% for displayData in item %}
+ {% set percent = "0%" %}
+ {% if displayData.percent > 0 %}
+ {% set percent = displayData.percent~"%" %}
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timeconnected-report-form.twig b/reports/timeconnected-report-form.twig
new file mode 100644
index 0000000..c6f8a3e
--- /dev/null
+++ b/reports/timeconnected-report-form.twig
@@ -0,0 +1,220 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Time Connected" %} | {% endblock %}
+
+{% block actionMenu %}
+
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+ {% trans "Time Connected" %}
+
+
+ {% include "report-selector.twig" %}
+
+
+
+
+
+
+
Blue is disconnected %
+
+
+
+
Green is connected %
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timeconnected-report-preview.twig b/reports/timeconnected-report-preview.twig
new file mode 100644
index 0000000..06c054d
--- /dev/null
+++ b/reports/timeconnected-report-preview.twig
@@ -0,0 +1,114 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
Blue is disconnected %
+
+
+
+
Green is Connected %
+
+
+
+
+
+
+
+ {% for key,displayStat in table.timeConnected %}
+
+ {% for option in table.displays[key] %}
+
{{ option }}
+ {% endfor %}
+
+ {% for item in displayStat %}
+
+ {% for displayData in item %}
+ {% set percent = "0%" %}
+ {% if displayData.percent > 0 %}
+ {% set percent = displayData.percent~"%" %}
+ {% endif %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timeconnected-schedule-form-add.twig b/reports/timeconnected-schedule-form-add.twig
new file mode 100644
index 0000000..73a2257
--- /dev/null
+++ b/reports/timeconnected-schedule-form-add.twig
@@ -0,0 +1,101 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timeconnected.report b/reports/timeconnected.report
new file mode 100644
index 0000000..ce3f2fc
--- /dev/null
+++ b/reports/timeconnected.report
@@ -0,0 +1,14 @@
+{
+ "name": "timeconnected",
+ "description": "Time Connected",
+ "class": "\\Xibo\\Report\\TimeConnected",
+ "type": "Report",
+ "output_type": "table",
+ "color":"orange",
+ "fa_icon": "fa-th",
+ "sort_order": 4,
+ "hidden": 0,
+ "category": "Display",
+ "feature": "displays.reporting",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/reports/timedisconnectedsummary-email-template.twig b/reports/timedisconnectedsummary-email-template.twig
new file mode 100644
index 0000000..41a5dea
--- /dev/null
+++ b/reports/timedisconnectedsummary-email-template.twig
@@ -0,0 +1,52 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "base-report.twig" %}
+
+{% block content %}
+
+ {% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Time Disconnected" %}
+
{% trans "Time Connected" %}
+
{% trans "Units" %}
+
+ {% for item in tableData %}
+
+
{{ item.displayId }}
+
{{ item.display }}
+
{{ item.timeDisconnected }}
+
{{ item.timeConnected }}
+
{{ item.postUnits }}
+
+ {% endfor %}
+
+
+{{ placeholder }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timedisconnectedsummary-report-form.twig b/reports/timedisconnectedsummary-report-form.twig
new file mode 100644
index 0000000..63d0be4
--- /dev/null
+++ b/reports/timedisconnectedsummary-report-form.twig
@@ -0,0 +1,339 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block title %}{% trans "Report: Time Connected Summary" %} | {% endblock %}
+
+{% block actionMenu %}
+ {% include "report-schedule-buttons.twig" %}
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timedisconnectedsummary-report-preview.twig b/reports/timedisconnectedsummary-report-preview.twig
new file mode 100644
index 0000000..81b42e2
--- /dev/null
+++ b/reports/timedisconnectedsummary-report-preview.twig
@@ -0,0 +1,114 @@
+{#
+/**
+ * Copyright (C) 2020 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+
+{% extends "authed.twig" %}
+{% import "inline.twig" as inline %}
+
+{% block actionMenu %}
+
+
+
+{% endblock %}
+
+{% block pageContent %}
+
+
+
+
+ {{ metadata.title }}
+ ({% trans "Generated on: " %}{{ metadata.generatedOn }})
+
{% trans "From" %} {{ metadata.periodStart }} {% trans "To" %} {{ metadata.periodEnd }}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Display ID" %}
+
{% trans "Display" %}
+
{% trans "Time Disconnected" %}
+
{% trans "Time Connected" %}
+
{% trans "Units" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block javaScript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timedisconnectedsummary-schedule-form-add.twig b/reports/timedisconnectedsummary-schedule-form-add.twig
new file mode 100644
index 0000000..458ed1e
--- /dev/null
+++ b/reports/timedisconnectedsummary-schedule-form-add.twig
@@ -0,0 +1,108 @@
+{#
+/**
+ * Copyright (C) 2022 Xibo Signage Ltd
+ *
+ * Xibo - Digital Signage - http://www.xibo.org.uk
+ *
+ * This file is part of Xibo.
+ *
+ * Xibo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Xibo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Xibo. If not, see .
+ */
+#}
+{% extends "form-base.twig" %}
+{% import "forms.twig" as forms %}
+
+{% block formTitle %}
+ {% trans "Add Report Schedule" %}
+{% endblock %}
+
+{% block formButtons %}
+ {% trans "Cancel" %}, XiboDialogClose()
+ {% trans "Save" %}, $("#reportScheduleAddForm").submit()
+{% endblock %}
+
+{% block callBack %}timeDisconnectedScheduleCallback{% endblock %}
+
+{% block formHtml %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/reports/timedisconnectedsummary.report b/reports/timedisconnectedsummary.report
new file mode 100644
index 0000000..0394985
--- /dev/null
+++ b/reports/timedisconnectedsummary.report
@@ -0,0 +1,14 @@
+{
+ "name": "timedisconnectedsummary",
+ "description": "Time Connected Summary",
+ "class": "\\Xibo\\Report\\TimeDisconnectedSummary",
+ "type": "Report",
+ "output_type": "both",
+ "color":"orange",
+ "fa_icon": "fa-tasks",
+ "sort_order": 4,
+ "hidden": 0,
+ "category": "Display",
+ "feature": "displays.reporting",
+ "adminOnly": 0
+}
\ No newline at end of file
diff --git a/tasks/anonymous-usage.task b/tasks/anonymous-usage.task
new file mode 100644
index 0000000..cd9d38c
--- /dev/null
+++ b/tasks/anonymous-usage.task
@@ -0,0 +1,5 @@
+{
+ "name": "Anonymous Usage",
+ "class": "\\Xibo\\XTR\\AnonymousUsageTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/auditlog-archiver.task b/tasks/auditlog-archiver.task
new file mode 100644
index 0000000..5d3a7ff
--- /dev/null
+++ b/tasks/auditlog-archiver.task
@@ -0,0 +1,13 @@
+{
+ "name": "AuditLog Archiver",
+ "class": "\\Xibo\\XTR\\AuditLogArchiveTask",
+ "options": {
+ "deleteInstead": 1,
+ "maxPeriods": 4,
+ "archiveOwner": "",
+ "maxAgeMonths": 1,
+ "deleteLimit": 1000,
+ "maxDeleteAttempts": -1,
+ "deleteSleep": 5
+ }
+}
\ No newline at end of file
diff --git a/tasks/campaign-scheduler.task b/tasks/campaign-scheduler.task
new file mode 100644
index 0000000..a9a327a
--- /dev/null
+++ b/tasks/campaign-scheduler.task
@@ -0,0 +1,5 @@
+{
+ "name": "Campaign Scheduler",
+ "class": "\\Xibo\\XTR\\CampaignSchedulerTask",
+ "options": {}
+}
diff --git a/tasks/clear-cached-media-data.task b/tasks/clear-cached-media-data.task
new file mode 100644
index 0000000..a5770ac
--- /dev/null
+++ b/tasks/clear-cached-media-data.task
@@ -0,0 +1,5 @@
+{
+ "name": "Clear Cached Media Data",
+ "class": "\\Xibo\\XTR\\ClearCachedMediaDataTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/dataset-convert.task b/tasks/dataset-convert.task
new file mode 100644
index 0000000..d0ca18e
--- /dev/null
+++ b/tasks/dataset-convert.task
@@ -0,0 +1,5 @@
+{
+ "name": "DataSet Convert",
+ "class": "\\Xibo\\XTR\\DataSetConvertTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/drop-player-cache.task b/tasks/drop-player-cache.task
new file mode 100644
index 0000000..c9c665e
--- /dev/null
+++ b/tasks/drop-player-cache.task
@@ -0,0 +1,5 @@
+{
+ "name": "Drop Player Cache",
+ "class": "\\Xibo\\XTR\\DropPlayerCacheTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/dynamic-playlist-sync.task b/tasks/dynamic-playlist-sync.task
new file mode 100644
index 0000000..f606c35
--- /dev/null
+++ b/tasks/dynamic-playlist-sync.task
@@ -0,0 +1,5 @@
+{
+ "name": "Sync Dynamic Playlists",
+ "class": "\\Xibo\\XTR\\DynamicPlaylistSyncTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/email-notifications.task b/tasks/email-notifications.task
new file mode 100644
index 0000000..395c06e
--- /dev/null
+++ b/tasks/email-notifications.task
@@ -0,0 +1,5 @@
+{
+ "name": "Email Notifications",
+ "class": "\\Xibo\\XTR\\EmailNotificationsTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/image-processing.task b/tasks/image-processing.task
new file mode 100644
index 0000000..78f89e2
--- /dev/null
+++ b/tasks/image-processing.task
@@ -0,0 +1,5 @@
+{
+ "name": "Image Processing",
+ "class": "\\Xibo\\XTR\\ImageProcessingTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/layout-convert.task b/tasks/layout-convert.task
new file mode 100644
index 0000000..258c1f1
--- /dev/null
+++ b/tasks/layout-convert.task
@@ -0,0 +1,5 @@
+{
+ "name": "Layout Convert",
+ "class": "\\Xibo\\XTR\\LayoutConvertTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/maintenance-daily.task b/tasks/maintenance-daily.task
new file mode 100644
index 0000000..8aa9720
--- /dev/null
+++ b/tasks/maintenance-daily.task
@@ -0,0 +1,5 @@
+{
+ "name": "Maintenance Daily",
+ "class": "\\Xibo\\XTR\\MaintenanceDailyTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/maintenance-regular.task b/tasks/maintenance-regular.task
new file mode 100644
index 0000000..99df75c
--- /dev/null
+++ b/tasks/maintenance-regular.task
@@ -0,0 +1,5 @@
+{
+ "name": "Maintenance Regular",
+ "class": "\\Xibo\\XTR\\MaintenanceRegularTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/media-orientation.task b/tasks/media-orientation.task
new file mode 100644
index 0000000..9f577d6
--- /dev/null
+++ b/tasks/media-orientation.task
@@ -0,0 +1,5 @@
+{
+ "name": "Media Orientation",
+ "class": "\\Xibo\\XTR\\MediaOrientationTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/notification-tidy.task b/tasks/notification-tidy.task
new file mode 100644
index 0000000..8cd1730
--- /dev/null
+++ b/tasks/notification-tidy.task
@@ -0,0 +1,9 @@
+{
+ "name": "Notification Tidy",
+ "class": "\\Xibo\\XTR\\NotificationTidyTask",
+ "options": {
+ "maxAgeDays": 7,
+ "systemOnly": 1,
+ "readOnly": 0
+ }
+}
\ No newline at end of file
diff --git a/tasks/purge-list-cleanup.task b/tasks/purge-list-cleanup.task
new file mode 100644
index 0000000..514c96c
--- /dev/null
+++ b/tasks/purge-list-cleanup.task
@@ -0,0 +1,5 @@
+{
+ "name": "Purge List Cleanup",
+ "class": "\\Xibo\\XTR\\PurgeListCleanupTask",
+ "options": []
+}
diff --git a/tasks/remote-dataset.task b/tasks/remote-dataset.task
new file mode 100644
index 0000000..24cf975
--- /dev/null
+++ b/tasks/remote-dataset.task
@@ -0,0 +1,5 @@
+{
+ "name": "Fetch Remote-DataSets",
+ "class": "\\Xibo\\XTR\\RemoteDataSetFetchTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/remove-old-screenshots.task b/tasks/remove-old-screenshots.task
new file mode 100644
index 0000000..e38d4ab
--- /dev/null
+++ b/tasks/remove-old-screenshots.task
@@ -0,0 +1,5 @@
+{
+ "name": "Remove Old Display Screenshots",
+ "class": "\\Xibo\\XTR\\RemoveOldScreenshotsTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/report-schedule.task b/tasks/report-schedule.task
new file mode 100644
index 0000000..ecc2506
--- /dev/null
+++ b/tasks/report-schedule.task
@@ -0,0 +1,5 @@
+{
+ "name": "Report Scheduling",
+ "class": "\\Xibo\\XTR\\ReportScheduleTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/schedule-reminder.task b/tasks/schedule-reminder.task
new file mode 100644
index 0000000..0e344ea
--- /dev/null
+++ b/tasks/schedule-reminder.task
@@ -0,0 +1,5 @@
+{
+ "name": "Schedule Reminder",
+ "class": "\\Xibo\\XTR\\ScheduleReminderTask",
+ "options": []
+}
\ No newline at end of file
diff --git a/tasks/seed-database.task b/tasks/seed-database.task
new file mode 100755
index 0000000..aa530a5
--- /dev/null
+++ b/tasks/seed-database.task
@@ -0,0 +1,5 @@
+{
+ "name": "Seed Database",
+ "class": "\\Xibo\\XTR\\SeedDatabaseTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/stats-archiver.task b/tasks/stats-archiver.task
new file mode 100644
index 0000000..0c6d918
--- /dev/null
+++ b/tasks/stats-archiver.task
@@ -0,0 +1,14 @@
+{
+ "name": "Stats Archiver",
+ "class": "\\Xibo\\XTR\\StatsArchiveTask",
+ "options": {
+ "periodSizeInDays": 7,
+ "maxPeriods": 4,
+ "archiveOwner": "",
+ "archiveStats": "Off",
+ "periodsToKeep": 1,
+ "statsDeleteMaxAttempts": 10,
+ "statsDeleteSleep": 3,
+ "limit": 10000
+ }
+}
\ No newline at end of file
diff --git a/tasks/stats-migration.task b/tasks/stats-migration.task
new file mode 100644
index 0000000..7f134eb
--- /dev/null
+++ b/tasks/stats-migration.task
@@ -0,0 +1,12 @@
+{
+ "name": "Stats Migration",
+ "class": "\\Xibo\\XTR\\StatsMigrationTask",
+ "options": {
+ "killSwitch": 0,
+ "numberOfRecords": 5000,
+ "numberOfLoops": 10,
+ "pauseBetweenLoops": 1,
+ "optimiseOnComplete": 1,
+ "configOverride": ""
+ }
+}
\ No newline at end of file
diff --git a/tasks/update-empty-video-durations.task b/tasks/update-empty-video-durations.task
new file mode 100644
index 0000000..0aac2a1
--- /dev/null
+++ b/tasks/update-empty-video-durations.task
@@ -0,0 +1,5 @@
+{
+ "name": "Update Empty Video Durations",
+ "class": "\\Xibo\\XTR\\UpdateEmptyVideoDurations",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/widget-compatibility.task b/tasks/widget-compatibility.task
new file mode 100755
index 0000000..7ff10cd
--- /dev/null
+++ b/tasks/widget-compatibility.task
@@ -0,0 +1,5 @@
+{
+ "name": "Widget Compatibility",
+ "class": "\\Xibo\\XTR\\WidgetCompatibilityTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/tasks/widget-sync.task b/tasks/widget-sync.task
new file mode 100644
index 0000000..618baf9
--- /dev/null
+++ b/tasks/widget-sync.task
@@ -0,0 +1,5 @@
+{
+ "name": "Widget Sync",
+ "class": "\\Xibo\\XTR\\WidgetSyncTask",
+ "options": {}
+}
\ No newline at end of file
diff --git a/test-pr.sh b/test-pr.sh
new file mode 100755
index 0000000..f7a2538
--- /dev/null
+++ b/test-pr.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2025 Xibo Signage Ltd
+#
+# Xibo - Digital Signage - https://xibosignage.com
+#
+# This file is part of Xibo.
+#
+# Xibo is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# Xibo is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Xibo. If not, see .
+#
+
+# Default values
+SERVER_PORT=80
+
+while getopts p:d:s: option; do
+ case "${option}" in
+ p) PR_NUMBER=${OPTARG};;
+ d) DELETE_PORT=${OPTARG};;
+ s) SERVER_PORT=${OPTARG};;
+ esac
+done
+
+# Create a network if it doesn't exist
+NETWORK_NAME="test-pr-network"
+docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 || docker network create "$NETWORK_NAME"
+
+if [ "$DELETE_PORT" == "all" ]; then
+ echo "Deleting all test containers..."
+
+ # Stop and remove all test-pr-* containers
+ docker ps -a --format '{{.Names}}' | grep "^test-pr-" | while read -r container_name; do
+ docker stop "$container_name" && docker rm "$container_name"
+ done
+
+ # Remove network if no containers are using it
+ docker network rm $NETWORK_NAME
+
+ exit
+elif [ -n "$DELETE_PORT" ]; then
+ echo "Deleting containers for port $DELETE_PORT..."
+
+ # Stop and remove containers associated with the specific SERVER_PORT
+ docker ps -a --format '{{.Names}}' | grep "test-pr-.*-$DELETE_PORT" | while read -r container_name; do
+ docker stop "$container_name" && docker rm "$container_name"
+ done
+
+ # Remove network if no containers are using it
+ remaining_containers=$(docker ps -a --format '{{.Names}}' | grep "^test-pr-" | wc -l)
+ if [ "$remaining_containers" -eq 0 ]; then
+ docker network rm $NETWORK_NAME
+ fi
+
+ exit
+fi
+
+
+# Pull necessary Docker images
+echo "Pulling Docker images..."
+docker pull mysql:8
+docker pull ghcr.io/xibosignage/xibo-xmr:latest
+docker pull ghcr.io/xibosignage/xibo-cms:test-"$PR_NUMBER"
+docker pull mongo:4.2
+
+# Run the MySQL container
+docker run --name test-pr-db-"$SERVER_PORT" \
+ --network "$NETWORK_NAME" \
+ -e MYSQL_RANDOM_ROOT_PASSWORD=yes \
+ -e MYSQL_DATABASE=cms \
+ -e MYSQL_USER=cms \
+ -e MYSQL_PASSWORD=jenkins \
+ -d \
+ mysql:8
+
+# Check if MongoDB container exists before creating
+if ! docker ps -a --format '{{.Names}}' | grep -q "test-pr-mongo"; then
+ echo "Starting new MongoDB container..."
+ docker run --name test-pr-mongo \
+ --network "$NETWORK_NAME" \
+ -e MONGO_INITDB_ROOT_USERNAME=root \
+ -e MONGO_INITDB_ROOT_PASSWORD=example \
+ -d \
+ -p 27071:27071 \
+ mongo:4.2
+else
+ echo "MongoDB container already exists, skipping creation."
+fi
+
+docker run --name test-pr-xmr-"$SERVER_PORT" -d ghcr.io/xibosignage/xibo-xmr:latest
+
+# Run the CMS container
+docker run --name test-pr-web-"$SERVER_PORT" \
+ --network "$NETWORK_NAME" \
+ -e MYSQL_HOST=test-pr-db-"$SERVER_PORT" \
+ -e MYSQL_USER=cms \
+ -e MYSQL_PASSWORD=jenkins \
+ -e CMS_DEV_MODE=true \
+ -e XMR_HOST=test-pr-xmr-"$SERVER_PORT" \
+ -e CMS_USAGE_REPORT=false \
+ -e INSTALL_TYPE=ci \
+ -e MYSQL_BACKUP_ENABLED=false \
+ --link test-pr-db-"$SERVER_PORT" \
+ --link test-pr-xmr-"$SERVER_PORT" \
+ --link test-pr-mongo \
+ -p "$SERVER_PORT":80 \
+ -d \
+ ghcr.io/xibosignage/xibo-cms:test-"$PR_NUMBER"
+
+echo "Containers starting, waiting for ready event"
+
+docker exec -t test-pr-web-"$SERVER_PORT" /bin/bash -c "/usr/local/bin/wait-for-command.sh -q -t 300 -c \"nc -z localhost 80\""
+docker exec -t test-pr-web-"$SERVER_PORT" /bin/bash -c "chown -R www-data.www-data /var/www/cms"
+docker exec --user www-data -t test-pr-web-"$SERVER_PORT" /bin/bash -c "cd /var/www/cms; /usr/bin/php bin/run.php 1"
+sleep 5
+
+echo "CMS running on port $SERVER_PORT"
\ No newline at end of file
diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php
new file mode 100644
index 0000000..ddeebe2
--- /dev/null
+++ b/tests/Bootstrap.php
@@ -0,0 +1,38 @@
+.
+ */
+
+error_reporting(E_ALL);
+ini_set('display_errors', 1);
+
+define('XIBO', true);
+define('PROJECT_ROOT', realpath(__DIR__ . '/..'));
+
+require_once PROJECT_ROOT . '/vendor/autoload.php';
+require_once PROJECT_ROOT . '/tests/LocalWebTestCase.php';
+require_once PROJECT_ROOT . '/tests/XmdsTestCase.php';
+
+if (!file_exists(PROJECT_ROOT . '/web/settings.php'))
+ die('Not configured');
+
+\Xibo\Tests\LocalWebTestCase::setEnvironment();
+
+\Xibo\Helper\Translate::InitLocale(null, 'en_GB');
\ No newline at end of file
diff --git a/tests/Helper/DisplayHelperTrait.php b/tests/Helper/DisplayHelperTrait.php
new file mode 100644
index 0000000..29e6d32
--- /dev/null
+++ b/tests/Helper/DisplayHelperTrait.php
@@ -0,0 +1,148 @@
+getLogger()->debug('Creating Display called ' . $hardwareId);
+
+ // This is a dummy pubKey and isn't used by anything important
+ $xmrPubkey = '-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDmdnXL4gGg3yJfmqVkU1xsGSQI
+3b6YaeAKtWuuknIF1XAHAHtl3vNhQN+SmqcNPOydhK38OOfrdb09gX7OxyDh4+JZ
+inxW8YFkqU0zTqWaD+WcOM68wTQ9FCOEqIrbwWxLQzdjSS1euizKy+2GcFXRKoGM
+pbBhRgkIdydXoZZdjQIDAQAB
+-----END PUBLIC KEY-----';
+
+ // Register our display
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId,
+ $hardwareId,
+ $type,
+ null,
+ null,
+ null,
+ '00:16:D9:C9:AL:69',
+ $xmrChannel,
+ $xmrPubkey
+ );
+
+ // Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+
+ if (count($displays) != 1)
+ $this->fail('Display was not added correctly');
+
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+
+ // Set the initial status
+ if ($status !== null)
+ $this->displaySetStatus($display, $status);
+
+ return $display;
+ }
+
+ /**
+ * @param XiboDisplay $display
+ * @param int $status
+ */
+ protected function displaySetStatus($display, $status)
+ {
+ $display->mediaInventoryStatus = $status;
+
+ $this->getStore()->update('UPDATE `display` SET MediaInventoryStatus = :status, auditingUntil = :auditingUntil WHERE displayId = :displayId', [
+ 'displayId' => $display->displayId,
+ 'auditingUntil' => Carbon::now()->addSeconds(86400)->format('U'),
+ 'status' => $status
+ ]);
+ $this->getStore()->commitIfNecessary();
+ $this->getStore()->close();
+ }
+
+ /**
+ * @param XiboDisplay $display
+ */
+ protected function displaySetLicensed($display)
+ {
+ $display->licensed = 1;
+
+ $this->getStore()->update('UPDATE `display` SET licensed = 1, auditingUntil = :auditingUntil WHERE displayId = :displayId', [
+ 'displayId' => $display->displayId,
+ 'auditingUntil' => Carbon::now()->addSeconds(86400)->format('U')
+ ]);
+ $this->getStore()->commitIfNecessary();
+ $this->getStore()->close();
+ }
+
+ /**
+ * @param XiboDisplay $display
+ * @param string $timeZone
+ */
+ protected function displaySetTimezone($display, $timeZone)
+ {
+ $this->getStore()->update('UPDATE `display` SET timeZone = :timeZone WHERE displayId = :displayId', [
+ 'displayId' => $display->displayId,
+ 'timeZone' => $timeZone
+ ]);
+ $this->getStore()->commitIfNecessary();
+ $this->getStore()->close();
+ }
+
+ /**
+ * @param XiboDisplay $display
+ */
+ protected function deleteDisplay($display)
+ {
+ $display->delete();
+ }
+
+ /**
+ * @param XiboDisplay $display
+ * @param int $status
+ * @return bool
+ */
+ protected function displayStatusEquals($display, $status)
+ {
+ // Requery the Display
+ try {
+ $check = (new XiboDisplay($this->getEntityProvider()))->getById($display->displayId);
+
+ $this->getLogger()->debug('Tested Display ' . $display->display . '. Status returned is ' . $check->mediaInventoryStatus);
+
+ return $check->mediaInventoryStatus === $status;
+
+ } catch (XiboApiException $xiboApiException) {
+ $this->getLogger()->error('API exception for ' . $display->displayId. ': ' . $xiboApiException->getMessage());
+ return false;
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/tests/Helper/LayoutHelperTrait.php b/tests/Helper/LayoutHelperTrait.php
new file mode 100644
index 0000000..a713f7e
--- /dev/null
+++ b/tests/Helper/LayoutHelperTrait.php
@@ -0,0 +1,287 @@
+.
+ */
+
+
+namespace Xibo\Tests\Helper;
+
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboResolution;
+use Xibo\OAuth2\Client\Entity\XiboWidget;
+use Xibo\OAuth2\Client\Exception\XiboApiException;
+
+/**
+ * Trait LayoutHelperTrait
+ * @package Helper
+ */
+trait LayoutHelperTrait
+{
+ /**
+ * @param int|null $status
+ * @return XiboLayout
+ */
+ protected function createLayout($status = null)
+ {
+ // Create a Layout for us to work with.
+ $layout = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ Random::generateString(),
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->getLogger()->debug('Layout created with name ' . $layout->layout);
+
+ if ($status !== null) {
+ // Set the initial status of this Layout to Built
+ $this->setLayoutStatus($layout, $status);
+ }
+
+ return $layout;
+ }
+
+ /**
+ * @param XiboLayout $layout
+ * @param int $status
+ * @return $this
+ */
+ protected function setLayoutStatus($layout, $status)
+ {
+ $layout->status = $status;
+ $this->getStore()->update('UPDATE `layout` SET `status` = :status WHERE layoutId = :layoutId', ['layoutId' => $layout->layoutId, 'status' => $status]);
+ $this->getStore()->commitIfNecessary();
+ $this->getStore()->close();
+ return $this;
+ }
+
+ /**
+ * Build the Layout ready for XMDS
+ * @param XiboLayout $layout
+ * @return $this
+ */
+ protected function buildLayout($layout)
+ {
+ // Call the status route
+ $this->getEntityProvider()->get('/layout/status/' . $layout->layoutId);
+
+ return $this;
+ }
+
+ /**
+ * @param XiboLayout $layout
+ */
+ protected function deleteLayout($layout)
+ {
+ $layout->delete();
+ }
+
+ /**
+ * @param XiboLayout $layout
+ * @param int $status
+ * @return bool
+ */
+ protected function layoutStatusEquals($layout, $status)
+ {
+ // Requery the Display
+ try {
+ $check = (new XiboLayout($this->getEntityProvider()))->getById($layout->layoutId);
+
+ $this->getLogger()->debug('Tested Layout ' . $layout->layout . '. Status returned is ' . $check->status);
+
+ return $check->status === $status;
+
+ } catch (XiboApiException $xiboApiException) {
+ $this->getLogger()->error('API exception for ' . $layout->layoutId . ': ' . $xiboApiException->getMessage());
+ return false;
+ }
+
+ }
+
+ /**
+ * @param $type
+ * @return int
+ */
+ protected function getResolutionId($type)
+ {
+ if ($type === 'landscape') {
+ $width = 1920;
+ $height = 1080;
+ } else if ($type === 'portrait') {
+ $width = 1080;
+ $height = 1920;
+ } else {
+ return -10;
+ }
+
+ //$this->getLogger()->debug('Querying for ' . $width . ', ' . $height);
+
+ $resolutions = (new XiboResolution($this->getEntityProvider()))->get(['width' => $width, 'height' => $height]);
+
+ if (count($resolutions) <= 0)
+ return -10;
+
+ return $resolutions[0]->resolutionId;
+ }
+
+ /**
+ * @param XiboLayout $layout
+ * @return XiboLayout
+ */
+ protected function checkout($layout)
+ {
+ $this->getLogger()->debug('Checkout ' . $layout->layoutId);
+
+ $response = $this->getEntityProvider()->put('/layout/checkout/' . $layout->layoutId);
+
+ // Swap the Layout object to use the one returned.
+ /** @var XiboLayout $layout */
+ $layout = $this->constructLayoutFromResponse($response);
+
+ $this->getLogger()->debug('LayoutId is now: ' . $layout->layoutId);
+
+ return $layout;
+ }
+
+ /**
+ * @param XiboLayout $layout
+ * @return XiboLayout
+ */
+ protected function publish($layout)
+ {
+ $this->getLogger()->debug('Publish ' . $layout->layoutId);
+
+ $response = $this->getEntityProvider()->put('/layout/publish/' . $layout->layoutId , [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ // Swap the Layout object to use the one returned.
+ /** @var XiboLayout $layout */
+ $layout = $this->constructLayoutFromResponse($response);
+
+ $this->getLogger()->debug('LayoutId is now: ' . $layout->layoutId);
+
+ return $layout;
+ }
+
+ /**
+ * @param XiboLayout $layout
+ * @return XiboLayout
+ */
+ protected function discard($layout)
+ {
+ $this->getLogger()->debug('Discard ' . $layout->layoutId);
+
+ $response = $this->getEntityProvider()->put('/layout/discard/' . $layout->layoutId);
+
+ // Swap the Layout object to use the one returned.
+ /** @var XiboLayout $layout */
+ $layout = $this->constructLayoutFromResponse($response);
+
+ $this->getLogger()->debug('LayoutId is now: ' . $layout->layoutId);
+
+ return $layout;
+ }
+
+ /**
+ * @param $layout
+ * @return $this
+ */
+ protected function addSimpleWidget($layout)
+ {
+ $this->getEntityProvider()->post('/playlist/widget/clock/' . $layout->regions[0]->regionPlaylist->playlistId, [
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * @param $layout
+ * @return $this
+ */
+ protected function addSimpleTextWidget($layout)
+ {
+ $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId, [
+ 'text' => 'PHPUNIT TEST TEXT',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * @param $response
+ * @return \Xibo\OAuth2\Client\Entity\XiboEntity|XiboLayout
+ */
+ private function constructLayoutFromResponse($response)
+ {
+ $hydratedRegions = [];
+ $hydratedWidgets = [];
+ /** @var XiboLayout $layout */
+ $layout = new XiboLayout($this->getEntityProvider());
+ $layout = $layout->hydrate($response);
+
+ $this->getLogger()->debug('Constructing Layout from Response: ' . $layout->layoutId);
+
+ if (isset($response['regions'])) {
+ foreach ($response['regions'] as $item) {
+ /** @var XiboRegion $region */
+ $region = new XiboRegion($this->getEntityProvider());
+ $region->hydrate($item);
+ /** @var XiboPlaylist $playlist */
+ $playlist = new XiboPlaylist($this->getEntityProvider());
+ $playlist->hydrate($item['regionPlaylist']);
+ foreach ($playlist->widgets as $widget) {
+ /** @var XiboWidget $widgetObject */
+ $widgetObject = new XiboWidget($this->getEntityProvider());
+ $widgetObject->hydrate($widget);
+ $hydratedWidgets[] = $widgetObject;
+ }
+ $playlist->widgets = $hydratedWidgets;
+ $region->regionPlaylist = $playlist;
+ $hydratedRegions[] = $region;
+ }
+ $layout->regions = $hydratedRegions;
+ } else {
+ $this->getLogger()->debug('No regions returned with Layout object');
+ }
+
+ return $layout;
+ }
+
+ /**
+ * @param $layout
+ * @return XiboLayout
+ */
+ protected function getDraft($layout)
+ {
+ $draft = (new XiboLayout($this->getEntityProvider()))->get(['parentId' => $layout->layoutId, 'showDrafts' => 1, 'embed' => 'regions,playlists,widgets']);
+
+ return $draft[0];
+ }
+}
\ No newline at end of file
diff --git a/tests/Helper/MockPlayerActionService.php b/tests/Helper/MockPlayerActionService.php
new file mode 100644
index 0000000..8e6d96c
--- /dev/null
+++ b/tests/Helper/MockPlayerActionService.php
@@ -0,0 +1,78 @@
+.
+ */
+namespace Xibo\Tests\Helper;
+
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\PlayerActionServiceInterface;
+
+/**
+ * Class MockPlayerActionService
+ * @package Helper
+ */
+class MockPlayerActionService implements PlayerActionServiceInterface
+{
+ /** @var \Xibo\Service\LogServiceInterface */
+ private $log;
+
+ private $displays = [];
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct(ConfigServiceInterface $config, $log, $triggerPlayerActions)
+ {
+ $this->log = $log;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function sendAction($displays, $action): void
+ {
+ $this->log->debug('MockPlayerActionService: sendAction');
+
+ if (!is_array($displays)) {
+ $displays = [$displays];
+ }
+
+ foreach ($displays as $display) {
+ $this->displays[] = $display->displayId;
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getQueue(): array
+ {
+ $this->log->debug('MockPlayerActionService: getQueue');
+ return $this->displays;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function processQueue(): void
+ {
+ $this->log->debug('MockPlayerActionService: processQueue');
+ }
+}
\ No newline at end of file
diff --git a/tests/Helper/NullPlayerActionService.php b/tests/Helper/NullPlayerActionService.php
new file mode 100644
index 0000000..8b3ef1e
--- /dev/null
+++ b/tests/Helper/NullPlayerActionService.php
@@ -0,0 +1,68 @@
+.
+ */
+namespace Xibo\Tests\Helper;
+
+use Xibo\Service\ConfigServiceInterface;
+use Xibo\Service\PlayerActionServiceInterface;
+
+/**
+ * Class NullPlayerActionService
+ * @package Helper
+ */
+class NullPlayerActionService implements PlayerActionServiceInterface
+{
+ /** @var \Xibo\Service\LogServiceInterface */
+ private $log;
+
+ /**
+ * @inheritdoc
+ */
+ public function __construct(ConfigServiceInterface $config, $log, $triggerPlayerActions)
+ {
+ $this->log = $log;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function sendAction($displays, $action): void
+ {
+ $this->log->debug('NullPlayerActionService: sendAction');
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getQueue(): array
+ {
+ $this->log->debug('NullPlayerActionService: getQueue');
+ return [];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function processQueue(): void
+ {
+ $this->log->debug('NullPlayerActionService: processQueue');
+ }
+}
diff --git a/tests/LocalWebTestCase.php b/tests/LocalWebTestCase.php
new file mode 100644
index 0000000..777ef62
--- /dev/null
+++ b/tests/LocalWebTestCase.php
@@ -0,0 +1,570 @@
+.
+ */
+
+namespace Xibo\Tests;
+use Monolog\Handler\NullHandler;
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger;
+use Monolog\Processor\UidProcessor;
+use Nyholm\Psr7\ServerRequest;
+use PHPUnit\Framework\TestCase as PHPUnit_TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Slim\App;
+use Slim\Http\ServerRequest as Request;
+use Slim\Views\TwigMiddleware;
+use Xibo\Entity\Application;
+use Xibo\Entity\User;
+use Xibo\Factory\ContainerFactory;
+use Xibo\Factory\TaskFactory;
+use Xibo\Middleware\State;
+use Xibo\Middleware\Storage;
+use Xibo\OAuth2\Client\Provider\XiboEntityProvider;
+use Xibo\Service\DisplayNotifyService;
+use Xibo\Service\ReportService;
+use Xibo\Storage\PdoStorageService;
+use Xibo\Storage\StorageServiceInterface;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Tests\Helper\MockPlayerActionService;
+use Xibo\Tests\Middleware\TestAuthMiddleware;
+use Xibo\Tests\Xmds\XmdsWrapper;
+use Xibo\XTR\TaskInterface;
+
+/**
+ * Class LocalWebTestCase
+ * @package Xibo\Tests
+ */
+class LocalWebTestCase extends PHPUnit_TestCase
+{
+ /** @var ContainerInterface */
+ public static $container;
+
+ /** @var LoggerInterface */
+ public static $logger;
+
+ /** @var TaskInterface */
+ public static $taskService;
+
+ /** @var XiboEntityProvider */
+ public static $entityProvider;
+
+ /** @var XmdsWrapper */
+ public static $xmds;
+
+ /** @var App */
+ protected $app;
+
+ /**
+ * Get Entity Provider
+ * @return XiboEntityProvider
+ */
+ public function getEntityProvider()
+ {
+ return self::$entityProvider;
+ }
+
+ /**
+ * Get Xmds Wrapper
+ * @return XmdsWrapper
+ */
+ public function getXmdsWrapper()
+ {
+ return self::$xmds;
+ }
+
+ /**
+ * Gets the Slim instance configured
+ * @return App
+ * @throws \Exception
+ */
+ public function getSlimInstance()
+ {
+ // Create the container for dependency injection.
+ try {
+ $container = ContainerFactory::create();
+ } catch (\Exception $e) {
+ die($e->getMessage());
+ }
+
+ // Create a Slim application
+ $app = \DI\Bridge\Slim\Bridge::create($container);
+ $twigMiddleware = TwigMiddleware::createFromContainer($app);
+
+ // Create a logger
+ $handlers = [];
+ if (isset($_SERVER['PHPUNIT_LOG_TO_FILE']) && $_SERVER['PHPUNIT_LOG_TO_FILE']) {
+ $handlers[] = new StreamHandler(PROJECT_ROOT . '/library/log.txt', Logger::DEBUG);
+ }
+
+ if (isset($_SERVER['PHPUNIT_LOG_WEB_TO_CONSOLE']) && $_SERVER['PHPUNIT_LOG_WEB_TO_CONSOLE']) {
+ $handlers[] = new StreamHandler(STDERR, Logger::DEBUG);
+ }
+
+ if (count($handlers) <= 0) {
+ $handlers[] = new NullHandler();
+ }
+
+ $container->set('logger', function (ContainerInterface $container) use ($handlers) {
+ $logger = new Logger('PHPUNIT');
+
+ $uidProcessor = new UidProcessor();
+ $logger->pushProcessor($uidProcessor);
+ foreach ($handlers as $handler) {
+ $logger->pushHandler($handler);
+ }
+
+ return $logger;
+ });
+
+ // Config
+ $container->set('name', 'test');
+ $container->get('configService');
+
+ // Set app state
+ \Xibo\Middleware\State::setState($app, $this->createRequest('GET', '/'));
+
+ // Setting Middleware
+ $app->add(new \Xibo\Middleware\ListenersMiddleware($app));
+ $app->add(new TestAuthMiddleware($app));
+ $app->add(new State($app));
+ $app->add($twigMiddleware);
+ $app->add(new Storage($app));
+ $app->add(new Middleware\TestXmr($app));
+ $app->addRoutingMiddleware();
+
+ // Add Error Middleware
+ $errorMiddleware = $app->addErrorMiddleware(true, true, true);
+ $errorMiddleware->setDefaultErrorHandler(\Xibo\Middleware\Handlers::testErrorHandler($container));
+
+ // All routes
+ require PROJECT_ROOT . '/lib/routes-web.php';
+ require PROJECT_ROOT . '/lib/routes.php';
+
+ // Add the route for running a task manually
+ $app->get('/tasks/{id}', ['\Xibo\Controller\Task','run']);
+
+ return $app;
+ }
+
+ /**
+ * @param string $method
+ * @param string $path
+ * @param array $headers
+ * @param string $requestAttrVal
+ * @param bool|false $ajaxHeader
+ * @param null $body
+ * @return ResponseInterface
+ */
+ protected function sendRequest(string $method, string $path, $body = null, array $headers = ['HTTP_ACCEPT'=>'application/json'], $requestAttrVal = 'test', $ajaxHeader = false): ResponseInterface
+ {
+ // Create a request for tests
+ $request = new Request(new ServerRequest($method, $path, $headers));
+ $request = $request->withAttribute('_entryPoint', $requestAttrVal);
+
+ // If we are using POST or PUT method then we expect to have Body provided, add it to the request
+ if (in_array($method, ['POST', 'PUT']) && $body != null) {
+
+ $request = $request->withParsedBody($body);
+
+ // in case we forgot to set Content-Type header for PUT requests
+ if ($method === 'PUT') {
+ $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded');
+ }
+ }
+
+ if ($ajaxHeader === true) {
+ $request = $request->withHeader('X-Requested-With', 'XMLHttpRequest');
+ }
+
+ if ($method == 'GET' && $body != null) {
+ $request = $request->withQueryParams($body);
+ }
+
+ // send the request and return the response
+ return $this->app->handle($request);
+ }
+
+ /**
+ * @param string $method
+ * @param string $path
+ * @param null $body
+ * @param array $headers
+ * @param array $serverParams
+ * @return Request
+ */
+ protected function createRequest(string $method, string $path, $body = null, array $headers = ['HTTP_ACCEPT'=>'application/json'], $serverParams = []): Request
+ {
+ // Create a request for tests
+ $request = new Request(new ServerRequest($method, $path, $headers, $body, '', $serverParams));
+ $request = $request->withAttribute('_entryPoint', 'test');
+
+ return $request;
+ }
+
+ /**
+ * Create a global container for all tests to share.
+ * @throws \Exception
+ */
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+
+ // Configure global test state
+ // We want to ensure there is a
+ // - global DB object
+ // - phpunit user who executes the tests through Slim
+ // - an API application owned by phpunit with client_credentials grant type
+ if (self::$container == null) {
+ self::getLogger()->debug('Creating Container');
+
+ // Create a new container
+ $container = ContainerFactory::create();
+
+ // Create a logger
+ $handlers = [];
+ if (isset($_SERVER['PHPUNIT_LOG_TO_FILE']) && $_SERVER['PHPUNIT_LOG_TO_FILE']) {
+ $handlers[] = new StreamHandler(PROJECT_ROOT . '/library/log.txt', Logger::INFO);
+ } else {
+ $handlers[] = new NullHandler();
+ }
+
+ if (isset($_SERVER['PHPUNIT_LOG_CONTAINER_TO_CONSOLE']) && $_SERVER['PHPUNIT_LOG_CONTAINER_TO_CONSOLE']) {
+ $handlers[] = new StreamHandler(STDERR, Logger::DEBUG);
+ }
+
+ $container->set('logger', function (ContainerInterface $container) use ($handlers) {
+ $logger = new Logger('PHPUNIT');
+
+ $uidProcessor = new UidProcessor();
+ $logger->pushProcessor($uidProcessor);
+ foreach ($handlers as $handler) {
+ $logger->pushHandler($handler);
+ }
+
+ return $logger;
+ });
+
+ // Initialise config
+ $container->get('configService');
+ $container->get('configService')->setDependencies($container->get('store'), '/');
+
+ // This is our helper container.
+ $container->set('name', 'phpunit');
+
+ // Configure the container with Player Action and Display Notify
+ // Player Action Helper
+ $container->set('playerActionService', function (ContainerInterface $c) {
+ return new MockPlayerActionService(
+ $c->get('configService'),
+ $c->get('logService'),
+ false
+ );
+ });
+
+ // Register the display notify service
+ $container->set('displayNotifyService', function (ContainerInterface $c) {
+ return new DisplayNotifyService(
+ $c->get('configService'),
+ $c->get('logService'),
+ $c->get('store'),
+ $c->get('pool'),
+ $c->get('playerActionService'),
+ $c->get('scheduleFactory')
+ );
+ });
+
+ // Register the report service
+ $container->set('reportService', function (ContainerInterface $c) {
+ return new ReportService(
+ $c,
+ $c->get('store'),
+ $c->get('timeSeriesStore'),
+ $c->get('logService'),
+ $c->get('configService'),
+ $c->get('sanitizerService'),
+ $c->get('savedReportFactory')
+ );
+ });
+
+ //
+ // Find the PHPUnit user and if we don't create it
+ try {
+ /** @var User $user */
+ $user = $container->get('userFactory')->getByName('phpunit');
+ $user->setChildAclDependencies($container->get('userGroupFactory'));
+
+ // Load the user
+ $user->load(false);
+
+ } catch (NotFoundException $e) {
+ // Create the phpunit user with a random password
+ /** @var \Xibo\Entity\User $user */
+ $user = $container->get('userFactory')->create();
+ $user->setChildAclDependencies($container->get('userGroupFactory'));
+ $user->userTypeId = 1;
+ $user->userName = 'phpunit';
+ $user->libraryQuota = 0;
+ $user->homePageId = 'statusdashboard.view';
+ $user->homeFolderId = 1;
+ $user->isSystemNotification = 1;
+ $user->setNewPassword(\Xibo\Helper\Random::generateString());
+ $user->save();
+ $container->get('store')->commitIfNecessary();
+ }
+
+ // Set on the container
+ $container->set('user', $user);
+
+ // Find the phpunit user and if we don't, complain
+ try {
+ /** @var User $admin */
+ $admin = $container->get('userFactory')->getByName('phpunit');
+
+ } catch (NotFoundException $e) {
+ die ('Cant proceed without the phpunit user');
+ }
+
+ // Check to see if there is an API application we can use
+ try {
+ /** @var Application $application */
+ $application = $container->get('applicationFactory')->getByName('phpunit');
+ } catch (NotFoundException $e) {
+ // Add it
+ $application = $container->get('applicationFactory')->create();
+ $application->name = ('phpunit');
+ $application->authCode = 0;
+ $application->clientCredentials = 1;
+ $application->userId = $admin->userId;
+ $application->assignScope($container->get('applicationScopeFactory')->getById('all'));
+ $application->save();
+
+ /** @var PdoStorageService $store */
+ $store = $container->get('store');
+ $store->commitIfNecessary();
+ }
+ //
+
+ // Register a provider and entity provider to act as our API wrapper
+ $provider = new \Xibo\OAuth2\Client\Provider\Xibo([
+ 'clientId' => $application->key,
+ 'clientSecret' => $application->secret,
+ 'redirectUri' => null,
+ 'baseUrl' => 'http://localhost'
+ ]);
+
+ // Discover the CMS key for XMDS
+ /** @var PdoStorageService $store */
+ $store = $container->get('store');
+ $key = $store->select('SELECT value FROM `setting` WHERE `setting` = \'SERVER_KEY\'', [])[0]['value'];
+ $store->commitIfNecessary();
+
+ // Create an XMDS wrapper for the tests to use
+ $xmds = new XmdsWrapper('http://localhost/xmds.php', $key);
+
+ // Store our entityProvider
+ self::$entityProvider = new XiboEntityProvider($provider);
+
+ // Store our XmdsWrapper
+ self::$xmds = $xmds;
+
+ // Store our container
+ self::$container = $container;
+ }
+ }
+
+ /**
+ * Convenience function to skip a test with a reason and close output buffers nicely.
+ * @param string $reason
+ */
+ public function skipTest($reason)
+ {
+ $this->markTestSkipped($reason);
+ }
+
+ /**
+ * Get Store
+ * @return StorageServiceInterface
+ */
+ public function getStore()
+ {
+ return self::$container->get('store');
+ }
+
+ /**
+ * Get a task object
+ * @param string $task The path of the task class
+ * @return TaskInterface
+ * @throws NotFoundException
+ */
+ public function getTask($task)
+ {
+ $c = self::$container;
+
+ /** @var TaskFactory $taskFactory */
+ $taskFactory = $c->get('taskFactory');
+ $task = $taskFactory->getByClass($task);
+
+ /** @var TaskInterface $taskClass */
+ $taskClass = new $task->class();
+
+ return $taskClass
+ ->setSanitizer($c->get('sanitizerService'))
+ ->setUser($c->get('user'))
+ ->setConfig($c->get('configService'))
+ ->setLogger($c->get('logService'))
+ ->setPool($c->get('pool'))
+ ->setStore($c->get('store'))
+ ->setTimeSeriesStore($c->get('timeSeriesStore'))
+ ->setDispatcher($c->get('dispatcher'))
+ ->setFactories($c)
+ ->setTask($task);
+ }
+
+ /**
+ * @return LoggerInterface
+ * @throws \Exception
+ */
+ public static function getLogger()
+ {
+ // Create if necessary
+ if (self::$logger === null) {
+ if (isset($_SERVER['PHPUNIT_LOG_TO_CONSOLE']) && $_SERVER['PHPUNIT_LOG_TO_CONSOLE']) {
+ self::$logger = new Logger('TESTS', [new StreamHandler(STDERR, Logger::DEBUG)]);
+ } else {
+ self::$logger = new NullLogger();
+ }
+ }
+
+ return self::$logger;
+ }
+
+ /**
+ * Get the queue of actions.
+ * @return int[]
+ */
+ public function getPlayerActionQueue()
+ {
+ /** @var \Xibo\Service\PlayerActionServiceInterface $service */
+ $service = $this->app->getContainer()->get('playerActionService');
+
+ if ($service === null) {
+ $this->fail('Test has not used the client and therefore cannot determine XMR activity');
+ }
+
+ return $service->getQueue();
+ }
+
+ /**
+ * @param $name
+ * @param $class
+ */
+ protected static function installModuleIfNecessary($name, $class)
+ {
+ // Make sure the HLS widget is installed
+ $res = self::$container->get('store')->select('SELECT * FROM `module` WHERE `module` = :module', ['module' => $name]);
+
+ if (count($res) <= 0) {
+ // Install the module
+ self::$container->get('store')->insert('
+ INSERT INTO `module` (`Module`, `Name`, `Enabled`, `RegionSpecific`, `Description`,
+ `SchemaVersion`, `ValidExtensions`, `PreviewEnabled`, `assignable`, `render_as`, `settings`, `viewPath`, `class`, `defaultDuration`, `installName`)
+ VALUES (:module, :name, :enabled, :region_specific, :description,
+ :schema_version, :valid_extensions, :preview_enabled, :assignable, :render_as, :settings, :viewPath, :class, :defaultDuration, :installName)
+ ', [
+ 'module' => $name,
+ 'name' => $name,
+ 'enabled' => 1,
+ 'region_specific' => 1,
+ 'description' => $name,
+ 'schema_version' => 1,
+ 'valid_extensions' => null,
+ 'preview_enabled' => 1,
+ 'assignable' => 1,
+ 'render_as' => 'html',
+ 'settings' => json_encode([]),
+ 'viewPath' => '../modules',
+ 'class' => $class,
+ 'defaultDuration' => 10,
+ 'installName' => $name
+ ]);
+ self::$container->get('store')->commitIfNecessary();
+ }
+ }
+
+ /**
+ * @inheritDoc
+ * @throws \Exception
+ */
+ public function setUp(): void
+ {
+ self::getLogger()->debug('LocalWebTestCase: setUp');
+ parent::setUp();
+
+ // Establish a local reference to the Slim app object
+ $this->app = $this->getSlimInstance();
+ }
+
+ /**
+ * @inheritDoc
+ * @throws \Exception
+ */
+ public function tearDown(): void
+ {
+ self::getLogger()->debug('LocalWebTestCase: tearDown');
+
+ // Close and tidy up the app
+ $this->app->getContainer()->get('store')->close();
+ $this->app = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Set the _SERVER vars for the suite
+ * @param array $userSettings
+ */
+ public static function setEnvironment($userSettings = [])
+ {
+ $defaults = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => '/',
+ 'SCRIPT_NAME' => '',
+ 'PATH_INFO' => '/',
+ 'QUERY_STRING' => '',
+ 'SERVER_NAME' => 'local.dev',
+ 'SERVER_PORT' => 80,
+ 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'HTTP_ACCEPT_LANGUAGE' => 'en-US,en;q=0.8',
+ 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.3',
+ 'USER_AGENT' => 'Slim Framework',
+ 'REMOTE_ADDR' => '127.0.0.1',
+ ];
+
+ $environmentSettings = array_merge($userSettings, $defaults);
+
+ foreach ($environmentSettings as $key => $value) {
+ $_SERVER[$key] = $value;
+ }
+ }
+}
diff --git a/tests/Middleware/TestAuthMiddleware.php b/tests/Middleware/TestAuthMiddleware.php
new file mode 100644
index 0000000..9201000
--- /dev/null
+++ b/tests/Middleware/TestAuthMiddleware.php
@@ -0,0 +1,75 @@
+.
+ */
+
+
+namespace Xibo\Tests\Middleware;
+
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Server\MiddlewareInterface as Middleware;
+use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
+use Slim\App;
+use Xibo\Entity\User;
+
+
+/**
+ * Class TestAuthMiddleware
+ * @package Xibo\Tests\Middleware
+ *
+ */
+class TestAuthMiddleware implements Middleware
+{
+ /* @var App $app */
+ private $app;
+
+ /**
+ * Xmr constructor.
+ * @param $app
+ */
+ public function __construct($app)
+ {
+ $this->app = $app;
+ }
+
+ /**
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ * @throws \Xibo\Support\Exception\GeneralException
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+ $container = $app->getContainer();
+
+ /** @var User $user */
+ $user = $container->get('userFactory')->getByName('phpunit');
+ $user->setChildAclDependencies($app->getContainer()->get('userGroupFactory'));
+
+ // Load the user
+ $user->load(false);
+
+ $container->set('user', $user);
+
+ return $handler->handle($request);
+ }
+}
\ No newline at end of file
diff --git a/tests/Middleware/TestXmr.php b/tests/Middleware/TestXmr.php
new file mode 100644
index 0000000..0e7ecdc
--- /dev/null
+++ b/tests/Middleware/TestXmr.php
@@ -0,0 +1,96 @@
+app = $app;
+ }
+
+ /**
+ * Process
+ * @param Request $request
+ * @param RequestHandler $handler
+ * @return Response
+ */
+ public function process(Request $request, RequestHandler $handler): Response
+ {
+ $app = $this->app;
+
+ self::setXmr($app);
+
+ // Pass along the request
+ $response = $handler->handle($request);
+
+ // Handle display notifications
+ if ($app->getContainer()->get('displayNotifyService') != null) {
+ try {
+ $app->getContainer()->get('displayNotifyService')->processQueue();
+ } catch (GeneralException $e) {
+ $app->getContainer()->get('logger')->error('Unable to Process Queue of Display Notifications due to %s', $e->getMessage());
+ }
+ }
+
+ // Re-terminate any DB connections
+ $app->getContainer()->get('store')->close();
+
+ return $response;
+ }
+
+ /**
+ * Set XMR
+ * @param \Slim\App $app
+ * @param bool $triggerPlayerActions
+ */
+ public static function setXmr($app, $triggerPlayerActions = true)
+ {
+ // Player Action Helper
+ $app->getContainer()->set('playerActionService', function() use ($app, $triggerPlayerActions) {
+ return new MockPlayerActionService(
+ $app->getContainer()->get('configService'),
+ $app->getContainer()->get('logService'),
+ false
+ );
+ });
+
+ // Register the display notify service
+ $app->getContainer()->set('displayNotifyService', function () use ($app) {
+ return new DisplayNotifyService(
+ $app->getContainer()->get('configService'),
+ $app->getContainer()->get('logService'),
+ $app->getContainer()->get('store'),
+ $app->getContainer()->get('pool'),
+ $app->getContainer()->get('playerActionService'),
+ $app->getContainer()->get('scheduleFactory')
+ );
+ });
+ }
+}
\ No newline at end of file
diff --git a/tests/XMDS.http b/tests/XMDS.http
new file mode 100644
index 0000000..77d4118
--- /dev/null
+++ b/tests/XMDS.http
@@ -0,0 +1,193 @@
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ {{displayName}}
+ {{clientType}}
+ 4
+ 400
+ {{macAddress}}
+ trial
+ XMR_test_channel
+ -----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8tUH0MqtiB7Hq7a+Au0v
+3MHFDnlwBmSz9S+44dGir0d119pbZQKb1D5JaGTWsBq0Ca4yAW/XtDwL5Olbue8n
+y2PBygUArJulUQdCeguAKmHCcRw6rce0ZsQymUe84dclf9l7yyCbo1VTDBH651UT
+a/ei6ATBPeLhh98lDKDpz/RmsXuW695thxhTRXPIILDDKYkK5yQF2DNEgVoShFjG
+TRys5AL2ZUrGHrrT5dxOfbNE0sU8qvoYVQIjyUVhANzh66bu8QTbjDcRLMiKE1lJ
+l5U/XOnNkKi+qNimFWRKlNS0sOvPdTJHHZZEaoFa2FXGYGL8/XAidwT6YSY702Gb
+8wIDAQAB
+-----END PUBLIC KEY-----
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ 12
+ 33
+ {{testWidgetId}}
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ bundle
+ 1
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ {{testWidgetId}}
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ [{"code":5000, "reason": "Too long: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ <log><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Prepare Layout Failed. Exception raised was: Default layout</message></trace><trace date="2024-07-20 20:04:28" category="Error"><logdate>10/26/2016 8:04:28 PM</logdate><thread>UI Thread</thread><method>MainForm - ChangeToNextLayout</method><message>Layout Change to failed. Exception raised was: Default layout</message></trace></log>
+
+
+
+
+###
+
+POST {{url}}/xmds.php?v=7
+Content-Type: application/xml
+
+
+
+
+ {{serverKey}}
+ {{hardwareKey}}
+ <records><stat fromdt="2024-08-01 00:00:00" todt="2024-08-01 00:05:00" type="layout" scheduleid="48" layoutid="133" mediaid="null" tag="" count="250" /></records>
+
+
+
+
+###
+
+# Get the fileID from the Required Files response.
+GET {{url}}/xmds.php?file=12.xlf&displayId=1&type=L&itemId=12&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230502T000000Z&X-Amz-Expires=1683048895&X-Amz-SignedHeaders=host&X-Amz-Signature=7c876be170afb29d194e7b035be6969198c22a32c22e163e2696754bb1163f5d
+
+###
+
+GET {{url}}/xmds.php?file=6fdcddfc0c386f5abce4f8c8b5983b1a&displayId=1&type=P&itemId=4&fileType=font
diff --git a/tests/Xmds/GetDataTest.php b/tests/Xmds/GetDataTest.php
new file mode 100644
index 0000000..5fc0f35
--- /dev/null
+++ b/tests/Xmds/GetDataTest.php
@@ -0,0 +1,105 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use DOMDocument;
+use DOMXPath;
+use Xibo\Tests\XmdsTestCase;
+
+/**
+ * Get data tests for xmds v7
+ * @property string $dataSetXml
+ */
+class GetDataTest extends XmdsTestCase
+{
+ // The widgetId of our expected widget (if we change the default layout this ID will change).
+ const WIDGET_ID = 7;
+
+ use XmdsHelperTrait;
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // to make sure Display is logged in, otherwise WidgetSyncTask will not sync data.
+ $this->sendRequest(
+ 'POST',
+ $this->register(
+ 'PHPUnit7',
+ 'phpunitv7',
+ 'android'
+ ),
+ 7
+ );
+ }
+ public function testGetData()
+ {
+ // Fresh RF
+ $this->sendRequest('POST', $this->getRf(7), 7);
+
+ // Execute Widget Sync task so we can have data for our Widget
+ exec('cd /var/www/cms; php bin/run.php 9');
+
+ // XMDS GetData with our dataSet Widget
+ $response = $this->sendRequest('POST', $this->getWidgetData(7, self::WIDGET_ID));
+ $content = $response->getBody()->getContents();
+
+ // expect GetDataResponse
+ $this->assertStringContainsString(
+ '',
+ $content,
+ 'GetData received incorrect response'
+ );
+
+ $document = new DOMDocument();
+ $document->loadXML($content);
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//data)');
+
+ $array = json_decode($result, true);
+
+ // go through GetData response and see what we have
+ foreach ($array as $key => $item) {
+ // data and meta expected to not be empty
+ if ($key === 'data' || $key === 'meta') {
+ $this->assertNotEmpty($item);
+ $this->assertNotEmpty($key);
+ }
+
+ if ($key === 'data') {
+ $i = 0;
+ // go through the expected 2 rows in our dataSet data and see if the column/value matches
+ foreach ($item as $row) {
+ $this->assertNotEmpty($row);
+ if ($i === 0) {
+ $this->assertSame('Example text value', $row['Text']);
+ $this->assertSame(1, $row['Number']);
+ } else if ($i === 1) {
+ $this->assertSame('PHPUnit text', $row['Text']);
+ $this->assertSame(2, $row['Number']);
+ }
+ $i++;
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Xmds/GetDependencyTest.php b/tests/Xmds/GetDependencyTest.php
new file mode 100644
index 0000000..e07045d
--- /dev/null
+++ b/tests/Xmds/GetDependencyTest.php
@@ -0,0 +1,235 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use DOMDocument;
+use DOMXPath;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Xibo\Tests\XmdsTestCase;
+
+/**
+ * GetDependency tests, fonts, bundle
+ */
+class GetDependencyTest extends XmdsTestCase
+{
+ use XmdsHelperTrait;
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public static function successCasesBundle(): array
+ {
+ return [
+ [7],
+ ];
+ }
+
+ public static function successCasesFont(): array
+ {
+ return [
+ [7, 'Aileron-Heavy.otf'],
+ [7, 'fonts.css'],
+ [6, 'Aileron-Heavy.otf'],
+ [6, 'fonts.css'],
+ [5, 'Aileron-Heavy.otf'],
+ [5, 'fonts.css'],
+ [4, 'Aileron-Heavy.otf'],
+ [4, 'fonts.css'],
+ ];
+ }
+
+ public static function successCasesBundleOld(): array
+ {
+ return [
+ [6],
+ [5],
+ [4],
+ ];
+ }
+
+ #[DataProvider('successCasesFont')]
+ public function testGetFont($version, $fileName)
+ {
+ $rf = $this->sendRequest('POST', $this->getRf($version), $version);
+
+ $response = $rf->getBody()->getContents();
+ $path = null;
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//RequiredFilesXml)');
+ $array = json_decode(json_encode(simplexml_load_string($result)), true);
+
+ foreach ($array as $item) {
+ foreach ($item as $file) {
+ if (!empty($file['@attributes'])
+ && !empty($file['@attributes']['saveAs'])
+ && $file['@attributes']['saveAs'] === $fileName
+ ) {
+ if ($version === 7) {
+ $this->assertSame('dependency', $file['@attributes']['type']);
+ } else {
+ $this->assertSame('media', $file['@attributes']['type']);
+ }
+
+ $path = strstr($file['@attributes']['path'], '?');
+ }
+ }
+ }
+
+ $this->assertNotEmpty($path);
+
+ // Font dependency is still http download, try to get it here
+ $getFile = $this->getFile($path);
+ $this->assertSame(200, $getFile->getStatusCode());
+ $this->assertNotEmpty($getFile->getBody()->getContents());
+ }
+
+ #[DataProvider('successCasesBundle')]
+ public function testGetBundlev7($version)
+ {
+ $rf = $this->sendRequest('POST', $this->getRf($version), $version);
+ $response = $rf->getBody()->getContents();
+ $size = null;
+ $id = null;
+ $type = null;
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//RequiredFilesXml)');
+ $array = json_decode(json_encode(simplexml_load_string($result)), true);
+
+ foreach ($array as $item) {
+ foreach ($item as $file) {
+ if (!empty($file['@attributes'])
+ && !empty($file['@attributes']['saveAs'])
+ && $file['@attributes']['saveAs'] === 'bundle.min.js'
+ ) {
+ $size = $file['@attributes']['size'];
+ $type = $file['@attributes']['fileType'];
+ $id = $file['@attributes']['id'];
+ }
+ }
+ }
+
+ $this->assertNotEmpty($size);
+ $this->assertNotEmpty($type);
+ $this->assertNotEmpty($id);
+
+ // construct the xml for GetDependency wsdl request
+ $bundleXml = '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'.$version.'
+ '. $type .'
+ '. $id .'
+ 0
+ '. $size .'
+
+
+';
+
+ // try to call GetDependency with our xml
+ $getBundle = $this->sendRequest('POST', $bundleXml, $version);
+ $getBundleResponse = $getBundle->getBody()->getContents();
+ // expect success
+ $this->assertSame(200, $getBundle->getStatusCode());
+ // expect not empty body
+ $this->assertNotEmpty($getBundleResponse);
+ // expect response format
+ $this->assertStringContainsString(
+ '',
+ $getBundleResponse,
+ 'GetDependency getBundle received incorrect response'
+ );
+ }
+
+ #[DataProvider('successCasesBundleOld')]
+ public function testGetBundleOld($version)
+ {
+ $rf = $this->sendRequest('POST', $this->getRf($version), $version);
+ $response = $rf->getBody()->getContents();
+ $size = null;
+ $id = null;
+ $type = null;
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//RequiredFilesXml)');
+ $array = json_decode(json_encode(simplexml_load_string($result)), true);
+
+ foreach ($array as $item) {
+ foreach ($item as $file) {
+ if (!empty($file['@attributes'])
+ && !empty($file['@attributes']['saveAs'])
+ && $file['@attributes']['saveAs'] === 'bundle.min.js'
+ ) {
+ $size = $file['@attributes']['size'];
+ $type = $file['@attributes']['type'];
+ $id = $file['@attributes']['id'];
+ }
+ }
+ }
+
+ $this->assertNotEmpty($size);
+ $this->assertNotEmpty($type);
+ $this->assertNotEmpty($id);
+
+ // construct the xml for GetDependency wsdl request
+ $bundleXml = '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'.$version.'
+ '. $id .'
+ '. $type .'
+ 0
+ '. $size .'
+
+
+';
+
+ // try to call GetFile with our xml
+ $getBundle = $this->sendRequest('POST', $bundleXml, $version);
+ $getBundleResponse = $getBundle->getBody()->getContents();
+ // expect success
+ $this->assertSame(200, $getBundle->getStatusCode());
+ // expect not empty body
+ $this->assertNotEmpty($getBundleResponse);
+ // expect response format
+ $this->assertStringContainsString(
+ '',
+ $getBundleResponse,
+ 'GetDependency getBundle received incorrect response'
+ );
+ }
+}
diff --git a/tests/Xmds/NotifyStatusTest.php b/tests/Xmds/NotifyStatusTest.php
new file mode 100644
index 0000000..28c9f4a
--- /dev/null
+++ b/tests/Xmds/NotifyStatusTest.php
@@ -0,0 +1,215 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use Xibo\Tests\XmdsTestCase;
+
+/**
+ * Various Notify Status tests
+ */
+final class NotifyStatusTest extends XmdsTestCase
+{
+ use XmdsHelperTrait;
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public static function successCases(): array
+ {
+ return [
+ [7],
+ [6],
+ [5],
+ [4],
+ ];
+ }
+
+ public static function failureCases(): array
+ {
+ return [
+ [3],
+ ];
+ }
+
+ #[DataProvider('successCases')]
+ public function testCurrentLayout(int $version)
+ {
+ $request = $this->sendRequest('POST', $this->notifyStatus($version, '{"currentLayoutId":1}'), $version);
+
+ $this->assertStringContainsString(
+ 'true',
+ $request->getBody()->getContents(),
+ 'Notify Current Layout received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testCurrentLayoutFailure(int $version)
+ {
+ // disable exception on http_error in guzzle, so we can still check the response
+ $request = $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"currentLayoutId":1}'),
+ $version,
+ false
+ );
+
+ $this->assertSame(500, $request->getStatusCode());
+ // check the fault code
+ $this->assertStringContainsString(
+ 'SOAP-ENV:Server',
+ $request->getBody(),
+ 'Notify Current Layout received incorrect response'
+ );
+
+ // check the fault string
+ $this->assertStringContainsString(
+ 'Procedure \'NotifyStatus\' not present',
+ $request->getBody(),
+ 'Notify Current Layout received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testCurrentLayoutExceptionFailure(int $version)
+ {
+ // we are expecting 500 Server Exception here for xmds 3
+ $this->expectException('GuzzleHttp\Exception\ServerException');
+ $this->expectExceptionCode(500);
+ $request = $this->sendRequest('POST', $this->notifyStatus($version, '{"currentLayoutId":1}'), $version);
+ }
+
+ #[DataProvider('successCases')]
+ public function testGeoLocation($version)
+ {
+ $request = $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"latitude":52.3676, "longitude":4.9041}'),
+ $version
+ );
+
+ $this->assertStringContainsString(
+ 'true',
+ $request->getBody()->getContents(),
+ 'Notify Geo Location received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testGeoLocationFailure(int $version)
+ {
+ // disable exception on http_error in guzzle, so we can still check the response
+ $request = $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"latitude":52.3676, "longitude":4.9041}'),
+ $version,
+ false
+ );
+
+ $this->assertSame(500, $request->getStatusCode());
+ // check the fault code
+ $this->assertStringContainsString(
+ 'SOAP-ENV:Server',
+ $request->getBody(),
+ 'Notify Geo Location received incorrect response'
+ );
+
+ // check the fault string
+ $this->assertStringContainsString(
+ 'Procedure \'NotifyStatus\' not present',
+ $request->getBody(),
+ 'Notify Geo Location received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testGeoLocationExceptionFailure(int $version)
+ {
+ // we are expecting 500 Server Exception here for xmds 3
+ $this->expectException('GuzzleHttp\Exception\ServerException');
+ $this->expectExceptionCode(500);
+ $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"latitude":52.3676, "longitude":4.9041}'),
+ $version,
+ );
+ }
+
+ #[DataProvider('successCases')]
+ public function testOrientation(int $version)
+ {
+ $request = $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"width":7680, "height":4320}'),
+ $version,
+ );
+
+ $this->assertStringContainsString(
+ 'true',
+ $request->getBody()->getContents(),
+ 'Notify Orientation received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testOrientationFailure(int $version)
+ {
+ // disable exception on http_error in guzzle, so we can still check the response
+ $request = $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"width":7680, "height":4320}'),
+ $version,
+ false
+ );
+
+ $this->assertSame(500, $request->getStatusCode());
+ // check the fault code
+ $this->assertStringContainsString(
+ 'SOAP-ENV:Server',
+ $request->getBody(),
+ 'Notify Orientation received incorrect response'
+ );
+
+ // check the fault string
+ $this->assertStringContainsString(
+ 'Procedure \'NotifyStatus\' not present',
+ $request->getBody(),
+ 'Notify Orientation received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testOrientationExceptionFailure(int $version)
+ {
+ // we are expecting 500 Server Exception here for xmds 3
+ $this->expectException('GuzzleHttp\Exception\ServerException');
+ $this->expectExceptionCode(500);
+ $this->sendRequest(
+ 'POST',
+ $this->notifyStatus($version, '{"width":7680, "height":4320}'),
+ $version,
+ );
+ }
+}
diff --git a/tests/Xmds/RegisterDisplayTest.php b/tests/Xmds/RegisterDisplayTest.php
new file mode 100644
index 0000000..d39bc1e
--- /dev/null
+++ b/tests/Xmds/RegisterDisplayTest.php
@@ -0,0 +1,134 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use DOMDocument;
+use DOMXPath;
+use Xibo\Tests\XmdsTestCase;
+
+/**
+ * Register Displays tests
+ */
+class RegisterDisplayTest extends XmdsTestCase
+{
+ use XmdsHelperTrait;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public function testRegisterDisplayAuthed()
+ {
+ $request = $this->sendRequest(
+ 'POST',
+ $this->register(
+ 'PHPUnit7',
+ 'phpunitv7',
+ 'android'
+ ),
+ 7
+ );
+
+ $response = $request->getBody()->getContents();
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//ActivationMessage)');
+ $innerDocument = new DOMDocument();
+ $innerDocument->loadXML($result);
+
+ $this->assertSame('READY', $innerDocument->documentElement->getAttribute('code'));
+ $this->assertSame(
+ 'Display is active and ready to start.',
+ $innerDocument->documentElement->getAttribute('message')
+ );
+ }
+
+ public function testRegisterDisplayNoAuth()
+ {
+ $request = $this->sendRequest(
+ 'POST',
+ $this->register(
+ 'PHPUnitWaiting',
+ 'phpunitwaiting',
+ 'android'
+ ),
+ 7
+ );
+ $response = $request->getBody()->getContents();
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//ActivationMessage)');
+ $innerDocument = new DOMDocument();
+ $innerDocument->loadXML($result);
+
+ $this->assertSame('WAITING', $innerDocument->documentElement->getAttribute('code'));
+ $this->assertSame(
+ 'Display is Registered and awaiting Authorisation from an Administrator in the CMS',
+ $innerDocument->documentElement->getAttribute('message')
+ );
+
+ $array = json_decode(json_encode(simplexml_load_string($result)), true);
+
+ foreach ($array as $key => $value) {
+ if ($key === 'commercialLicence') {
+ $this->assertSame('trial', $value);
+ }
+ }
+ }
+
+ public function testRegisterNewDisplay()
+ {
+ $request = $this->sendRequest(
+ 'POST',
+ $this->register(
+ 'PHPUnitAddedTest' . mt_rand(1, 10),
+ 'phpunitaddedtest',
+ 'android'
+ ),
+ 7
+ );
+
+ $response = $request->getBody()->getContents();
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//ActivationMessage)');
+ $innerDocument = new DOMDocument();
+ $innerDocument->loadXML($result);
+
+ $this->assertSame('ADDED', $innerDocument->documentElement->getAttribute('code'));
+ $this->assertSame(
+ 'Display is now Registered and awaiting Authorisation from an Administrator in the CMS',
+ $innerDocument->documentElement->getAttribute('message')
+ );
+ }
+}
diff --git a/tests/Xmds/ReportFaultsTest.php b/tests/Xmds/ReportFaultsTest.php
new file mode 100644
index 0000000..a2ed3ff
--- /dev/null
+++ b/tests/Xmds/ReportFaultsTest.php
@@ -0,0 +1,98 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use Xibo\Tests\XmdsTestCase;
+
+/**
+ * Report fault tests
+ */
+final class ReportFaultsTest extends XmdsTestCase
+{
+ use XmdsHelperTrait;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public static function successCases(): array
+ {
+ return [
+ [7],
+ [6],
+ ];
+ }
+
+ public static function failureCases(): array
+ {
+ return [
+ [5],
+ [4],
+ [3],
+ ];
+ }
+
+ #[DataProvider('successCases')]
+ public function testSendFaultSuccess(int $version)
+ {
+ $request = $this->sendRequest('POST', $this->reportFault($version), $version);
+
+ $this->assertStringContainsString(
+ 'true',
+ $request->getBody()->getContents(),
+ 'Send fault received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testSendFaultFailure(int $version)
+ {
+ // disable exception on http_error in guzzle, so we can still check the response
+ $request = $this->sendRequest('POST', $this->reportFault($version), $version, false);
+
+ // check the fault code
+ $this->assertStringContainsString(
+ 'SOAP-ENV:Server',
+ $request->getBody(),
+ 'Send fault received incorrect response'
+ );
+
+ // check the fault string
+ $this->assertStringContainsString(
+ 'Procedure \'ReportFaults\' not present',
+ $request->getBody(),
+ 'Send fault received incorrect response'
+ );
+ }
+
+ #[DataProvider('failureCases')]
+ public function testSendFaultExceptionFailure(int $version)
+ {
+ // we are expecting 500 Server Exception here for xmds 3,4 and 5
+ $this->expectException('GuzzleHttp\Exception\ServerException');
+ $this->expectExceptionCode(500);
+ $this->sendRequest('POST', $this->reportFault($version), $version);
+ }
+}
diff --git a/tests/Xmds/SubmitLogTest.php b/tests/Xmds/SubmitLogTest.php
new file mode 100644
index 0000000..f483031
--- /dev/null
+++ b/tests/Xmds/SubmitLogTest.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use GuzzleHttp\Exception\GuzzleException;
+use Xibo\Tests\xmdsTestCase;
+
+class SubmitLogTest extends XmdsTestCase
+{
+ use XmdsHelperTrait;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ /**
+ * Submit log with category event
+ * @return void
+ * @throws GuzzleException
+ */
+ public function testSubmitEventLog()
+ {
+ $request = $this->sendRequest(
+ 'POST',
+ $this->submitEventLog('7'),
+ 7
+ );
+
+ $this->assertStringContainsString(
+ 'true',
+ $request->getBody()->getContents(),
+ 'Submit Log received incorrect response'
+ );
+ }
+}
diff --git a/tests/Xmds/SyncTest.php b/tests/Xmds/SyncTest.php
new file mode 100644
index 0000000..cad1be0
--- /dev/null
+++ b/tests/Xmds/SyncTest.php
@@ -0,0 +1,124 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+use DOMDocument;
+use DOMXPath;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Xibo\Tests\xmdsTestCase;
+
+/**
+ * Sync Schedule and Register tests
+ */
+class SyncTest extends XmdsTestCase
+{
+ use XmdsHelperTrait;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public static function registerSuccessCases(): array
+ {
+ return [
+ [7],
+ [6],
+ ];
+ }
+
+ public function testScheduleSyncEvent()
+ {
+ $request = $this->sendRequest('POST', $this->getSchedule('PHPUnit7'), 7);
+
+ $response = $request->getBody()->getContents();
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//ScheduleXml)');
+ $innerDocument = new DOMDocument();
+ $innerDocument->loadXML($result);
+ $layouts = $innerDocument->documentElement->getElementsByTagName('layout');
+
+ $i = 0;
+ foreach ($layouts as $layout) {
+ if ($i === 0) {
+ $this->assertSame('8', $layout->getAttribute('file'));
+ $this->assertSame('1', $layout->getAttribute('syncEvent'));
+ $this->assertSame('2', $layout->getAttribute('scheduleid'));
+ } else if ($i === 1) {
+ $this->assertSame('6', $layout->getAttribute('file'));
+ $this->assertSame('0', $layout->getAttribute('syncEvent'));
+ $this->assertSame('1', $layout->getAttribute('scheduleid'));
+ }
+ $i++;
+ }
+ }
+
+ #[DataProvider('registerSuccessCases')]
+ public function testRegisterDisplay($version)
+ {
+ if ($version === 7) {
+ $this->sendRequest('POST', $this->notifyStatus($version, '{"lanIpAddress":"192.168.0.3"}'), $version);
+ $xml = $this->register(
+ 'PHPUnit7',
+ 'phpunitv7',
+ 'android'
+ );
+ } else {
+ $xml = $this->register(
+ 'PHPUnit6',
+ 'phpunitv6',
+ 'android'
+ );
+ }
+
+ $request = $this->sendRequest('POST', $xml, $version);
+ $response = $request->getBody()->getContents();
+
+ $document = new DOMDocument();
+ $document->loadXML($response);
+
+ $xpath = new DOMXpath($document);
+ $result = $xpath->evaluate('string(//ActivationMessage)');
+ $innerDocument = new DOMDocument();
+ $innerDocument->loadXML($result);
+
+ $this->assertSame('READY', $innerDocument->documentElement->getAttribute('code'));
+ $this->assertSame(
+ 'Display is active and ready to start.',
+ $innerDocument->documentElement->getAttribute('message')
+ );
+
+ $syncNodes = $innerDocument->getElementsByTagName('syncGroup');
+ $this->assertSame(1, count($syncNodes));
+
+ if ($version === 7) {
+ $this->assertSame('lead', $syncNodes->item(0)->textContent);
+ } else {
+ $this->assertSame('192.168.0.3', $syncNodes->item(0)->textContent);
+ }
+ }
+}
diff --git a/tests/Xmds/XmdsHelperTrait.php b/tests/Xmds/XmdsHelperTrait.php
new file mode 100644
index 0000000..b111325
--- /dev/null
+++ b/tests/Xmds/XmdsHelperTrait.php
@@ -0,0 +1,136 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+trait XmdsHelperTrait
+{
+ public function getRf(string $version)
+ {
+ return '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'.$version.'
+
+
+';
+ }
+
+ public function notifyStatus(string $version, string $status)
+ {
+ return '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'.$version.'
+ '.$status.'
+
+
+';
+ }
+
+ public function register(
+ $hardwareKey,
+ $displayName,
+ $clientType,
+ $clientVersion = '4',
+ $clientCode = '400',
+ $macAddress = 'CC:40:D0:46:3C:A8'
+ ) {
+ return '
+
+
+ 6v4RduQhaw5Q
+ ' . $hardwareKey . '
+ ' . $displayName . '
+ ' . $clientType . '
+ ' . $clientVersion . '
+ ' . $clientCode . '
+ ' . $macAddress . '
+
+
+';
+ }
+
+ public function getSchedule($hardwareKey)
+ {
+ return '
+
+
+ 6v4RduQhaw5Q
+ ' . $hardwareKey . '
+
+
+';
+ }
+
+ public function reportFault($version)
+ {
+ return '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'.$version.'
+ [{"date":"2023-04-20 17:03:52","expires":"2023-04-21 17:03:52","code":"10001","reason":"Test","scheduleId":"0","layoutId":0,"regionId":"0","mediaId":"0","widgetId":"0"}]
+
+
+ ';
+ }
+
+ public function getWidgetData($version, $widgetId)
+ {
+ return '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'. $version .'
+ '.$widgetId.'
+
+
+';
+ }
+
+ public function submitEventLog($version): string
+ {
+ return '
+
+
+ 6v4RduQhaw5Q
+ PHPUnit'. $version .'
+ <log><event date="2024-04-10 12:45:55" category="event"><eventType>App Start</eventType><message>Detailed message about this event</message><alertType>both</alertType><refId></refId></event></log>
+
+
+ ';
+ }
+}
diff --git a/tests/Xmds/XmdsWrapper.php b/tests/Xmds/XmdsWrapper.php
new file mode 100644
index 0000000..f5f2362
--- /dev/null
+++ b/tests/Xmds/XmdsWrapper.php
@@ -0,0 +1,189 @@
+.
+ */
+
+namespace Xibo\Tests\Xmds;
+
+/**
+ * Class XmdsWrapper
+ * @package Xibo\Tests\Xmds
+ */
+class XmdsWrapper
+{
+ private $URL;
+ private $KEY;
+ private $version;
+ protected $client;
+
+ /**
+ * XmdsWrapper constructor.
+ * @param string $URL
+ * @param string $KEY
+ * @param string $version
+ * @throws \SoapFault
+ */
+ public function __construct($URL = 'http://localhost/xmds.php', $KEY = 'test', $version = '7')
+ {
+ $this->URL = $URL;
+ $this->KEY = $KEY;
+ $this->version = $version;
+
+ ini_set('soap.wsdl_cache_enabled', 0);
+ ini_set('soap.wsdl_cache_ttl', 900);
+ ini_set('default_socket_timeout', 15);
+
+ $options = [
+ 'uri'=>'http://schemas.xmlsoap.org/soap/envelope/',
+ 'style'=>SOAP_RPC,
+ 'use'=>SOAP_ENCODED,
+ 'soap_version'=>SOAP_1_1,
+ 'cache_wsdl'=>WSDL_CACHE_NONE,
+ 'connection_timeout'=>15,
+ 'trace'=>true,
+ 'encoding'=>'UTF-8',
+ 'exceptions'=>true,
+ ];
+
+ $this->client = new \SoapClient($this->URL . '?wsdl&v=' . $this->version, $options);
+ }
+
+ /**
+ * @param $hardwareKey
+ * @param $displayName
+ * @param string $clientType
+ * @param string $clientVersion
+ * @param string $clientCode
+ * @param string $operatingSystem
+ * @param string $macAddress
+ * @param string $xmrChannel
+ * @param string $xmrPubKey
+ * @return mixed
+ * @throws \SoapFault
+ */
+ function RegisterDisplay($hardwareKey, $displayName, $clientType='windows', $clientVersion='', $clientCode='', $operatingSystem='', $macAddress='', $xmrChannel='', $xmrPubKey='')
+ {
+ return $this->client->RegisterDisplay($this->KEY,
+ $hardwareKey,
+ $displayName,
+ $clientType,
+ $clientVersion,
+ $clientCode,
+ $operatingSystem,
+ $macAddress,
+ $xmrChannel,
+ $xmrPubKey
+ );
+ }
+
+ /**
+ * Request Required Files
+ * @param $hardwareKey
+ * @return mixed
+ * @throws \SoapFault
+ */
+ function RequiredFiles($hardwareKey)
+ {
+ return $this->client->RequiredFiles($this->KEY, $hardwareKey);
+ }
+
+ /**
+ * Request a file
+ * @param $hardwareKey
+ * @param $fileId
+ * @param $fileType
+ * @param $chunkOffset
+ * @param $chunkSize
+ * @return mixed
+ * @throws \SoapFault
+ */
+ function GetFile($hardwareKey, $fileId, $fileType, $chunkOffset, $chunkSize)
+ {
+ return $this->client->GetFile($this->KEY,
+ $hardwareKey,
+ $fileId,
+ $fileType,
+ $chunkOffset,
+ $chunkSize
+ );
+ }
+
+ /**
+ * Request Schedule
+ * @param $hardwareKey
+ * @return mixed
+ * @throws \SoapFault
+ */
+ function Schedule($hardwareKey)
+ {
+ return $this->client->Schedule($this->KEY, $hardwareKey);
+ }
+
+ function BlackList()
+ {
+
+ }
+
+ function SubmitLog()
+ {
+
+ }
+
+ /**
+ * Submit Stats
+ * @param $hardwareKey
+ * @param $statXml
+ * @return mixed
+ * @throws \SoapFault
+ */
+ function SubmitStats($hardwareKey, $statXml)
+ {
+ return $this->client->SubmitStats($this->KEY, $hardwareKey, $statXml);
+
+ }
+
+ function MediaInventory()
+ {
+
+ }
+
+ /**
+ * @param string $hardwareKey
+ * @param int $layoutId
+ * @param int $regionId
+ * @param string $mediaId
+ * @return string
+ * @throws \SoapFault
+ */
+ function GetResource($hardwareKey, $layoutId, $regionId, $mediaId)
+ {
+ return $this->client->GetResource($this->KEY, $hardwareKey, $layoutId, $regionId, $mediaId);
+ }
+
+ function NotifyStatus()
+ {
+
+ }
+
+ function SubmitScreenShot()
+ {
+
+ }
+}
diff --git a/tests/XmdsTestCase.php b/tests/XmdsTestCase.php
new file mode 100644
index 0000000..93b9341
--- /dev/null
+++ b/tests/XmdsTestCase.php
@@ -0,0 +1,180 @@
+.
+ */
+
+namespace Xibo\Tests;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+class xmdsTestCase extends TestCase
+{
+ /** @var ContainerInterface */
+ public static $container;
+
+ /** @var LoggerInterface */
+ public static $logger;
+
+ /** @var Client */
+ public $client;
+
+ /**
+ * @inheritDoc
+ */
+ public function getGuzzleClient(array $requestOptions = []): Client
+ {
+ if ($this->client === null) {
+ $this->client = new Client($requestOptions);
+ }
+
+ return $this->client;
+ }
+
+ /**
+ * @param string $method
+ * @param string $body
+ * @param string $path
+ * @param array $headers
+ * @return ResponseInterface
+ * @throws GuzzleException
+ */
+ protected function sendRequest(
+ string $method = 'POST',
+ string $body = '',
+ string $version = '7',
+ bool $httpErrors = true,
+ string $path = 'http://localhost/xmds.php?v=',
+ array $headers = ['HTTP_ACCEPT'=>'text/xml']
+ ): ResponseInterface {
+ // Create a request for tests
+ return $this->client->request($method, $path . $version, [
+ 'headers' => $headers,
+ 'body' => $body,
+ 'http_errors' => $httpErrors
+ ]);
+ }
+
+ protected function getFile(
+ string $fileQuery = '',
+ string $method = 'GET',
+ string $basePath = 'http://localhost/xmds.php',
+ ): ResponseInterface {
+ // Create a request for tests
+ return $this->client->request($method, $basePath . $fileQuery);
+ }
+
+ /**
+ * Create a global container for all tests to share.
+ * @throws \Exception
+ */
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+ }
+
+ /**
+ * Convenience function to skip a test with a reason and close output buffers nicely.
+ * @param string $reason
+ */
+ public function skipTest(string $reason): void
+ {
+ $this->markTestSkipped($reason);
+ }
+
+ /**
+ * @return Logger|NullLogger|LoggerInterface
+ */
+ public static function getLogger(): Logger|NullLogger|LoggerInterface
+ {
+ // Create if necessary
+ if (self::$logger === null) {
+ if (isset($_SERVER['PHPUNIT_LOG_TO_CONSOLE']) && $_SERVER['PHPUNIT_LOG_TO_CONSOLE']) {
+ self::$logger = new Logger('TESTS', [new StreamHandler(STDERR, Logger::DEBUG)]);
+ } else {
+ self::$logger = new NullLogger();
+ }
+ }
+
+ return self::$logger;
+ }
+
+ /**
+ * @inheritDoc
+ * @throws \Exception
+ */
+ public function setUp(): void
+ {
+ self::getLogger()->debug('xmdsTestCase: setUp');
+ parent::setUp();
+
+ // Establish a local reference to the Slim app object
+ $this->client = $this->getGuzzleClient();
+ }
+
+ /**
+ * @inheritDoc
+ * @throws \Exception
+ */
+ public function tearDown(): void
+ {
+ self::getLogger()->debug('xmdsTestCase: tearDown');
+
+ // Close and tidy up the app
+ $this->client = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Set the _SERVER vars for the suite
+ * @param array $userSettings
+ */
+ public static function setEnvironment(array $userSettings = []): void
+ {
+ $defaults = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => '/',
+ 'SCRIPT_NAME' => '',
+ 'PATH_INFO' => '/',
+ 'QUERY_STRING' => '',
+ 'SERVER_NAME' => 'local.dev',
+ 'SERVER_PORT' => 80,
+ 'HTTP_ACCEPT' => 'text/xml,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'HTTP_ACCEPT_LANGUAGE' => 'en-US,en;q=0.8',
+ 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.3',
+ 'USER_AGENT' => 'Slim Framework',
+ 'REMOTE_ADDR' => '127.0.0.1',
+ ];
+
+ $environmentSettings = array_merge($userSettings, $defaults);
+
+ foreach ($environmentSettings as $key => $value) {
+ $_SERVER[$key] = $value;
+ }
+ }
+}
diff --git a/tests/Xmr/playerSub.php b/tests/Xmr/playerSub.php
new file mode 100644
index 0000000..d29b939
--- /dev/null
+++ b/tests/Xmr/playerSub.php
@@ -0,0 +1,74 @@
+.
+ */
+
+// This is a simple XMR client which connects to the display added in XMDS.http
+// performing actions in the CMS which affect the display should be logged here.
+
+define('PROJECT_ROOT', realpath(__DIR__ . '/../..'));
+
+require PROJECT_ROOT . '/vendor/autoload.php';
+
+// RSA key
+$fp = fopen(PROJECT_ROOT . '/library/certs/private.key', 'r');
+$privateKey = openssl_get_privatekey(fread($fp, 8192));
+fclose($fp);
+
+// Sub
+$loop = React\EventLoop\Factory::create();
+
+$context = new React\ZMQ\Context($loop);
+
+$sub = $context->getSocket(ZMQ::SOCKET_SUB);
+$sub->connect('tcp://xmr:9505');
+$sub->subscribe('H');
+$sub->subscribe('XMR_test_channel');
+
+$sub->on('messages', function ($msg) use ($privateKey) {
+ try {
+ if ($msg[0] == 'H') {
+ echo '[' . date('Y-m-d H:i:s') . '] Heartbeat...' . PHP_EOL;
+ return;
+ }
+
+ // Expect messages to have a length of 3
+ if (count($msg) != 3) {
+ throw new InvalidArgumentException('Incorrect Message Length');
+ }
+
+ // Message will be: channel, key, message
+ if ($msg[0] != 'XMR_test_channel') {
+ throw new InvalidArgumentException('Channel does not match');
+ }
+
+ // Decrypt
+ $output = null;
+ openssl_open(base64_decode($msg[2]), $output, base64_decode($msg[1]), $privateKey, 'RC4');
+
+ echo '[' . date('Y-m-d H:i:s') . '] Received: ' . $output . PHP_EOL;
+ } catch (InvalidArgumentException $e) {
+ echo '[' . date('Y-m-d H:i:s') . '] E: ' . $e->getMessage() . PHP_EOL;
+ }
+});
+
+$loop->run();
+
+openssl_free_key($privateKey);
\ No newline at end of file
diff --git a/tests/http-client.env.json b/tests/http-client.env.json
new file mode 100644
index 0000000..9ebc50e
--- /dev/null
+++ b/tests/http-client.env.json
@@ -0,0 +1,11 @@
+{
+ "dev": {
+ "url": "http://localhost",
+ "serverKey": "test",
+ "hardwareKey": "phpstorm",
+ "displayName": "PHPStorm",
+ "clientType": "windows",
+ "macAddress": "00:00:00:00:00:00",
+ "testWidgetId": "1660"
+ }
+}
diff --git a/tests/integration/AboutTest.php b/tests/integration/AboutTest.php
new file mode 100644
index 0000000..514a26d
--- /dev/null
+++ b/tests/integration/AboutTest.php
@@ -0,0 +1,95 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+use League\OAuth2\Client\Token\AccessToken;
+
+/**
+ * Class AboutTest
+ * @package Xibo\Tests\Integration
+ */
+class AboutTest extends \Xibo\Tests\LocalWebTestCase
+{
+ /**
+ * Shows CMS version
+ * @throws \Exception
+ */
+ public function testVersion()
+ {
+ $response = $this->sendRequest('GET', '/about');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $body = json_decode($response->getBody());
+
+ $this->assertSame(200, $body->status);
+ $this->assertSame(true, $body->success);
+ $this->assertSame(false, $body->grid);
+ $this->assertNotEmpty($body->data, 'Empty Data');
+ $this->assertNotEmpty($body->data->version, 'Empty Version');
+ }
+
+ /**
+ * Test that the API is initialised and making authenticated requests.
+ */
+ public function testApiInitialisedTest()
+ {
+ $this->assertNotNull($this->getEntityProvider(), 'Entity Provider not set');
+ $this->assertNotNull($this->getEntityProvider()->getProvider(), 'Provider not set');
+ }
+
+ /**
+ * @depends testApiInitialisedTest
+ */
+ public function testApiAccessTest()
+ {
+ $provider = $this->getEntityProvider()->getProvider();
+ $token = $provider->getAccessToken('client_credentials');
+
+ $this->assertNotNull($token);
+ $this->assertNotTrue($token->hasExpired(), 'Expired Token');
+ $this->assertInstanceOf('League\OAuth2\Client\Token\AccessToken', $token);
+
+ return $token;
+ }
+
+ /**
+ * @param AccessToken $token
+ * @depends testApiAccessTest
+ */
+ public function testApiUserTest(AccessToken $token)
+ {
+ $provider = $this->getEntityProvider()->getProvider();
+
+ try {
+ $me = $provider->getResourceOwner($token);
+ } catch (\Exception $exception) {
+ $this->fail('API connect not successful: ' . $exception->getMessage());
+ }
+
+ $this->assertNotNull($me);
+ $this->assertArrayHasKey('userId', $me->toArray());
+ $this->assertNotEmpty($me->getId());
+ $this->assertNotEquals(0, $me->getId());
+ }
+}
diff --git a/tests/integration/AuditLogTest.php b/tests/integration/AuditLogTest.php
new file mode 100644
index 0000000..f02f4a9
--- /dev/null
+++ b/tests/integration/AuditLogTest.php
@@ -0,0 +1,67 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+
+class AuditLogTest extends \Xibo\Tests\LocalWebTestCase
+{
+ public function testSearch()
+ {
+ $response = $this->sendRequest('GET','/audit');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertObjectHasAttribute('data', $object->data, $response->getBody());
+ $this->assertObjectHasAttribute('draw', $object->data, $response->getBody());
+ $this->assertObjectHasAttribute('recordsTotal', $object->data, $response->getBody());
+ $this->assertObjectHasAttribute('recordsFiltered', $object->data, $response->getBody());
+
+ // Make sure the recordsTotal is not greater than 10 (the default paging)
+ $this->assertLessThanOrEqual(10, count($object->data->data));
+ }
+
+ public function testExportForm()
+ {
+ $response = $this->sendRequest('GET','/audit/form/export');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+ }
+
+ /**
+ */
+ public function testExport()
+ {
+ $response = $this->sendRequest('GET','/audit/export', [
+ 'filterFromDt' => Carbon::now()->subSeconds(86400)->format(DateFormatHelper::getSystemFormat()),
+ 'filterToDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ ]);
+ $this->assertSame(200, $response->getStatusCode());
+ }
+}
diff --git a/tests/integration/Cache/CampaignDeleteTest.php b/tests/integration/Cache/CampaignDeleteTest.php
new file mode 100644
index 0000000..90cd03b
--- /dev/null
+++ b/tests/integration/Cache/CampaignDeleteTest.php
@@ -0,0 +1,147 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class CampaignDeleteTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class CampaignDeleteTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboCampaign */
+ protected $campaign;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboSchedule */
+ protected $event;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Campaign
+ $this->campaign = (new XiboCampaign($this->getEntityProvider()))->create(Random::generateString());
+
+ // Assign the Layout to the Campaign
+ $this->getEntityProvider()->post('/campaign/layout/assign/' . $this->campaign->campaignId, [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Date
+ $date = Carbon::now();
+
+ // Schedule the Campaign "always" onto our display
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ $date->format(DateFormatHelper::getSystemFormat()),
+ $date->addHours(3)->format(DateFormatHelper::getSystemFormat()),
+ $this->campaign->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ parent::tearDown();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Delete the Campaign
+ $this->sendRequest('DELETE', '/campaign/' . $this->campaign->campaignId);
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/CampaignLayoutAssignTest.php b/tests/integration/Cache/CampaignLayoutAssignTest.php
new file mode 100644
index 0000000..3db8824
--- /dev/null
+++ b/tests/integration/Cache/CampaignLayoutAssignTest.php
@@ -0,0 +1,146 @@
+.
+ */
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class CampaignLayoutAssignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class CampaignLayoutAssignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboCampaign */
+ protected $campaign;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboSchedule */
+ protected $event;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Campaign Layout Unassign Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Campaign
+ $this->campaign = (new XiboCampaign($this->getEntityProvider()))->create(Random::generateString());
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Date
+ $date = Carbon::now();
+
+ // Schedule the Campaign "always" onto our display
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ $date->format(DateFormatHelper::getSystemFormat()),
+ $date->addHours(3)->format(DateFormatHelper::getSystemFormat()),
+ $this->campaign->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the Campaign
+ $this->campaign->delete();
+
+ parent::tearDown();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt Done as expected');
+
+ // Add the Layout we have prepared to the existing Campaign
+ $this->sendRequest('POST', '/campaign/layout/assign/' . $this->campaign->campaignId, [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt Pending as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/CampaignLayoutUnassignTest.php b/tests/integration/Cache/CampaignLayoutUnassignTest.php
new file mode 100644
index 0000000..9795273
--- /dev/null
+++ b/tests/integration/Cache/CampaignLayoutUnassignTest.php
@@ -0,0 +1,155 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class CampaignLayoutUnassignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class CampaignLayoutUnassignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboCampaign */
+ protected $campaign;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboSchedule */
+ protected $event;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Campaign
+ $this->campaign = (new XiboCampaign($this->getEntityProvider()))->create(Random::generateString());
+
+ // Assign the Layout to the Campaign
+ $this->getEntityProvider()->post('/campaign/layout/assign/' . $this->campaign->campaignId, [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Date
+ $date = Carbon::now();
+
+ // Schedule the Campaign "always" onto our display
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ $date->format(DateFormatHelper::getSystemFormat()),
+ $date->addHours(3)->format(DateFormatHelper::getSystemFormat()),
+ $this->campaign->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the Campaign
+ $this->campaign->delete();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Unassign requires edit
+ $this->sendRequest('PUT', '/campaign/' . $this->campaign->campaignId, [
+ 'name' => $this->campaign->campaign,
+ 'manageLayouts' => 1,
+ 'layoutIds' => [] // empty list
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
diff --git a/tests/integration/Cache/DataSetDataEditTest.php b/tests/integration/Cache/DataSetDataEditTest.php
new file mode 100644
index 0000000..db0c09f
--- /dev/null
+++ b/tests/integration/Cache/DataSetDataEditTest.php
@@ -0,0 +1,159 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\DataSetColumn;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDataSet;
+use Xibo\OAuth2\Client\Entity\XiboDataSetColumn;
+use Xibo\OAuth2\Client\Entity\XiboDataSetView;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboWidget;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DataSetDataEditTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DataSetDataEditTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboDataSet */
+ protected $dataSet;
+
+ /** @var DataSetColumn */
+ protected $dataSetColumn;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboWidget */
+ protected $widget;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Add a DataSet
+ $this->dataSet = (new XiboDataSet($this->getEntityProvider()))->create(Random::generateString(), 'Test');
+
+ // Add a Column
+ $this->dataSetColumn = (new XiboDataSetColumn($this->getEntityProvider()))->create($this->dataSet->dataSetId,
+ Random::generateString(),
+ '',
+ 1,
+ 1,
+ 1,
+ '');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a couple of text widgets to the region
+ $response = $this->getEntityProvider()->post('/playlist/widget/datasetview/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'step' => 1,
+ 'dataSetId' => $this->dataSet->dataSetId
+ ]);
+
+ $this->widget = (new XiboDataSetView($this->getEntityProvider()))->hydrate($response);
+
+ // Check in
+ $this->layout = $this->publish($this->layout);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the DataSet
+ $this->dataSet->deleteWData();
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Add Data to the DataSet
+ $this->sendRequest('POST','/dataset/data/'. $this->dataSet->dataSetId, [
+ 'dataSetColumnId_' . $this->dataSetColumn->dataSetColumnId => '1'
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupDisplayAssignTest.php b/tests/integration/Cache/DisplayGroupDisplayAssignTest.php
new file mode 100644
index 0000000..00f2433
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupDisplayAssignTest.php
@@ -0,0 +1,145 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupDisplayAssignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupDisplayAssignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplayGroup */
+ protected $displayGroup;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display Group
+ $this->displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create(Random::generateString(), 'Cache Test', 0, null);
+
+ // Schedule the Layout "always" onto our display group
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->displayGroup->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the Display Group
+ $this->displayGroup->delete();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Add the Layout we have prepared to the Display Group
+ $response = $this->sendRequest('POST','/displaygroup/' . $this->displayGroup->displayGroupId . '/display/assign', [
+ 'displayId' => [$this->display->displayId]
+ ]);
+
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupDisplayUnassignTest.php b/tests/integration/Cache/DisplayGroupDisplayUnassignTest.php
new file mode 100644
index 0000000..21876d0
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupDisplayUnassignTest.php
@@ -0,0 +1,147 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupDisplayUnassignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupDisplayUnassignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplayGroup */
+ protected $displayGroup;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display Group
+ $this->displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create(Random::generateString(), 'Cache Test', 0, null);
+
+ // Schedule the Layout "always" onto our display group
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->displayGroup->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Assign my Display to the Group
+ $this->getEntityProvider()->post('/displaygroup/' . $this->displayGroup->displayGroupId . '/display/assign', [
+ 'displayId' => [$this->display->displayId]
+ ]);
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the Display Group
+ $this->displayGroup->delete();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Unassign
+ $this->sendRequest('POST','/displaygroup/' . $this->displayGroup->displayGroupId . '/display/unassign', [
+ 'displayId' => [$this->display->displayId]
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupDynamicDisplayTest.php b/tests/integration/Cache/DisplayGroupDynamicDisplayTest.php
new file mode 100644
index 0000000..2e70b40
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupDynamicDisplayTest.php
@@ -0,0 +1,181 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupDynamicDisplayTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupDynamicDisplayTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplayGroup */
+ protected $displayGroup;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Create a Display Group
+ // this matches all displays created by the test suite
+ $this->displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create(
+ Random::generateString(),
+ 'Cache Test',
+ 1,
+ 'phpunit');
+
+ $this->getLogger()->debug('DisplayGroup created with ID ' . $this->displayGroup->displayGroupId);
+
+ // Schedule the Layout "always" onto our display group
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->displayGroup->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->getLogger()->debug('Schedule created with ID ' . $event->eventId);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Run regular maintenance to add the new display to our group.
+ $this->runRegularMaintenance();
+
+ $this->getLogger()->debug('Display created with ID ' . $this->display->displayId);
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display Group
+ $this->displayGroup->delete();
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ $this->getLogger()->debug('Renaming display');
+
+ // Rename the display
+ $response = $this->sendRequest('PUT','/display/' . $this->display->displayId, [
+ 'display' => Random::generateString(10, 'testedited'),
+ 'defaultLayoutId' => $this->display->defaultLayoutId,
+ 'auditingUntil' => null,
+ 'licensed' => $this->display->licensed,
+ 'license' => $this->display->license,
+ 'incSchedule' => $this->display->incSchedule,
+ 'emailAlert' => $this->display->emailAlert,
+ 'wakeOnLanEnabled' => $this->display->wakeOnLanEnabled,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ // There isn't anything directly on the display - so that will NOT trigger anything. The schedule is on the Display Group.
+ $this->getLogger()->debug('Finished renaming display');
+
+ $this->assertLessThan(300, $response->getStatusCode(), 'Non-success status code, body =' . $response->getBody()->getContents());
+
+ // Initially we're expecting no change.
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Run regular maintenance
+ $this->runRegularMaintenance();
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Our player action would have been sent by regular maintenance, not by the edit.
+ // Make sure we don't have one here.
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+
+ private function runRegularMaintenance()
+ {
+ $this->getLogger()->debug('Running Regular Maintenance');
+ exec('cd /var/www/cms; php bin/run.php 2');
+ $this->getLogger()->debug('Finished Regular Maintenance');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupLayoutAssignTest.php b/tests/integration/Cache/DisplayGroupLayoutAssignTest.php
new file mode 100644
index 0000000..a4bccc7
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupLayoutAssignTest.php
@@ -0,0 +1,115 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Xibo\Entity\Display;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupLayoutAssignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupLayoutAssignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Add the Layout we have prepared to the Display Group
+ $response = $this->sendRequest('POST','/displaygroup/' . $this->display->displayGroupId . '/layout/assign', [
+ 'layoutId' => [$this->layout->layoutId]
+ ]);
+
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupLayoutUnssignTest.php b/tests/integration/Cache/DisplayGroupLayoutUnssignTest.php
new file mode 100644
index 0000000..e2d37ae
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupLayoutUnssignTest.php
@@ -0,0 +1,120 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Xibo\Entity\Display;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupLayoutUnssignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupLayoutUnssignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a simple widget
+ $this->addSimpleWidget($layout);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Assign the Layout to the Display
+ $this->getEntityProvider()->post('/displaygroup/' . $this->display->displayGroupId . '/layout/assign', [
+ 'layoutId' => [$this->layout->layoutId]
+ ]);
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Add the Layout we have prepared to the Display Group
+ $response = $this->sendRequest('POST','/displaygroup/' . $this->display->displayGroupId . '/layout/unassign', [
+ 'layoutId' => [$this->layout->layoutId]
+ ]);
+
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupMediaAssignTest.php b/tests/integration/Cache/DisplayGroupMediaAssignTest.php
new file mode 100644
index 0000000..fea06f7
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupMediaAssignTest.php
@@ -0,0 +1,102 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Xibo\Entity\Display;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupMediaAssignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupMediaAssignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Add a media item
+ $this->media = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->media->deleteAssigned();
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Add the Layout we have prepared to the Display Group
+ $this->sendRequest('POST','/displaygroup/' . $this->display->displayGroupId . '/media/assign', [
+ 'mediaId' => [$this->media->mediaId]
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/DisplayGroupMediaUnassignTest.php b/tests/integration/Cache/DisplayGroupMediaUnassignTest.php
new file mode 100644
index 0000000..022d30f
--- /dev/null
+++ b/tests/integration/Cache/DisplayGroupMediaUnassignTest.php
@@ -0,0 +1,107 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Xibo\Entity\Display;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupMediaUnassignTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class DisplayGroupMediaUnassignTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Add a media item
+ $this->media = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Assign the mediaId to the display
+ $this->getEntityProvider()->post('/displaygroup/' . $this->display->displayGroupId . '/media/assign', [
+ 'mediaId' => [$this->media->mediaId]
+ ]);
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->media->deleteAssigned();
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Add the Layout we have prepared to the Display Group
+ $this->sendRequest('POST','/displaygroup/' . $this->display->displayGroupId . '/media/unassign', [
+ 'mediaId' => [$this->media->mediaId]
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/GetResourceTest.php b/tests/integration/Cache/GetResourceTest.php
new file mode 100644
index 0000000..32b1cf1
--- /dev/null
+++ b/tests/integration/Cache/GetResourceTest.php
@@ -0,0 +1,167 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class GetResourceTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class GetResourceTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a resource heavy module to the Layout (one that will download images)
+ $response = $this->getEntityProvider()->post('/playlist/widget/ticker/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'uri' => 'http://ceu.xibo.co.uk/mediarss/feed.xml',
+ 'duration' => 100,
+ 'useDuration' => 1,
+ 'sourceId' => 1,
+ 'templateId' => 'media-rss-with-title'
+ ]);
+
+ // Edit the Ticker to add the template
+ $this->widget = (new XiboTicker($this->getEntityProvider()))->hydrate($response);
+
+ // Checkin
+ $this->layout = $this->publish($this->layout);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 3);
+
+ // Build the Layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Layout is already status 1
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+
+ $this->assertContains('file="' . $this->layout->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ $this->assertContains('layoutid="' . $this->layout->layoutId . '"', $rf, 'Layout not in Required Files');
+
+ // Call Get Resource
+ $this->getLogger()->debug('Calling GetResource - for ' . $this->layout->layoutId . ' - ' . $this->layout->regions[0]->regionId . ' - ' . $this->widget->widgetId);
+
+ $this->getXmdsWrapper()->GetResource($this->display->license, $this->layout->layoutId, $this->layout->regions[0]->regionId, $this->widget->widgetId);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutBuildTest.php b/tests/integration/Cache/LayoutBuildTest.php
new file mode 100644
index 0000000..b259bb8
--- /dev/null
+++ b/tests/integration/Cache/LayoutBuildTest.php
@@ -0,0 +1,151 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutBuildTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutBuildTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Layout is already status 1
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 3), 'Pre-Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Pre-Display Status isnt as expected');
+
+ // Publish (which builds)
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layout->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $object = json_decode($response->getBody(), true);
+
+ $this->layout = $this->constructLayoutFromResponse($object['data']);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutChangeActionTest.php b/tests/integration/Cache/LayoutChangeActionTest.php
new file mode 100644
index 0000000..559a20f
--- /dev/null
+++ b/tests/integration/Cache/LayoutChangeActionTest.php
@@ -0,0 +1,99 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Xibo\Entity\Display;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutChangeActionTest
+ *
+ * Tests whether a Layout Edit updates the Cache Appropriately
+ *
+ * @package integration\Cache
+ */
+class LayoutChangeActionTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Layout Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout(1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure we're in good condition to start
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Edit the Layout
+ $this->sendRequest('POST','/displaygroup/' . $this->display->displayGroupId . '/action/changeLayout', [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutDeleteTest.php b/tests/integration/Cache/LayoutDeleteTest.php
new file mode 100644
index 0000000..c0212fc
--- /dev/null
+++ b/tests/integration/Cache/LayoutDeleteTest.php
@@ -0,0 +1,121 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Exception\XiboApiException;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutDeleteTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutDeleteTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Layout Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout(1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ if ($this->layout !== null)
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Delete the Layout we've got created for us.
+ $this->sendRequest('DELETE','/layout/' . $this->layout->layoutId);
+
+ // Check its deleted
+ try {
+ $this->layoutStatusEquals($this->layout, 0);
+ } catch (XiboApiException $xiboApiException) {
+ $this->assertEquals(404, $xiboApiException->getCode(), 'Expecting a 404, got ' . $xiboApiException->getCode());
+ }
+
+ $this->layout = null;
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutEditTest.php b/tests/integration/Cache/LayoutEditTest.php
new file mode 100644
index 0000000..db34175
--- /dev/null
+++ b/tests/integration/Cache/LayoutEditTest.php
@@ -0,0 +1,145 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutEditTest
+ *
+ * Tests whether a Layout Edit updates the Cache Appropriately
+ *
+ * @package integration\Cache
+ */
+class LayoutEditTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Layout Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout(1);
+
+ // We need to add a widget to it, so that the Layout tests out as valid
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->layout = $this->publish($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure we're in good condition to start
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Checkout this Layout
+ $layout = $this->checkout($this->layout);
+
+ // Validate the display status after we've checked out
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected after checkout');
+
+ // Edit the Layout
+ $response = $this->sendRequest('PUT','/layout/background/' . $layout->layoutId, [
+ 'backgroundColor' => $layout->backgroundColor,
+ 'backgroundzIndex' => $layout->backgroundzIndex
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertEquals(200, $response->getStatusCode(), 'Transaction Status Incorrect');
+
+ // Check in the Layout
+ $this->layout = $this->publish($this->layout);
+
+ // Validate the layout status afterwards (publish builds the layout)
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected after publish');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutInCampaignStatusTest.php b/tests/integration/Cache/LayoutInCampaignStatusTest.php
new file mode 100644
index 0000000..60d2064
--- /dev/null
+++ b/tests/integration/Cache/LayoutInCampaignStatusTest.php
@@ -0,0 +1,168 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutInCampaignStatusTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutInCampaignStatusTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboCampaign */
+ protected $campaign;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Campaign
+ $this->campaign = (new XiboCampaign($this->getEntityProvider()))->create(Random::generateString());
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Assign the layout to our campaign
+ $this->getEntityProvider()->post('/campaign/layout/assign/' . $this->campaign->campaignId, [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Campaign "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->campaign->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the Campaign
+ $this->campaign->delete();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Layout is already status 1
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 3), 'Pre-Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Pre-Display Status isnt as expected');
+
+ // Publish (which builds)
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layout->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getStatusCode() . $response->getBody()->getContents());
+
+ $response = json_decode($response->getBody(), true);
+ $this->layout = $this->constructLayoutFromResponse($response['data']);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutOverlayActionTest.php b/tests/integration/Cache/LayoutOverlayActionTest.php
new file mode 100644
index 0000000..2f99b7d
--- /dev/null
+++ b/tests/integration/Cache/LayoutOverlayActionTest.php
@@ -0,0 +1,99 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Xibo\Entity\Display;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutOverlayActionTest
+ *
+ * Tests whether a Layout Edit updates the Cache Appropriately
+ *
+ * @package integration\Cache
+ */
+class LayoutOverlayActionTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Layout Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout(1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure we're in good condition to start
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Edit the Layout
+ $this->sendRequest('POST','/displaygroup/' . $this->display->displayGroupId . '/action/overlayLayout', [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetInheritTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetInheritTest.php
new file mode 100644
index 0000000..9d33752
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetInheritTest.php
@@ -0,0 +1,272 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaInheritWidgetInheritTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaInheritWidgetInheritTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOff
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media Inherit, Widget Inherit, Output => [0, 'Inherit', 'Inherit', 0]
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media Inherit, Widget Inherit, Output => [1, 'Inherit', 'Inherit', 1]
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetOffTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetOffTest.php
new file mode 100644
index 0000000..64e0e31
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetOffTest.php
@@ -0,0 +1,282 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaInheritWidgetOffTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaInheritWidgetOffTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Set global widget enable stat set to Off
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Off');
+ $this->getStore()->commitIfNecessary();
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Set global widget enable stat set to Inherit
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Inherit');
+ $this->getStore()->commitIfNecessary();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media Inherit, Widget Off, Output => [0, 'Inherit', 'Off', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media Inherit, Widget Off, Output => [1, 'Off', 'Off', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetOnTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetOnTest.php
new file mode 100644
index 0000000..8f3433c
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaInheritWidgetOnTest.php
@@ -0,0 +1,280 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaInheritWidgetOnTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaInheritWidgetOnTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Set global widget enable stat set to On
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'On');
+ $this->getStore()->commitIfNecessary();
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Set global widget enable stat set to Inherit
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Inherit');
+ $this->getStore()->commitIfNecessary();
+ parent::tearDown();
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media Inherit, Widget On, Output => [0, 'Inherit', 'On', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media Inherit, Widget On, Output => [1, 'Inherit', 'On', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetInheritTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetInheritTest.php
new file mode 100644
index 0000000..1aa33a9
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetInheritTest.php
@@ -0,0 +1,285 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaOffWidgetInheritTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaOffWidgetInheritTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Edit the media to set enableStat On
+ $this->media->edit(
+ $this->media->name,
+ $this->media->duration,
+ $this->media->retired,
+ $this->media->tags,
+ $this->media->updateInLayouts,
+ 'Off'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOff
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media Off, Widget Inherit, Output => [0, 'Off', 'Inherit', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media Off, Widget Inherit, Output => [1, 'Off', 'Inherit', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetOffTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetOffTest.php
new file mode 100644
index 0000000..363e9ed
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetOffTest.php
@@ -0,0 +1,292 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaOffWidgetOffTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaOffWidgetOffTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Set global widget enable stat set to Off
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Off');
+ $this->getStore()->commitIfNecessary();
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Edit the media to set enableStat On
+ $this->media->edit(
+ $this->media->name,
+ $this->media->duration,
+ $this->media->retired,
+ $this->media->tags,
+ $this->media->updateInLayouts,
+ 'Off'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Set global widget enable stat set to Inherit
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Inherit');
+ $this->getStore()->commitIfNecessary();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media Off, Widget Off, Output => [0, 'Off', 'Off', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media Off, Widget Off, Output => [1, 'Off', 'Off', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetOnTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetOnTest.php
new file mode 100644
index 0000000..4f3c26b
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOffWidgetOnTest.php
@@ -0,0 +1,292 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaOffWidgetOnTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaOffWidgetOnTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Set global widget enable stat set to On
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'On');
+ $this->getStore()->commitIfNecessary();
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Edit the media to set enableStat On
+ $this->media->edit(
+ $this->media->name,
+ $this->media->duration,
+ $this->media->retired,
+ $this->media->tags,
+ $this->media->updateInLayouts,
+ 'Off'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Set global widget enable stat set to Inherit
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Inherit');
+ $this->getStore()->commitIfNecessary();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media Off, Widget On, Output => [0, 'Off', 'On', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media Off, Widget On, Output => [1, 'Off', 'On', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetInheritTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetInheritTest.php
new file mode 100644
index 0000000..f1eefc9
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetInheritTest.php
@@ -0,0 +1,283 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaOnWidgetInheritTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaOnWidgetInheritTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Edit the media to set enableStat On
+ $this->media->edit(
+ $this->media->name,
+ $this->media->duration,
+ $this->media->retired,
+ $this->media->tags,
+ $this->media->updateInLayouts,
+ 'On'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOff
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+ parent::tearDown();
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media On, Widget Inherit, Output => [0, 'On', 'Inherit', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media On, Widget Inherit, Output => [1, 'On', 'Inherit', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetOffTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetOffTest.php
new file mode 100644
index 0000000..b5ef9c6
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetOffTest.php
@@ -0,0 +1,294 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaOnWidgetOffTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaOnWidgetOffTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Set global widget enable stat set to Off
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Off');
+ $this->getStore()->commitIfNecessary();
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Edit the media to set enableStat On
+ $this->media->edit(
+ $this->media->name,
+ $this->media->duration,
+ $this->media->retired,
+ $this->media->tags,
+ $this->media->updateInLayouts,
+ 'On'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Set global widget enable stat set to Inherit
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Inherit');
+ $this->getStore()->commitIfNecessary();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media On, Widget Off, Output => [0, 'On', 'Off', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media On, Widget Off, Output => [1, 'On', 'Off', 0],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetOnTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetOnTest.php
new file mode 100644
index 0000000..4b2f674
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLMediaOnWidgetOnTest.php
@@ -0,0 +1,294 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLMediaOnWidgetOnTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLMediaOnWidgetOnTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $mediaOn;
+
+ protected $widgetId;
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Set global widget enable stat set to On
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'On');
+ $this->getStore()->commitIfNecessary();
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ // Upload some media - enableStat is Inherit (from global media stat setting)
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(
+ Random::generateString(8, 'API Video'),
+ PROJECT_ROOT . '/tests/resources/HLH264.mp4'
+ );
+
+ // Edit the media to set enableStat On
+ $this->media->edit(
+ $this->media->name,
+ $this->media->duration,
+ $this->media->retired,
+ $this->media->tags,
+ $this->media->updateInLayouts,
+ 'On'
+ );
+
+ // Assign the media we've edited to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOff->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ // Assign the media we've created to our regions playlist- widget with Inherit (from global widget stat setting)
+ $playlist2 = (new XiboPlaylist($this->getEntityProvider()))
+ ->assign([$this->media->mediaId], 10, $layoutOn->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId2 = $playlist2->widgets[0]->widgetId;
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Set global widget enable stat set to Inherit
+ self::$container->get('configService')->changeSetting('WIDGET_STATS_ENABLED_DEFAULT', 'Inherit');
+ $this->getStore()->commitIfNecessary();
+
+ parent::tearDown();
+
+ }
+ //
+
+// Logic Table
+//
+// Widget With Media
+// LAYOUT MEDIA WIDGET Media stats collected?
+// ON ON ON YES Widget takes precedence // Match - 1
+// ON OFF ON YES Widget takes precedence // Match - 1
+// ON INHERIT ON YES Widget takes precedence // Match - 1
+//
+// OFF ON ON YES Widget takes precedence // Match - 1
+// OFF OFF ON YES Widget takes precedence // Match - 1
+// OFF INHERIT ON YES Widget takes precedence // Match - 1
+//
+// ON ON OFF NO Widget takes precedence // Match - 2
+// ON OFF OFF NO Widget takes precedence // Match - 2
+// ON INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// OFF ON OFF NO Widget takes precedence // Match - 2
+// OFF OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT OFF NO Widget takes precedence // Match - 2
+//
+// ON ON INHERIT YES Media takes precedence // Match - 3
+// ON OFF INHERIT NO Media takes precedence // Match - 4
+// ON INHERIT INHERIT YES Media takes precedence and Inherited from Layout // Match - 5
+//
+// OFF ON INHERIT YES Media takes precedence // Match - 3
+// OFF OFF INHERIT NO Media takes precedence // Match - 4
+// OFF INHERIT INHERIT NO Media takes precedence and Inherited from Layout // Match - 6
+////
+
+ public function testLayoutOff()
+ {
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 0
+ $this->assertContains('', $xmlString );
+
+ // Layout Off, Media On, Widget On, Output => [0, 'On', 'On', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+ public function testLayoutOn()
+ {
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+
+ // Layout enable stat 1
+ $this->assertContains('', $xmlString );
+
+ // Layout On, Media On, Widget On, Output => [1, 'On', 'On', 1],
+ $this->assertContains('', $xmlString );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutProofOfPlayXMLWithoutMediaTest.php b/tests/integration/Cache/LayoutProofOfPlayXMLWithoutMediaTest.php
new file mode 100644
index 0000000..619f084
--- /dev/null
+++ b/tests/integration/Cache/LayoutProofOfPlayXMLWithoutMediaTest.php
@@ -0,0 +1,280 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutProofOfPlayXMLWithoutMediaTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class LayoutProofOfPlayXMLWithoutMediaTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layoutOff;
+
+ /** @var XiboLayout */
+ protected $layoutOn;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ protected $media;
+
+ protected $widgetId2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout with enableStat Off (by default)
+ $this->layoutOff = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layoutOff will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOff->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a layout with enableStat On
+ $this->layoutOn = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit description',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+
+ // Create a Display2
+ $this->display2 = $this->createDisplay();
+
+ // Schedule the LayoutOn "always" onto our display
+ // deleting the layoutOn will remove this at the end
+ $event2 = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->addSeconds(3600)->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layoutOn->campaignId,
+ [$this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display2, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display2);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the LayoutOff
+ $this->deleteLayout($this->layoutOff);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the LayoutOn
+ $this->deleteLayout($this->layoutOn);
+
+ // Delete the Display2
+ $this->deleteDisplay($this->display2);
+
+ parent::tearDown();
+ }
+ //
+
+// Logic Table
+//
+// Widget Without Media
+// LAYOUT WIDGET Widget stats collected?
+// ON ON YES Widget takes precedence // Match - 1
+// ON OFF NO Widget takes precedence // Match - 2
+// ON INHERIT YES Inherited from Layout // Match - 7
+// OFF ON YES Widget takes precedence // Match - 1
+// OFF OFF NO Widget takes precedence // Match - 2
+// OFF INHERIT NO Inherited from Layout // Match - 8
+
+
+ /**
+ * Each array is a test run
+ * Format (enableStat)
+ * @return array
+ */
+ public function layoutEnableStatOffCases()
+ {
+ return [
+ // Layout enableStat Off options - for layout and widget and their expected result (Widget stats collected?) in enableStat (media node attribute)
+ 'Layout Off Media On' => [0, 'On', 1],
+ 'Layout Off Media Off' => [0, 'Off', 0],
+ 'Layout Off Media Inherit' => [0, 'Inherit', 0]
+ ];
+ }
+
+ /**
+ * Each array is a test run
+ * Format (enableStat)
+ * @return array
+ */
+ public function layoutEnableStatOnCases()
+ {
+ return [
+ // Layout enableStat On options - for layout and widget and their expected result (Widget stats collected?) in enableStat (media node attribute)
+ 'Layout On Media On' => [1, 'On', 1],
+ 'Layout On Media Off' => [1, 'Off', 0],
+ 'Layout On Media Inherit' => [1, 'Inherit', 1]
+ ];
+ }
+
+ /**
+ * Edit
+ * @dataProvider layoutEnableStatOffCases
+ */
+ public function testLayoutOff($layoutEnableStat, $widgetEnableStat, $outputEnableStat)
+ {
+ // Checkout
+ $layoutOff = $this->getDraft($this->layoutOff);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layoutOff->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'] , [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1,
+ 'enableStat' => $widgetEnableStat
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOff->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOff = $this->constructLayoutFromResponse($response['data']);
+ $this->getLogger()->debug($this->layoutOff->enableStat);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display->license);
+ $this->assertContains('file="' . $this->layoutOff->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display->license, $this->layoutOff->layoutId, 'layout', 0, 0);
+ $this->assertContains('', $xmlString );
+ $this->assertContains('', $xmlString );
+ }
+
+ /**
+ * Edit
+ * @dataProvider layoutEnableStatOnCases
+ */
+ public function testLayoutOn($layoutEnableStat, $widgetEnableStat, $outputEnableStat)
+ {
+ // Checkout
+ $layoutOn = $this->getDraft($this->layoutOn);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layoutOn->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'] , [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1,
+ 'enableStat' => $widgetEnableStat
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Publish layout
+ $response = $this->sendRequest('PUT','/layout/publish/' . $this->layoutOn->layoutId, [
+ 'publishNow' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $response = json_decode($response->getBody(), true);
+
+ $this->layoutOn = $this->constructLayoutFromResponse($response['data']);
+ $this->getLogger()->debug($this->layoutOn->enableStat);
+
+ // Confirm our Layout is in the Schedule
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+ $this->assertContains('file="' . $this->layoutOn->layoutId . '"', $schedule, 'Layout not scheduled');
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($this->display2->license);
+
+ // Get XML string for player
+ $xmlString = $this->getXmdsWrapper()->GetFile($this->display2->license, $this->layoutOn->layoutId, 'layout', 0, 0);
+ $this->assertContains('', $xmlString );
+ $this->assertContains('', $xmlString );
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LayoutRetireTest.php b/tests/integration/Cache/LayoutRetireTest.php
new file mode 100644
index 0000000..7a858ce
--- /dev/null
+++ b/tests/integration/Cache/LayoutRetireTest.php
@@ -0,0 +1,109 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+class LayoutRetireTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Layout Retire Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout(1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Retire the Layout we've got created for us.
+ $this->sendRequest('PUT','/layout/retire/' . $this->layout->layoutId, [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ // Validate the layout status
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/LibraryReviseTest.php b/tests/integration/Cache/LibraryReviseTest.php
new file mode 100644
index 0000000..02b5710
--- /dev/null
+++ b/tests/integration/Cache/LibraryReviseTest.php
@@ -0,0 +1,146 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LibraryReviseTest
+ *
+ * Tests whether a Layout Edit updates the Cache Appropriately
+ *
+ * @package integration\Cache
+ */
+class LibraryReviseTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Layout Edit Test');
+
+ // Upload some media
+ $this->media = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-flowers-001.jpg');
+
+ // Create a Layout
+ $this->layout = $this->createLayout(1);
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add it to the Layout
+ (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Publish
+ $this->layout = $this->publish($this->layout);
+
+ // Set the Layout status (force it)
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+
+ $this->getLogger()->debug('Finished setup');
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure we're in good condition to start
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout status is not as expected');
+
+ // Replace the Media
+ $this->media = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-flowers-002.jpg', $this->media->mediaId, 1, 1);
+
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 3), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/PlaylistReorderTest.php b/tests/integration/Cache/PlaylistReorderTest.php
new file mode 100644
index 0000000..25eba1e
--- /dev/null
+++ b/tests/integration/Cache/PlaylistReorderTest.php
@@ -0,0 +1,157 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class PlaylistReorderTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class PlaylistReorderTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ protected $widget1;
+ protected $widget2;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Region Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a couple of text widgets to the region
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget1 = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget B',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget2 = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Publish
+ $this->layout = $this->publish($this->layout);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Checkout
+ $layout = $this->checkout($this->layout);
+
+ // Edit region
+ $this->sendRequest('POST','/playlist/order/' . $layout->regions[0]->regionPlaylist->playlistId, [
+ 'widgets' => [
+ $this->widget1->widgetId => 2,
+ $this->widget2->widgetId => 1
+ ]
+ ]);
+
+ // This shouldn't effect the display
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Publish
+ $this->layout = $this->publish($this->layout);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/RegionDeleteTest.php b/tests/integration/Cache/RegionDeleteTest.php
new file mode 100644
index 0000000..eee774d
--- /dev/null
+++ b/tests/integration/Cache/RegionDeleteTest.php
@@ -0,0 +1,141 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class RegionDeleteTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class RegionDeleteTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Region Delete Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a widget to the existing region
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Add a region to the Layout
+ $this->region = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,300,75,125);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Edit region
+ $this->sendRequest('DELETE','/region/' . $this->region->regionId);
+
+ // This shouldn't effect the display
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Checkin
+ $this->layout = $this->publish($this->layout);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/RegionEditTest.php b/tests/integration/Cache/RegionEditTest.php
new file mode 100644
index 0000000..60209ed
--- /dev/null
+++ b/tests/integration/Cache/RegionEditTest.php
@@ -0,0 +1,144 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class RegionEditTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class RegionEditTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboRegion */
+ protected $region;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Region Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ // Add a widget to the existing region
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Edit region
+ $this->sendRequest('PUT','/region/' . $this->layout->regions[0]->regionId, [
+ 'width' => 700,
+ 'height' => 500,
+ 'top' => 400,
+ 'left' => 400,
+ 'loop' => 0,
+ 'zIndex' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Checkin
+ $this->layout = $this->publish($this->layout);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/ScheduleChangeInsideRfTest.php b/tests/integration/Cache/ScheduleChangeInsideRfTest.php
new file mode 100644
index 0000000..8e7f45d
--- /dev/null
+++ b/tests/integration/Cache/ScheduleChangeInsideRfTest.php
@@ -0,0 +1,159 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ScheduleChangeInsideRfTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class ScheduleChangeInsideRfTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboSchedule */
+ protected $event;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Layout is already status 1
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Change the Schedule
+ $response = $this->sendRequest('PUT','/schedule/' . $this->event->eventId, [
+ 'fromDt' => Carbon::createFromTimestamp($this->event->fromDt)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::createFromTimestamp($this->event->toDt)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->event->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/ScheduleChangeOutsideRfTest.php b/tests/integration/Cache/ScheduleChangeOutsideRfTest.php
new file mode 100644
index 0000000..0dbbe0c
--- /dev/null
+++ b/tests/integration/Cache/ScheduleChangeOutsideRfTest.php
@@ -0,0 +1,161 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ScheduleChangeOutsideRfTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class ScheduleChangeOutsideRfTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboTicker */
+ protected $widget;
+
+ /** @var XiboSchedule */
+ protected $event;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Dates outside of RF
+ $date = Carbon::now()->addMonth();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ $date->format(DateFormatHelper::getSystemFormat()),
+ $date->addHour()->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Layout is already status 1
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Change the Schedule
+ $this->sendRequest('PUT','/schedule/' . $this->event->eventId, [
+ 'fromDt' => date(DateFormatHelper::getSystemFormat(), $this->event->fromDt),
+ 'toDt' => date(DateFormatHelper::getSystemFormat(), $this->event->toDt),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->event->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/WidgetDeleteTest.php b/tests/integration/Cache/WidgetDeleteTest.php
new file mode 100644
index 0000000..572fc60
--- /dev/null
+++ b/tests/integration/Cache/WidgetDeleteTest.php
@@ -0,0 +1,145 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class WidgetDeleteTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class WidgetDeleteTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ protected $widget;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Region Delete Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget B',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Edit region
+ $response = $this->sendRequest('DELETE','/playlist/widget/' . $this->widget->widgetId);
+
+ $this->assertEquals(200, $response->getStatusCode(), 'Transaction Status Incorrect');
+
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Publish
+ $this->layout = $this->publish($this->layout);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Cache/WidgetEditTest.php b/tests/integration/Cache/WidgetEditTest.php
new file mode 100644
index 0000000..7c2ec1d
--- /dev/null
+++ b/tests/integration/Cache/WidgetEditTest.php
@@ -0,0 +1,142 @@
+.
+ */
+
+
+namespace Xibo\Tests\integration\Cache;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class WidgetEditTest
+ * @package Xibo\Tests\integration\Cache
+ */
+class WidgetEditTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ protected $widget;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache Region Edit Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId, [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Layout "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Edit region
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widget->widgetId, [
+ 'text' => 'Edited Text',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertEquals(200, $response->getStatusCode(), 'Transaction Status Incorrect');
+
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Somehow test that we have issued an XMR request
+ $this->assertFalse(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/CampaignLayoutManagementTest.php b/tests/integration/CampaignLayoutManagementTest.php
new file mode 100644
index 0000000..b70c887
--- /dev/null
+++ b/tests/integration/CampaignLayoutManagementTest.php
@@ -0,0 +1,142 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class CampaignLayoutManagementTest
+ * @package Xibo\Tests\integration
+ */
+class CampaignLayoutManagementTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboCampaign */
+ protected $campaign;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Campaign and Layout
+ $this->campaign = (new XiboCampaign($this->getEntityProvider()))->create(Random::generateString());
+ $this->layout = $this->createLayout();
+ $this->layout = $this->publish($this->layout);
+ }
+
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Campaign
+ $this->campaign->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Assign Layout
+ */
+ public function testAssignOneLayout()
+ {
+ // Assign one layout
+ $response = $this->sendRequest('POST', '/campaign/layout/assign/' . $this->campaign->campaignId, [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Request failed: ' . $response->getBody()->getContents());
+
+ // Get this campaign and check it has 1 layout assigned
+ $campaignCheck = (new XiboCampaign($this->getEntityProvider()))->getById($this->campaign->campaignId);
+ $this->assertSame($this->campaign->campaignId, $campaignCheck->campaignId, $response->getBody());
+ $this->assertSame(1, $campaignCheck->numberLayouts, $response->getBody());
+ }
+
+ /**
+ * Assign Layout
+ */
+ public function testAssignTwoLayouts()
+ {
+ $response = $this->sendRequest('PUT', '/campaign/' . $this->campaign->campaignId, [
+ 'name' => $this->campaign->campaign,
+ 'manageLayouts' => 1,
+ 'layoutIds' => [$this->layout->layoutId, $this->layout->layoutId]
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Request failed');
+
+ // Get this campaign and check it has 2 layouts assigned
+ $campaignCheck = (new XiboCampaign($this->getEntityProvider()))->getById($this->campaign->campaignId);
+ $this->assertSame($this->campaign->campaignId, $campaignCheck->campaignId, $response->getBody());
+ $this->assertSame(2, $campaignCheck->numberLayouts, $response->getBody());
+ }
+
+ /**
+ * Unassign Layout
+ */
+ public function testUnassignLayout()
+ {
+ $this->getEntityProvider()->post('/campaign/layout/assign/' . $this->campaign->campaignId, [
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ $response = $this->sendRequest('PUT', '/campaign/' . $this->campaign->campaignId, [
+ 'name' => $this->campaign->campaign,
+ 'manageLayouts' => 1,
+ 'layoutIds' => []
+ ]);
+
+ $campaignCheck = (new XiboCampaign($this->getEntityProvider()))->getById($this->campaign->campaignId);
+ $this->assertSame($this->campaign->campaignId, $campaignCheck->campaignId, $response->getBody());
+ $this->assertSame(0, $campaignCheck->numberLayouts, $response->getBody());
+ }
+
+ /**
+ * Assign Layout to layout specific campaignId - expect failure
+ * @throws \Exception
+ */
+ public function testAssignLayoutFailure()
+ {
+ // Call assign on the layout specific campaignId
+ $request = $this->createRequest('POST', '/campaign/layout/assign/' . $this->layout->campaignId);
+ $request = $request->withParsedBody([
+ 'layoutId' => $this->layout->layoutId
+ ]);
+
+ try {
+ $this->app->handle($request);
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame(422, $exception->getCode(), 'Expecting failure, received ' . $exception->getMessage());
+ }
+ }
+}
diff --git a/tests/integration/CampaignTest.php b/tests/integration/CampaignTest.php
new file mode 100644
index 0000000..66c2b34
--- /dev/null
+++ b/tests/integration/CampaignTest.php
@@ -0,0 +1,216 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboResolution;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class CampaignTest
+ * @package Xibo\Tests
+ */
+class CampaignTest extends LocalWebTestCase
+{
+
+ protected $startCampaigns;
+ protected $startLayouts;
+
+ /**
+ * setUp - called before every test automatically
+ */
+
+ public function setup()
+ {
+ parent::setup();
+ $this->startCampaigns = (new XiboCampaign($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all campaigns that weren't there initially
+ $finalCamapigns = (new XiboCampaign($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining campaigns and nuke them
+ foreach ($finalCamapigns as $campaign) {
+ /** @var XiboCampaign $campaign */
+ $flag = true;
+ foreach ($this->startCampaigns as $startCampaign) {
+ if ($startCampaign->campaignId == $campaign->campaignId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $campaign->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $campaign->campaignId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ // tearDown all layouts that weren't there initially
+ $finalLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining layouts and nuke them
+ foreach ($finalLayouts as $layout) {
+ /** @var XiboLayout $layout */
+ $flag = true;
+ foreach ($this->startLayouts as $startLayout) {
+ if ($startLayout->layoutId == $layout->layoutId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $layout->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $layout->layoutId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @param $type
+ * @return int
+ */
+ private function getResolutionId($type)
+ {
+ if ($type === 'landscape') {
+ $width = 1920;
+ $height = 1080;
+ } else if ($type === 'portrait') {
+ $width = 1080;
+ $height = 1920;
+ } else {
+ return -10;
+ }
+
+ //$this->getLogger()->debug('Querying for ' . $width . ', ' . $height);
+
+ $resolutions = (new XiboResolution($this->getEntityProvider()))->get(['width' => $width, 'height' => $height]);
+
+ if (count($resolutions) <= 0)
+ return -10;
+
+ return $resolutions[0]->resolutionId;
+ }
+
+ /**
+ * Show Campaigns
+ */
+ public function testListAll()
+ {
+ # Get list of all campaigns
+ $response = $this->sendRequest('GET', '/campaign');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $object = json_decode($response->getBody());
+
+ # Check if call was successful
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertNotEmpty($object->data);
+ }
+
+ /**
+ * Add Campaign
+ */
+ public function testAdd()
+ {
+ # Generate random name
+ $name = Random::generateString(8, 'phpunit');
+ # Add campaign
+ $response = $this->sendRequest('POST', '/campaign', ['name' => $name]);
+
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Check if campaign has he name we want it to have
+ $this->assertSame($name, $object->data->campaign);
+ }
+
+ /**
+ * Test edit
+ */
+ public function testEdit()
+ {
+ # Generate name and add campaign
+ $name = Random::generateString(8, 'phpunit');
+ $campaign = (new XiboCampaign($this->getEntityProvider()))->create($name);
+ # Generate new random name
+ $newName = Random::generateString(8, 'phpunit');
+ # Edit the campaign we added and change the name
+ $response = $this->sendRequest('PUT', '/campaign/' . $campaign->campaignId, ['name' => $newName]);
+
+ # check if cal was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ # Check if campaign has the new name now
+ $this->assertSame($newName, $object->data->campaign);
+ }
+
+ /**
+ * Test Delete
+ */
+ public function testDelete()
+ {
+ # generate two random names
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known campaigns
+ $camp1 = (new XiboCampaign($this->getEntityProvider()))->create($name1);
+ $camp2 = (new XiboCampaign($this->getEntityProvider()))->create($name2);
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE', '/campaign/' . $camp2->campaignId);
+ # This should return 204 for success
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+
+ # Check only one remains
+ $campaigns = (new XiboCampaign($this->getEntityProvider()))->get();
+ $this->assertEquals(count($this->startCampaigns) + 1, count($campaigns));
+ $flag = false;
+ foreach ($campaigns as $campaign) {
+ if ($campaign->campaignId == $camp1->campaignId) {
+ $flag = true;
+ }
+ }
+ # Check if everything is in order
+ $this->assertTrue($flag, 'Campaign ID ' . $camp1->campaignId . ' was not found after deleting a different campaign');
+ # Cleanup
+ $camp1->delete();
+ }
+}
diff --git a/tests/integration/ClockTest.php b/tests/integration/ClockTest.php
new file mode 100644
index 0000000..e13e9a9
--- /dev/null
+++ b/tests/integration/ClockTest.php
@@ -0,0 +1,38 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+
+class ClockTest extends \Xibo\Tests\LocalWebTestCase
+{
+ public function testView()
+ {
+ $response = $this->sendRequest('GET','/clock');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+ $response = json_decode($response->getBody());
+ $this->assertNotEmpty($response->data);
+
+ }
+}
diff --git a/tests/integration/CommandTest.php b/tests/integration/CommandTest.php
new file mode 100644
index 0000000..f4a67b5
--- /dev/null
+++ b/tests/integration/CommandTest.php
@@ -0,0 +1,276 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCommand;
+use Xibo\Tests\LocalWebTestCase;
+
+
+class CommandTest extends LocalWebTestCase
+{
+ protected $startCommands;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startCommands = (new XiboCommand($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all commands that weren't there initially
+ $finalCommands = (new XiboCommand($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining commands and nuke them
+ foreach ($finalCommands as $command) {
+ /** @var XiboCommand $command */
+ $flag = true;
+ foreach ($this->startCommands as $startCom) {
+ if ($startCom->commandId == $command->commandId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $command->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $command->commandId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * Shows this user commands
+ */
+ public function testListAll()
+ {
+ # Get the list of all commands
+ $response = $this->sendRequest('GET','/command');
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * testAddSuccess - test adding various commands that should be valid
+ * @dataProvider provideSuccessCases
+ * @group minimal
+ */
+ public function testAddSuccess($commandName, $commandDescription, $commandCode)
+ {
+
+ // Loop through any pre-existing commands to make sure we're not
+ // going to get a clash
+ foreach ($this->startCommands as $tmpCom) {
+ if ($tmpCom->command == $commandName) {
+ $this->skipTest("There is a pre-existing command with this name");
+ return;
+ }
+ }
+ # Add new comands with arguments from provideSuccessCases
+ $response = $this->sendRequest('POST','/command', [
+ 'command' => $commandName,
+ 'description' => $commandDescription,
+ 'code' => $commandCode
+ ]);
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Check if commands were added successfully and have correct parameters
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($commandName, $object->data->command);
+ $this->assertSame($commandDescription, $object->data->description);
+ $this->assertSame($commandCode, $object->data->code);
+
+ # Check again that the command was added correctly
+ $command = (new XiboCommand($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($commandName, $command->command);
+ $this->assertSame($commandDescription, $command->description);
+ $this->assertSame($commandCode, $command->code);
+ # Clean up the commands as we no longer need it
+ $this->assertTrue($command->delete(), 'Unable to delete ' . $command->commandId);
+ }
+
+ /**
+ * Each array is a test run
+ * Format (command name, description, code)
+ * @return array
+ */
+
+ public function provideSuccessCases()
+ {
+ # Cases we provide to testAddSuccess, you can extend it by simply adding new case here
+ return [
+ 'reboot' => ['test command', 'test description', 'reboot'],
+ 'binary' => ['test command 2', '|01100100|01100001|01101110|00001101', 'binary'],
+ 'sleep' => ['test command 3', 'test description', 'sleep'],
+ ];
+ }
+
+
+ /**
+ * testAddFailure - test adding various commands that should be invalid
+ * @dataProvider provideFailureCases
+ */
+ public function testAddFailure($commandName, $commandDescription, $commandCode)
+ {
+ # Add new commands with arguments from provideFailureCases
+ $response = $this->sendRequest('POST','/command', [
+ 'command' => $commandName,
+ 'description' => $commandDescription,
+ 'code' => $commandCode
+ ]);
+ # Check if commands are failing as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Each array is a test run
+ * Format (command name, description, code)
+ * @return array
+ */
+
+ public function provideFailureCases()
+ {
+ # Cases we provide to testAddFailure, you can extend it by simply adding new case here
+ return [
+ 'No code' => ['No code', 'aa', NULL],
+ 'Code with space' => ['Code with space', 'Code with space', 'Code with space'],
+ 'Code with symbol' => ['Code with symbol', 'Code with symbol', 'Codewithsymbol$$'],
+ 'No description' => ['no description', NULL, 'code'],
+ 'No Name' => [NULL, 'Bienvenue à la suite de tests Xibo', 'beep'],
+ 'Only Name' => ['Deutsch Prüfung 1', NULL, NULL],
+ 'Empty' => [NULL, NULL, NULL]
+ ];
+ }
+
+ /**
+ * List all commands known set
+ * @group minimal
+ * @depends testAddSuccess
+ */
+ public function testListKnown()
+ {
+ $cases = $this->provideSuccessCases();
+ $commands = [];
+ // Check each possible case to ensure it's not pre-existing
+ // If it is, skip over it
+ foreach ($cases as $case) {
+ $flag = true;
+ foreach ($this->startCommands as $tmpCom) {
+ if ($case[0] == $tmpCom->command) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ $commands[] = (new XiboCommand($this->getEntityProvider()))->create($case[0],$case[1],$case[2]);
+ }
+ }
+
+ $response = $this->sendRequest('GET','/command');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # There should be as many commands as we created plus the number we started with in the system
+ $this->assertEquals(count($commands) + count($this->startCommands), $object->data->recordsTotal);
+ # Clean up the groups we created
+ foreach ($commands as $com) {
+ $com->delete();
+ }
+ }
+
+ /**
+ * Edit an existing command
+ */
+ public function testEdit()
+ {
+ # Load in a known command
+ /** @var XiboCommand $command */
+ $command = (new XiboCommand($this->getEntityProvider()))->create('phpunit command', 'phpunit description', 'phpunitcode');
+ # Generate new name and description
+ $name = Random::generateString(8, 'command');
+ $description = Random::generateString(8, 'description');
+ # Change name and description of earlier created command
+ $response = $this->sendRequest('PUT','/command/' . $command->commandId, [
+ 'command' => $name,
+ 'description' => $description,
+ 'code' => $command->code
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->command);
+ $this->assertSame($description, $object->data->description);
+ # Check that the command name and description were actually renamed
+ $command = (new XiboCommand($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $command->command);
+ $this->assertSame($description, $command->description);
+ # Clean up the Layout as we no longer need it
+ $command->delete();
+ }
+
+ /**
+ * Test delete
+ * @group minimal
+ */
+ public function testDelete()
+ {
+ # Generate random names
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known commands
+ $command1 = (new XiboCommand($this->getEntityProvider()))->create($name1, 'phpunit description', 'code');
+ $command2 = (new XiboCommand($this->getEntityProvider()))->create($name2, 'phpunit description', 'codetwo');
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE','/command/' . $command2->commandId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Check only one remains
+ $commands = (new XiboCommand($this->getEntityProvider()))->get();
+ $this->assertEquals(count($this->startCommands) + 1, count($commands));
+ $flag = false;
+ foreach ($commands as $command) {
+ if ($command->commandId == $command1->commandId) {
+ $flag = true;
+ }
+ }
+ $this->assertTrue($flag, 'Command ID ' . $command1->commandId . ' was not found after deleting a different command');
+ # Clean up the first command as we no longer need it
+ $command1->delete();
+ }
+}
diff --git a/tests/integration/DataSetRemoteTest.php b/tests/integration/DataSetRemoteTest.php
new file mode 100644
index 0000000..3187a87
--- /dev/null
+++ b/tests/integration/DataSetRemoteTest.php
@@ -0,0 +1,161 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDataSet;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Test remote datasets
+ */
+class DataSetRemoteTest extends LocalWebTestCase
+{
+ /** @var \Xibo\OAuth2\Client\Entity\XiboDataSet */
+ private $dataSet;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // copy json file to /web folder
+ shell_exec('cp -r ' . PROJECT_ROOT . '/tests/resources/RemoteDataSet.json ' . PROJECT_ROOT . '/web');
+
+ $this->dataSet = (new XiboDataSet($this->getEntityProvider()))
+ ->create(
+ Random::generateString(8, 'phpunit'),
+ '',
+ 'remote',
+ 1,
+ 'GET',
+ 'http://localhost/RemoteDataSet.json',
+ '',
+ '',
+ '',
+ '',
+ 1,
+ 0,
+ null,
+ 'data'
+ );
+
+ // Add columns
+ $this->dataSet->createColumn(
+ 'title',
+ null,
+ 1,
+ 1,
+ 3,
+ null,
+ 'title'
+ );
+ $this->dataSet->createColumn(
+ 'identifier',
+ null,
+ 2,
+ 2,
+ 3,
+ null,
+ 'id'
+ );
+ $this->dataSet->createColumn(
+ 'date',
+ null,
+ 3,
+ 3,
+ 3,
+ null,
+ 'Date'
+ );
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the dataset
+ $this->dataSet->deleteWData();
+
+ // remove json file from /web folder
+ shell_exec('rm -r ' . PROJECT_ROOT . '/web/RemoteDataSet.json');
+
+ parent::tearDown();
+ }
+
+ public function testRemoteDataSetData()
+ {
+ // call the remote dataSet test
+ $response = $this->sendRequest('POST', '/dataset/remote/test', [
+ 'testDataSetId' => $this->dataSet->dataSetId,
+ 'dataSet' => $this->dataSet->dataSet,
+ 'code' => 'remote',
+ 'isRemote' => 1,
+ 'method' => 'GET',
+ 'uri' => 'http://localhost/RemoteDataSet.json',
+ 'dataRoot' => 'data',
+ 'refreshRate' => 0,
+ 'clearRate' => 1,
+ 'sourceId' => 1,
+ 'limitPolicy' => 'stop'
+ ]);
+
+ // HTTP response code
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ // Expect a JSON body
+ $object = json_decode($response->getBody());
+
+ // Data and ID parameters
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ // Make sure we have the same dataset back
+ $this->assertSame($object->id, $this->dataSet->dataSetId);
+
+ // Make sure we parsed out some entries.
+ $this->assertNotEmpty($object->data->entries);
+ $this->assertNotEmpty($object->data->processed);
+
+ // The entries should match our sample file.
+ $this->assertSame(3, $object->data->number);
+
+ // First record
+ $this->assertSame(1, $object->data->processed[0][0]->identifier);
+ $this->assertSame('Title 1', $object->data->processed[0][0]->title);
+ $this->assertSame('2019-07-29 13:11:00', $object->data->processed[0][0]->date);
+
+ // Second record
+ $this->assertSame(2, $object->data->processed[0][1]->identifier);
+ $this->assertFalse(property_exists($object->data->processed[0][1], 'title'));
+ $this->assertSame('2019-07-30 03:04:00', $object->data->processed[0][1]->date);
+
+ // Third record
+ $this->assertSame(3, $object->data->processed[0][2]->identifier);
+ $this->assertSame('1', $object->data->processed[0][2]->title);
+ $this->assertFalse(property_exists($object->data->processed[0][2], 'date'));
+ }
+}
diff --git a/tests/integration/DataSetTest.php b/tests/integration/DataSetTest.php
new file mode 100644
index 0000000..f1e67bf
--- /dev/null
+++ b/tests/integration/DataSetTest.php
@@ -0,0 +1,546 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDataSet;
+use Xibo\OAuth2\Client\Entity\XiboDataSetColumn;
+use Xibo\OAuth2\Client\Entity\XiboDataSetRow;
+use Xibo\Tests\LocalWebTestCase;
+
+class DataSetTest extends LocalWebTestCase
+{
+ protected $startDataSets;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startDataSets = (new XiboDataSet($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all datasets that weren't there initially
+ $finalDataSets = (new XiboDataSet($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ $difference = array_udiff($finalDataSets, $this->startDataSets, function ($a, $b) {
+ /** @var XiboDataSet $a */
+ /** @var XiboDataSet $b */
+ return $a->dataSetId - $b->dataSetId;
+ });
+
+ # Loop over any remaining datasets and nuke them
+ foreach ($difference as $dataSet) {
+ /** @var XiboDataSet $dataSet */
+ try {
+ $dataSet->deleteWData();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $dataSet->dataSetId . '. E: ' . $e->getMessage() . PHP_EOL);
+ }
+ }
+ parent::tearDown();
+ }
+
+ /*
+ * List all datasets
+ */
+ public function testListAll()
+ {
+ $response = $this->sendRequest('GET','/dataset');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * @group add
+ */
+ public function testAdd()
+ {
+ # Generate random name
+ $name = Random::generateString(8, 'phpunit');
+ # Add dataset
+ $response = $this->sendRequest('POST','/dataset', [
+ 'dataSet' => $name,
+ 'description' => 'PHP Unit Test'
+ ]);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Check if dataset has the correct name
+ $this->assertSame($name, $object->data->dataSet);
+ }
+
+
+ /**
+ * Test edit
+ * @depends testAdd
+ */
+ public function testEdit()
+ {
+ # Create a new dataset
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create('phpunit dataset', 'phpunit description');
+ # Generate new name and description
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'New description';
+ # Edit the name and description
+ $response = $this->sendRequest('PUT','/dataset/' . $dataSet->dataSetId, [
+ 'dataSet' => $name,
+ 'description' => $description
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ # Check if name and description were correctly changed
+ $this->assertSame($name, $object->data->dataSet);
+ $this->assertSame($description, $object->data->description);
+ # Deeper check by querying for dataset again
+ $dataSetCheck = (new XiboDataSet($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $dataSetCheck->dataSet);
+ $this->assertSame($description, $dataSetCheck->description);
+ # Clean up the dataset as we no longer need it
+ $dataSet->delete();
+ }
+
+ /**
+ * @depends testEdit
+ */
+ public function testDelete()
+ {
+ # Generate new random names
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known dataSets
+ $data1 = (new XiboDataSet($this->getEntityProvider()))->create($name1, 'phpunit description');
+ $data2 = (new XiboDataSet($this->getEntityProvider()))->create($name2, 'phpunit description');
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE','/dataset/' . $data2->dataSetId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Check only one remains
+ $dataSets = (new XiboDataSet($this->getEntityProvider()))->get();
+ $this->assertEquals(count($this->startDataSets) + 1, count($dataSets));
+ $flag = false;
+ foreach ($dataSets as $dataSet) {
+ if ($dataSet->dataSetId == $data1->dataSetId) {
+ $flag = true;
+ }
+ }
+ $this->assertTrue($flag, 'dataSet ID ' . $data1->dataSetId . ' was not found after deleting a different dataset');
+ }
+
+ # TO DO /dataset/import/
+
+ /**
+ * @dataProvider provideSuccessCases
+ */
+ public function testAddColumnSuccess($columnName, $columnListContent, $columnOrd, $columnDataTypeId, $columnDataSetColumnTypeId, $columnFormula)
+ {
+ # Create radom name and description
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit column add';
+ # Create new dataset
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Create new columns with arguments from provideSuccessCases
+ $response = $this->sendRequest('POST','/dataset/' . $dataSet->dataSetId . '/column', [
+ 'heading' => $columnName,
+ 'listContent' => $columnListContent,
+ 'columnOrder' => $columnOrd,
+ 'dataTypeId' => $columnDataTypeId,
+ 'dataSetColumnTypeId' => $columnDataSetColumnTypeId,
+ 'formula' => $columnFormula
+ ]);
+ # Check that call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Check that columns have correct parameters
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($columnName, $object->data->heading);
+ $this->assertSame($columnListContent, $object->data->listContent);
+ $this->assertSame($columnOrd, $object->data->columnOrder);
+ $this->assertSame($columnDataTypeId, $object->data->dataTypeId);
+ $this->assertSame($columnDataSetColumnTypeId, $object->data->dataSetColumnTypeId);
+ $this->assertSame($columnFormula, $object->data->formula);
+ # Check that column was correctly added
+ $column = (new XiboDataSetColumn($this->getEntityProvider()))->getById($dataSet->dataSetId, $object->id);
+ $this->assertSame($columnName, $column->heading);
+ # Clean up the dataset as we no longer need it
+ $this->assertTrue($dataSet->delete(), 'Unable to delete ' . $dataSet->dataSetId);
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($columnName, $columnListContent, $columnOrd, $columnDataTypeId, $columnDataSetColumnTypeId, $columnFormula)
+ * @return array
+ */
+
+ public function provideSuccessCases()
+ {
+ # Cases we provide to testAddColumnSucess, you can extend it by simply adding new case here
+ return [
+ # Value
+ 'Value String' => ['Test Column Value String', NULL, 2, 1, 1, NULL],
+ 'List Content' => ['Test Column list content', 'one,two,three', 2, 1, 1, NULL],
+ 'Value Number' => ['Test Column Value Number', NULL, 2, 2, 1, NULL],
+ 'Value Date' => ['Test Column Value Date', NULL, 2, 3, 1, NULL],
+ 'External Image' => ['Test Column Value External Image', NULL, 2, 4, 1, NULL],
+ 'Library Image' => ['Test Column Value Internal Image', NULL, 2, 5, 1, NULL],
+ # Formula
+ 'Formula' => ['Test Column Formula', NULL, 2, 5, 1, 'Where Name = Dan'],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFailureCases
+ */
+ public function testAddColumnFailure($columnName, $columnListContent, $columnOrd, $columnDataTypeId, $columnDataSetColumnTypeId, $columnFormula)
+ {
+ # Create random name and description
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit column add failure';
+ # Create new columns that we expect to fail with arguments from provideFailureCases
+ /** @var XiboDataSet $dataSet */
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ $response = $this->sendRequest('POST','/dataset/' . $dataSet->dataSetId . '/column', [
+ 'heading' => $columnName,
+ 'listContent' => $columnListContent,
+ 'columnOrder' => $columnOrd,
+ 'dataTypeId' => $columnDataTypeId,
+ 'dataSetColumnTypeId' => $columnDataSetColumnTypeId,
+ 'formula' => $columnFormula
+ ]);
+ # Check if cases are failing as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($columnName, $columnListContent, $columnOrd, $columnDataTypeId, $columnDataSetColumnTypeId, $columnFormula)
+ * @return array
+ */
+
+ public function provideFailureCases()
+ {
+ # Cases we provide to testAddColumnFailure, you can extend it by simply adding new case here
+ return [
+ // Value
+ 'Incorrect dataType' => ['incorrect data type', NULL, 2, 12, 1, NULL],
+ 'Incorrect columnType' => ['incorrect column type', NULL, 2, 19, 1, NULL],
+ 'Empty Name' => [NULL, NULL, 2, 3, 1, NULL],
+ 'Symbol Name' => ['a.b.c', NULL, 2, 3, 1, NULL],
+ 'Symbol Name 2' => ['$£"', NULL, 2, 3, 1, NULL]
+ ];
+ }
+
+ /**
+ * Search columns for DataSet
+ */
+ public function testListAllColumns()
+ {
+ # Create new dataSet
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit column list';
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Add a new column to our dataset
+ $nameCol = Random::generateString(8, 'phpunit');
+ $dataSet->createColumn($nameCol,'', 2, 1, 1, '');
+ # Search for columns
+ $response = $this->sendRequest('GET','/dataset/' . $dataSet->dataSetId . '/column');
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # Clean up as we no longer need it
+ $dataSet->delete();
+ }
+
+ /**
+ * Test edit column
+ */
+ public function testColumnEdit()
+ {
+ # Create dataSet
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit column edit';
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Add new column to our dataset
+ $nameCol = Random::generateString(8, 'phpunit');
+ $column = (new XiboDataSetColumn($this->getEntityProvider()))->create($dataSet->dataSetId, $nameCol,'', 2, 1, 1, '');
+ # Generate new random name
+ $nameNew = Random::generateString(8, 'phpunit');
+ # Edit our column and change the name
+ $response = $this->sendRequest('PUT','/dataset/' . $dataSet->dataSetId . '/column/' . $column->dataSetColumnId, [
+ 'heading' => $nameNew,
+ 'listContent' => '',
+ 'columnOrder' => $column->columnOrder,
+ 'dataTypeId' => $column->dataTypeId,
+ 'dataSetColumnTypeId' => $column->dataSetColumnTypeId,
+ 'formula' => ''
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Check if our column has updated name
+ $this->assertSame($nameNew, $object->data->heading);
+ # Clean up as we no longer need it
+ $dataSet->delete();
+ }
+
+ /**
+ * @param $dataSetId
+ * @depends testAddColumnSuccess
+ */
+ public function testDeleteColumn()
+ {
+ # Create dataSet
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit column delete';
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Add new column to our dataset
+ $nameCol = Random::generateString(8, 'phpunit');
+ $column = (new XiboDataSetColumn($this->getEntityProvider()))->create($dataSet->dataSetId, $nameCol,'', 2, 1, 1, '');
+ # delete column
+ $response = $this->sendRequest('DELETE','/dataset/' . $dataSet->dataSetId . '/column/' . $column->dataSetColumnId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ }
+
+ /*
+ * GET data
+ */
+
+ public function testGetData()
+ {
+ # Create dataSet
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit';
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Call get data
+ $response = $this->sendRequest('GET','/dataset/data/' . $dataSet->dataSetId);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # Clean up
+ $dataSet->delete();
+ }
+
+ /**
+ * Test add row
+ */
+ public function testRowAdd()
+ {
+ # Create a new dataset to use
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit row add';
+ /** @var XiboDataSet $dataSet */
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Create column and add it to our dataset
+ $nameCol = Random::generateString(8, 'phpunit');
+ $column = (new XiboDataSetColumn($this->getEntityProvider()))->create($dataSet->dataSetId, $nameCol,'', 2, 1, 1, '');
+ # Add new row to our dataset and column
+ $response = $this->sendRequest('POST','/dataset/data/' . $dataSet->dataSetId, [
+ 'dataSetColumnId_' . $column->dataSetColumnId => 'test',
+ ]);
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Get the row id
+ $row = $dataSet->getData();
+ $this->getLogger()->debug(json_encode($row));
+ # Check if data was correctly added to the row
+ $this->assertArrayHasKey($nameCol, $row[0]);
+ $this->assertSame($row[0][$nameCol], 'test');
+ # Clean up as we no longer need it, deleteWData will delete dataset even if it has data assigned to it
+ $dataSet->deleteWData();
+ }
+ /**
+ * Test edit row
+ * @dataProvider provideSuccessCasesRow
+ */
+ public function testRowEdit($data)
+ {
+ # Create a new dataset to use
+ /** @var XiboDataSet $dataSet */
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit row edit';
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Generate a new name for the new column
+ $nameCol = Random::generateString(8, 'phpunit');
+ # Create new column and add it to our dataset
+ $column = (new XiboDataSetColumn($this->getEntityProvider()))->create($dataSet->dataSetId, $nameCol,'', 2, 1, 1, '');
+ # Add new row with data to our dataset
+ $rowD = 'test';
+ $row = (new XiboDataSetRow($this->getEntityProvider()))->create($dataSet->dataSetId, $column->dataSetColumnId, $rowD);
+ # Edit row data
+ $response = $this->sendRequest('PUT','/dataset/data/' . $dataSet->dataSetId . '/' . $row['id'], [
+ 'dataSetColumnId_' . $column->dataSetColumnId => $data
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # get the row id
+ $rowCheck = $dataSet->getData();
+ # Check if data was correctly added to the row
+ $this->assertArrayHasKey($nameCol, $rowCheck[0]);
+ if ($data == Null){
+ $this->assertSame($rowCheck[0][$nameCol], $rowD);
+ }
+ else {
+ $this->assertSame($rowCheck[0][$nameCol], $data);
+ }
+
+ # Clean up as we no longer need it, deleteWData will delete dataset even if it has data assigned to it
+ $dataSet->deleteWData();
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($data)
+ * @return array
+ */
+
+ public function provideSuccessCasesRow()
+ {
+ # Cases we provide to testRowEdit, you can extend it by simply adding new case here
+ return [
+ # Value
+ 'String' => ['API EDITED ROW'],
+ 'Null' => [NULL],
+ 'number as string' => ['1212']
+ ];
+ }
+
+ /*
+ * delete row data
+ */
+ public function testRowDelete()
+ {
+ # Create a new dataset to use
+ /** @var XiboDataSet $dataSet */
+ $name = Random::generateString(8, 'phpunit');
+ $description = 'PHP Unit row delete';
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, $description);
+ # Generate a new name for the new column
+ $nameCol = Random::generateString(8, 'phpunit');
+ # Create new column and add it to our dataset
+ $column = (new XiboDataSetColumn($this->getEntityProvider()))->create($dataSet->dataSetId, $nameCol,'', 2, 1, 1, '');
+ # Add new row data
+ $row = (new XiboDataSetRow($this->getEntityProvider()))->create($dataSet->dataSetId, $column->dataSetColumnId, 'Row Data');
+ # Delete row
+ $response = $this->sendRequest('DELETE','/dataset/data/' . $dataSet->dataSetId . '/' . $row['id']);
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up as we no longer need it, deleteWData will delete dataset even if it has data assigned to it
+ $dataSet->deleteWData();
+ }
+
+ public function testAddRemoteDataSet()
+ {
+ $name = Random::generateString(8, 'phpunit');
+ # Add dataset
+ $response = $this->sendRequest('POST','/dataset', [
+ 'dataSet' => $name,
+ 'code' => 'remote',
+ 'isRemote' => 1,
+ 'method' => 'GET',
+ 'uri' => 'http://localhost/resources/RemoteDataSet.json',
+ 'dataRoot' => 'data',
+ 'refreshRate' => 0,
+ 'clearRate' => 1,
+ 'sourceId' => 1,
+ 'limitPolicy' => 'stop'
+ ]);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ # Check dataSet object
+ $this->assertSame($name, $object->data->dataSet);
+ $this->assertSame(1, $object->data->isRemote);
+ $this->assertSame('http://localhost/resources/RemoteDataSet.json', $object->data->uri);
+ $this->assertSame(1, $object->data->clearRate);
+ $this->assertSame(0, $object->data->refreshRate);
+ $this->assertSame(0, $object->data->lastClear);
+ $this->assertSame(1, $object->data->sourceId);
+ }
+
+ public function testEditRemoteDataSet()
+ {
+ $name = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+
+ // add DataSet with wrapper
+ $dataSet = (new XiboDataSet($this->getEntityProvider()))->create($name, '', 'remote', 1, 'GET', 'http://localhost/resources/RemoteDataSet.json', '', '', '', '', 1, 0, null, 'data');
+
+ // Edit DataSet
+ $response = $this->sendRequest('PUT','/dataset/' . $dataSet->dataSetId, [
+ 'dataSet' => $name2,
+ 'code' => 'remote',
+ 'isRemote' => 1,
+ 'method' => 'GET',
+ 'uri' => 'http://localhost/resources/RemoteDataSet.json',
+ 'dataRoot' => 'data',
+ 'clearRate' => 3600,
+ 'refreshRate' => 1,
+ 'sourceId' => 1,
+ 'limitPolicy' => 'stop'
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ # Check dataSet object
+ $this->assertSame($name2, $object->data->dataSet);
+ $this->assertSame(1, $object->data->isRemote);
+ $this->assertSame('http://localhost/resources/RemoteDataSet.json', $object->data->uri);
+ $this->assertSame(3600, $object->data->clearRate);
+ $this->assertSame(1, $object->data->refreshRate);
+ $this->assertSame(1, $object->data->sourceId);
+ }
+}
diff --git a/tests/integration/DaypartTest.php b/tests/integration/DaypartTest.php
new file mode 100644
index 0000000..989f6ea
--- /dev/null
+++ b/tests/integration/DaypartTest.php
@@ -0,0 +1,223 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDaypart;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DaypartTest
+ * @package Xibo\Tests\Integration
+ */
+
+class DaypartTest extends LocalWebTestCase
+{
+ /** @var XiboDaypart[] */
+ protected $startDayparts;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startDayparts = (new XiboDaypart($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ $this->getLogger()->debug('There are ' . count($this->startDayparts) . ' dayparts at the start of the test');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all dayparts that weren't there initially
+ $finalDayparts = (new XiboDaypart($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining dayparts and nuke them
+ foreach ($finalDayparts as $daypart) {
+ /** @var XiboDaypart $daypart */
+ $flag = true;
+ foreach ($this->startDayparts as $startDaypart) {
+ if ($startDaypart->dayPartId == $daypart->dayPartId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $daypart->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $daypart->dayPartId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * testAddSuccess - test adding various daypart that should be valid
+ * @dataProvider provideSuccessCases
+ */
+ public function testAddSuccess($name, $description, $startTime, $endTime, $exceptionDays, $exceptionStartTimes, $exceptionEndTimes)
+ {
+ # Create daypart with arguments from provideSuccessCases
+ $response = $this->sendRequest('POST','/daypart', [
+ 'name' => $name,
+ 'description' => $description,
+ 'startTime' => $startTime,
+ 'endTime' => $endTime,
+ 'exceptionDays' => $exceptionDays,
+ 'exceptionStartTimes' => $exceptionStartTimes,
+ 'exceptionEndTimes' => $exceptionEndTimes
+ ]);
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame($description, $object->data->description);
+ # Check that the daypart was really added
+ $dayparts = (new XiboDaypart($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->assertEquals(count($this->startDayparts) + 1, count($dayparts));
+ # Check that the daypart was added correctly
+ $daypart = (new XiboDaypart($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $daypart->name);
+ $this->assertSame($description, $daypart->description);
+ # Clean up the daypart as we no longer need it
+ $this->assertTrue($daypart->delete(), 'Unable to delete ' . $daypart->dayPartId);
+ }
+
+ /**
+ * testAddFailure - test adding various daypart that should be invalid
+ * @dataProvider provideFailureCases
+ */
+ public function testAddFailure($name, $description, $startTime, $endTime, $exceptionDays, $exceptionStartTimes, $exceptionEndTimes)
+ {
+ # Create daypart with arguments from provideFailureCases
+ $response = $this->sendRequest('POST','/daypart', [
+ 'name' => $name,
+ 'description' => $description,
+ 'startTime' => $startTime,
+ 'endTime' => $endTime,
+ 'exceptionDays' => $exceptionDays,
+ 'exceptionStartTimes' => $exceptionStartTimes,
+ 'exceptionEndTimes' => $exceptionEndTimes
+ ]);
+ # check if they fail as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $description, $startTime, $endTime, $exceptionDays, $exceptionStartTimes, $exceptionEndTimes)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ # Data for testAddSuccess, easily expandable - just add another set of data below
+ return [
+ 'No exceptions' => ['phpunit daypart', 'API', '02:00', '06:00', NULL, NULL, NULL],
+ 'Except Monday' => ['phpunit daypart exception', NULL, '02:00', '06:00', ['Monday'], ['00:01'], ['23:59']]
+ ];
+ }
+ /**
+ * Each array is a test run
+ * Format ($name, $description, $startTime, $endTime, $exceptionDays, $exceptionStartTimes, $exceptionEndTimes)
+ * @return array
+ */
+ public function provideFailureCases()
+ {
+ # Data for testAddfailure, easily expandable - just add another set of data below
+ // TODO we should probably validate description and day names in daypart Controller.
+ return [
+ 'Empty title' => [NULL, 'should be invalid', '07:00', '10:00', NULL, NULL, NULL],
+ //'Description over 254 characters' => ['Too long description', Random::generateString(258), '07:00', '10:00', NULL, NULL, NULL],
+ 'Wrong time data type' => ['Time as integer','should be incorrect', 21, 22, NULL, NULL, NULL],
+ //'Wrong day name' => ['phpunit daypart exception', NULL, '02:00', '06:00', ['Cabbage'], ['00:01'], ['23:59']]
+ ];
+ }
+
+ /**
+ * Edit an existing daypart
+ */
+ public function testEdit()
+ {
+ #Create new daypart
+ $daypart = (new XiboDaypart($this->getEntityProvider()))->create('phpunit daypart', 'API', '02:00', '06:00', NULL, NULL, NULL);
+ # Change the daypart name and description
+ $name = Random::generateString(8, 'phpunit');
+ $description = Random::generateString(8, 'description');
+ $response = $this->sendRequest('PUT','/daypart/' . $daypart->dayPartId, [
+ 'name' => $name,
+ 'description' => $description,
+ 'startTime' => '02:00',
+ 'endTime' => '06:00',
+ 'exceptionDays' => $daypart->exceptionDays,
+ 'exceptionStartTimes' => $daypart->exceptionStartTimes,
+ 'exceptionEndTimes' => $daypart->exceptionEndTimes
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame($description, $object->data->description);
+ # Check that the daypart was actually renamed
+ $daypart = (new XiboDaypart($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $daypart->name);
+ $this->assertSame($description, $daypart->description);
+ # Clean up the Daypart as we no longer need it
+ $daypart->delete();
+ }
+
+ /**
+ * Test delete
+ * @group minimal
+ */
+ public function testDelete()
+ {
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known Dayparts
+ $daypart1 = (new XiboDaypart($this->getEntityProvider()))->create($name1, 'API', '02:00', '06:00', NULL, NULL, NULL);
+ $daypart2 = (new XiboDaypart($this->getEntityProvider()))->create($name2, 'API', '12:00', '16:00', NULL, NULL, NULL);
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE','/daypart/' . $daypart2->dayPartId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Check only one remains
+ $dayparts = (new XiboDaypart($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->assertEquals(count($this->startDayparts) + 1, count($dayparts));
+ $flag = false;
+ foreach ($dayparts as $daypart) {
+ if ($daypart->dayPartId == $daypart1->dayPartId) {
+ $flag = true;
+ }
+ }
+ $this->assertTrue($flag, 'Daypart ID ' . $daypart1->dayPartId . ' was not found after deleting a different daypart');
+ $daypart1->delete();
+ }
+}
diff --git a/tests/integration/DisplayGroupCopyTest.php b/tests/integration/DisplayGroupCopyTest.php
new file mode 100644
index 0000000..8b11fe2
--- /dev/null
+++ b/tests/integration/DisplayGroupCopyTest.php
@@ -0,0 +1,138 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Tests copying a display group.
+ */
+class DisplayGroupCopyTest extends LocalWebTestCase
+{
+ use DisplayHelperTrait;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboDisplayGroup */
+ protected $displayGroup;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class($this) . ' Test');
+
+ // Create a couple of displays to use in the test
+ $this->display = $this->createDisplay();
+ $this->display2 = $this->createDisplay();
+
+ // Create a display group and assign both displays
+ $this->displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create(
+ 'phpunit_' . bin2hex(random_bytes(4)),
+ '',
+ 0,
+ null
+ );
+
+ // Assign our two displays
+ $this->displayGroup->assignDisplay($this->display->displayId);
+ $this->displayGroup->assignDisplay($this->display2->displayId);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ $this->deleteDisplay($this->display2);
+ $this->displayGroup->delete();
+ }
+ //
+
+ public function testCopyPlain()
+ {
+ $response = $this->sendRequest('POST', '/displaygroup/' . $this->displayGroup->displayGroupId . '/copy', [
+ 'displayGroup' => 'phpunit_' . bin2hex(random_bytes(4)),
+ 'description' => 'copied',
+ 'copyMembers' => 0,
+ 'copyAssignments' => 0,
+ 'copyTags' => 0,
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame('copied', $object->data->description);
+
+ // Check there aren't any displays assigned.
+ $results = $this->getStore()->select('SELECT COUNT(*) AS cnt FROM lkdisplaydg WHERE displayGroupId = :displayGroupId', [
+ 'displayGroupId' => $object->id
+ ]);
+
+ $this->assertEquals(0, intval($results[0]['cnt']));
+
+ (new XiboDisplayGroup($this->getEntityProvider()))->getById($object->id)->delete();
+ }
+
+ public function testCopyMembers()
+ {
+ $response = $this->sendRequest('POST', '/displaygroup/' . $this->displayGroup->displayGroupId . '/copy', [
+ 'displayGroup' => 'phpunit_' . bin2hex(random_bytes(4)),
+ 'description' => 'copied',
+ 'copyMembers' => 1,
+ 'copyAssignments' => 0,
+ 'copyTags' => 0,
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame('copied', $object->data->description);
+
+ // Check there aren't any displays assigned.
+ $results = $this->getStore()->select('SELECT COUNT(*) AS cnt FROM lkdisplaydg WHERE displayGroupId = :displayGroupId', [
+ 'displayGroupId' => $object->id
+ ]);
+
+ $this->assertEquals(2, intval($results[0]['cnt']));
+
+ (new XiboDisplayGroup($this->getEntityProvider()))->getById($object->id)->delete();
+ }
+}
diff --git a/tests/integration/DisplayGroupTest.php b/tests/integration/DisplayGroupTest.php
new file mode 100644
index 0000000..5dfdd10
--- /dev/null
+++ b/tests/integration/DisplayGroupTest.php
@@ -0,0 +1,899 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCommand;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DisplayGroupTest
+ * @package Xibo\Tests\Integration
+ */
+class DisplayGroupTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ protected $startDisplayGroups;
+ protected $startDisplays;
+ protected $startLayouts;
+ protected $startCommands;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startDisplayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startDisplays = (new XiboDisplay($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startCommands = (new XiboCommand($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all display groups that weren't there initially
+ $finalDisplayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ # Loop over any remaining display groups and nuke them
+ foreach ($finalDisplayGroups as $displayGroup) {
+ /** @var XiboDisplayGroup $displayGroup */
+
+ $flag = true;
+
+ foreach ($this->startDisplayGroups as $startGroup) {
+ if ($startGroup->displayGroupId == $displayGroup->displayGroupId) {
+ $flag = false;
+ }
+ }
+
+ if ($flag) {
+ try {
+ $displayGroup->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $displayGroup->displayGroupId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ // Tear down any displays that weren't there before
+ $finalDisplays = (new XiboDisplay($this->getEntityProvider()))->get();
+
+ # Loop over any remaining displays and nuke them
+ foreach ($finalDisplays as $display) {
+ /** @var XiboDisplay $display */
+
+ $flag = true;
+
+ foreach ($this->startDisplays as $startDisplay) {
+ if ($startDisplay->displayId == $display->displayId) {
+ $flag = false;
+ }
+ }
+
+ if ($flag) {
+ try {
+ $display->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $display->displayId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ // tearDown all layouts that weren't there initially
+ $finalLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining layouts and nuke them
+ foreach ($finalLayouts as $layout) {
+ /** @var XiboLayout $layout */
+ $flag = true;
+ foreach ($this->startLayouts as $startLayout) {
+ if ($startLayout->layoutId == $layout->layoutId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $layout->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $layout->layoutId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ // tearDown all commands that weren't there initially
+ $finalCommands = (new XiboCommand($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining commands and nuke them
+ foreach ($finalCommands as $command) {
+ /** @var XiboCommand $command */
+ $flag = true;
+ foreach ($this->startCommands as $startCom) {
+ if ($startCom->commandId == $command->commandId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $command->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $command->commandId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * List all display groups known empty
+ * @group minimal
+ * @group destructive
+ */
+ public function testListEmpty()
+ {
+ if (count($this->startDisplayGroups) > 0) {
+ $this->skipTest("There are pre-existing DisplayGroups");
+ return;
+ }
+ $response = $this->sendRequest('GET','/displaygroup');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # There should be no DisplayGroups in the system
+ $this->assertEquals(0, $object->data->recordsTotal);
+ }
+
+ /**
+ * testAddSuccess - test adding various Display Groups that should be valid
+ * @dataProvider provideSuccessCases
+ * @group minimal
+ */
+ public function testAddSuccess($groupName, $groupDescription, $isDynamic, $expectedDynamic, $dynamicCriteria, $expectedDynamicCriteria)
+ {
+ // Loop through any pre-existing DisplayGroups to make sure we're not
+ // going to get a clash
+
+ foreach ($this->startDisplayGroups as $tmpGroup) {
+ if ($tmpGroup->displayGroup == $groupName) {
+ $this->skipTest("There is a pre-existing DisplayGroup with this name");
+ return;
+ }
+ }
+
+ $response = $this->sendRequest('POST','/displaygroup', [
+ 'displayGroup' => $groupName,
+ 'description' => $groupDescription,
+ 'isDynamic' => $isDynamic,
+ 'dynamicCriteria' => $dynamicCriteria
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($groupName, $object->data->displayGroup);
+ $this->assertSame($groupDescription, $object->data->description);
+ $this->assertSame($expectedDynamic, $object->data->isDynamic);
+ $this->assertSame($expectedDynamicCriteria, $object->data->dynamicCriteria);
+ # Check that the group was really added
+ $displayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['length' => 1000]);
+ $this->assertEquals(count($this->startDisplayGroups) + 1, count($displayGroups));
+ # Check that the group was added correctly
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($groupName, $displayGroup->displayGroup);
+ $this->assertSame($groupDescription, $displayGroup->description);
+ $this->assertSame($expectedDynamic, $displayGroup->isDynamic);
+ $this->assertSame($expectedDynamicCriteria, $displayGroup->dynamicCriteria);
+ # Clean up the DisplayGroup as we no longer need it
+ $this->assertTrue($displayGroup->delete(), 'Unable to delete ' . $displayGroup->displayGroupId);
+ }
+
+ /**
+ * testAddFailure - test adding various Display Groups that should be invalid
+ * @dataProvider provideFailureCases
+ * @group minimal
+ */
+ public function testAddFailure($groupName, $groupDescription, $isDynamic, $dynamicCriteria)
+ {
+ $response = $this->sendRequest('POST','/displaygroup', [
+ 'displayGroup' => $groupName,
+ 'description' => $groupDescription,
+ 'isDynamic' => $isDynamic,
+ 'dynamicCriteria' => $dynamicCriteria
+ ]);
+
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * List all display groups known set
+ * @group minimal
+ * @depends testAddSuccess
+ */
+ public function testListKnown()
+ {
+ # Load in a known set of display groups
+ # We can assume this works since we depend upon the test which
+ # has previously added and removed these without issue:
+ $cases = $this->provideSuccessCases();
+ $displayGroups = [];
+ // Check each possible case to ensure it's not pre-existing
+ // If it is, skip over it
+ foreach ($cases as $case) {
+ $flag = true;
+
+ foreach ($this->startDisplayGroups as $tmpGroup) {
+ if ($case[0] == $tmpGroup->displayGroup) {
+ $flag = false;
+ }
+ }
+
+ if ($flag) {
+ $displayGroups[] = (new XiboDisplayGroup($this->getEntityProvider()))->create($case[0],$case[1],$case[2],$case[3]);
+ }
+ }
+
+ $response = $this->sendRequest('GET','/displaygroup');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # There should be as many groups as we created plus the number we started with in the system
+ $this->assertEquals(count($displayGroups) + count($this->startDisplayGroups), $object->data->recordsTotal);
+ # Clean up the groups we created
+ foreach ($displayGroups as $group) {
+ $group->delete();
+ }
+ }
+
+ /**
+ * List specific display groups
+ * @group minimal
+ * @group destructive
+ * @depends testListKnown
+ * @depends testAddSuccess
+ * @dataProvider provideSuccessCases
+ */
+ public function testListFilter($groupName, $groupDescription, $isDynamic, $expectedDynamic, $dynamicCriteria, $expectedDynamicCriteria)
+ {
+ if (count($this->startDisplayGroups) > 0) {
+ $this->skipTest("There are pre-existing DisplayGroups");
+ return;
+ }
+ # Load in a known set of display groups
+ # We can assume this works since we depend upon the test which
+ # has previously added and removed these without issue:
+ $cases = $this->provideSuccessCases();
+ $displayGroups = [];
+ foreach ($cases as $case) {
+ $displayGroups[] = (new XiboDisplayGroup($this->getEntityProvider()))->create($case[0], $case[1], $case[2], $case[3]);
+ }
+ $response = $this->sendRequest('GET','/displaygroup', [
+ 'displayGroup' => $groupName
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # There should be at least one match
+ $this->assertGreaterThanOrEqual(1, $object->data->recordsTotal);
+ $flag = false;
+ # Check that for the records returned, $groupName is in the groups names
+ foreach ($object->data->data as $group) {
+ if (strpos($groupName, $group->displayGroup) == 0) {
+ $flag = true;
+ }
+ else {
+ // The object we got wasn't the exact one we searched for
+ // Make sure all the words we searched for are in the result
+ foreach (array_map('trim',explode(",",$groupName)) as $word) {
+ assertTrue((strpos($word, $group->displayGroup) !== false), 'Group returned did not match the query string: ' . $group->displayGroup);
+ }
+ }
+ }
+
+ $this->assertTrue($flag, 'Search term not found');
+
+ foreach ($displayGroups as $group) {
+ $group->delete();
+ }
+ }
+
+
+ /**
+ * Each array is a test run
+ * Format
+ * (Group Name, Group Description, isDynamic, Returned isDynamic (0 or 1),
+ * Criteria for Dynamic group, Returned Criteria for Dynamic group)
+ * For example, if you set isDynamic to 0 and send criteria, it will come back
+ * with criteria = null
+ * These are reused in other tests so please ensure Group Name is unique
+ * through the dataset
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+
+ return [
+ // Multi-language non-dynamic groups
+ 'English 1' => ['phpunit test group', 'Api', 0, 0, null, null],
+ 'English 2' => ['another phpunit test group', 'Api', 0, 0, null, null],
+ 'French 1' => ['Test de Français 1', 'Bienvenue à la suite de tests Xibo', 0, 0, null, null],
+ 'German 1' => ['Deutsch Prüfung 1', 'Weiß mit schwarzem Text', 0, 0, null, null],
+ 'Simplified Chinese 1' => ['试验组', '测试组描述', 0, 0, null, null],
+ // Multi-language dynamic groups
+ 'English Dynamic 1' => ['phpunit test dynamic group', 'Api', 1, 1, 'test', 'test'],
+ 'French Dynamic 1' => ['Test de Français 2', 'Bienvenue à la suite de tests Xibo', 1, 1, 'test', 'test'],
+ 'German Dynamic 1' => ['Deutsch Prüfung 2', 'Weiß mit schwarzem Text', 1, 1, 'test', 'test'],
+ // Tests for the various allowed values for isDynamic = 1
+ 'isDynamic on' => ['phpunit group dynamic is on', 'Api', 'on', 1, 'test', 'test'],
+ 'isDynamic true' => ['phpunit group dynamic is true', 'Api', 'true', 1, 'test', 'test'],
+ // Invalid isDynamic flag (the CMS sanitises these for us to false)
+ 'isDynamic is 7 null criteria' => ['Invalid isDynamic flag 1', 'Invalid isDynamic flag', 7, 0, null, null],
+ 'isDynamic is 7 with criteria' => ['Invalid isDynamic flag 2 ', 'Invalid isDynamic flag', 7, 0, 'criteria', 'criteria'],
+ 'isDynamic is invalid null criteria' => ['Invalid isDynamic flag alpha 1', 'Invalid isDynamic flag alpha', 'invalid', 0, null, null],
+ 'isDynamic is invalid with criteria' => ['Invalid isDynamic flag alpha 2', 'Invalid isDynamic flag alpha', 'invalid', 0, 'criteria', 'criteria']
+ ];
+ }
+
+ /**
+ * Each array is a test run
+ * Format
+ * (Group Name, Group Description, isDynamic, Criteria for Dynamic group)
+ * @return array
+ */
+ public function provideFailureCases()
+ {
+
+ return [
+ // Description is limited to 255 characters
+ 'Description over 254 characters' => ['Too long description', Random::generateString(255), 0, null],
+ // If isDynamic = 1 then criteria must be set
+ 'No dynamicCriteria on dynamic group' => ['No dynamic criteria', 'No dynamic criteria', 1, null],
+ // Missing group names
+ 'Group name empty' => ['', 'Group name is empty', 0, null],
+ 'Group name null' => [null, 'Group name is null', 0, null]
+ ];
+ }
+
+ /**
+ * Try and add two display groups with the same name
+ * @group minimal
+ * @depends testAddSuccess
+ */
+ public function testAddDuplicate()
+ {
+ $flag = true;
+ foreach ($this->startDisplayGroups as $group) {
+ if ($group->displayGroup == 'phpunit displaygroup') {
+ $flag = false;
+ }
+ }
+
+ # Load in a known display group if it's not there already
+ if ($flag) {
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit displaygroup', 'phpunit displaygroup', 0, '');
+ }
+
+ $response = $this->sendRequest('POST','/displaygroup', [
+ 'displayGroup' => 'phpunit displaygroup',
+ 'description' => 'phpunit displaygroup',
+ 'isDynamic' => 0,
+ 'dynamicCriteria' => ''
+ ]);
+
+ $this->assertSame(409, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ $displayGroup->delete();
+ }
+
+ /**
+ * Edit an existing display group
+ * @depends testAddSuccess
+ * @group minimal
+ */
+ public function testEdit()
+ {
+ foreach ($this->startDisplayGroups as $group) {
+ if ($group->displayGroup == 'phpunit displaygroup') {
+ $this->skipTest('displayGroup already exists with that name');
+ return;
+ }
+ }
+ # Load in a known display group
+ /** @var XiboDisplayGroup $displayGroup */
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit displaygroup', 'phpunit displaygroup', 0, '');
+ # Change the group name and description
+ # Change it to a dynamic group with a fixed criteria
+ $name = Random::generateString(8, 'phpunit');
+ $description = Random::generateString(8, 'description');
+ $criteria = 'test';
+
+ $response = $this->sendRequest('PUT','/displaygroup/' . $displayGroup->displayGroupId, [
+ 'displayGroup' => $name,
+ 'description' => $description,
+ 'isDynamic' => 1,
+ 'dynamicCriteria' => $criteria
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->displayGroup);
+ $this->assertSame($description, $object->data->description);
+ $this->assertSame(1, $object->data->isDynamic);
+ $this->assertSame($criteria, $object->data->dynamicCriteria);
+ # Check that the group was actually renamed
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $displayGroup->displayGroup);
+ $this->assertSame($description, $displayGroup->description);
+ $this->assertSame(1, $displayGroup->isDynamic);
+ $this->assertSame($criteria, $displayGroup->dynamicCriteria);
+ # Clean up the DisplayGroup as we no longer need it
+ $displayGroup->delete();
+ }
+
+ /**
+ * Test delete
+ * @depends testAddSuccess
+ * @group minimal
+ */
+ public function testDelete()
+ {
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known display groups
+ $displayGroup1 = (new XiboDisplayGroup($this->getEntityProvider()))->create($name1, 'phpunit description', 0, '');
+ $displayGroup2 = (new XiboDisplayGroup($this->getEntityProvider()))->create($name2, 'phpunit description', 0, '');
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE','/displaygroup/' . $displayGroup2->displayGroupId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Check only one remains
+ $groups = (new XiboDisplayGroup($this->getEntityProvider()))->get();
+ $this->assertEquals(count($this->startDisplayGroups) + 1, count($groups));
+
+ $flag = false;
+ foreach ($groups as $group) {
+ if ($group->displayGroupId == $displayGroup1->displayGroupId) {
+ $flag = true;
+ }
+ }
+
+ $this->assertTrue($flag, 'DisplayGroup ID ' . $displayGroup1->displayGroupId . ' was not found after deleting a different DisplayGroup');
+ # Clean up
+ $displayGroup1->delete();
+ }
+
+ /**
+ * Assign new displays Test
+ */
+ public function testAssignDisplay()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get();
+ $display = null;
+
+ foreach ($displays as $disp) {
+ if ($disp->license == $hardwareId) {
+ $display = $disp;
+ }
+ }
+
+ if ($display === null) {
+ $this->fail('Display was not added correctly');
+ }
+ # Create a DisplayGroup to add the display to
+ $name = Random::generateString(8, 'phpunit');
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Call assign display to display group
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/display/assign', [
+ 'displayId' => [$display->displayId]
+ ]);
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Get a list of all Displays in the group
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['displayGroupId' => $displayGroup->displayGroupId]);
+ # Check that there's only us in that group
+ $this->assertEquals(1, count($displays));
+ $this->assertEquals($display->displayId, $displays[0]->displayId);
+ # Clean up
+ $displayGroup->delete();
+ $display->delete();
+ }
+
+ /**
+ * Try to assign display to isDisplaySpecific displayGroupId
+ */
+ public function testAssignDisplayFailure()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get();
+ $display = null;
+
+ foreach ($displays as $disp) {
+ if ($disp->license == $hardwareId) {
+ $display = $disp;
+ }
+ }
+
+ if ($display === null) {
+ $this->fail('Display was not added correctly');
+ }
+
+ # Call assign display to display specific display group
+ $response = $this->sendRequest('POST','/displaygroup/' . $display->displayGroupId . '/display/assign', [
+ 'displayId' => [$display->displayId]
+ ]);
+
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Unassign displays Test
+ */
+ public function testUnassignDisplay()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get();
+ $display = null;
+
+ foreach ($displays as $disp) {
+ if ($disp->license == $hardwareId) {
+ $display = $disp;
+ }
+ }
+
+ if ($display === null) {
+ $this->fail('Display was not added correctly');
+ }
+
+ # Create display group
+ $name = Random::generateString(8, 'phpunit');
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Assign display to display group
+ $displayGroup->assignDisplay([$display->displayId]);
+ # Unassign display from display group
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/display/unassign', [
+ 'displayId' => [$display->displayId]
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Clean up
+ $displayGroup->delete();
+ $display->delete();
+ }
+
+ /**
+ * Try to unassign display from isDisplaySpecific displayGroupId
+ */
+ public function testUnassignDisplayFailure()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get();
+ $display = null;
+
+ foreach ($displays as $disp) {
+ if ($disp->license == $hardwareId) {
+ $display = $disp;
+ }
+ }
+
+ if ($display === null) {
+ $this->fail('Display was not added correctly');
+ }
+
+ # Create display group
+ $name = Random::generateString(8, 'phpunit');
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Assign display to display group - should be successful
+ $displayGroup->assignDisplay([$display->displayId]);
+ # Unassign display from isDisplaySpecific display group - should fail
+ $response = $this->sendRequest('POST','/displaygroup/' . $display->displayGroupId . '/display/unassign', [
+ 'displayId' => [$display->displayId]
+ ]);
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Assign new display group Test
+ */
+ public function testAssignGroup()
+ {
+ # Generate new random names
+ $name = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ $displayGroup2 = (new XiboDisplayGroup($this->getEntityProvider()))->create($name2, 'phpunit description', 0, '');
+ # Assign second display group to the first one
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/displayGroup/assign', [
+ 'displayGroupId' => [$displayGroup2->displayGroupId]
+ ]);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $displayGroup2->delete();
+ }
+
+ /**
+ * Unassign displays group Test
+ */
+ public function testUnassignGroup()
+ {
+ # Generate new random names
+ $name = Random::generateString(8, 'PARENT');
+ $name2 = Random::generateString(8, 'CHILD');
+ # Create new display groups
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ $displayGroup2 = (new XiboDisplayGroup($this->getEntityProvider()))->create($name2, 'phpunit description', 0, '');
+ # Assign second display group to the first one
+
+ $displayGroup->assignDisplayGroup([$displayGroup2->displayGroupId]);
+ # Unassign second display group from the first one
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/displayGroup/unassign', [
+ 'displayGroupId' => [$displayGroup2->displayGroupId]
+ ]);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $displayGroup2->delete();
+ }
+
+ /**
+ * Assign new media file to a group Test
+ */
+ public function testAssignMedia()
+ {
+ # Generate new random name
+ $name = Random::generateString(8, 'phpunit');
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Upload a known files
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('API video 12', PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+ $media2 = (new XiboLibrary($this->getEntityProvider()))->create('API image 12', PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+ # Assign two files o the display group and unassign one of them
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/media/assign', [
+ 'mediaId' => [$media->mediaId, $media2->mediaId],
+ 'unassignMediaId' => [$media2->mediaId]
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $media->delete();
+ $media2->delete();
+ }
+
+ /**
+ * Unassign media files from a group Test
+ */
+ public function testUnassignMedia()
+ {
+ # Generate new random name
+ $name = Random::generateString(8, 'phpunit');
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Upload a known file
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('API image 29', PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+ # Assign media to display Group
+ $displayGroup->assignMedia([$media->mediaId]);
+ # Unassign the media from the display group
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/media/unassign', [
+ 'mediaId' => [$media->mediaId]
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $media->delete();
+ }
+
+ /**
+ * Assign new layouts to a group Test
+ */
+ public function testAssignLayout()
+ {
+ # Create new random name
+ $name = Random::generateString(8, 'phpunit');
+
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+
+ # Create new layouts
+ $layout = $this->createLayout();
+ $layout2 = $this->createLayout();
+
+ # Assign both layouts to display group then unassign the second layout from it
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/layout/assign', [
+ 'layoutId' => [$layout->layoutId, $layout2->layoutId],
+ 'unassignLayoutsId' => [$layout2->layoutId]
+ ]);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $layout->delete();
+ $layout2->delete();
+ }
+
+ /**
+ * Unassign layouts from a group Test
+ */
+ public function testUnassignLayout()
+ {
+ # Create new random name
+ $name = Random::generateString(8, 'phpunit');
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+
+ # Create new layout
+ $layout = $this->createLayout();
+
+ # assign layout to display group
+ $displayGroup->assignLayout([$layout->layoutId]);
+ # unassign layout from display group
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/layout/unassign', [
+ 'layoutId' => [$layout->layoutId]
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $layout->delete();
+ }
+
+ /**
+ * Collect now action test
+ */
+ public function testCollectNow()
+ {
+ # Generate random name
+ $name = Random::generateString(8, 'phpunit');
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Call callectNow
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/action/collectNow');
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ }
+
+ /**
+ * Change Layout action test
+ */
+ public function testChangeLayout()
+ {
+ # Generate random name
+ $name = Random::generateString(8, 'phpunit');
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+
+ # Create new layout
+ $layout = $this->createLayout();
+
+ # Call changeLayout
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/action/changeLayout', [
+ 'layoutId' => $layout->layoutId,
+ 'duration' => 900,
+ 'downloadRequired' => 1,
+ 'changeMode' => 'queue'
+ ]);
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $layout->delete();
+ }
+
+ /**
+ * Revert to Schedule action test
+ */
+ public function testRevertToSchedule()
+ {
+ # Generate random name and create new display group
+ $name = Random::generateString(8, 'phpunit');
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Call RevertToSchedule
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/action/revertToSchedule');
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ }
+
+ /**
+ * Send command action test
+ */
+ public function testCommand()
+ {
+ # Generate random name and create new display group
+ $name = Random::generateString(8, 'phpunit');
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create($name, 'phpunit description', 0, '');
+ # Create new command
+ $command = (new XiboCommand($this->getEntityProvider()))->create('phpunit command', 'phpunit description', 'phpunitcode');
+ # Send command to display group
+ $response = $this->sendRequest('POST','/displaygroup/' . $displayGroup->displayGroupId . '/action/command' , [
+ 'commandId' => $command->commandId
+ ]);
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $command->delete();
+ }
+}
diff --git a/tests/integration/DisplayProfileTest.php b/tests/integration/DisplayProfileTest.php
new file mode 100644
index 0000000..6226458
--- /dev/null
+++ b/tests/integration/DisplayProfileTest.php
@@ -0,0 +1,171 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+use Xibo\OAuth2\Client\Entity\XiboDisplayProfile;
+
+/**
+ * Class DisplayProfileTest
+ * @package Xibo\Tests\Integration
+ */
+class DisplayProfileTest extends \Xibo\Tests\LocalWebTestCase
+{
+
+ protected $startProfiles;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startProfiles = (new XiboDisplayProfile($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all profiles that weren't there initially
+ $finalProfiles = (new XiboDisplayProfile($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ // Loop over any remaining profiles and nuke them
+ foreach ($finalProfiles as $displayProfile) {
+ /** @var XiboDisplayProfile $displayProfile */
+ $flag = true;
+ foreach ($this->startProfiles as $startProfile) {
+ if ($startProfile->displayProfileId == $displayProfile->displayProfileId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $displayProfile->delete();
+ } catch (\Exception $e) {
+ $this->getLogger()->error('Unable to delete ' . $displayProfile->displayProfileId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+ /**
+ * Shows all display profiles
+ */
+ public function testListAll()
+ {
+ # Get list of all display profiles
+ $response = $this->sendRequest('GET','/displayprofile');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+
+ /**
+ * testAddSuccess - test adding various display profiles that should be valid
+ * @dataProvider provideSuccessCases
+ * @group minimal
+ */
+ public function testAddSuccess($profileName, $profileType, $profileIsDefault)
+ {
+ // Loop through any pre-existing profiles to make sure we're not
+ // going to get a clash
+ foreach ($this->startProfiles as $tmpProfile) {
+ if ($tmpProfile->name == $profileName) {
+ $this->skipTest("There is a pre-existing profiles with this name");
+ return;
+ }
+ }
+
+ $response = $this->sendRequest('POST','/displayprofile', [
+ 'name' => $profileName,
+ 'type' => $profileType,
+ 'isDefault' => $profileIsDefault
+ ]);
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($profileName, $object->data->name);
+ $this->assertSame($profileType, $object->data->type);
+ $this->assertSame($profileIsDefault, $object->data->isDefault);
+ # Check that the profile was added correctly
+ $profile = (new XiboDisplayProfile($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($profileName, $profile->name);
+ $this->assertSame($profileType, $profile->type);
+ # Clean up the Profiles as we no longer need it
+ $this->assertTrue($profile->delete(), 'Unable to delete ' . $profile->displayProfileId);
+ }
+
+ /**
+ * Each array is a test run
+ * Format (profile name, type(windows/android), isDefault flag)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ // Cases we provide to testAddSuccess, you can extend it by simply adding new case here
+ return [
+ 'Android notDefault' => ['test profile', 'android', 0],
+ 'Windows notDefault' => ['different test profile', 'windows', 0],
+ 'French Android' => ['Test de Français 1', 'android', 0],
+ 'Linux' => ['Test de Français 1', 'linux', 0],
+ 'Tizen' => ['Test de Français 1', 'sssp', 0],
+ 'webOS' => ['Test de Français 1', 'lg', 0]
+ ];
+ }
+
+ /**
+ * testAddFailure - test adding various profiles that should be invalid
+ * @dataProvider provideFailureCases
+ */
+ public function testAddFailure($profileName, $profileType, $profileIsDefault)
+ {
+ # Add new display profile with arguments from provideFailureCases
+ $response = $this->sendRequest('POST','/displayprofile', [
+ 'name' => $profileName,
+ 'type' => $profileType,
+ 'isDefault' => $profileIsDefault
+ ]);
+ # Check if it fails as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Each array is a test run
+ * Format (profile name, type(windows/android), isDefault flag)
+ * @return array
+ */
+
+ public function provideFailureCases()
+ {
+ # Cases we provide to testAddFailure, you can extend it by simply adding new case here
+ return [
+ 'NULL Type' => ['no type', NULL, 0],
+ 'NULL name' => [NULL, 'android', 1],
+ 'is Default 1' => ['TEST PHP', 'android', 1]
+ ];
+ }
+}
diff --git a/tests/integration/DisplayProfileTestDelete.php b/tests/integration/DisplayProfileTestDelete.php
new file mode 100644
index 0000000..42cf770
--- /dev/null
+++ b/tests/integration/DisplayProfileTestDelete.php
@@ -0,0 +1,75 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplayProfile;
+use Xibo\OAuth2\Client\Exception\XiboApiException;
+
+/**
+ * Class DisplayProfileTest
+ * @package Xibo\Tests\Integration
+ */
+class DisplayProfileTestDelete extends \Xibo\Tests\LocalWebTestCase
+{
+ /** @var XiboDisplayProfile */
+ private $displayProfile;
+
+ public function setup()
+ {
+ parent::setup();
+
+ $this->displayProfile = (new XiboDisplayProfile($this->getEntityProvider()))->create(Random::generateString(), 'android', 0);
+ }
+
+ protected function tearDown()
+ {
+ if ($this->displayProfile !== null) {
+ $this->displayProfile->delete();
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test delete
+ */
+ public function testDelete()
+ {
+ // Delete the one we created last
+ $response = $this->sendRequest('DELETE','/displayprofile/' . $this->displayProfile->displayProfileId);
+
+ // This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+
+ // Check only one remains
+ try {
+ $displayProfile = (new XiboDisplayProfile($this->getEntityProvider()))->getById($this->displayProfile->displayProfileId);
+
+ $this->fail('Display profile ID ' . $this->displayProfile->displayProfileId . ' was not found after deleting a different Display Profile');
+ } catch (XiboApiException $exception) {
+ // We know we've deleted it, so no clear for tearDown
+ $this->displayProfile = null;
+ }
+ }
+}
diff --git a/tests/integration/DisplayProfileTestEdit.php b/tests/integration/DisplayProfileTestEdit.php
new file mode 100644
index 0000000..12270a3
--- /dev/null
+++ b/tests/integration/DisplayProfileTestEdit.php
@@ -0,0 +1,112 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplayProfile;
+
+/**
+ * Class DisplayProfileTest
+ * @package Xibo\Tests\Integration
+ */
+class DisplayProfileTestEdit extends \Xibo\Tests\LocalWebTestCase
+{
+ /** @var XiboDisplayProfile */
+ private $displayProfile;
+
+ public function setup()
+ {
+ parent::setup();
+
+ $this->displayProfile = (new XiboDisplayProfile($this->getEntityProvider()))->create(Random::generateString(), 'android', 0);
+ }
+
+ protected function tearDown()
+ {
+ $this->displayProfile->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Edit an existing profile
+ */
+ public function testEdit()
+ {
+ // Call edit on the profile.
+ $name = Random::generateString(8, 'phpunit');
+ $response = $this->sendRequest('PUT','/displayprofile/' . $this->displayProfile->displayProfileId, [
+ 'name' => $name,
+ 'type' => $this->displayProfile->type,
+ 'isDefault' => $this->displayProfile->isDefault
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ // Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame('android', $object->data->type);
+
+ // Check that the profile was actually renamed
+ $displayProfile = (new XiboDisplayProfile($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $displayProfile->name);
+ }
+
+ /**
+ * Edit an existing profile
+ */
+ public function testEditConfig()
+ {
+ // Call edit on the profile.
+ $name = Random::generateString(8, 'phpunit');
+ $response = $this->sendRequest('PUT','/displayprofile/' . $this->displayProfile->displayProfileId, [
+ 'name' => $name,
+ 'type' => $this->displayProfile->type,
+ 'isDefault' => $this->displayProfile->isDefault,
+ 'emailAddress' => 'phpunit@xibo.org.uk'
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getStatusCode());
+
+ $object = json_decode($response->getBody());
+
+ // Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame('android', $object->data->type);
+
+ foreach ($object->data->config as $config) {
+ if ($config->name === 'emailAddress') {
+ $this->assertSame('phpunit@xibo.org.uk', $config->value, json_encode($object->data->config));
+ }
+ }
+
+ // Check that the profile was actually renamed
+ $displayProfile = (new XiboDisplayProfile($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $displayProfile->name);
+ }
+}
diff --git a/tests/integration/DisplayTest.php b/tests/integration/DisplayTest.php
new file mode 100644
index 0000000..62b13bb
--- /dev/null
+++ b/tests/integration/DisplayTest.php
@@ -0,0 +1,293 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+
+class DisplayTest extends \Xibo\Tests\LocalWebTestCase
+{
+ protected $startDisplays;
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startDisplays = (new XiboDisplay($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Tear down any displays that weren't there before
+ $finalDisplays = (new XiboDisplay($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ # Loop over any remaining displays and nuke them
+ foreach ($finalDisplays as $display) {
+ /** @var XiboDisplay $display */
+ $flag = true;
+ foreach ($this->startDisplays as $startDisplay) {
+ if ($startDisplay->displayId == $display->displayId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $display->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $display->displayId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * Shows list of all displays Test
+ */
+ public function testListAll()
+ {
+ # Get all displays
+ $response = $this->sendRequest('GET','/display');
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * Delete Display Test
+ */
+ public function testDelete()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+ if (count($displays) != 1)
+ $this->fail('Display was not added correctly');
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+ $response = $this->sendRequest('DELETE','/display/' . $display->displayId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ }
+
+ /**
+ * Edit Display test, expecting success
+ */
+ public function testEditSuccess()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+
+ if (count($displays) != 1) {
+ $this->fail('Display was not added correctly');
+ }
+
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+ $auditingTime = time()+3600;
+ # Edit display and change its name
+ $response = $this->sendRequest('PUT','/display/' . $display->displayId, [
+ 'display' => 'API EDITED',
+ 'defaultLayoutId' => $display->defaultLayoutId,
+ 'auditingUntil' => Carbon::createFromTimestamp($auditingTime)->format(DateFormatHelper::getSystemFormat()),
+ 'licensed' => $display->licensed,
+ 'license' => $display->license,
+ 'incSchedule' => $display->incSchedule,
+ 'emailAlert' => $display->emailAlert,
+ 'wakeOnLanEnabled' => $display->wakeOnLanEnabled,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Check if display has new edited name
+ $this->assertSame('API EDITED', $object->data->display);
+ }
+
+ /**
+ * Edit Display Type and reference, expecting success
+ */
+ public function testEditDisplayType()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display Type');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+
+ if (count($displays) != 1) {
+ $this->fail('Display was not added correctly');
+ }
+
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+ $auditingTime = time()+3600;
+ # Edit display and change its name
+ $response = $this->sendRequest('PUT', '/display/' . $display->displayId, [
+ 'display' => 'PHPUnit Test Display Type - EDITED',
+ 'defaultLayoutId' => $display->defaultLayoutId,
+ 'auditingUntil' => Carbon::createFromTimestamp($auditingTime)->format(DateFormatHelper::getSystemFormat()),
+ 'licensed' => $display->licensed,
+ 'license' => $display->license,
+ 'incSchedule' => $display->incSchedule,
+ 'emailAlert' => $display->emailAlert,
+ 'wakeOnLanEnabled' => $display->wakeOnLanEnabled,
+ 'displayTypeId' => 1,
+ 'ref1' => 'Lorem ipsum',
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Check if display has new edited name
+ $this->assertSame(1, $object->data->displayTypeId);
+ $this->assertSame('Lorem ipsum', $object->data->ref1);
+ }
+
+ /**
+ * Edit Display test, expecting failure
+ */
+ public function testEditFailure()
+ {
+ # Create a Display in the system
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId, 'PHPUnit Test Display');
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+ if (count($displays) != 1)
+ $this->fail('Display was not added correctly');
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+ # Edit display and change its hardwareKey
+ $response = $this->sendRequest('PUT','/display/' . $display->displayId, [
+ 'display' => 'API EDITED',
+ 'defaultLayoutId' => $display->defaultLayoutId,
+ 'licensed' => $display->licensed,
+ 'license' => null,
+ 'incSchedule' => $display->incSchedule,
+ 'emailAlert' => $display->emailAlert,
+ 'wakeOnLanEnabled' => $display->wakeOnLanEnabled,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if call failed as expected (license cannot be null)
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getBody());
+ }
+
+ /**
+ * Request screenshot Test
+ */
+ public function testScreenshot()
+ {
+ # Generate names for display and xmr channel
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $xmrChannel = Random::generateString(50);
+ # This is a dummy pubKey and isn't used by anything important
+ $xmrPubkey = '-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDmdnXL4gGg3yJfmqVkU1xsGSQI
+3b6YaeAKtWuuknIF1XAHAHtl3vNhQN+SmqcNPOydhK38OOfrdb09gX7OxyDh4+JZ
+inxW8YFkqU0zTqWaD+WcOM68wTQ9FCOEqIrbwWxLQzdjSS1euizKy+2GcFXRKoGM
+pbBhRgkIdydXoZZdjQIDAQAB
+-----END PUBLIC KEY-----';
+ # Register our display
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId,
+ 'PHPUnit Test Display',
+ 'windows',
+ null,
+ null,
+ null,
+ '00:16:D9:C9:AL:69',
+ $xmrChannel,
+ $xmrPubkey
+ );
+
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+ if (count($displays) != 1)
+ $this->fail('Display was not added correctly');
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+ # Check if xmr channel and pubkey were registered correctly
+ $this->assertSame($xmrChannel, $display->xmrChannel, 'XMR Channel not set correctly by XMDS Register Display');
+ $this->assertSame($xmrPubkey, $display->xmrPubKey, 'XMR PubKey not set correctly by XMDS Register Display');
+ # Call request screenshot
+ $response = $this->sendRequest('PUT','/display/requestscreenshot/' . $display->displayId);
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ }
+
+ /**
+ * Wake On Lan Test
+ */
+ public function testWoL()
+ {
+ # Create dummy hardware key and mac address
+ $hardwareId = Random::generateString(12, 'phpunit');
+ $macAddress = '00-16-D9-C9-AE-69';
+ # Register our display
+ $this->getXmdsWrapper()->RegisterDisplay($hardwareId,
+ 'PHPUnit Test Display',
+ 'windows',
+ null,
+ null,
+ null,
+ $macAddress,
+ Random::generateString(50),
+ Random::generateString(50)
+ );
+ # Now find the Id of that Display
+ $displays = (new XiboDisplay($this->getEntityProvider()))->get(['hardwareKey' => $hardwareId]);
+ if (count($displays) != 1)
+ $this->fail('Display was not added correctly');
+ /** @var XiboDisplay $display */
+ $display = $displays[0];
+ # Check if mac address was added correctly
+ $this->assertSame($macAddress, $display->macAddress, 'Mac Address not set correctly by XMDS Register Display');
+ $auditingTime = time()+3600;
+ # Edit display and add broadcast channel
+ $display->edit(
+ $display->display,
+ $display->description,
+ $display->tags,
+ Carbon::createFromTimestamp($auditingTime)->format(DateFormatHelper::getSystemFormat()),
+ $display->defaultLayoutId,
+ $display->licensed,
+ $display->license,
+ $display->incSchedule,
+ $display->emailAlert,
+ $display->alertTimeout,
+ $display->wakeOnLanEnabled,
+ null,
+ '127.0.0.1');
+ # Call WOL
+ $response = $this->sendRequest('POST','/display/wol/' . $display->displayId);
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ }
+}
diff --git a/tests/integration/FaultTest.php b/tests/integration/FaultTest.php
new file mode 100644
index 0000000..1ce9125
--- /dev/null
+++ b/tests/integration/FaultTest.php
@@ -0,0 +1,60 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+/**
+ * Class FaultTest
+ * @package Xibo\Tests\Integration
+ */
+class FaultTest extends \Xibo\Tests\LocalWebTestCase
+{
+ /**
+ * Collect data
+ * This test modifies headers and we therefore need to run in a separate process
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function testCollect()
+ {
+ $response = $this->sendRequest('GET','/fault/collect', ['outputLog' => 'on']);
+ $this->assertSame(200, $response->getStatusCode());
+ }
+
+ /**
+ * test turning debug on
+ */
+ public function testDebugOn()
+ {
+ $response = $this->sendRequest('PUT','/fault/debug/on');
+ $this->assertSame(200, $response->getStatusCode());
+ }
+
+ /**
+ * test turning debug off
+ */
+ public function testDebugOff()
+ {
+ $response = $this->sendRequest('PUT','/fault/debug/off');
+ $this->assertSame(200, $response->getStatusCode());
+ }
+}
diff --git a/tests/integration/FontTest.php b/tests/integration/FontTest.php
new file mode 100644
index 0000000..4007b8d
--- /dev/null
+++ b/tests/integration/FontTest.php
@@ -0,0 +1,169 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Entity\Display;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+class FontTest extends LocalWebTestCase
+{
+ use DisplayHelperTrait;
+
+ private $testFileName = 'PHPUNIT FONT TEST';
+ private $testFilePath = PROJECT_ROOT . '/tests/resources/UglyTypist.ttf';
+ protected $startFonts;
+ // TODO create api wrapper for fonts :)
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startFonts = $this->getEntityProvider()->get('/fonts', ['start' => 0, 'length' => 10000]);
+ }
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all media files that weren't there initially
+ $finalFonts = $this->getEntityProvider()->get('/fonts', ['start' => 0, 'length' => 10000]);
+ # Loop over any remaining font files and nuke them
+ foreach ($finalFonts as $font) {
+ $flag = true;
+ foreach ($this->startFonts as $startFont) {
+ if ($startFont['id'] == $font['id']) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $this->getEntityProvider()->delete('/fonts/'.$font['id'].'/delete');
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete font ' . $font['id'] . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * List all file in library
+ */
+ public function testListAll()
+ {
+ # Get all library items
+ $response = $this->sendRequest('GET', '/fonts');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ // we expect fonts distributed with CMS to be there.
+ $this->assertNotEmpty($object->data->data);
+ }
+
+ public function testUpload()
+ {
+ $uploadResponse = $this->uploadFontFile();
+ $uploadedFileObject = $uploadResponse['files'][0];
+ $this->assertNotEmpty($uploadedFileObject);
+ $this->assertSame(filesize($this->testFilePath), $uploadedFileObject['size']);
+ $this->assertSame(basename($this->testFilePath), $uploadedFileObject['fileName']);
+
+ $this->getLogger()->debug(
+ 'Uploaded font ' . $uploadedFileObject['name'] .
+ ' with ID ' . $uploadedFileObject['id'] .
+ ' Stored as ' . $uploadedFileObject['fileName']
+ );
+
+ $fontRecord = $this->getEntityProvider()->get('/fonts', ['name' => $this->testFileName])[0];
+ $this->assertNotEmpty($fontRecord);
+ $this->assertSame(filesize($this->testFilePath), $fontRecord['size']);
+ $this->assertSame(basename($this->testFilePath), $fontRecord['fileName']);
+ }
+
+ public function testDelete()
+ {
+ $upload = $this->uploadFontFile();
+
+ $response = $this->sendRequest('DELETE', '/fonts/' . $upload['files'][0]['id']. '/delete');
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ }
+
+ public function testFontDependencies()
+ {
+ $upload = $this->uploadFontFile();
+ $size = $upload['files'][0]['size'];
+ $md5 = $upload['files'][0]['md5'];
+
+ // Create a Display
+ $display = $this->createDisplay();
+ $this->displaySetStatus($display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($display);
+
+ // Call Required Files
+ $rf = $this->getXmdsWrapper()->RequiredFiles($display->license);
+
+ $this->assertContains('file download="http" size="'.$size.'" md5="'.$md5.'" saveAs="'.basename($this->testFilePath).'" type="dependency" fileType="font" ', $rf, 'Font not in Required Files');
+
+ // Delete the Display
+ $this->deleteDisplay($display);
+ }
+
+ public function testFontCss()
+ {
+ $fontCssPath = PROJECT_ROOT . '/library/fonts/fonts.css';
+
+ // upload file, this should also update fonts.css file
+ $this->uploadFontFile();
+ // read css file
+ $fontsCss = file_get_contents($fontCssPath);
+
+ // get the record
+ $fontRecord = $this->getEntityProvider()->get('/fonts', ['name' => $this->testFileName])[0];
+
+ // check if the uploaded font was added to player fonts.css file.
+ $this->assertContains('font-family: \''.$fontRecord['familyName'].'\';', $fontsCss, 'Font not in fonts.css');
+ $this->assertContains('src: url(\''.basename($this->testFilePath).'\');', $fontsCss, 'Font not in fonts.css');
+ }
+
+ private function uploadFontFile()
+ {
+ $payload = [
+ [
+ 'name' => 'name',
+ 'contents' => $this->testFileName
+ ],
+ [
+ 'name' => 'files',
+ 'contents' => fopen($this->testFilePath, 'r')
+ ]
+ ];
+
+ return $this->getEntityProvider()->post('/fonts', ['multipart' => $payload]);
+ }
+}
diff --git a/tests/integration/InteractiveFeaturesTest.php b/tests/integration/InteractiveFeaturesTest.php
new file mode 100644
index 0000000..5a7a103
--- /dev/null
+++ b/tests/integration/InteractiveFeaturesTest.php
@@ -0,0 +1,434 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+
+/**
+ * Class AboutTest
+ * @package Xibo\Tests\Integration
+ */
+class InteractiveFeaturesTest extends \Xibo\Tests\LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this));
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Get Draft
+ $layout = $this->getDraft($this->layout);
+
+ $this->addSimpleTextWidget($layout);
+
+ $this->layout = $this->publish($this->layout);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ parent::tearDown();
+ }
+ //
+
+ /**
+ * Test Add Region Drawer
+ */
+ public function testAddDrawer()
+ {
+ $layout = $this->checkout($this->layout);
+ // add Drawer Region
+ $response = $this->sendRequest('POST', '/region/drawer/' . $layout->layoutId);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $body = json_decode($response->getBody());
+
+ $this->assertSame(201, $body->status);
+ $this->assertSame(true, $body->success);
+ $this->assertSame(false, $body->grid);
+ $this->assertNotEmpty($body->data, 'Empty Data');
+
+ $this->assertSame(1, $body->data->isDrawer);
+ $this->assertContains('drawer', $body->data->name);
+
+ // get the layout
+ $layout = $this->getEntityProvider()->get('/layout', ['layoutId' => $layout->layoutId, 'embed' => 'regions'])[0];
+ // check if regions and drawers arrays are not empty
+ $this->assertNotEmpty($layout['drawers']);
+ $this->assertNotEmpty($layout['regions']);
+ }
+
+ /**
+ * Test Add Region Drawer
+ */
+ public function testDeleteDrawer()
+ {
+ $layout = $this->checkout($this->layout);
+ // add Drawer Region
+ $drawer = $this->getEntityProvider()->post('/region/drawer/' . $layout->layoutId);
+
+ // delete Drawer Region
+ $response = $this->sendRequest('DELETE', '/region/' . $drawer['regionId']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+ $body = json_decode($response->getBody());
+ $this->assertSame(204, $body->status);
+ $this->assertSame(true, $body->success);
+ }
+
+ public function testListAll()
+ {
+ $response = $this->sendRequest('GET','/action');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * Add Action
+ * @dataProvider AddActionSuccessCases
+ * @param string $source
+ * @param string $triggerType
+ * @param string|null $triggerCode
+ * @param string $actionType
+ * @param string $target
+ * @param string|null $layoutCode
+ */
+ public function testAddActionSuccess(?string $source, ?string $triggerType, ?string $triggerCode, string $actionType, string $target, ?string $layoutCode)
+ {
+ $layout = $this->checkout($this->layout);
+ $sourceId = null;
+ $targetId = null;
+ $widgetId = null;
+
+ // Add a couple of text widgets to the region
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget B',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $widget2 = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // depending on the source from AddActionsCases, the sourceId will be different
+ if ($source === 'layout') {
+ $sourceId = $layout->layoutId;
+ } elseif ($source === 'region') {
+ $sourceId = $layout->regions[0]->regionId;
+ } else {
+ $sourceId = $widget->widgetId;
+ }
+
+ // depending on the target screen|region we may need targetId
+ if ($target === 'region') {
+ $targetId = $layout->regions[0]->regionId;
+ }
+
+ if ($actionType == 'navWidget') {
+ $widgetId = $widget2->widgetId;
+ }
+
+ $response = $this->sendRequest('POST', '/action', [
+ 'triggerType' => $triggerType,
+ 'triggerCode' => $triggerCode,
+ 'actionType' => $actionType,
+ 'target' => $target,
+ 'targetId' => $targetId,
+ 'widgetId' => $widgetId,
+ 'layoutCode' => $layoutCode,
+ 'source' => $source,
+ 'sourceId' => $sourceId,
+ 'layoutId' => $layout->layoutId
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $body = json_decode($response->getBody());
+ $this->assertSame(201, $body->status);
+ $this->assertSame(true, $body->success);
+ $this->assertSame(false, $body->grid);
+
+ $this->assertNotEmpty($body->data, 'Empty Data');
+ $this->assertSame($layout->layoutId, $body->data->layoutId);
+ $this->assertSame($sourceId, $body->data->sourceId);
+ $this->assertSame($triggerType, $body->data->triggerType);
+ $this->assertSame($triggerCode, $body->data->triggerCode);
+ $this->assertSame($actionType, $body->data->actionType);
+ $this->assertSame($target, $body->data->target);
+ $this->assertSame($targetId, $body->data->targetId);
+ }
+
+ /**
+ * Each array is a test run
+ * Format (string $source, string $triggerType, string|null $triggerCode, string $actionType, string $target, string LayoutCode)
+ * @return array
+ */
+ public function AddActionSuccessCases()
+ {
+ return [
+ 'Layout' => ['layout', 'touch', 'trigger code', 'next', 'screen', null],
+ 'Layout with region target' => ['layout', 'touch', null, 'previous', 'region', null],
+ 'Region' => ['region', 'webhook', 'test', 'previous', 'screen', null],
+ 'Region with region target' => ['region', 'touch', null, 'previous', 'region', null],
+ 'Widget' => ['widget', 'touch', null, 'next', 'screen', null],
+ 'Widget with region target' => ['widget', 'touch', null, 'next', 'region', null],
+ 'Navigate to Widget' => ['layout', 'touch', null, 'navWidget', 'screen', null],
+ 'Navigate to Layout with code' => ['layout', 'touch', null, 'navLayout', 'screen', 'CodeIdentifier'],
+ 'Web UI' => [null, null, null, 'next', 'screen', null]
+ ];
+ }
+
+ public function testEditAction()
+ {
+ $layout = $this->checkout($this->layout);
+ $action = $this->getEntityProvider()->post('/action', [
+ 'actionType' => 'previous',
+ 'target' => 'screen',
+ 'layoutId' => $layout->layoutId
+ ]);
+
+ $response = $this->sendRequest('PUT', '/action/' . $action['actionId'], [
+ 'source' => 'layout',
+ 'sourceId' => $layout->layoutId,
+ 'triggerType' => 'webhook',
+ 'triggerCode' => 'new code',
+ 'actionType' => 'next',
+ 'target' => 'region',
+ 'targetId' => $layout->regions[0]->regionId
+ ], ['Content-Type' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $body = json_decode($response->getBody());
+ $this->assertSame(200, $body->status);
+ $this->assertSame(true, $body->success);
+ $this->assertSame(false, $body->grid);
+
+ $this->assertNotEmpty($body->data, 'Empty Data');
+ $this->assertSame($layout->layoutId, $body->data->sourceId);
+ $this->assertSame($layout->layoutId, $body->data->layoutId);
+ $this->assertSame('webhook', $body->data->triggerType);
+ $this->assertSame('new code', $body->data->triggerCode);
+ $this->assertSame('next', $body->data->actionType);
+ $this->assertSame('region', $body->data->target);
+ $this->assertSame($layout->regions[0]->regionId, $body->data->targetId);
+ }
+
+ public function testDeleteAction()
+ {
+ $layout = $this->checkout($this->layout);
+
+ $action = $this->getEntityProvider()->post('/action', [
+ 'triggerType' => 'webhook',
+ 'triggerCode' => 'test',
+ 'actionType' => 'previous',
+ 'target' => 'screen',
+ 'layoutId' => $layout->layoutId
+ ]);
+
+ $response = $this->sendRequest('DELETE', '/action/' . $action['actionId']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $body = json_decode($response->getBody());
+ $this->assertSame(204, $body->status);
+ $this->assertSame(true, $body->success);
+ $this->assertSame(false, $body->grid);
+
+ // check if one action remains with our Layout Id.
+ $actions = $this->getEntityProvider()->get('/action', ['sourceId' => $layout->layoutId]);
+ $this->assertSame(0, count($actions));
+ }
+
+ /**
+ * Add Action
+ * @dataProvider editActionFailureCases
+ * @param string $source
+ * @param string $triggerType
+ * @param string|null $triggerCode
+ * @param string $actionType
+ * @param string $target
+ */
+ public function testEditActionFailure(string $source, string $triggerType, ?string $triggerCode, string $actionType, string $target)
+ {
+ $layout = $this->checkout($this->layout);
+ $action = $this->getEntityProvider()->post('/action', [
+ 'actionType' => 'previous',
+ 'target' => 'screen',
+ 'layoutId' => $layout->layoutId
+ ]);
+
+ $targetId = null;
+ $widgetId = null;
+ $layoutCode = null;
+
+ if ($source === 'layout') {
+ $sourceId = $layout->layoutId;
+ } elseif ($source === 'region') {
+ $sourceId = $layout->regions[0]->regionId;
+ } else {
+ $sourceId = null;
+ }
+
+ $response = $this->sendRequest('PUT', '/action/' . $action['actionId'], [
+ 'triggerType' => $triggerType,
+ 'triggerCode' => $triggerCode,
+ 'actionType' => $actionType,
+ 'target' => $target,
+ 'targetId' => $targetId,
+ 'source' => $source,
+ 'sourceId' => $sourceId
+ ]);
+
+ $body = json_decode($response->getBody());
+
+ // in other failure cases, we expect to get invalidArgument exception.
+ $this->assertSame(422, $response->getStatusCode());
+
+ // get the error message for cases and make sure we return correct one.
+ if ($source === 'playlist') {
+ $this->assertSame('Invalid source', $body->error);
+ }
+
+ // wrong trigger type case
+ if ($triggerType === 'notExistingType') {
+ $this->assertSame('Invalid trigger type', $body->error);
+ }
+
+ // wrong trigger type case
+ if ($actionType === 'wrongAction') {
+ $this->assertSame('Invalid action type', $body->error);
+ }
+
+ // wrong target case
+ if ($target === 'world') {
+ $this->assertSame('Invalid target', $body->error);
+ }
+
+ // test case when we have target set to region, but we don't set targetId to any regionId
+ if ($target === 'region') {
+ $this->assertSame('Please select a Region', $body->error);
+ }
+
+ // trigger code in non layout
+ if ($triggerType === 'webhook' && $triggerCode === null) {
+ $this->assertSame('Please provide trigger code', $body->error);
+ }
+
+ // navWidget without widgetId
+ if ($actionType === 'navWidget' && $widgetId == null) {
+ $this->assertSame('Please select a Widget', $body->error);
+ }
+
+ // navLayout without layoutCode
+ if ($actionType === 'navLayout' && $layoutCode == null) {
+ $this->assertSame('Please enter Layout code', $body->error);
+ }
+ }
+
+ /**
+ * Each array is a test run
+ * Format (string $source, string $triggerType, string|null $triggerCode, string $actionType, string $target)
+ * @return array
+ */
+ public function editActionFailureCases()
+ {
+ return [
+ 'Wrong source' => ['playlist', 'touch', null, 'next', 'screen'],
+ 'Wrong trigger type' => ['layout', 'notExistingType', null, 'previous', 'screen'],
+ 'Wrong action type' => ['layout', 'touch', null, 'wrongAction', 'screen'],
+ 'Wrong target' => ['layout', 'touch', null, 'next', 'world'],
+ 'Target region without targetId' => ['layout', 'touch', 'trigger code', 'next', 'region'],
+ 'Missing trigger code for webhook' => ['region', 'webhook', null, 'next', 'screen'],
+ 'Navigate to Widget without widgetId' => ['layout', 'touch', null, 'navWidget', 'screen'],
+ 'Navigate to Layout without layoutCode' => ['layout', 'touch', null, 'navLayout', 'screen']
+ ];
+ }
+
+ public function testCopyLayoutWithActions()
+ {
+ $layout = $this->checkout($this->layout);
+
+ $this->getEntityProvider()->post('/action', [
+ 'triggerType' => 'touch',
+ 'actionType' => 'previous',
+ 'target' => 'screen',
+ 'layout' => $layout->layoutId
+ ]);
+
+ $this->layout = $this->publish($this->layout);
+
+ $response = $this->sendRequest('POST', '/layout/copy/' . $this->layout->layoutId, ['copyMediaFiles' => 0, 'name' => Random::generateString()]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response);
+
+ $body = json_decode($response->getBody());
+ $this->assertSame(201, $body->status);
+
+ $newLayoutId = $body->id;
+ $newLayout = $this->getEntityProvider()->get('/layout', ['layoutId' => $newLayoutId, 'embed' => 'regions,actions'])[0];
+ $this->assertNotEmpty($newLayout['actions']);
+ // delete the copied layout
+ (new XiboLayout($this->getEntityProvider()))->getById($newLayoutId)->delete();
+ }
+}
diff --git a/tests/integration/LayoutDraftTest.php b/tests/integration/LayoutDraftTest.php
new file mode 100644
index 0000000..bb23b9e
--- /dev/null
+++ b/tests/integration/LayoutDraftTest.php
@@ -0,0 +1,88 @@
+.
+ */
+namespace Xibo\Tests\integration;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutDraftTest
+ * @package Xibo\Tests\integration
+ */
+class LayoutDraftTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout */
+ private $layout;
+
+ public function setup()
+ {
+ parent::setup();
+
+ $this->layout = $this->createLayout();
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+ // This should always be the original, regardless of whether we checkout/discard/etc
+ $this->layout->delete();
+ }
+
+ /**
+ * Test adding a region to a Layout that has been checked out, but use the parent
+ */
+ public function testAddRegionCheckoutParent()
+ {
+ // Add region to our layout with data from regionSuccessCases
+ $response = $this->sendRequest('POST','/region/' . $this->layout->layoutId, [
+ 'width' => 100,
+ 'height' => 100,
+ 'top' => 10,
+ 'left' => 10
+ ]);
+ $this->assertSame(422, $response->getStatusCode(), 'Status Incorrect');
+ $object = json_decode($response->getBody());
+ $this->assertSame(false, $object->success);
+ $this->assertSame(422, $object->httpStatus);
+ }
+
+ /**
+ * Test adding a region to a Layout that has been checked out, using the draft
+ */
+ public function testAddRegionCheckout()
+ {
+ // Checkout the Parent, but add a Region to the Original
+ $layout = $this->getDraft($this->layout);
+
+ // Add region to our layout with data from regionSuccessCases
+ $response = $this->sendRequest('POST','/region/' . $layout->layoutId, [
+ 'width' => 100,
+ 'height' => 100,
+ 'top' => 10,
+ 'left' => 10
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/LayoutLockTest.php b/tests/integration/LayoutLockTest.php
new file mode 100644
index 0000000..726be40
--- /dev/null
+++ b/tests/integration/LayoutLockTest.php
@@ -0,0 +1,120 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Exception\XiboApiException;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+class LayoutLockTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout */
+ private $layout;
+
+ public function setup()
+ {
+ parent::setup();
+
+ $this->layout = $this->createLayout();
+
+ // Get Draft
+ $layout = $this->getDraft($this->layout);
+
+ $this->addSimpleWidget($layout);
+
+ $this->layout = $this->publish($this->layout);
+
+ // Set the Layout status
+ $this->setLayoutStatus($this->layout, 1);
+ }
+
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ parent::tearDown();
+ }
+
+ public function testIsLockedObject()
+ {
+ // draft
+ $layout = $this->checkout($this->layout);
+
+ // add simple Widget via API
+ $this->addSimpleWidget($layout);
+
+ // Get the Layout object via web request
+ $response = $this->sendRequest('GET', '/layout', ['layoutId' => $layout->layoutId], [], 'layoutLock');
+ $body = json_decode($response->getBody());
+ $layoutObject = $body[0];
+
+ // check if the isLocked dynamic object is there and is not empty, then check the values inside of it.
+ // we expect it to be locked with our LayoutId and API entryPoint
+ $this->assertNotEmpty($layoutObject->isLocked);
+ $this->assertSame($layout->layoutId, $layoutObject->isLocked->layoutId);
+ $this->assertSame('API', $layoutObject->isLocked->entryPoint);
+ }
+
+ public function testApiToWeb()
+ {
+ // draft
+ $layout = $this->checkout($this->layout);
+
+ // add simple Widget via API
+ $this->addSimpleWidget($layout);
+
+ // layout should be locked for our User with API entry point for 5 min.
+ // attempt to add another Widget via web request
+ $response = $this->sendRequest('POST', '/playlist/widget/clock/' . $layout->regions[0]->regionPlaylist->playlistId, [
+ 'duration' => 100,
+ 'useDuration' => 1
+ ], [], 'layoutLock');
+
+ $this->assertSame(403, $response->getStatusCode());
+ $body = json_decode($response->getBody());
+ $this->assertSame(403, $body->httpStatus);
+ $this->assertContains('Layout ID ' . $layout->layoutId . ' is locked by another User! Lock expires on:', $body->error);
+ }
+
+ public function testWebToApi()
+ {
+ // draft
+ $layout = $this->checkout($this->layout);
+
+ // call Layout status via web request, this will trigger the Layout Lock Middleware as well
+ $this->sendRequest('GET', '/layout/status/' . $layout->layoutId, [], [], 'layoutLock');
+
+ // attempt to add Widget via API
+ try {
+ $this->addSimpleWidget($layout);
+ } catch (XiboApiException $exception) {
+ $this->assertContains('Layout ID ' . $layout->layoutId . ' is locked by another User! Lock expires on:', $exception->getMessage());
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/tests/integration/LayoutTest.php b/tests/integration/LayoutTest.php
new file mode 100644
index 0000000..9001ebc
--- /dev/null
+++ b/tests/integration/LayoutTest.php
@@ -0,0 +1,903 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboRegion;
+use Xibo\OAuth2\Client\Entity\XiboResolution;
+use Xibo\Support\Exception\InvalidArgumentException;
+use Xibo\Support\Exception\NotFoundException;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LayoutTest
+ * @package Xibo\Tests\Integration
+ */
+class LayoutTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout[] */
+ protected $startLayouts;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all layouts that weren't there initially
+ $finalLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ // Loop over any remaining layouts and nuke them
+ foreach ($finalLayouts as $layout) {
+ /** @var XiboLayout $layout */
+ $flag = true;
+ foreach ($this->startLayouts as $startLayout) {
+ if ($startLayout->layoutId == $layout->layoutId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $layout->delete();
+ } catch (\Exception $e) {
+ $this->getLogger()->error('Unable to delete ' . $layout->layoutId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * @param $type
+ * @return int
+ */
+ private function getResolutionId($type)
+ {
+ if ($type === 'landscape') {
+ $width = 1920;
+ $height = 1080;
+ } else if ($type === 'portrait') {
+ $width = 1080;
+ $height = 1920;
+ } else {
+ return -10;
+ }
+
+ //$this->getLogger()->debug('Querying for ' . $width . ', ' . $height);
+
+ $resolutions = (new XiboResolution($this->getEntityProvider()))->get(['width' => $width, 'height' => $height]);
+
+ if (count($resolutions) <= 0)
+ return -10;
+
+ return $resolutions[0]->resolutionId;
+ }
+
+ /**
+ * List all layouts known empty
+ */
+ public function testListEmpty()
+ {
+ # Check that there is one layout in the database (the 'default layout')
+ if (count($this->startLayouts) > 1) {
+ $this->skipTest("There are pre-existing Layouts");
+ return;
+ }
+
+ $response = $this->sendRequest('GET','/layout');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # There should be one default layout in the system
+ $this->assertEquals(1, $object->data->recordsTotal);
+ }
+
+ /**
+ * testAddSuccess - test adding various Layouts that should be valid
+ * @dataProvider provideSuccessCases
+ */
+ public function testAddSuccess($layoutName, $layoutDescription, $layoutTemplateId, $layoutResolutionType)
+ {
+ $layoutResolutionId = $this->getResolutionId($layoutResolutionType);
+
+ # Create layouts with arguments from provideSuccessCases
+ $response = $this->sendRequest('POST','/layout', [
+ 'name' => $layoutName,
+ 'description' => $layoutDescription,
+ 'layoutId' => $layoutTemplateId,
+ 'resolutionId' => $layoutResolutionId
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ $this->assertSame($layoutName, $object->data->layout);
+ $this->assertSame($layoutDescription, $object->data->description);
+
+ # Check that the layout was really added
+ $layouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->assertEquals(count($this->startLayouts) + 1, count($layouts));
+
+ # Check that the layout was added correctly
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($layoutName, $layout->layout);
+ $this->assertSame($layoutDescription, $layout->description);
+
+ # Clean up the Layout as we no longer need it
+ $this->assertTrue($layout->delete(), 'Unable to delete ' . $layout->layoutId);
+ }
+
+ /**
+ * testAddFailure - test adding various Layouts that should be invalid
+ * @dataProvider provideFailureCases
+ */
+ public function testAddFailure($layoutName, $layoutDescription, $layoutTemplateId, $layoutResolutionType)
+ {
+ $layoutResolutionId = $this->getResolutionId($layoutResolutionType);
+
+ # Create layouts with arguments from provideFailureCases
+ $request = $this->createRequest('POST','/layout');
+ $request->withParsedBody([
+ 'name' => $layoutName,
+ 'description' => $layoutDescription,
+ 'layoutId' => $layoutTemplateId,
+ 'resolutionId' => $layoutResolutionId
+ ]);
+
+ try {
+ $this->app->handle($request);
+ } catch (InvalidArgumentException $e) {
+ # check if they fail as expected
+ $this->assertSame(422, $e->getCode(), 'Expecting failure, received ' . $e->getMessage());
+ } catch (NotFoundException $e ) {
+ $this->assertSame(404, $e->getCode(), 'Expecting failure, received ' . $e->getMessage());
+ }
+
+ }
+
+ /**
+ * List all layouts known set
+ * @group minimal
+ */
+ public function testListKnown()
+ {
+ $cases = $this->provideSuccessCases();
+ $layouts = [];
+
+ // Check each possible case to ensure it's not pre-existing
+ // If it is, skip over it
+ foreach ($cases as $case) {
+ $flag = true;
+ foreach ($this->startLayouts as $tmpLayout) {
+ if ($case[0] == $tmpLayout->layout) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ $layouts[] = (new XiboLayout($this->getEntityProvider()))->create($case[0],$case[1],$case[2],$this->getResolutionId($case[3]));
+ }
+ }
+
+ $response = $this->sendRequest('GET','/layout');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ # There should be as many layouts as we created plus the number we started with in the system
+ $this->assertEquals(count($layouts) + count($this->startLayouts), $object->data->recordsTotal);
+
+ # Clean up the Layouts we created
+ foreach ($layouts as $lay) {
+ $lay->delete();
+ }
+ }
+
+ /**
+ * List specific layouts
+ * @group minimal
+ * @group destructive
+ * @depends testListKnown
+ * @depends testAddSuccess
+ * @dataProvider provideSuccessCases
+ */
+ public function testListFilter($layoutName, $layoutDescription, $layoutTemplateId, $layoutResolutionType)
+ {
+ if (count($this->startLayouts) > 1) {
+ $this->skipTest("There are pre-existing Layouts");
+ return;
+ }
+
+ # Load in a known set of layouts
+ # We can assume this works since we depend upon the test which
+ # has previously added and removed these without issue:
+ $cases = $this->provideSuccessCases();
+ $layouts = [];
+ foreach ($cases as $case) {
+ $layouts[] = (new XiboLayout($this->getEntityProvider()))->create($case[0], $case[1], $case[2], $this->getResolutionId($case[3]));
+ }
+
+ // Fitler for our specific layout
+ $response = $this->sendRequest('GET','/layout', ['name' => $layoutName]);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ # There should be at least one match
+ $this->assertGreaterThanOrEqual(1, $object->data->recordsTotal);
+
+ $flag = false;
+ # Check that for the records returned, $layoutName is in the groups names
+ foreach ($object->data->data as $lay) {
+ if (strpos($layoutName, $lay->layout) == 0) {
+ $flag = true;
+ }
+ else {
+ // The object we got wasn't the exact one we searched for
+ // Make sure all the words we searched for are in the result
+ foreach (array_map('trim',explode(",",$layoutName)) as $word) {
+ assertTrue((strpos($word, $lay->layout) !== false), 'Layout returned did not match the query string: ' . $lay->layout);
+ }
+ }
+ }
+
+ $this->assertTrue($flag, 'Search term not found');
+
+ // Remove the Layouts we've created
+ foreach ($layouts as $lay) {
+ $lay->delete();
+ }
+ }
+
+ /**
+ * Each array is a test run
+ * Format (LayoutName, description, layoutID (template), resolution ID)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ # Data for testAddSuccess, easily expandable - just add another set of data below
+ return [
+ // Multi-language layouts
+ 'English 1' => ['phpunit test Layout', 'Api', NULL, 'landscape'],
+ 'French 1' => ['Test de Français 1', 'Bienvenue à la suite de tests Xibo', NULL, 'landscape'],
+ 'German 1' => ['Deutsch Prüfung 1', 'Weiß mit schwarzem Text', NULL, 'landscape'],
+ 'Simplified Chinese 1' => ['试验组', '测试组描述', NULL, 'landscape'],
+ 'Portrait layout' => ['Portrait layout', '1080x1920', '', 'portrait'],
+ 'No Description' => ['Just the title and resolution', NULL, '', 'portrait'],
+ 'Just title' => ['Just the name', NULL, NULL, 'portrait']
+ ];
+ }
+ /**
+ * Each array is a test run
+ * Format (LayoutName, description, layoutID (template), resolution ID)
+ * @return array
+ */
+ public function provideFailureCases()
+ {
+ # Data for testAddfailure, easily expandable - just add another set of data below
+ return [
+ // Description is limited to 255 characters
+ 'Description over 254 characters' => ['Too long description', Random::generateString(255), '', 'landscape'],
+ // Missing layout names
+ 'layout name empty' => ['', 'Layout name is empty', '', 'landscape'],
+ 'Layout name null' => [null, 'Layout name is null', '', 'landscape'],
+ 'Wrong resolution ID' => ['id not found', 'not found exception', '', 'invalid']
+ ];
+ }
+
+ /**
+ * Try and add two layouts with the same name
+ */
+ public function testAddDuplicate()
+ {
+ # Check if there are layouts with that name already in the system
+ $flag = true;
+ foreach ($this->startLayouts as $layout) {
+ if ($layout->layout == 'phpunit layout') {
+ $flag = false;
+ }
+ }
+ # Load in a known layout if it's not there already
+ $landscapeId = $this->getResolutionId('landscape');
+
+ if ($flag) {
+ (new XiboLayout($this->getEntityProvider()))->create(
+ 'phpunit layout',
+ 'phpunit layout',
+ '',
+ $landscapeId
+ );
+ }
+
+ $response = $this->sendRequest('POST','/layout', [
+ 'name' => 'phpunit layout',
+ 'description' => 'phpunit layout',
+ 'resolutionId' => $landscapeId
+ ]);
+ $this->assertSame(409, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode() . '. Body = ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(false, $object->success);
+ $this->assertContains('You already own a Layout called ', $object->error);
+ }
+
+ /**
+ * Edit an existing layout
+ */
+ public function testEdit()
+ {
+ // Create a known layout with a random name for us to work with.
+ // it will automatically get deleted in tearDown()
+ $layout = $this->createLayout();
+
+ // We do not need to checkout the Layout to perform an edit of its top level data.
+ // Change the layout name and description
+ $name = Random::generateString(8, 'phpunit');
+ $description = Random::generateString(8, 'description');
+ $response = $this->sendRequest('PUT','/layout/' . $layout->layoutId, [
+ 'name' => $name,
+ 'description' => $description
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+
+ # Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->layout);
+ $this->assertSame($description, $object->data->description);
+ # Check that the layout was actually renamed
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $layout->layout);
+ $this->assertSame($description, $layout->description);
+ # Clean up the Layout as we no longer need it
+ $layout->delete();
+ }
+
+ /**
+ * Edit an existing layout that should fail because of negative value in the backgroundzIndex
+ */
+ public function testEditFailure()
+ {
+ // Create a known layout with a random name for us to work with.
+ // it will automatically get deleted in tearDown()
+ $layout = $this->createLayout();
+
+ // Set a background z-index that is outside parameters
+ $response = $this->sendRequest('PUT','/layout/' . $layout->layoutId, [
+ 'backgroundColor' => $layout->backgroundColor,
+ 'backgroundzIndex' => -1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getBody());
+ }
+
+ /**
+ * Test delete
+ * @group minimal
+ */
+ public function testDelete()
+ {
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known layouts
+ $layout1 = (new XiboLayout($this->getEntityProvider()))->create($name1, 'phpunit description', '', $this->getResolutionId('landscape'));
+ $layout2 = (new XiboLayout($this->getEntityProvider()))->create($name2, 'phpunit description', '', $this->getResolutionId('landscape'));
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE','/layout/' . $layout2->layoutId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Check only one remains
+ $layouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->assertEquals(count($this->startLayouts) + 1, count($layouts));
+ $flag = false;
+ foreach ($layouts as $layout) {
+ if ($layout->layoutId == $layout1->layoutId) {
+ $flag = true;
+ }
+ }
+ $this->assertTrue($flag, 'Layout ID ' . $layout1->layoutId . ' was not found after deleting a different layout');
+ $layout1->delete();
+ }
+
+ /**
+ * Try to delete a layout that is assigned to a campaign
+ */
+ public function testDeleteAssigned()
+ {
+ # Load in a known layout
+ /** @var XiboLayout $layout */
+ $layout = (new XiboLayout($this->getEntityProvider()))->create('phpunit layout assigned', 'phpunit layout', '', $this->getResolutionId('landscape'));
+ // Make a campaign with a known name
+ $name = Random::generateString(8, 'phpunit');
+ $campaign = (new XiboCampaign($this->getEntityProvider()))->create($name);
+
+ // Assign layout to campaign
+ $this->getEntityProvider()->post('/campaign/layout/assign/' . $campaign->campaignId, [
+ 'layoutId' => $layout->layoutId
+ ]);
+
+ # Check if it's assigned
+ $campaignCheck = (new XiboCampaign($this->getEntityProvider()))->getById($campaign->campaignId);
+ $this->assertSame(1, $campaignCheck->numberLayouts);
+ # Try to Delete the layout assigned to the campaign
+ $response = $this->sendRequest('DELETE','/layout/' . $layout->layoutId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ }
+
+ /**
+ * Test Layout Retire
+ */
+ public function testRetire()
+ {
+ // Get known layout
+ $layout = $this->createLayout();
+
+ // Call retire
+ $response = $this->sendRequest('PUT','/layout/retire/' . $layout->layoutId, [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $this->assertSame(200, $response->getStatusCode());
+
+ // Get the same layout again and make sure its retired = 1
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($layout->layoutId);
+ $this->assertSame(1, $layout->retired, 'Retired flag not updated');
+ }
+
+ /**
+ * Test Unretire
+ */
+ public function testUnretire()
+ {
+ // Get known layout
+ /** @var XiboLayout $layout */
+ $layout = $this->createLayout();
+
+ // Retire it
+ $this->getEntityProvider()->put('/layout/retire/' . $layout->layoutId);
+
+ // Call layout edit with this Layout
+ $response = $this->sendRequest('PUT','/layout/unretire/' . $layout->layoutId, [], [
+ 'CONTENT_TYPE' => 'application/x-www-form-urlencoded'
+ ]);
+
+ // Make sure that was successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+
+ // Get the same layout again and make sure its retired = 0
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($layout->layoutId);
+
+ $this->assertSame(0, $layout->retired, 'Retired flag not updated. ' . $response->getBody());
+ }
+
+ /**
+ * Add new region to a specific layout
+ * @dataProvider regionSuccessCases
+ */
+ public function testAddRegionSuccess($regionWidth, $regionHeight, $regionTop, $regionLeft)
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ // Add region to our layout with data from regionSuccessCases
+ $response = $this->sendRequest('POST','/region/' . $layout->layoutId, [
+ 'width' => $regionWidth,
+ 'height' => $regionHeight,
+ 'top' => $regionTop,
+ 'left' => $regionLeft
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Check if region has intended values
+ $this->assertSame($regionWidth, $object->data->width);
+ $this->assertSame($regionHeight, $object->data->height);
+ $this->assertSame($regionTop, $object->data->top);
+ $this->assertSame($regionLeft, $object->data->left);
+ }
+
+ /**
+ * Each array is a test run
+ * Format (width, height, top, left)
+ * @return array
+ */
+ public function regionSuccessCases()
+ {
+ return [
+ // various correct regions
+ 'region 1' => [500, 350, 100, 150],
+ 'region 2' => [350, 200, 50, 50],
+ 'region 3' => [69, 69, 20, 420],
+ 'region 4 no offsets' => [69, 69, 0, 0]
+ ];
+ }
+
+ /**
+ * testAddFailure - test adding various regions that should be invalid
+ * @dataProvider regionFailureCases
+ */
+ public function testAddRegionFailure($regionWidth, $regionHeight, $regionTop, $regionLeft, $expectedHttpCode, $expectedWidth, $expectedHeight)
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ # Add region to our layout with datafrom regionFailureCases
+ $response = $this->sendRequest('POST','/region/' . $layout->layoutId, [
+ 'width' => $regionWidth,
+ 'height' => $regionHeight,
+ 'top' => $regionTop,
+ 'left' => $regionLeft
+ ]);
+
+ # Check if we receive failure as expected
+ $this->assertSame($expectedHttpCode, $response->getStatusCode(), 'Expecting failure, received ' . $response->getBody());
+ if ($expectedHttpCode == 200) {
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($expectedWidth, $object->data->width);
+ $this->assertSame($expectedHeight, $object->data->height);
+ }
+ }
+
+ /**
+ * Each array is a test run
+ * Format (width, height, top, left)
+ * @return array
+ */
+ public function regionFailureCases()
+ {
+ return [
+ // various incorrect regions
+ 'region no size' => [NULL, NULL, 20, 420, 200, 250, 250],
+ 'region negative dimensions' => [-69, -420, 20, 420, 422, null, null]
+ ];
+ }
+
+ /**
+ * Edit known region
+ */
+ public function testEditRegion()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ # Add region to our layout
+ $region = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,300,75,125);
+
+ # Edit region
+ $response = $this->sendRequest('PUT','/region/' . $region->regionId, [
+ 'name' => $layout->layout . ' edited',
+ 'width' => 700,
+ 'height' => 500,
+ 'top' => 400,
+ 'left' => 400,
+ 'loop' => 0,
+ 'zIndex' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Check if region has updated values
+ $this->assertSame(700, $object->data->width);
+ $this->assertSame(500, $object->data->height);
+ $this->assertSame(400, $object->data->top);
+ $this->assertSame(400, $object->data->left);
+ }
+
+ /**
+ * Edit known region that should fail because of negative z-index value
+ */
+ public function testEditRegionFailure()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ # Add region to our layout
+ $region = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,300,75,125);
+ # Edit region
+ $response = $this->sendRequest('PUT','/region/' . $region->regionId, [
+ 'width' => 700,
+ 'height' => 500,
+ 'top' => 400,
+ 'left' => 400,
+ 'loop' => 0,
+ 'zIndex' => -1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ # Check if it failed
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getBody());
+ }
+
+ /**
+ * delete region test
+ */
+ public function testDeleteRegion()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ $region = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200, 670, 100, 100);
+
+ # Delete region
+ $response = $this->sendRequest('DELETE','/region/' . $region->regionId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ }
+
+ /**
+ * Add tag to a layout
+ */
+ public function testAddTag()
+ {
+ # Create layout
+ $name = Random::generateString(8, 'phpunit');
+ $layout = (new XiboLayout($this->getEntityProvider()))->create($name, 'phpunit description', '', $this->getResolutionId('landscape'));
+ # Assign new tag to our layout
+ $response = $this->sendRequest('POST','/layout/' . $layout->layoutId . '/tag' , [
+ 'tag' => ['API']
+ ]);
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($layout->layoutId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ foreach ($layout->tags as $tag) {
+ $this->assertSame('API', $tag['tag']);
+ }
+ }
+
+ /**
+ * Delete tags from layout
+ */
+ public function testDeleteTag()
+ {
+ $name = Random::generateString(8, 'phpunit');
+ $layout = (new XiboLayout($this->getEntityProvider()))->create($name, 'phpunit description', '', $this->getResolutionId('landscape'));
+ $tag = 'API';
+ $layout->addTag($tag);
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($layout->layoutId);
+
+ $response = $this->sendRequest('POST','/layout/' . $layout->layoutId . '/untag', [
+ 'tag' => [$tag]
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $layout->delete();
+ }
+
+ /**
+ * Calculate layout status
+ */
+ public function testStatus()
+ {
+ # Create layout
+ $name = Random::generateString(8, 'phpunit');
+ $layout = (new XiboLayout($this->getEntityProvider()))->create($name, 'phpunit description', '', $this->getResolutionId('landscape'));
+ # Calculate layouts status
+ $response = $this->sendRequest('GET','/layout/status/' . $layout->layoutId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ }
+
+ /**
+ * Copy Layout Test
+ */
+ public function testCopy()
+ {
+ # Load in a known layout
+ /** @var XiboLayout $layout */
+ $layout = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit layout',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ // Generate new random name
+ $nameCopy = Random::generateString(8, 'phpunit');
+
+ // Call copy
+ $response = $this->sendRequest('POST','/layout/copy/' . $layout->layoutId, [
+ 'name' => $nameCopy,
+ 'description' => 'Copy',
+ 'copyMediaFiles' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ # Check if copied layout has correct name
+ $this->assertSame($nameCopy, $object->data->layout);
+
+ # Clean up the Layout as we no longer need it
+ $this->assertTrue($layout->delete(), 'Unable to delete ' . $layout->layoutId);
+ }
+
+ /**
+ * Position Test
+ */
+ public function testPosition()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ # Create Two known regions and add them to that layout
+ $region1 = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,670,75,125);
+ $region2 = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,300,475,625);
+
+ # Reposition regions on that layout
+ $regionJson = json_encode([
+ [
+ 'regionid' => $region1->regionId,
+ 'width' => 700,
+ 'height' => 500,
+ 'top' => 400,
+ 'left' => 400
+ ],
+ [
+ 'regionid' => $region2->regionId,
+ 'width' => 100,
+ 'height' => 100,
+ 'top' => 40,
+ 'left' => 40
+ ]
+ ]);
+
+ $response = $this->sendRequest('PUT','/region/position/all/' . $layout->layoutId, [
+ 'regions' => $regionJson
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(true, $object->success);
+ $this->assertSame(200, $object->status);
+ }
+
+ /**
+ * Position Test with incorrect parameters (missing height and incorrect spelling)
+ */
+ public function testPositionFailure()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ # Create Two known regions and add them to that layout
+ $region1 = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,670,75,125);
+ $region2 = (new XiboRegion($this->getEntityProvider()))->create($layout->layoutId, 200,300,475,625);
+
+ # Reposition regions on that layout with incorrect/missing parameters
+ $regionJson = json_encode([
+ [
+ 'regionid' => $region1->regionId,
+ 'width' => 700,
+ 'top' => 400,
+ 'left' => 400
+ ],
+ [
+ 'regionid' => $region2->regionId,
+ 'heigTH' => 100,
+ 'top' => 40,
+ 'left' => 40
+ ]
+ ]);
+
+ $response = $this->sendRequest('PUT','/region/position/all/' . $layout->layoutId, [
+ 'regions' => $regionJson
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ # Check if it fails as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(false, $object->success);
+ $this->assertSame(422, $object->httpStatus);
+ }
+
+ /**
+ * Add a Drawer to the Layout
+ */
+ public function testAddDrawer()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ // Add Drawer
+ $response = $this->sendRequest('POST', '/region/drawer/' . $layout->layoutId);
+
+ // Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ // Check if drawer has the right values
+ $this->assertSame($layout->width, $object->data->width);
+ $this->assertSame($layout->height, $object->data->height);
+ $this->assertSame(0, $object->data->top);
+ $this->assertSame(0, $object->data->left);
+ }
+
+ /**
+ * Edit a Drawer to the Layout
+ */
+ public function testSaveDrawer()
+ {
+ // Create a Layout and Checkout
+ $layout = $this->createLayout();
+ $layout = $this->getDraft($layout);
+
+ // Add Drawer
+ $drawer = $this->getEntityProvider()->post('/region/drawer/' . $layout->layoutId);
+
+ // Save drawer
+ $response = $this->sendRequest('PUT', '/region/drawer/' . $drawer['regionId'], [
+ 'width' => 1280,
+ 'height' => 720
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ // Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ // Check if drawer has the right values
+ $this->assertSame(1280, $object->data->width);
+ $this->assertSame(720, $object->data->height);
+ $this->assertSame(0, $object->data->top);
+ $this->assertSame(0, $object->data->left);
+ }
+}
diff --git a/tests/integration/LibraryTest.php b/tests/integration/LibraryTest.php
new file mode 100644
index 0000000..8f52a46
--- /dev/null
+++ b/tests/integration/LibraryTest.php
@@ -0,0 +1,280 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\LocalWebTestCase;
+
+class LibraryTest extends LocalWebTestCase
+{
+ protected $startMedias;
+ protected $mediaName;
+ protected $mediaType;
+ protected $mediaId;
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startMedias = (new XiboLibrary($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all media files that weren't there initially
+ $finalMedias = (new XiboLibrary($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining media files and nuke them
+ foreach ($finalMedias as $media) {
+ /** @var XiboLibrary $media */
+ $flag = true;
+ foreach ($this->startMedias as $startMedia) {
+ if ($startMedia->mediaId == $media->mediaId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $media->deleteAssigned();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $media->mediaId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * List all file in library
+ */
+ public function testListAll()
+ {
+ # Get all library items
+ $response = $this->sendRequest('GET','/library');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * Add new file to library
+ */
+ public function testAdd()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('API video test', PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+
+ $media->delete();
+ }
+
+ /**
+ * Add new file to library and replace old one in all layouts
+ */
+ public function testReplace()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('flowers', PROJECT_ROOT . '/tests/resources/xts-flowers-001.jpg');
+ # Replace the image and update it in all layouts (name, file location, old media id, replace in all layouts flag, delete old revision flag)
+ $media2 = (new XiboLibrary($this->getEntityProvider()))->create('API replace image', PROJECT_ROOT . '/tests/resources/xts-flowers-002.jpg', $media->mediaId, 1, 1);
+ }
+
+ /**
+ * try to add not allowed filetype
+ */
+ public function testAddEmpty()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $this->expectException('\Xibo\OAuth2\Client\Exception\XiboApiException');
+
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('API incorrect file 2', PROJECT_ROOT . '/tests/resources/empty.txt');
+ }
+
+ /**
+ * Add tags to media
+ */
+ public function testAddTag()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('flowers 2', PROJECT_ROOT . '/tests/resources/xts-flowers-001.jpg');
+
+ $response = $this->sendRequest('POST','/library/' . $media->mediaId . '/tag', [
+ 'tag' => ['API']
+ ]);
+ $media = (new XiboLibrary($this->getEntityProvider()))->getById($media->mediaId);
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ foreach ($media->tags as $tag) {
+ $this->assertSame('API', $tag['tag']);
+ }
+ $media->delete();
+ }
+
+ /**
+ * Delete tags from media
+ * @group broken
+ */
+ public function testDeleteTag()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('flowers', PROJECT_ROOT . '/tests/resources/xts-flowers-001.jpg');
+ $media->AddTag('API');
+ $media = (new XiboLibrary($this->getEntityProvider()))->getById($media->mediaId);
+ $this->assertSame('API', $media->tags);
+
+ $response = $this->sendRequest('POST','/library/' . $media->mediaId . '/untag', [
+ 'tag' => ['API']
+ ]);
+ $media = (new XiboLibrary($this->getEntityProvider()))->getById($media->mediaId);
+ print_r($media->tags);
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $media->delete();
+ }
+
+ /**
+ * Edit media file
+ */
+ public function testEdit()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('API video 4', PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+ # Generate new random name
+ $name = Random::generateString(8, 'phpunit');
+ # Edit media file, change the name
+ $response = $this->sendRequest('PUT','/library/' . $media->mediaId, [
+ 'name' => $name,
+ 'duration' => 50,
+ 'retired' => $media->retired,
+ 'tags' => $media->tags,
+ 'updateInLayouts' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertSame($name, $object->data->name);
+ $media = (new XiboLibrary($this->getEntityProvider()))->getById($media->mediaId);
+ $this->assertSame($name, $media->name);
+ $media->delete();
+ }
+
+ /**
+ * Test delete added media
+ */
+ public function testDelete()
+ {
+ # Using XiboLibrary wrapper to upload new file to the CMS, need to provide (name, file location)
+ $media = (new XiboLibrary($this->getEntityProvider()))->create('API video 4', PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+ # Delete added media file
+ $response = $this->sendRequest('DELETE','/library/' . $media->mediaId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ }
+
+ /**
+ * Library tidy
+ */
+ public function testTidy()
+ {
+ $response = $this->sendRequest('DELETE','/library/tidy');
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ }
+
+ public function testUploadFromUrl()
+ {
+ shell_exec('cp -r ' . PROJECT_ROOT . '/tests/resources/rss/image1.jpg ' . PROJECT_ROOT . '/web');
+
+ $response = $this->sendRequest('POST','/library/uploadUrl', [
+ 'url' => 'http://localhost/image1.jpg'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertNotEmpty($object->data, 'Empty Response');
+ $this->assertSame('image', $object->data->mediaType);
+ $this->assertSame(0, $object->data->expires);
+ $this->assertSame('image1', $object->data->name);
+ $this->assertNotEmpty($object->data->mediaId, 'Not successful, MediaId is empty');
+
+ $module = $this->getEntityProvider()->get('/module', ['name' => 'Image']);
+ $moduleDefaultDuration = $module[0]['defaultDuration'];
+
+ $this->assertSame($object->data->duration, $moduleDefaultDuration);
+
+ shell_exec('rm -r ' . PROJECT_ROOT . '/web/image1.jpg');
+ }
+
+ public function testUploadFromUrlWithType()
+ {
+ shell_exec('cp -r ' . PROJECT_ROOT . '/tests/resources/rss/image2.jpg ' . PROJECT_ROOT . '/web');
+
+ $response = $this->getEntityProvider()->post('/library/uploadUrl?envelope=1', [
+ 'url' => 'http://localhost/image2.jpg',
+ 'type' => 'image'
+ ]);
+
+ $this->assertSame(201, $response['status'], json_encode($response));
+ $this->assertNotEmpty($response['data'], 'Empty Response');
+ $this->assertSame('image', $response['data']['mediaType']);
+ $this->assertSame(0, $response['data']['expires']);
+ $this->assertSame('image2', $response['data']['name']);
+ $this->assertNotEmpty($response['data']['mediaId'], 'Not successful, MediaId is empty');
+
+ $module = $this->getEntityProvider()->get('/module', ['name' => 'Image']);
+ $moduleDefaultDuration = $module[0]['defaultDuration'];
+
+ $this->assertSame($response['data']['duration'], $moduleDefaultDuration);
+
+ shell_exec('rm -r ' . PROJECT_ROOT . '/web/image2.jpg');
+ }
+
+ public function testUploadFromUrlWithTypeAndName()
+ {
+ shell_exec('cp -r ' . PROJECT_ROOT . '/tests/resources/HLH264.mp4 ' . PROJECT_ROOT . '/web');
+
+ $response = $this->getEntityProvider()->post('/library/uploadUrl?envelope=1', [
+ 'url' => 'http://localhost/HLH264.mp4',
+ 'type' => 'video',
+ 'optionalName' => 'PHPUNIT URL upload video'
+ ]);
+
+ $this->assertSame(201, $response['status'], json_encode($response));
+ $this->assertNotEmpty($response['data'], 'Empty Response');
+ $this->assertSame('video', $response['data']['mediaType']);
+ $this->assertSame(0, $response['data']['expires']);
+ $this->assertSame('PHPUNIT URL upload video', $response['data']['name']);
+ $this->assertNotEmpty($response['data']['mediaId'], 'Not successful, MediaId is empty');
+
+ // for videos we expect the Media duration to be the actual video duration.
+ $this->assertSame($response['data']['duration'], 78);
+
+ shell_exec('rm -r ' . PROJECT_ROOT . '/web/HLH264.mp4');
+ }
+}
diff --git a/tests/integration/MenuBoardCategoryTest.php b/tests/integration/MenuBoardCategoryTest.php
new file mode 100644
index 0000000..a8c4b76
--- /dev/null
+++ b/tests/integration/MenuBoardCategoryTest.php
@@ -0,0 +1,118 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\LocalWebTestCase;
+
+class MenuBoardCategoryTest extends LocalWebTestCase
+{
+ private $menuBoard;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->menuBoard = $this->getEntityProvider()->post('/menuboard', [
+ 'name' => Random::generateString(10, 'phpunit'),
+ 'description' => 'Description for test Menu Board'
+ ]);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ if ($this->menuBoard['menuId'] !== null) {
+ $this->getEntityProvider()->delete('/menuboard/' . $this->menuBoard['menuId']);
+ }
+ }
+
+ public function testListEmpty()
+ {
+ $response = $this->sendRequest('GET', '/menuboard/' . $this->menuBoard['menuId'] . '/categories');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(0, $object->data->recordsTotal);
+ }
+
+ public function testAdd()
+ {
+ $media = (new XiboLibrary($this->getEntityProvider()))->create(Random::generateString(10, 'API Image'), PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+ $name = Random::generateString(10, 'Category Add');
+
+ $response = $this->sendRequest('POST', '/menuboard/' . $this->menuBoard['menuId'] . '/category', [
+ 'name' => $name,
+ 'mediaId' => $media->mediaId
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame($media->mediaId, $object->data->mediaId);
+
+ $media->delete();
+ }
+
+ public function testEdit()
+ {
+ $menuBoardCategory = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoard['menuId'] . '/category', [
+ 'name' => 'Test Menu Board Category Edit'
+ ]);
+ $name = Random::generateString(10, 'Category Edit');
+
+ $response = $this->sendRequest('PUT', '/menuboard/' . $menuBoardCategory['menuCategoryId'] . '/category', [
+ 'name' => $name,
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ }
+
+ public function testDelete()
+ {
+ $menuBoardCategory = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoard['menuId'] . '/category', [
+ 'name' => 'Test Menu Board Category Delete'
+ ]);
+
+ $response = $this->sendRequest('DELETE', '/menuboard/' . $menuBoardCategory['menuCategoryId'] . '/category');
+
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ }
+}
diff --git a/tests/integration/MenuBoardProductTest.php b/tests/integration/MenuBoardProductTest.php
new file mode 100644
index 0000000..350bc6c
--- /dev/null
+++ b/tests/integration/MenuBoardProductTest.php
@@ -0,0 +1,198 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\LocalWebTestCase;
+
+class MenuBoardProductTest extends LocalWebTestCase
+{
+ private $menuBoard;
+ private $menuBoardCategory;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->menuBoard = $this->getEntityProvider()->post('/menuboard', [
+ 'name' => Random::generateString(10, 'phpunit'),
+ 'description' => 'Description for test Menu Board'
+ ]);
+
+ $this->menuBoardCategory = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoard['menuId'] . '/category', [
+ 'name' => 'Test Menu Board Category Edit'
+ ]);
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ if ($this->menuBoard['menuId'] !== null) {
+ $this->getEntityProvider()->delete('/menuboard/' . $this->menuBoard['menuId']);
+ }
+ }
+
+ public function testListEmpty()
+ {
+ $response = $this->sendRequest('GET', '/menuboard/' . $this->menuBoardCategory['menuCategoryId'] . '/products');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(0, $object->data->recordsTotal);
+ }
+
+ public function testAdd()
+ {
+ $media = (new XiboLibrary($this->getEntityProvider()))->create(Random::generateString(10, 'API Image'), PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+ $name = Random::generateString(10, 'Product Add');
+
+ $response = $this->sendRequest('POST', '/menuboard/' . $this->menuBoardCategory['menuCategoryId'] . '/product', [
+ 'name' => $name,
+ 'mediaId' => $media->mediaId,
+ 'price' => '$12.40',
+ 'description' => 'Product Description',
+ 'allergyInfo' => 'N/A',
+ 'availability' => 1,
+ 'productOptions' => ['small', 'medium', 'large'],
+ 'productValues' => ['$10.40', '$15.40', '$20.20']
+
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame($media->mediaId, $object->data->mediaId);
+ $this->assertSame('$12.40', $object->data->price);
+ $this->assertSame('Product Description', $object->data->description);
+ $this->assertSame('N/A', $object->data->allergyInfo);
+ $this->assertSame(1, $object->data->availability);
+
+ // product options are ordered by option name
+ $this->assertSame($object->id, $object->data->productOptions[2]->menuProductId);
+ $this->assertSame('small', $object->data->productOptions[2]->option);
+ $this->assertSame('$10.40', $object->data->productOptions[2]->value);
+ $this->assertSame($object->id, $object->data->productOptions[1]->menuProductId);
+ $this->assertSame('medium', $object->data->productOptions[1]->option);
+ $this->assertSame('$15.40', $object->data->productOptions[1]->value);
+ $this->assertSame($object->id, $object->data->productOptions[0]->menuProductId);
+ $this->assertSame('large', $object->data->productOptions[0]->option);
+ $this->assertSame('$20.20', $object->data->productOptions[0]->value);
+
+ $media->delete();
+ }
+
+ /**
+ * @dataProvider provideFailureCases
+ */
+ public function testAddFailure($name, $price)
+ {
+ $response = $this->sendRequest('POST', '/menuboard/' . $this->menuBoardCategory['menuCategoryId'] . '/product', [
+ 'name' => $name,
+ 'price' => $price
+ ]);
+
+ # check if they fail as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Each array is a test run
+ * Format (name, price, productOptions, productValues)
+ * @return array
+ */
+ public function provideFailureCases()
+ {
+ return [
+ 'empty name' => ['', '$11', [], []],
+ 'empty price' => ['Test Product', null, [], []]
+ ];
+ }
+
+ public function testEdit()
+ {
+ $menuBoardProduct = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoardCategory['menuCategoryId'] . '/product', [
+ 'name' => 'Test Menu Board Product Edit',
+ 'price' => '$11.11',
+ 'productOptions' => ['small', 'medium', 'large'],
+ 'productValues' => ['$10.40', '$15.40', '$20.20']
+ ]);
+ $name = Random::generateString(10, 'Product Edit');
+
+ $response = $this->sendRequest('PUT', '/menuboard/' . $menuBoardProduct['menuProductId'] . '/product', [
+ 'name' => $name,
+ 'price' => '$9.99',
+ 'description' => 'Product Description Edited',
+ 'allergyInfo' => '',
+ 'availability' => 1,
+ 'productOptions' => ['small', 'medium', 'large'],
+ 'productValues' => ['$8.40', '$12.40', '$15.20']
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame('$9.99', $object->data->price);
+ $this->assertSame('Product Description Edited', $object->data->description);
+ $this->assertSame('', $object->data->allergyInfo);
+ $this->assertSame(1, $object->data->availability);
+
+ // product options are ordered by option name
+ $this->assertSame($object->id, $object->data->productOptions[2]->menuProductId);
+ $this->assertSame('small', $object->data->productOptions[2]->option);
+ $this->assertSame('$8.40', $object->data->productOptions[2]->value);
+ $this->assertSame($object->id, $object->data->productOptions[1]->menuProductId);
+ $this->assertSame('medium', $object->data->productOptions[1]->option);
+ $this->assertSame('$12.40', $object->data->productOptions[1]->value);
+ $this->assertSame($object->id, $object->data->productOptions[0]->menuProductId);
+ $this->assertSame('large', $object->data->productOptions[0]->option);
+ $this->assertSame('$15.20', $object->data->productOptions[0]->value);
+ }
+
+ public function testDelete()
+ {
+ $menuBoardProduct = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoardCategory['menuCategoryId'] . '/product', [
+ 'name' => 'Test Menu Board Category Delete',
+ 'price' => '$11.11'
+ ]);
+
+ $response = $this->sendRequest('DELETE', '/menuboard/' . $menuBoardProduct['menuProductId'] . '/product');
+
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ }
+}
diff --git a/tests/integration/MenuBoardTest.php b/tests/integration/MenuBoardTest.php
new file mode 100644
index 0000000..2c0c8ba
--- /dev/null
+++ b/tests/integration/MenuBoardTest.php
@@ -0,0 +1,114 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\Tests\LocalWebTestCase;
+
+class MenuBoardTest extends LocalWebTestCase
+{
+ private $menuBoardId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ }
+
+ public function tearDown()
+ {
+ parent::tearDown();
+
+ if ($this->menuBoardId !== null) {
+ $this->getEntityProvider()->delete('/menuboard/' . $this->menuBoardId);
+ }
+ }
+
+ public function testListAll()
+ {
+ $response = $this->sendRequest('GET', '/menuboards');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ public function testAdd()
+ {
+ $name = Random::generateString(10, 'MenuBoard Add');
+ $response = $this->sendRequest('POST', '/menuboard', [
+ 'name' => $name,
+ 'description' => 'Description for test Menu Board Add'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame('Description for test Menu Board Add', $object->data->description);
+
+ $this->menuBoardId = $object->id;
+ }
+
+ public function testEdit()
+ {
+ $menuBoard = $this->getEntityProvider()->post('/menuboard', [
+ 'name' => 'Test Menu Board Edit',
+ 'description' => 'Description for test Menu Board Edit'
+ ]);
+ $name = Random::generateString(10, 'MenuBoard Edit');
+ $response = $this->sendRequest('PUT', '/menuboard/' . $menuBoard['menuId'], [
+ 'name' => $name,
+ 'description' => 'Test Menu Board Edited description'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->name);
+ $this->assertSame('Test Menu Board Edited description', $object->data->description);
+
+ $this->menuBoardId = $object->id;
+ }
+
+ public function testDelete()
+ {
+ $menuBoard = $this->getEntityProvider()->post('/menuboard', [
+ 'name' => 'Test Menu Board Delete',
+ 'description' => 'Description for test Menu Board Delete'
+ ]);
+
+ $response = $this->sendRequest('DELETE', '/menuboard/' . $menuBoard['menuId']);
+
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ }
+}
diff --git a/tests/integration/NotificationTest.php b/tests/integration/NotificationTest.php
new file mode 100644
index 0000000..888a275
--- /dev/null
+++ b/tests/integration/NotificationTest.php
@@ -0,0 +1,196 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\OAuth2\Client\Entity\XiboNotification;
+use Xibo\Tests\LocalWebTestCase;
+
+class NotificationTest extends LocalWebTestCase
+{
+ /** @var XiboDisplayGroup[] */
+ protected $startDisplayGroups;
+
+ /** @var XiboNotification[] */
+ protected $startNotifications;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startDisplayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startNotifications = (new XiboNotification($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all display groups that weren't there initially
+ $finalDisplayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ # Loop over any remaining display groups and nuke them
+ foreach ($finalDisplayGroups as $displayGroup) {
+ /** @var XiboDisplayGroup $displayGroup */
+
+ $flag = true;
+
+ foreach ($this->startDisplayGroups as $startGroup) {
+ if ($startGroup->displayGroupId == $displayGroup->displayGroupId) {
+ $flag = false;
+ }
+ }
+
+ if ($flag) {
+ try {
+ $displayGroup->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $displayGroup->displayGroupId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ // tearDown all notifications that weren't there initially
+ $finalNotifications = (new XiboNotification($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+
+ # Loop over any remaining notifications and nuke them
+ foreach ($finalNotifications as $notification) {
+ /** @var XiboNotification $notification */
+
+ $flag = true;
+
+ foreach ($this->startNotifications as $startNotf) {
+ if ($startNotf->notificationId == $notification->notificationId) {
+ $flag = false;
+ }
+ }
+
+ if ($flag) {
+ try {
+ $notification->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $notification->notificationId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * List notifications
+ */
+ public function testListAll()
+ {
+ # Get all notifications
+ $response = $this->sendRequest('GET','/notification');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * Create new Notification
+ */
+ public function testAdd()
+ {
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'notification description', 0, '');
+ # Add new notification and assign it to our display group
+ $subject = 'API Notification';
+ $response = $this->sendRequest('POST','/notification', [
+ 'subject' => $subject,
+ 'body' => 'Notification body text',
+ 'releaseDt' => '2016-09-01 00:00:00',
+ 'isEmail' => 0,
+ 'isInterrupt' => 0,
+ 'displayGroupIds' => [$displayGroup->displayGroupId]
+ // 'userGroupId' =>
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ # Check if the subject is correctly set
+ $this->assertSame($subject, $object->data->subject);
+ }
+
+ /**
+ * Delete notification
+ */
+ public function testDelete()
+ {
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+ # Create new notification
+ $notification = (new XiboNotification($this->getEntityProvider()))->create('API subject', 'API body', '2016-09-01 00:00:00', 0, 0, [$displayGroup->displayGroupId]);
+ # Delete notification
+ $response = $this->sendRequest('DELETE','/notification/' . $notification->notificationId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status);
+ # Clean up
+ $displayGroup->delete();
+ }
+
+ /**
+ * Edit notification
+ */
+ public function testEdit()
+ {
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+ # Create new notification
+ $notification = (new XiboNotification($this->getEntityProvider()))->create('API subject', 'API body', '2016-09-01 00:00:00', 0, 0, [$displayGroup->displayGroupId]);
+ $notification->releaseDt = Carbon::createFromTimestamp($notification->releaseDt)->format(DateFormatHelper::getSystemFormat());
+ # Create new subject
+ $subjectNew = 'Subject edited via API';
+ # Edit our notification
+ $response = $this->sendRequest('PUT','/notification/' . $notification->notificationId, [
+ 'subject' => $subjectNew,
+ 'body' => $notification->body,
+ 'releaseDt' => $notification->releaseDt,
+ 'isEmail' => $notification->isEmail,
+ 'isInterrupt' => $notification->isInterrupt,
+ 'displayGroupIds' => [$displayGroup->displayGroupId]
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+
+ # Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($subjectNew, $object->data->subject);
+ # Clean up
+ $displayGroup->delete();
+ $notification->delete();
+ }
+}
diff --git a/tests/integration/PlayerSoftwareTest.php b/tests/integration/PlayerSoftwareTest.php
new file mode 100644
index 0000000..b1f9c15
--- /dev/null
+++ b/tests/integration/PlayerSoftwareTest.php
@@ -0,0 +1,204 @@
+.
+ */
+namespace Xibo\Tests\integration;
+
+use Xibo\Entity\Display;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboDisplayProfile;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class PlayerSoftwareTest
+ * @package Xibo\Tests\integration
+ */
+class PlayerSoftwareTest extends LocalWebTestCase
+{
+ use DisplayHelperTrait;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $media2;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplayProfile */
+ protected $displayProfile;
+
+ protected $version;
+ protected $version2;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) . ' Test');
+
+ // Upload version files
+ $uploadVersion = $this->uploadVersionFile(Random::generateString(), PROJECT_ROOT . '/tests/resources/Xibo_for_Android_v1.7_R61.apk');
+ $this->version = $uploadVersion['files'][0];
+ $uploadVersion2 = $this->uploadVersionFile(Random::generateString(), PROJECT_ROOT . '/tests/resources/Xibo_for_Android_v1.8_R108.apk');
+ $this->version2 = $uploadVersion2['files'][0];
+
+ // Create a Display
+ $this->display = $this->createDisplay(null, 'android');
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Create a display profile
+ $this->displayProfile = (new XiboDisplayProfile($this->getEntityProvider()))->create(Random::generateString(), 'android', 0);
+
+ // Edit display profile to add the uploaded apk to the config
+ $this->getEntityProvider()->put('/displayprofile/' . $this->displayProfile->displayProfileId, [
+ 'name' => $this->displayProfile->name,
+ 'type' => $this->displayProfile->type,
+ 'isDefault' => $this->displayProfile->isDefault,
+ 'versionMediaId' => $this->version['id']
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ // Delete the version files we've been working with
+ $this->getEntityProvider()->delete('/playersoftware/' . $this->version['id']);
+ $this->getEntityProvider()->delete('/playersoftware/' . $this->version2['id']);
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ // Delete the Display profile
+ $this->displayProfile->delete();
+
+ parent::tearDown();
+ }
+ //
+
+ public function testVersionFromProfile()
+ {
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Edit display, assign it to the created display profile
+ $response = $this->sendRequest('PUT','/display/' . $this->display->displayId, [
+ 'display' => $this->display->display,
+ 'licensed' => $this->display->licensed,
+ 'license' => $this->display->license,
+ 'defaultLayoutId' => $this->display->defaultLayoutId,
+ 'displayProfileId' => $this->displayProfile->displayProfileId,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'] );
+
+ // Check response
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame($this->displayProfile->displayProfileId, $object->data->displayProfileId, $response->getBody());
+
+ // Ensure the Version Instructions are present on the Register Display call and that
+ // Register our display
+ $register = $this->getXmdsWrapper()->RegisterDisplay($this->display->license,
+ $this->display->license,
+ 'android',
+ null,
+ null,
+ null,
+ '00:16:D9:C9:AL:69',
+ $this->display->xmrChannel,
+ $this->display->xmrPubKey
+ );
+
+ $this->getLogger()->debug($register);
+
+ $this->assertContains($this->version['fileName'], $register, 'Version information not in Register');
+ $this->assertContains('61', $register, 'Version information Code not in Register');
+ }
+
+ public function testVersionOverride()
+ {
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Edit display, set the versionMediaId
+ $response = $this->sendRequest('PUT','/display/' . $this->display->displayId, [
+ 'display' => $this->display->display,
+ 'licensed' => $this->display->licensed,
+ 'license' => $this->display->license,
+ 'versionMediaId' => $this->version2['id'],
+ 'defaultLayoutId' => $this->display->defaultLayoutId,
+ 'displayProfileId' => $this->displayProfile->displayProfileId
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'] );
+
+ // Check response
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame($this->displayProfile->displayProfileId, $object->data->displayProfileId, $response->getBody());
+ $this->assertNotEmpty($object->data->overrideConfig);
+
+ foreach ($object->data->overrideConfig as $override) {
+ if ($override->name === 'versionMediaId') {
+ $this->assertSame($this->version2['id'], $override->value, json_encode($object->data->overrideConfig));
+ }
+ }
+
+ // call register
+ $register = $this->getXmdsWrapper()->RegisterDisplay($this->display->license,
+ $this->display->license,
+ 'android',
+ null,
+ null,
+ null,
+ '00:16:D9:C9:AL:69',
+ $this->display->xmrChannel,
+ $this->display->xmrPubKey
+ );
+ // make sure the media ID set on the display itself is in the register
+ $this->getLogger()->debug($register);
+ $this->assertContains($this->version2['fileName'], $register, 'Version information not in Register');
+ $this->assertContains('108', $register, 'Version information Code not in Register');
+ }
+
+ private function uploadVersionFile($fileName, $filePath)
+ {
+ $payload = [
+ [
+ 'name' => 'name',
+ 'contents' => $fileName
+ ],
+ [
+ 'name' => 'files',
+ 'contents' => fopen($filePath, 'r')
+ ]
+ ];
+
+ return $this->getEntityProvider()->post('/playersoftware', ['multipart' => $payload]);
+ }
+}
diff --git a/tests/integration/PlaylistTest.php b/tests/integration/PlaylistTest.php
new file mode 100644
index 0000000..ec87ecd
--- /dev/null
+++ b/tests/integration/PlaylistTest.php
@@ -0,0 +1,169 @@
+.
+ */
+namespace Xibo\Tests\integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class PlaylistTest
+ * @package Xibo\Tests\integration
+ */
+class PlaylistTest extends LocalWebTestCase
+{
+ /** @var XiboPlaylist[] */
+ private $playlists;
+
+ /** @var XiboPlaylist */
+ private $duplicateName;
+
+ public function setup()
+ {
+ parent::setup();
+
+ $this->duplicateName = (new XiboPlaylist($this->getEntityProvider()))->hydrate($this->getEntityProvider()->post('/playlist', [
+ 'name' => Random::generateString(5, 'playlist')
+ ]));
+
+ // Add a Playlist to use for the duplicate name test
+ $this->playlists[] = $this->duplicateName;
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tearing down, removing ' . count($this->playlists));
+
+ // Delete any Playlists we've added
+ foreach ($this->playlists as $playlist) {
+ $this->getEntityProvider()->delete('/playlist/' . $playlist->playlistId);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @return array
+ */
+ public function addPlaylistCases()
+ {
+ return [
+ 'Normal add' => [200, Random::generateString(5, 'playlist'), null, 0, null, null],
+ 'Tags add' => [200, Random::generateString(5, 'playlist'), 'test', 0, null, null],
+ 'Dynamic add' => [200, Random::generateString(5, 'playlist'), null, 1, 'test', null]
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function addPlaylistFailureCases()
+ {
+ return [
+ 'Dynamic without filter' => [422, Random::generateString(5, 'playlist'), null, 1, null, null],
+ 'Without a name' => [422, null, null, 1, null, null]
+ ];
+ }
+
+ /**
+ * @dataProvider addPlaylistCases
+ */
+ public function testAddPlaylist($statusCode, $name, $tags, $isDynamic, $nameFilter, $tagFilter)
+ {
+ // Add this Playlist
+ $response = $this->sendRequest('POST', '/playlist', [
+ 'name' => $name,
+ 'tags' => $tags,
+ 'isDynamic' => $isDynamic,
+ 'filterMediaName' => $nameFilter,
+ 'filterMediaTag' => $tagFilter
+ ]);
+
+ // Check the response headers
+ $this->assertSame($statusCode, $response->getStatusCode(), 'Not successful: ' . $response->getStatusCode() . $response->getBody());
+
+ // Make sure we have a useful body
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, 'Missing data');
+ $this->assertObjectHasAttribute('id', $object, 'Missing id');
+
+ // Add to the list of playlists to clean up
+ if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
+ $this->playlists[] = (new XiboPlaylist($this->getEntityProvider()))->hydrate((array)$object->data);
+ }
+
+ // Get the Playlists back out from the API, to double check it has been created as we expected
+ /** @var XiboPlaylist $playlistCheck */
+ $playlistCheck = (new XiboPlaylist($this->getEntityProvider()))->hydrate($this->getEntityProvider()->get('/playlist', ['playlistId' => $object->id])[0]);
+
+ $this->assertEquals($name, $playlistCheck->name, 'Names are not identical');
+ }
+
+ /**
+ * @dataProvider addPlaylistFailureCases
+ */
+ public function testAddPlaylistFailure($statusCode, $name, $tags, $isDynamic, $nameFilter, $tagFilter)
+ {
+ $response = $this->sendRequest('POST', '/playlist', [
+ 'name' => $name,
+ 'tags' => $tags,
+ 'isDynamic' => $isDynamic,
+ 'filterMediaName' => $nameFilter,
+ 'filterMediaTag' => $tagFilter
+ ]);
+
+ // Check the response headers
+ $this->assertSame($statusCode, $response->getStatusCode(), 'Not successful: ' . $response->getStatusCode() . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, 'Missing data');
+ $this->assertSame([], $object->data);
+ $this->assertObjectHasAttribute('error', $object, 'Missing error');
+ $this->assertObjectNotHasAttribute('id', $object);
+ }
+
+ /**
+ * Edit test
+ */
+ public function testEditPlaylist()
+ {
+ // New name
+ $newName = Random::generateString(5, 'playlist');
+
+ // Take the duplicate name playlist, and edit it
+ $response = $this->sendRequest('PUT','/playlist/' . $this->duplicateName->playlistId, [
+ 'name' => $newName,
+ 'tags' => null,
+ 'isDynamic' => 0,
+ 'nameFilter' => null,
+ 'tagFilter' => null
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ // Check the response headers
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getStatusCode() . $response->getBody());
+
+ /** @var XiboPlaylist $playlistCheck */
+ $playlistCheck = (new XiboPlaylist($this->getEntityProvider()))->hydrate($this->getEntityProvider()->get('/playlist', ['playlistId' => $this->duplicateName->playlistId])[0]);
+
+ $this->assertEquals($newName, $playlistCheck->name, 'Names are not identical');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/PlaylistWidgetListTest.php b/tests/integration/PlaylistWidgetListTest.php
new file mode 100644
index 0000000..25eee34
--- /dev/null
+++ b/tests/integration/PlaylistWidgetListTest.php
@@ -0,0 +1,89 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class PlaylistTest
+ * @package Xibo\Tests\Integration
+ */
+class PlaylistWidgetListTest extends LocalWebTestCase
+{
+ /** @var XiboPlaylist */
+ private $playlist;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Playlist
+ $this->playlist = (new XiboPlaylist($this->getEntityProvider()))->hydrate($this->getEntityProvider()->post('/playlist', [
+ 'name' => Random::generateString(5, 'playlist')
+ ]));
+
+ // Assign some Widgets
+ $this->getEntityProvider()->post('/playlist/widget/clock/' . $this->playlist->playlistId, [
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $text = $this->getEntityProvider()->post('/playlist/widget/text/' . $this->playlist->playlistId);
+ $this->getEntityProvider()->put('/playlist/widget/' . $text['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Playlist
+
+ parent::tearDown();
+ }
+
+ /**
+ * List all items in playlist
+ */
+ public function testGetWidget()
+ {
+ // Search widgets on our playlist
+ $response = $this->sendRequest('GET','/playlist/widget', [
+ 'playlistId' => $this->playlist->playlistId
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(2, $object->data->recordsTotal);
+ }
+}
diff --git a/tests/integration/ProofOfPlayOnOff.php b/tests/integration/ProofOfPlayOnOff.php
new file mode 100644
index 0000000..a7bbb37
--- /dev/null
+++ b/tests/integration/ProofOfPlayOnOff.php
@@ -0,0 +1,295 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboImage;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ProofOfPlayOnOff
+ * @package Xibo\Tests\Integration
+ */
+class ProofOfPlayOnOff extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboLayout */
+ protected $layout2;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // get draft Layout
+ $layout = $this->getDraft($this->layout);
+
+ // Create another Layout with stat enabled
+ $this->layout2 = (new XiboLayout($this->getEntityProvider()))->create(
+ Random::generateString(8, 'phpunit'),
+ 'phpunit layout',
+ '',
+ $this->getResolutionId('landscape'),
+ 1
+ );
+
+ // Upload some media
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create('API video '.rand(1,400), PROJECT_ROOT . '/tests/resources/HLH264.mp4');
+
+ // Assign the media we've created to our regions playlist.
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear down for ' . get_class($this) . ' Test');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the second Layout we've been working with
+ $this->deleteLayout($this->layout2);
+
+ // Delete the media record
+ $this->media->deleteAssigned();
+ }
+
+ /**
+ * Each array is a test run
+ * Format (enableStat)
+ * @return array
+ */
+ public function enableStatLayoutCases()
+ {
+ return [
+ // various correct enableStat flag
+ 'Layout enableStat Off' => [0],
+ 'Layout enableStat On' => [1]
+ ];
+ }
+
+ /**
+ * Each array is a test run
+ * Format (enableStat)
+ * @return array
+ */
+ public function enableStatMediaAndWidgetCases()
+ {
+ return [
+ // various correct enableStat options - for both media and widget are same
+ 'enableStat Off' => ['Off'],
+ 'enableStat On' => ['On'],
+ 'enableStat Inherit' => ['Inherit']
+ ];
+ }
+
+ /**
+ * Add enableStat flag was set to 0 when creating the layout
+ */
+ public function testAddLayoutEnableStatOff()
+ {
+ // Check that the layout enable stat sets to off
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($this->layout->layoutId);
+ $this->assertSame(0, $layout->enableStat);
+ }
+
+ /**
+ * Add enableStat flag was set to 1 when creating the layout
+ */
+ public function testAddLayoutEnableStatOn()
+ {
+ // Check that the layout enable stat sets to on
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($this->layout2->layoutId);
+ $this->assertSame(1, $layout->enableStat);
+ }
+
+ /**
+ * Edit enableStat flag of an existing layout
+ * @dataProvider enableStatLayoutCases
+ */
+ public function testEditLayoutEnableStat($enableStat)
+ {
+ $name = Random::generateString(8, 'phpunit');
+ $description = Random::generateString(8, 'description');
+
+ $response = $this->sendRequest('PUT','/layout/' . $this->layout->layoutId, [
+ 'name' => $name,
+ 'description' => $description,
+ 'enableStat' => $enableStat
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame($enableStat, $object->data->enableStat);
+
+ // Check that the layout enable stat sets to on/off
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($enableStat, $layout->enableStat);
+ }
+
+ /**
+ * Edit enableStat flag of an existing media file
+ * @dataProvider enableStatMediaAndWidgetCases
+ */
+ public function testEditMediaEnableStat($enableStat)
+ {
+ $name = Random::generateString(8, 'phpunit');
+
+ // Edit media file
+ $response = $this->sendRequest('PUT','/library/' . $this->media->mediaId, [
+ 'name' => $name,
+ 'duration' => 50,
+ 'updateInLayouts' => 1,
+ 'enableStat' => $enableStat
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+
+ $object = json_decode($response->getBody());
+ $this->assertSame($enableStat, $object->data->enableStat);
+
+ $media = (new XiboLibrary($this->getEntityProvider()))->getById($this->media->mediaId);
+ $this->assertSame($enableStat, $media->enableStat);
+ }
+
+ /**
+ * @throws \Xibo\OAuth2\Client\Exception\XiboApiException
+ * @dataProvider enableStatMediaAndWidgetCases
+ */
+ public function testEditWidgetEnableStat($enableStat)
+ {
+ // Now try to edit our assigned Media Item.
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'enableStat' => $enableStat,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']
+ );
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboImage $widgetOptions */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $widgetOptions = (new XiboImage($this->getEntityProvider()))->hydrate($response[0]);
+
+ foreach ($widgetOptions->widgetOptions as $option) {
+ if ($option['option'] == 'enableStat') {
+ $this->assertSame($enableStat, $option['value']);
+ }
+ }
+ }
+
+ /**
+ * Copy Layout - enableStat flag copied from an existing layout
+ */
+ public function testCopyLayoutCheckEnableStat()
+ {
+ // Generate new random name
+ $nameCopy = Random::generateString(8, 'phpunit');
+
+ // Call copy
+ $response = $this->sendRequest('POST','/layout/copy/' . $this->layout2->layoutId, [
+ 'name' => $nameCopy,
+ 'description' => 'Copy',
+ 'copyMediaFiles' => 1
+ ], [
+ 'CONTENT_TYPE' => 'application/x-www-form-urlencoded'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+
+ // Check if copied layout has enableStat flag of copying layout
+ $object = json_decode($response->getBody());
+ $this->assertSame($this->layout2->enableStat, $object->data->enableStat);
+ }
+
+ /**
+ * Bulk On/Off Layout enableStat
+ * @dataProvider enableStatLayoutCases
+ */
+ public function testLayoutBulkEnableStat($enableStat)
+ {
+ // Call Set enable stat
+ $response = $this->sendRequest('PUT','/layout/setenablestat/' . $this->layout->layoutId, [
+ 'enableStat' => $enableStat
+ ], [
+ 'CONTENT_TYPE' => 'application/x-www-form-urlencoded'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+
+ $layout = (new XiboLayout($this->getEntityProvider()))->getById($this->layout->layoutId);
+ $this->assertSame($enableStat, $layout->enableStat);
+ }
+
+ /**
+ * Bulk On/Off/Inherit Media enableStat
+ * @dataProvider enableStatMediaAndWidgetCases
+ */
+ public function testMediaBulkEnableStat($enableStat)
+ {
+ // Call Set enable stat
+ $response = $this->sendRequest('PUT','/library/setenablestat/' . $this->media->mediaId, [
+ 'enableStat' => $enableStat
+ ], [
+ 'CONTENT_TYPE' => 'application/x-www-form-urlencoded'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+
+ $media = (new XiboLibrary($this->getEntityProvider()))->getById($this->media->mediaId);
+ $this->assertSame($enableStat, $media->enableStat);
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/ReportScheduleDataTest.php b/tests/integration/ReportScheduleDataTest.php
new file mode 100644
index 0000000..7e56036
--- /dev/null
+++ b/tests/integration/ReportScheduleDataTest.php
@@ -0,0 +1,188 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ReportScheduleDataTest
+ * @package Xibo\Tests\Integration
+ */
+class ReportScheduleDataTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ // Stat type
+ private $type;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ $this->type = 'layout';
+ $hardwareId = $this->display->license;
+
+ // Record some stats
+ $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+
+
+
+ '
+ );
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete stat records
+ self::$container->get('timeSeriesStore')
+ ->deleteStats(Carbon::now(), Carbon::now()->startOfDay()->subDays(10));
+ }
+
+ /**
+ * Check if proof of play statistics are correct
+ */
+ public function testProofOfPlayReportYesterday()
+ {
+ $response = $this->sendRequest('GET', '/report/data/proofofplayReport', [
+ 'reportFilter'=> 'yesterday',
+ 'groupByFilter' => 'byday',
+ 'displayId' => $this->display->displayId,
+ 'layoutId' => [$this->layout->layoutId],
+ 'type' => $this->type
+ ], ['HTTP_ACCEPT'=>'application/json'], 'web', true);
+
+ $this->getLogger()->debug('Response code is: ' . $response->getStatusCode());
+
+ $body = $response->getBody();
+
+ $this->getLogger()->debug($body);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($body);
+ $object = json_decode($body);
+ $this->assertObjectHasAttribute('table', $object, $body);
+ $this->assertSame(1, $object->table[0]->numberPlays);
+ }
+
+ /**
+ * Check if proof of play statistics are correct for Proof of play Report
+ */
+ public function testProofOfPlayReport()
+ {
+ $response = $this->sendRequest('GET', '/report/data/proofofplayReport', [
+ 'statsFromDt' => Carbon::now()->startOfDay()->subDays(3)->format(DateFormatHelper::getSystemFormat()),
+ 'statsToDt' => Carbon::now()->startOfDay()->subDays(2)->format(DateFormatHelper::getSystemFormat()),
+ 'groupByFilter' => 'byday',
+ 'displayId' => $this->display->displayId,
+ 'layoutId' => [$this->layout->layoutId],
+ 'type' => $this->type
+ ], ['HTTP_ACCEPT'=>'application/json'], 'web', true);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $body = $response->getBody();
+ $this->getLogger()->debug($body);
+ $this->assertNotEmpty($body);
+ $object = json_decode($body);
+ $this->assertObjectHasAttribute('table', $object, $response->getBody());
+ $this->assertSame(1, $object->table[0]->numberPlays);
+ }
+
+ /**
+ * Check if proof of play statistics are correct for Summary Report
+ */
+ public function testSummaryReport()
+ {
+ $response = $this->sendRequest('GET', '/report/data/summaryReport', [
+ 'statsFromDt' => Carbon::now()->startOfDay()->subDays(4)->format(DateFormatHelper::getSystemFormat()),
+ 'statsToDt' => Carbon::now()->startOfDay()->format(DateFormatHelper::getSystemFormat()),
+ 'groupByFilter' => 'byday',
+ 'displayId' => $this->display->displayId,
+ 'layoutId' => $this->layout->layoutId,
+ 'type' => $this->type
+ ], ['HTTP_ACCEPT'=>'application/json'], 'web', true);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('chart', $object, $response->getBody());
+ $expectedSeconds = Carbon::now()->startOfDay()->subDays(3)->format('U') -
+ Carbon::now()->startOfDay()->subDays(4)->format('U');
+ $this->assertSame($expectedSeconds, $object->chart->data->datasets[0]->data[0]);
+ }
+}
diff --git a/tests/integration/ReportScheduleTest.php b/tests/integration/ReportScheduleTest.php
new file mode 100644
index 0000000..38ec23f
--- /dev/null
+++ b/tests/integration/ReportScheduleTest.php
@@ -0,0 +1,208 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboReportSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ReportScheduleTest
+ * @package Xibo\Tests\Integration
+ */
+class ReportScheduleTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+
+ /**
+ * Each array is a test run
+ * Format (filter, reportName)
+ * @return array
+ */
+ public function filterCreateCases()
+ {
+ return [
+ 'proofofplayReport Daily' => ['daily', 'proofofplayReport'],
+ 'proofofplayReport Weekly' => ['weekly', 'proofofplayReport'],
+ 'proofofplayReport Monthly' => ['monthly', 'proofofplayReport'],
+ 'proofofplayReport Yearly' => ['yearly', 'proofofplayReport'],
+ 'summaryReport Daily' => ['daily', 'summaryReport'],
+ 'summaryReport Weekly' => ['weekly', 'summaryReport'],
+ 'summaryReport Monthly' => ['monthly', 'summaryReport'],
+ 'summaryReport Yearly' => ['yearly', 'summaryReport'],
+ 'distributionReport Daily' => ['daily', 'distributionReport'],
+ 'distributionReport Weekly' => ['weekly', 'distributionReport'],
+ 'distributionReport Monthly' => ['monthly', 'distributionReport'],
+ 'distributionReport Yearly' => ['yearly', 'distributionReport'],
+ ];
+ }
+
+ /**
+ * Create Report Schedule
+ * @dataProvider filterCreateCases
+ */
+ public function testCreateReportSchedule($filter, $report)
+ {
+ $reportSchedule = (new XiboReportSchedule($this->getEntityProvider()))
+ ->create('Report Schedule', $report, $filter, 'byhour', null,
+ $this->display->displayId, '{"type":"layout","selectedId":'.$this->layout->layoutId.',"eventTag":null}');
+
+ $this->assertSame($report, $reportSchedule->reportName);
+
+ // Delete Report Schedule
+ $reportSchedule->delete();
+ }
+
+ /**
+ * Report Schedule Delete All Saved Report
+ * @throws \Xibo\Support\Exception\NotFoundException
+ */
+ public function testReportScheduleDeleteAllSavedReport()
+ {
+ $reportSchedule = (new XiboReportSchedule($this->getEntityProvider()))
+ ->create('Report Schedule', 'proofofplayReport', 'daily');
+
+ $reportScheduleId = $reportSchedule->reportScheduleId;
+
+ $task = $this->getTask('\Xibo\XTR\ReportScheduleTask');
+ $task->run();
+ self::$container->get('store')->commitIfNecessary();
+
+ // Delete All Saved Report
+ $resDelete = $this->sendRequest('POST', '/report/reportschedule/' .
+ $reportScheduleId. '/deletesavedreport');
+ $this->assertSame(200, $resDelete->getStatusCode(), $resDelete->getBody());
+
+ // Delete Report Schedule
+ $reportSchedule->delete();
+ }
+
+ /**
+ * Report Schedule Toggle Active
+ */
+ public function testReportScheduleToggleActive()
+ {
+ $reportSchedule = (new XiboReportSchedule($this->getEntityProvider()))
+ ->create('Report Schedule', 'proofofplayReport', 'daily');
+
+ // Toggle Active
+ $response = $this->sendRequest('POST', '/report/reportschedule/'.
+ $reportSchedule->reportScheduleId.'/toggleactive');
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame('Paused Report Schedule', $object->message);
+
+ // Delete Report Schedule
+ $reportSchedule->delete();
+ }
+
+ /**
+ * Report Schedule Reset
+ */
+ public function testReportScheduleReset()
+ {
+
+ $reportSchedule = (new XiboReportSchedule($this->getEntityProvider()))
+ ->create('Report Schedule', 'proofofplayReport', 'daily');
+
+ // Reset
+ $response = $this->sendRequest('POST', '/report/reportschedule/'.
+ $reportSchedule->reportScheduleId.'/reset');
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertSame('Success', $object->message);
+
+ // Delete Report Schedule
+ $reportSchedule->delete();
+ }
+
+ /**
+ * Delete Saved Report
+ * @throws \Xibo\OAuth2\Client\Exception\XiboApiException|\Xibo\Support\Exception\NotFoundException
+ */
+ public function testDeleteSavedReport()
+ {
+ $reportSchedule = (new XiboReportSchedule($this->getEntityProvider()))
+ ->create('Report Schedule', 'proofofplayReport', 'daily');
+
+ // Create a saved report
+ $task = $this->getTask('\Xibo\XTR\ReportScheduleTask');
+ $task->run();
+ self::$container->get('store')->commitIfNecessary();
+
+ // Get updated report schedule's last saved report Id
+ $rs = (new XiboReportSchedule($this->getEntityProvider()))
+ ->getById($reportSchedule->reportScheduleId);
+
+ // Delete Saved Report
+ $response = $this->sendRequest('DELETE', '/report/savedreport/'.
+ $rs->lastSavedReportId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+
+ // Delete Report Schedule
+ $rs->delete();
+ }
+}
diff --git a/tests/integration/ResolutionTest.php b/tests/integration/ResolutionTest.php
new file mode 100644
index 0000000..6cb9ffc
--- /dev/null
+++ b/tests/integration/ResolutionTest.php
@@ -0,0 +1,232 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboResolution;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ResolutionTest
+ * @package Xibo\Tests\Integration
+ */
+class ResolutionTest extends LocalWebTestCase
+{
+
+ protected $startResolutions;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startResolutions = (new XiboResolution($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ }
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all resolutions that weren't there initially
+ $finalResolutions = (new XiboResolution($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining resolutions and nuke them
+ foreach ($finalResolutions as $resolution) {
+ /** @var XiboResolution $resolution */
+ $flag = true;
+ foreach ($this->startResolutions as $startRes) {
+ if ($startRes->resolutionId == $resolution->resolutionId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $resolution->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $resolution->resolutionId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ public function testListAll()
+ {
+ $response = $this->sendRequest('GET','/resolution');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ }
+
+ /**
+ * testAddSuccess - test adding various Resolutions that should be valid
+ * @dataProvider provideSuccessCases
+ * @group minimal
+ */
+ public function testAddSuccess($resolutionName, $resolutionWidth, $resolutionHeight)
+ {
+
+ # Loop through any pre-existing resolutions to make sure we're not
+ # going to get a clash
+ foreach ($this->startResolutions as $tmpRes) {
+ if ($tmpRes->resolution == $resolutionName) {
+ $this->skipTest("There is a pre-existing resolution with this name");
+ return;
+ }
+ }
+ # Create new resolutions with data from provideSuccessCases
+ $response = $this->sendRequest('POST','/resolution', [
+ 'resolution' => $resolutionName,
+ 'width' => $resolutionWidth,
+ 'height' => $resolutionHeight
+ ]);
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($resolutionName, $object->data->resolution);
+ $this->assertSame($resolutionWidth, $object->data->width);
+ $this->assertSame($resolutionHeight, $object->data->height);
+ # Check that the resolution was added correctly
+ $resolution = (new XiboResolution($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($resolutionName, $resolution->resolution);
+ $this->assertSame($resolutionWidth, $resolution->width);
+ $this->assertSame($resolutionHeight, $resolution->height);
+ # Clean up the Resolutions as we no longer need it
+ $this->assertTrue($resolution->delete(), 'Unable to delete ' . $resolution->resolutionId);
+ }
+
+ /**
+ * Each array is a test run
+ * Format (resolution name, width, height)
+ * @return array
+ */
+
+ public function provideSuccessCases()
+ {
+ # Sets of correct data, which should be successfully added
+ return [
+ 'resolution 1' => ['test resolution', 800, 200],
+ 'resolution 2' => ['different test resolution', 1069, 1699]
+ ];
+ }
+
+ /**
+ * testAddFailure - test adding various resolutions that should be invalid
+ * @dataProvider provideFailureCases
+ */
+ public function testAddFailure($resolutionName, $resolutionWidth, $resolutionHeight)
+ {
+ # create new resolution with data from provideFailureCases
+ $response = $this->sendRequest('POST','/resolution', [
+ 'resolution' => $resolutionName,
+ 'width' => $resolutionWidth,
+ 'height' => $resolutionHeight
+ ]);
+ # Check if it fails as expected
+ $this->assertSame(422, $response->getStatusCode(), 'Expecting failure, received ' . $response->getStatusCode());
+ }
+
+ /**
+ * Each array is a test run
+ * Format (resolution name, width, height)
+ * @return array
+ */
+ public function provideFailureCases()
+ {
+ # Sets of incorrect data, which should lead to a failure
+ return [
+ 'incorrect width and height' => ['wrong parameters', 'abc', NULL],
+ 'incorrect width' => [12, 'width', 1699]
+ ];
+ }
+
+ /**
+ * Edit an existing resolution
+ * @group minimal
+ */
+ public function testEdit()
+ {
+ # Load in a known resolution
+ /** @var XiboResolution $resolution */
+ $resolution = (new XiboResolution($this->getEntityProvider()))->create('phpunit resolution', 1200, 860);
+ $newWidth = 2400;
+ # Change the resolution name, width and enable flag
+ $name = Random::generateString(8, 'phpunit');
+
+ $response = $this->sendRequest('PUT','/resolution/' . $resolution->resolutionId, [
+ 'resolution' => $name,
+ 'width' => $newWidth,
+ 'height' => $resolution->height,
+ 'enabled' => 0
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ # Examine the returned object and check that it's what we expect
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ $this->assertSame($name, $object->data->resolution);
+ $this->assertSame($newWidth, $object->data->width);
+ $this->assertSame(0, $object->data->enabled);
+ # Check that the resolution was actually renamed
+ $resolution = (new XiboResolution($this->getEntityProvider()))->getById($object->id);
+ $this->assertSame($name, $resolution->resolution);
+ $this->assertSame($newWidth, $resolution->width);
+ # Clean up the resolution as we no longer need it
+ $resolution->delete();
+ }
+
+ /**
+ * Test delete
+ * @group minimal
+ */
+ public function testDelete()
+ {
+ # Generate two random names
+ $name1 = Random::generateString(8, 'phpunit');
+ $name2 = Random::generateString(8, 'phpunit');
+ # Load in a couple of known resolutions
+ $res1 = (new XiboResolution($this->getEntityProvider()))->create($name1, 1000, 500);
+ $res2 = (new XiboResolution($this->getEntityProvider()))->create($name2, 2000, 760);
+ # Delete the one we created last
+ $response = $this->sendRequest('DELETE','/resolution/' . $res2->resolutionId);
+ # This should return 204 for success
+ $object = json_decode($response->getBody());
+ $this->assertSame(204, $object->status, $response->getBody());
+ # Check only one remains
+ $resolutions = (new XiboResolution($this->getEntityProvider()))->get();
+ $this->assertEquals(count($this->startResolutions) + 1, count($resolutions));
+ $flag = false;
+ foreach ($resolutions as $res) {
+ if ($res->resolutionId == $res1->resolutionId) {
+ $flag = true;
+ }
+ }
+ $this->assertTrue($flag, 'Resolution ID ' . $res1->resolutionId . ' was not found after deleting a different Resolution');
+ # Clean up
+ $res1->delete();
+ }
+}
diff --git a/tests/integration/ScheduleDayPartTest.php b/tests/integration/ScheduleDayPartTest.php
new file mode 100644
index 0000000..de8aab8
--- /dev/null
+++ b/tests/integration/ScheduleDayPartTest.php
@@ -0,0 +1,188 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDaypart;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+class ScheduleDayPartTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $layout;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboDisplay */
+ protected $display;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboSchedule */
+ protected $event;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboDaypart */
+ protected $dayPart;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Make sure the Layout Status is as we expect
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Create a Day Part
+ // calculate a few hours either side of now
+ // must be tomorrow
+ // must not cross the day boundary
+ $now = Carbon::now()->startOfDay()->addDay()->addHour();
+
+ $this->dayPart = (new XiboDaypart($this->getEntityProvider()))->create(
+ Random::generateString(5),
+ '',
+ $now->format('H:i'),
+ $now->copy()->addHours(5)->format('H:i')
+ );
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the DayPart
+ $this->dayPart->delete();
+ }
+ //
+
+ public function testSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my player timezone
+ $date = Carbon::now()->addDay()->setTime(0,0,0);
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()));
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'dayPartId' => $this->dayPart->dayPartId,
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'scheduleRecurrenceType' => null,
+ 'scheduleRecurrenceDetail' => null,
+ 'scheduleRecurrenceRange' => null,
+ 'syncTimezone' => 0
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ $this->assertTrue(count($layouts) == 1, 'Unexpected number of events');
+
+ foreach ($layouts as $layout) {
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $xmlToDt = $layout->getAttribute('todt');
+ $this->assertEquals($date->format('Y-m-d') . ' ' . $this->dayPart->startTime . ':00', $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ $this->assertEquals($date->format('Y-m-d') . ' ' . $this->dayPart->endTime . ':00', $xmlToDt, 'To date doesnt match: ' . $xmlToDt);
+ }
+
+ // Also check this layout is in required files.
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->RequiredFiles($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Find using XPATH
+ $xpath = new \DOMXPath($xml);
+ $nodes =$xpath->query('//file[@type="layout"]');
+
+ $this->assertGreaterThanOrEqual(1, $nodes->count(), 'Layout not in required files');
+
+ $found = false;
+ foreach ($nodes as $node) {
+ /** @var \DOMNode $node */
+ if ($this->layout->layoutId == $node->attributes->getNamedItem('id')->nodeValue) {
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ $this->fail('Layout not found in Required Files XML');
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/ScheduleDisplayDeleteTest.php b/tests/integration/ScheduleDisplayDeleteTest.php
new file mode 100644
index 0000000..3b77ae2
--- /dev/null
+++ b/tests/integration/ScheduleDisplayDeleteTest.php
@@ -0,0 +1,114 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ScheduleDisplayDeleteTest
+ * @package Xibo\Tests\Integration
+ */
+class ScheduleDisplayDeleteTest extends LocalWebTestCase
+{
+ use DisplayHelperTrait;
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboDisplay */
+ protected $display2;
+
+ /** @var XiboSchedule */
+ protected $event;
+
+ //
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // We need 2 displays
+ $this->display = $this->createDisplay();
+ $this->display2 = $this->createDisplay();
+
+ // This is the remaining display we will test for the schedule
+ $this->displaySetLicensed($this->display2);
+
+ // 1 Layout
+ $this->layout = $this->createLayout();
+
+ // 1 Schedule
+ $this->event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $this->layout->campaignId,
+ [$this->display->displayGroupId, $this->display2->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout and Remaining Display
+ $this->deleteDisplay($this->display2);
+ $this->deleteLayout($this->layout);
+
+ parent::tearDown();
+ }
+ //
+
+ /**
+ * Do the test
+ */
+ public function test()
+ {
+ // Delete 1 display
+ $this->sendRequest('DELETE','/display/' . $this->display->displayId);
+
+ // Test to ensure the schedule remains
+ $schedule = $this->getXmdsWrapper()->Schedule($this->display2->license);
+
+ $this->assertContains('file="' . $this->layout->layoutId . '"', $schedule, 'Layout not scheduled');
+ }
+}
diff --git a/tests/integration/ScheduleNotificationTest.php b/tests/integration/ScheduleNotificationTest.php
new file mode 100644
index 0000000..1d0b2de
--- /dev/null
+++ b/tests/integration/ScheduleNotificationTest.php
@@ -0,0 +1,246 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ScheduleNotificationTest
+ * @package Xibo\Tests\Integration
+ */
+class ScheduleNotificationTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $layout;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboDisplay */
+ protected $display;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboSchedule */
+ protected $event;
+
+ protected $timeZone = 'Asia/Hong_Kong';
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetTimezone($this->display, $this->timeZone);
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Make sure the Layout Status is as we expect
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Check our timzone is set correctly
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->RegisterDisplay($this->display->license, $this->timeZone));
+ $this->assertEquals($this->timeZone, $xml->documentElement->getAttribute('localTimezone'), 'Timezone not correct');
+ $xml = null;
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ public function testSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my player timezone
+ $localNow = Carbon::now()->setTimezone($this->timeZone);
+ $date = $localNow->copy()->addHour()->startOfHour();
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()));
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $date->copy()->addMinutes(30)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'recurrenceType' => null,
+ 'recurrenceDetail' => null,
+ 'recurrenceRange' => null,
+ 'syncTimezone' => 0,
+ 'scheduleReminders' => [
+ [
+ 'reminder_value' => 1,
+ 'reminder_type' => 1,
+ 'reminder_option' => 1,
+ 'reminder_isEmailHidden' => 1
+ ],
+ [
+ 'reminder_value' => 1,
+ 'reminder_type' => 1,
+ 'reminder_option' => 2,
+ 'reminder_isEmailHidden' => 1
+ ]
+ ],
+ 'embed' => 'scheduleReminders'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ # Check if two reminders are created
+ $this->assertSame(2, count($object->data->scheduleReminders));
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check the filter from and to dates are correct
+ $this->assertEquals($localNow->startOfHour()->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterFrom'), 'Filter from date incorrect');
+ $this->assertEquals($localNow->addDays(2)->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterTo'), 'Filter to date incorrect');
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ $this->assertTrue(count($layouts) == 1, 'Unexpected number of events');
+
+ foreach ($layouts as $layout) {
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $this->assertEquals($date->format(DateFormatHelper::getSystemFormat()), $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ }
+ }
+
+ public function testRecurringSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my player timezone
+ // we start this schedule the day before
+ $localNow = Carbon::now()->setTimezone($this->timeZone);
+ $date = $localNow->copy()->subDay()->addHour()->startOfHour();
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()));
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $date->copy()->addMinutes(30)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'recurrenceType' => 'Day',
+ 'recurrenceDetail' => 1,
+ 'recurrenceRange' => null,
+ 'syncTimezone' => 0,
+ 'scheduleReminders' => [
+ [
+ 'reminder_value' => 1,
+ 'reminder_type' => 1,
+ 'reminder_option' => 1,
+ 'reminder_isEmailHidden' => 1
+ ],
+ [
+ 'reminder_value' => 1,
+ 'reminder_type' => 1,
+ 'reminder_option' => 2,
+ 'reminder_isEmailHidden' => 1
+ ]
+ ],
+ 'embed' => 'scheduleReminders'
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ # Check if two reminders are created
+ $this->assertSame(2, count($object->data->scheduleReminders));
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check the filter from and to dates are correct
+ $this->assertEquals($localNow->startOfHour()->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterFrom'), 'Filter from date incorrect');
+ $this->assertEquals($localNow->addDays(2)->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterTo'), 'Filter to date incorrect');
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ foreach ($layouts as $layout) {
+ // Move our day on (we know we're recurring by day), and that we started a day behind
+ $date->addDay();
+
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $this->assertEquals($date->format(DateFormatHelper::getSystemFormat()), $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ }
+ }
+}
diff --git a/tests/integration/ScheduleTest.php b/tests/integration/ScheduleTest.php
new file mode 100644
index 0000000..20a0fe9
--- /dev/null
+++ b/tests/integration/ScheduleTest.php
@@ -0,0 +1,404 @@
+.
+ */
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboCampaign;
+use Xibo\OAuth2\Client\Entity\XiboCommand;
+use Xibo\OAuth2\Client\Entity\XiboDisplayGroup;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboSchedule;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ScheduleTest
+ * @package Xibo\Tests\Integration
+ */
+class ScheduleTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ protected $route = '/schedule';
+
+ protected $startCommands;
+ protected $startDisplayGroups;
+ protected $startEvents;
+ protected $startLayouts;
+ protected $startCampaigns;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+ $this->startDisplayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startCampaigns = (new XiboCampaign($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ $this->startCommands = (new XiboCommand($this->getEntityProvider()))->get(['start' => 0, 'length' => 1000]);
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // tearDown all display groups that weren't there initially
+ $finalDisplayGroups = (new XiboDisplayGroup($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining display groups and nuke them
+ foreach ($finalDisplayGroups as $displayGroup) {
+ /** @var XiboDisplayGroup $displayGroup */
+ $flag = true;
+ foreach ($this->startDisplayGroups as $startGroup) {
+ if ($startGroup->displayGroupId == $displayGroup->displayGroupId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $displayGroup->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Unable to delete ' . $displayGroup->displayGroupId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ // tearDown all layouts that weren't there initially
+ $finalLayouts = (new XiboLayout($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining layouts and nuke them
+ foreach ($finalLayouts as $layout) {
+ /** @var XiboLayout $layout */
+ $flag = true;
+ foreach ($this->startLayouts as $startLayout) {
+ if ($startLayout->layoutId == $layout->layoutId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $layout->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Layout: Unable to delete ' . $layout->layoutId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+
+ // tearDown all campaigns that weren't there initially
+ $finalCamapigns = (new XiboCampaign($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining campaigns and nuke them
+ foreach ($finalCamapigns as $campaign) {
+ /** @var XiboCampaign $campaign */
+ $flag = true;
+ foreach ($this->startCampaigns as $startCampaign) {
+ if ($startCampaign->campaignId == $campaign->campaignId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $campaign->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Campaign: Unable to delete ' . $campaign->campaignId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ // tearDown all commands that weren't there initially
+ $finalCommands = (new XiboCommand($this->getEntityProvider()))->get(['start' => 0, 'length' => 10000]);
+ # Loop over any remaining commands and nuke them
+ foreach ($finalCommands as $command) {
+ /** @var XiboCommand $command */
+ $flag = true;
+ foreach ($this->startCommands as $startCom) {
+ if ($startCom->commandId == $command->commandId) {
+ $flag = false;
+ }
+ }
+ if ($flag) {
+ try {
+ $command->delete();
+ } catch (\Exception $e) {
+ fwrite(STDERR, 'Command: Unable to delete ' . $command->commandId . '. E:' . $e->getMessage());
+ }
+ }
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * testListAll
+ */
+ public function testListAll()
+ {
+ # list all scheduled events
+ $response = $this->sendRequest('GET','/schedule/data/events');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('result', $object, $response->getBody());
+ }
+
+ /**
+ * @group add
+ * @dataProvider provideSuccessCasesCampaign
+ */
+ public function testAddEventCampaign($isCampaign, $scheduleFrom, $scheduleTo, $scheduledayPartId, $scheduleRecurrenceType, $scheduleRecurrenceDetail, $scheduleRecurrenceRange, $scheduleOrder, $scheduleIsPriority)
+ {
+ # Create new display group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+ $layout = null;
+ $campaign = null;
+ # isCampaign checks if we want to add campaign or layout to our event
+ if ($isCampaign) {
+ # Create Campaign
+ /* @var XiboCampaign $campaign */
+ $campaign = (new XiboCampaign($this->getEntityProvider()))->create('phpunit');
+ # Create new event with data from provideSuccessCasesCampaign where isCampaign is set to true
+ $response = $this->sendRequest('POST', $this->route, [
+ 'fromDt' => Carbon::createFromTimestamp($scheduleFrom)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::createFromTimestamp($scheduleTo)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $campaign->campaignId,
+ 'displayGroupIds' => [$displayGroup->displayGroupId],
+ 'displayOrder' => $scheduleOrder,
+ 'isPriority' => $scheduleIsPriority,
+ 'scheduleRecurrenceType' => $scheduleRecurrenceType,
+ 'scheduleRecurrenceDetail' => $scheduleRecurrenceDetail,
+ 'scheduleRecurrenceRange' => $scheduleRecurrenceRange
+ ]);
+ } else {
+ # Create layout
+ $layout = $this->createLayout();
+
+ # Create new event with data from provideSuccessCasesCampaign where isCampaign is set to false
+ $response = $this->sendRequest('POST', $this->route, [
+ 'fromDt' => Carbon::createFromTimestamp($scheduleFrom)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::createFromTimestamp($scheduleTo)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $layout->campaignId,
+ 'displayGroupIds' => [$displayGroup->displayGroupId],
+ 'displayOrder' => $scheduleOrder,
+ 'isPriority' => $scheduleIsPriority,
+ 'scheduleRecurrenceType' => $scheduleRecurrenceType,
+ 'scheduleRecurrenceDetail' => $scheduleRecurrenceDetail,
+ 'scheduleRecurrenceRange' => $scheduleRecurrenceRange
+ ]);
+ }
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Clean up
+ $displayGroup->delete();
+ if ($campaign != null)
+ $campaign->delete();
+ if ($layout != null)
+ $layout->delete();
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($isCampaign, $scheduleFrom, $scheduleTo, $scheduledayPartId, $scheduleRecurrenceType, $scheduleRecurrenceDetail, $scheduleRecurrenceRange, $scheduleOrder, $scheduleIsPriority)
+ * @return array
+ */
+ public function provideSuccessCasesCampaign()
+ {
+ # Sets of data used in testAddEventCampaign, first argument (isCampaign) controls if it's layout or campaign
+ return [
+ 'Campaign no priority, no recurrence' => [true, time()+3600, time()+7200, 0, NULL, NULL, NULL, 0, 0],
+ 'Layout no priority, no recurrence' => [false, time()+3600, time()+7200, 0, NULL, NULL, NULL, 0, 0]
+ ];
+ }
+
+ /**
+ * @group add
+ * @dataProvider provideSuccessCasesCommand
+ */
+ public function testAddEventCommand($scheduleFrom, $scheduledayPartId, $scheduleRecurrenceType, $scheduleRecurrenceDetail, $scheduleRecurrenceRange, $scheduleOrder, $scheduleIsPriority)
+ {
+ # Create command
+ $command = (new XiboCommand($this->getEntityProvider()))->create('phpunit command', 'phpunit command desc', 'code');
+ # Create Display Group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+ # Create new event with scheduled command and data from provideSuccessCasesCommand
+ $response = $this->sendRequest('POST', $this->route, [
+ 'fromDt' => Carbon::createFromTimestamp($scheduleFrom)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 2,
+ 'commandId' => $command->commandId,
+ 'displayGroupIds' => [$displayGroup->displayGroupId],
+ 'displayOrder' => $scheduleOrder,
+ 'isPriority' => $scheduleIsPriority,
+ 'scheduleRecurrenceType' => $scheduleRecurrenceType,
+ 'scheduleRecurrenceDetail' => $scheduleRecurrenceDetail,
+ 'scheduleRecurrenceRange' => $scheduleRecurrenceRange
+ ]);
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Clean up
+ $displayGroup->delete();
+ $command->delete();
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($scheduleFrom, $scheduleDisplays, $scheduledayPartId, $scheduleRecurrenceType, $scheduleRecurrenceDetail, $scheduleRecurrenceRange, $scheduleOrder, $scheduleIsPriority)
+ * @return array
+ */
+ public function provideSuccessCasesCommand()
+ {
+ return [
+ 'Command no priority, no recurrence' => [time()+3600, 0, NULL, NULL, NULL, 0, 0],
+ ];
+ }
+
+ /**
+ * @group add
+ * @dataProvider provideSuccessCasesOverlay
+ */
+ public function testAddEventOverlay($scheduleFrom, $scheduleTo, $scheduleCampaignId, $scheduleDisplays, $scheduledayPartId, $scheduleRecurrenceType, $scheduleRecurrenceDetail, $scheduleRecurrenceRange, $scheduleOrder, $scheduleIsPriority)
+ {
+ # Create new dispay group
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+
+ # Create layout
+ $layout = $this->createLayout();
+
+ # Create new event with data from provideSuccessCasesOverlay
+ $response = $this->sendRequest('POST', $this->route, [
+ 'fromDt' => Carbon::createFromTimestamp($scheduleFrom)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::createFromTimestamp($scheduleTo)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 3,
+ 'campaignId' => $layout->campaignId,
+ 'displayGroupIds' => [$displayGroup->displayGroupId],
+ 'displayOrder' => $scheduleOrder,
+ 'isPriority' => $scheduleIsPriority,
+ 'scheduleRecurrenceType' => $scheduleRecurrenceType,
+ 'scheduleRecurrenceDetail' => $scheduleRecurrenceDetail,
+ 'scheduleRecurrenceRange' => $scheduleRecurrenceRange
+ ]);
+ # Check if call was successful
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Clean up
+ $displayGroup->delete();
+ if ($layout != null)
+ $layout->delete();
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($scheduleFrom, $scheduleTo, $scheduledayPartId, $scheduleRecurrenceType, $scheduleRecurrenceDetail, $scheduleRecurrenceRange, $scheduleOrder, $scheduleIsPriority)
+ * @return array
+ */
+ public function provideSuccessCasesOverlay()
+ {
+ return [
+ 'Overlay, no recurrence' => [time()+3600, time()+7200, 0, NULL, NULL, NULL, 0, 0, 0, 0],
+ ];
+ }
+ /**
+ * @group minimal
+ */
+ public function testEdit()
+ {
+ // Get a Display Group Id
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+ // Create Campaign
+ /* @var XiboCampaign $campaign */
+ $campaign = (new XiboCampaign($this->getEntityProvider()))->create('phpunit');
+ # Create new event
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $campaign->campaignId,
+ [$displayGroup->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+ $fromDt = time() + 3600;
+ $toDt = time() + 86400;
+ # Edit event
+ $response = $this->sendRequest('PUT',$this->route . '/' . $event->eventId, [
+ 'fromDt' => Carbon::createFromTimestamp($fromDt)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::createFromTimestamp($toDt)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $event->campaignId,
+ 'displayGroupIds' => [$displayGroup->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 1
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+ $this->assertSame(200, $response->getStatusCode(), "Not successful: " . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+ # Check if edit was successful
+ $this->assertSame($toDt, intval($object->data->toDt));
+ $this->assertSame($fromDt, intval($object->data->fromDt));
+ # Tidy up
+ $displayGroup->delete();
+ $campaign->delete();
+ }
+
+ /**
+ * @param $eventId
+ */
+ public function testDelete()
+ {
+
+ # Get a Display Group Id
+ $displayGroup = (new XiboDisplayGroup($this->getEntityProvider()))->create('phpunit group', 'phpunit description', 0, '');
+ # Create Campaign
+ /* @var XiboCampaign $campaign */
+ $campaign = (new XiboCampaign($this->getEntityProvider()))->create('phpunit');
+ # Create event
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ Carbon::now()->format(DateFormatHelper::getSystemFormat()),
+ Carbon::now()->addSeconds(7200)->format(DateFormatHelper::getSystemFormat()),
+ $campaign->campaignId,
+ [$displayGroup->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0,
+ 0
+ );
+ # Delete event
+ $response = $this->sendRequest('DELETE',$this->route . '/' . $event->eventId);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ # Clean up
+ $displayGroup->delete();
+ $campaign->delete();
+ }
+}
diff --git a/tests/integration/ScheduleTimezoneBaseCase.php b/tests/integration/ScheduleTimezoneBaseCase.php
new file mode 100644
index 0000000..9e0a506
--- /dev/null
+++ b/tests/integration/ScheduleTimezoneBaseCase.php
@@ -0,0 +1,311 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+use Carbon\Carbon;
+use Xibo\Entity\Display;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ScheduleTimezoneTest
+ * @package Xibo\Tests\integration
+ */
+class ScheduleTimezoneBaseCase extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+ use DisplayHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $layout;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboDisplay */
+ protected $display;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboSchedule */
+ protected $event;
+
+ protected $timeZone = 'Asia/Hong_Kong';
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->layout);
+
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ // Check us in again
+ $this->layout = $this->publish($this->layout);
+
+ // Build the layout
+ $this->buildLayout($this->layout);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ $this->displaySetTimezone($this->display, $this->timeZone);
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ // Make sure the Layout Status is as we expect
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Check our timzone is set correctly
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->RegisterDisplay($this->display->license, $this->timeZone));
+ $this->assertEquals($this->timeZone, $xml->documentElement->getAttribute('localTimezone'), 'Timezone not correct');
+ $xml = null;
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+ }
+ //
+
+ public function testSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my player timezone
+ $localNow = Carbon::now()->setTimezone($this->timeZone);
+ $date = $localNow->copy()->addHour()->startOfHour();
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()));
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $date->copy()->addMinutes(30)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'scheduleRecurrenceType' => null,
+ 'scheduleRecurrenceDetail' => null,
+ 'scheduleRecurrenceRange' => null,
+ 'syncTimezone' => 0
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check the filter from and to dates are correct
+ $this->assertEquals($localNow->startOfHour()->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterFrom'), 'Filter from date incorrect');
+ $this->assertEquals($localNow->addDays(2)->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterTo'), 'Filter to date incorrect');
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ $this->assertTrue(count($layouts) == 1, 'Unexpected number of events');
+
+ foreach ($layouts as $layout) {
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $this->assertEquals($date->format(DateFormatHelper::getSystemFormat()), $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ }
+ }
+
+ public function testRecurringSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my player timezone
+ // we start this schedule the day before
+ $localNow = Carbon::now()->setTimezone($this->timeZone);
+ $date = $localNow->copy()->subDay()->addHour()->startOfHour();
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()));
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $date->copy()->addMinutes(30)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'recurrenceType' => 'Day',
+ 'recurrenceDetail' => 1,
+ 'recurrenceRange' => null,
+ 'syncTimezone' => 0
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check the filter from and to dates are correct
+ $this->assertEquals($localNow->startOfHour()->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterFrom'), 'Filter from date incorrect');
+ $this->assertEquals($localNow->addDays(2)->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterTo'), 'Filter to date incorrect');
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ foreach ($layouts as $layout) {
+ // Move our day on (we know we're recurring by day), and that we started a day behind
+ $date->addDay();
+
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $this->assertEquals($date->format(DateFormatHelper::getSystemFormat()), $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ }
+ }
+
+ public function testSyncedSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my CMS timezone
+ $localNow = Carbon::now()->setTimezone($this->timeZone);
+
+ // If this was 8AM local CMS time, we would expect the resulting date/times in the XML to have the equivilent
+ // timezone specific date/times
+ $date = Carbon::now()->copy()->addHour()->startOfHour();
+ $localDate = $date->copy()->timezone($this->timeZone);
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()) . ' which is ' . $localDate->format(DateFormatHelper::getSystemFormat()) . ' local time.');
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $date->copy()->addMinutes(30)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'recurrenceType' => null,
+ 'recurrenceDetail' => null,
+ 'recurrenceRange' => null,
+ 'syncTimezone' => 1
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check the filter from and to dates are correct
+ $this->assertEquals($localNow->startOfHour()->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterFrom'), 'Filter from date incorrect');
+ $this->assertEquals($localNow->addDays(2)->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterTo'), 'Filter to date incorrect');
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ foreach ($layouts as $layout) {
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $this->assertEquals($localDate->format(DateFormatHelper::getSystemFormat()), $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ }
+ }
+
+ public function testSyncedRecurringSchedule()
+ {
+ // Our CMS is in GMT
+ // Create a schedule one hours time in my CMS timezone
+ $localNow = Carbon::now()->setTimezone($this->timeZone);
+
+ // If this was 8AM local CMS time, we would expect the resulting date/times in the XML to have the equivilent
+ // timezone specific date/times
+ $date = Carbon::now()->copy()->subDay()->addHour()->startOfHour();
+ $localDate = $date->copy()->timezone($this->timeZone);
+
+ $this->getLogger()->debug('Event start will be at: ' . $date->format(DateFormatHelper::getSystemFormat()) . ' which is ' . $localDate->format(DateFormatHelper::getSystemFormat()) . ' local time.');
+
+ $response = $this->sendRequest('POST','/schedule', [
+ 'fromDt' => $date->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => $date->copy()->addMinutes(30)->format(DateFormatHelper::getSystemFormat()),
+ 'eventTypeId' => 1,
+ 'campaignId' => $this->layout->campaignId,
+ 'displayGroupIds' => [$this->display->displayGroupId],
+ 'displayOrder' => 1,
+ 'isPriority' => 0,
+ 'recurrenceType' => 'Day',
+ 'recurrenceDetail' => 1,
+ 'recurrenceRange' => null,
+ 'syncTimezone' => 1
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Not successful: ' . $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ $xml = new \DOMDocument();
+ $xml->loadXML($this->getXmdsWrapper()->Schedule($this->display->license));
+ //$this->getLogger()->debug($xml->saveXML());
+
+ // Check the filter from and to dates are correct
+ $this->assertEquals($localNow->startOfHour()->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterFrom'), 'Filter from date incorrect');
+ $this->assertEquals($localNow->addDays(2)->format(DateFormatHelper::getSystemFormat()), $xml->documentElement->getAttribute('filterTo'), 'Filter to date incorrect');
+
+ // Check our event is present.
+ $layouts = $xml->getElementsByTagName('layout');
+
+ foreach ($layouts as $layout) {
+ // Move our day on (we know we're recurring by day), and that we started a day behind
+ // we use addRealDay because our synced calendar entry _should_ change its time over a DST switch
+ $localDate->addRealDay();
+
+ $xmlFromDt = $layout->getAttribute('fromdt');
+ $this->assertEquals($localDate->format(DateFormatHelper::getSystemFormat()), $xmlFromDt, 'From date doesnt match: ' . $xmlFromDt);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/ScheduleTimezoneHongKongTest.php b/tests/integration/ScheduleTimezoneHongKongTest.php
new file mode 100644
index 0000000..9243783
--- /dev/null
+++ b/tests/integration/ScheduleTimezoneHongKongTest.php
@@ -0,0 +1,29 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+
+class ScheduleTimezoneHongKongTest extends ScheduleTimezoneBaseCase
+{
+ protected $timeZone = 'Asia/Hong_Kong';
+}
\ No newline at end of file
diff --git a/tests/integration/ScheduleTimezoneLATest.php b/tests/integration/ScheduleTimezoneLATest.php
new file mode 100644
index 0000000..1aa8690
--- /dev/null
+++ b/tests/integration/ScheduleTimezoneLATest.php
@@ -0,0 +1,29 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+
+class ScheduleTimezoneLATest extends ScheduleTimezoneBaseCase
+{
+ protected $timeZone = 'America/Los_Angeles';
+}
\ No newline at end of file
diff --git a/tests/integration/ScheduleTimezoneLondonTest.php b/tests/integration/ScheduleTimezoneLondonTest.php
new file mode 100644
index 0000000..da821b5
--- /dev/null
+++ b/tests/integration/ScheduleTimezoneLondonTest.php
@@ -0,0 +1,29 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+
+class ScheduleTimezoneLondonTest extends ScheduleTimezoneBaseCase
+{
+ protected $timeZone = 'Europe/London';
+}
\ No newline at end of file
diff --git a/tests/integration/SearchFilterTest.php b/tests/integration/SearchFilterTest.php
new file mode 100644
index 0000000..9952ca4
--- /dev/null
+++ b/tests/integration/SearchFilterTest.php
@@ -0,0 +1,232 @@
+.
+ */
+namespace Xibo\Tests\integration;
+
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class SearchFilterTest
+ * @package Xibo\Tests\integration
+ */
+class SearchFilterTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboLayout */
+ protected $layout2;
+
+ /** @var XiboLayout */
+ protected $layout3;
+
+ /** @var XiboLayout */
+ protected $layout4;
+
+ /** @var XiboLayout */
+ protected $layout5;
+
+ /** @var XiboLayout */
+ protected $layout6;
+
+ //
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for ' . get_class($this) . ' Test');
+
+ // Create 6 layouts to test with
+ $this->layout = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ 'integration layout 1',
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->layout2 = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ 'integration example layout 2',
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->layout3 = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ 'integration layout 3',
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->layout4 = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ 'integration example 4',
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->layout5 = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ 'example layout 5',
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->layout6 = (new XiboLayout($this->getEntityProvider()))
+ ->create(
+ 'display different name',
+ 'PHPUnit Created Layout for Automated Integration Testing',
+ '',
+ $this->getResolutionId('landscape')
+ );
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ $this->deleteLayout($this->layout);
+ $this->deleteLayout($this->layout2);
+ $this->deleteLayout($this->layout3);
+ $this->deleteLayout($this->layout4);
+ $this->deleteLayout($this->layout5);
+ $this->deleteLayout($this->layout6);
+
+ parent::tearDown();
+ }
+ //
+
+ /**
+ * Search filter test.
+ *
+ * Single keyword
+ */
+ public function testSearch()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => 'integration']);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(4, $object->data->recordsFiltered);
+ }
+
+ /**
+ * Search filter test
+ *
+ * Comma separated
+ */
+ public function testSearchCommaSeparated()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => 'integration,example']);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(5, $object->data->recordsFiltered);
+ }
+
+ /**
+ * Search filter test
+ *
+ * Comma separated with not RLIKE filter
+ */
+ public function testSearchCommaSeparatedWithNotRlike()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => 'integration layout, -example']);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(4, $object->data->recordsFiltered);
+ }
+
+ /**
+ * Search filter test
+ *
+ * Comma separated with not RLIKE filter
+ */
+ public function testSearchCommaSeparatedWithNotRlike2()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => 'example, -layout']);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(4, $object->data->recordsFiltered);
+ }
+
+ /**
+ * Search filter test.
+ *
+ * partial match filter
+ */
+ public function testSearchPartialMatch()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => 'inte, exa']);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(5, $object->data->recordsFiltered);
+ }
+
+ /**
+ * Search filter test.
+ *
+ * using regexp
+ */
+ public function testSearchWithRegEx()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => 'name$', 'useRegexForName' => 1]);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(1, $object->data->recordsFiltered);
+ }
+
+ /**
+ * Search filter test.
+ *
+ * using regexp
+ */
+ public function testSearchWithRegEx2()
+ {
+ $response = $this->sendRequest('GET', '/layout', ['layout' => '^example, ^disp', 'useRegexForName' => 1]);
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertSame(2, $object->data->recordsFiltered);
+ }
+}
diff --git a/tests/integration/StatisticsEventTest.php b/tests/integration/StatisticsEventTest.php
new file mode 100644
index 0000000..0dac57d
--- /dev/null
+++ b/tests/integration/StatisticsEventTest.php
@@ -0,0 +1,158 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class StatisticsEventTest
+ * @package Xibo\Tests\Integration
+ */
+class StatisticsEventTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete stat records
+ self::$container->get('timeSeriesStore')
+ ->deleteStats(Carbon::now(), Carbon::now()->startOfDay()->subDays(10));
+ }
+
+ /**
+ * Check if proof of play statistics are correct
+ */
+ public function testProof()
+ {
+ $type = 'event';
+
+ // Checkout layout
+ $layout = $this->getDraft($this->layout);
+
+ $hardwareId = $this->display->license;
+
+ // One word name for the event
+ $eventName = Random::generateString(10, 'event');
+
+ // First insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Second insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Third insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Get stats and see if they match with what we expect
+ $response = $this->sendRequest('GET', '/stats', [
+ 'fromDt' => Carbon::now()->startOfDay()->subDays(5)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::now()->startOfDay()->format(DateFormatHelper::getSystemFormat()),
+ 'displayId' => $this->display->displayId,
+ 'type' => $type
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(3, $object->data->recordsTotal);
+ $this->assertCount(3, $object->data->data);
+ }
+}
diff --git a/tests/integration/StatisticsLayoutTest.php b/tests/integration/StatisticsLayoutTest.php
new file mode 100644
index 0000000..5d103b1
--- /dev/null
+++ b/tests/integration/StatisticsLayoutTest.php
@@ -0,0 +1,184 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class StatisticsLayoutTest
+ * @package Xibo\Tests\Integration
+ */
+class StatisticsLayoutTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete stat records
+ self::$container->get('timeSeriesStore')
+ ->deleteStats(Carbon::now(), Carbon::now()->startOfDay()->subDays(10));
+ }
+
+ /**
+ * Check if proof of play statistics are correct
+ */
+ public function testProof()
+ {
+ $type = 'layout';
+
+ $hardwareId = $this->display->license;
+
+ // Set start and date time
+ //
+ // $fromDt = '2018-02-12 00:00:00';
+ // $toDt = '2018-02-15 00:00:00';
+ //
+ // $fromDt2 = '2018-02-15 00:00:00';
+ // $toDt2 = '2018-02-16 00:00:00';
+ //
+ // $fromDt3 = '2018-02-16 00:00:00';
+ // $toDt3 = '2018-02-17 00:00:00';
+
+ // Add stats to the DB - known set
+ //
+ // 1 layout
+ // type,start,end,layout,media
+ // layout,2016-02-12 00:00:00, 2016-02-15 00:00:00, L1, NULL
+ //
+ // Result
+ // L1 72 hours
+ //
+ // 1 layout
+ // type,start,end,layout,media
+ // layout,2016-02-15 00:00:00, 2016-02-16 00:00:00, L1, NULL
+ //
+ // Result
+ // L1 24 hours
+ //
+ // 1 layout
+ // type,start,end,layout,media
+ // layout,2016-02-16 00:00:00, 2016-02-17 00:00:00, L1, NULL
+ //
+ // Result
+ // L1 24 hours
+
+ // First insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Second insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Third insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Get stats and see if they match with what we expect
+ $response = $this->sendRequest('GET', '/stats', [
+ 'fromDt' => Carbon::now()->startOfDay()->subDays(5)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::now()->startOfDay()->format(DateFormatHelper::getSystemFormat()),
+ 'displayId' => $this->display->displayId,
+ 'layoutId' => [$this->layout->layoutId],
+ 'type' => $type
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(3, $object->data->recordsTotal);
+ $this->assertCount(3, $object->data->data);
+ }
+}
diff --git a/tests/integration/StatisticsMediaTest.php b/tests/integration/StatisticsMediaTest.php
new file mode 100644
index 0000000..92c45ec
--- /dev/null
+++ b/tests/integration/StatisticsMediaTest.php
@@ -0,0 +1,214 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class StatisticsMediaTest
+ * @package Xibo\Tests\Integration
+ */
+class StatisticsMediaTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $media2;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboWidget */
+ private $widget;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboWidget */
+ private $widget2;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ // Upload some media
+ $this->media = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+
+ $this->media2 = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-layout-003-background.jpg');
+
+ // Checkout our Layout and add some Widgets to it.
+ $layout = $this->getDraft($this->layout);
+
+ // Add another region
+ // Assign media to the layouts default region.
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId, $this->media2->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Get Widget Ids
+ $this->widget = $playlist->widgets[0];
+ $this->widget2 = $playlist->widgets[1];
+
+ // Publish the Layout
+ $this->layout = $this->publish($this->layout);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the media records
+ $this->media->deleteAssigned();
+ $this->media2->deleteAssigned();
+
+ // Delete stat records
+ self::$container->get('timeSeriesStore')
+ ->deleteStats(Carbon::now(), Carbon::now()->startOfDay()->subDays(10));
+ }
+
+ /**
+ * Check if proof of play statistics are correct
+ */
+ public function testProof()
+ {
+ $type = 'media';
+
+ $hardwareId = $this->display->license;
+
+ // Set start and date time
+ //
+ // $fromDt = '2018-02-12 00:00:00';
+ // $toDt = '2018-02-17 00:00:00';
+
+ // Add stats to the DB - known set
+ //
+ // 1 layout, 2 region, 2 medias (1 per region)
+ // type,start,end,layout,media
+ // media,2018-02-12 00:00:00, 2018-02-13 00:00:00, L1, M1
+ // media,2018-02-13 00:00:00, 2018-02-14 00:00:00, L1, M1
+ // media,2018-02-16 00:00:00, 2018-02-17 12:00:00, L1, M1
+ // media,2018-02-14 00:00:00, 2018-02-15 00:00:00, L1, M2
+ // media,2018-02-15 00:00:00, 2018-02-16 00:00:00, L1, M2
+ // media,2018-02-16 00:00:00, 2018-02-16 12:00:00, L1, M2
+ //
+ // Result
+ // M1 60 hours
+ // M2 60 hours
+
+ // Insert all stats in one call to SubmitStats
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+
+
+
+
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Get stats and see if they match with what we expect
+ $response = $this->sendRequest('GET', '/stats', [
+ 'fromDt' => Carbon::now()->startOfDay()->subDays(5)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::now()->startOfDay()->format(DateFormatHelper::getSystemFormat()),
+ 'displayId' => $this->display->displayId,
+ 'layoutId' => [$this->layout->layoutId],
+ 'type' => $type
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(6, $object->data->recordsTotal);
+ $this->assertCount(6, $object->data->data);
+ }
+}
diff --git a/tests/integration/StatisticsTest.php b/tests/integration/StatisticsTest.php
new file mode 100644
index 0000000..84df389
--- /dev/null
+++ b/tests/integration/StatisticsTest.php
@@ -0,0 +1,246 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\OAuth2\Client\Entity\XiboStats;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class StatisticsTest
+ * @package Xibo\Tests\Integration
+ */
+class StatisticsTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $media2;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboWidget */
+ private $widget;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboWidget */
+ private $widget2;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboWidget */
+ private $textWidget;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ // Upload some media
+ $this->media = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+
+ $this->media2 = (new XiboLibrary($this->getEntityProvider()))
+ ->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-layout-003-background.jpg');
+
+ // Checkout our Layout and add some Widgets to it.
+ $layout = $this->getDraft($this->layout);
+
+ // Create and assign new text widget
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->textWidget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Add another region
+ // Assign media to the layouts default region.
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId, $this->media2->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Get Widget Ids
+ $this->widget = $playlist->widgets[0];
+ $this->widget2 = $playlist->widgets[1];
+
+ // Publish the Layout
+ $this->layout = $this->publish($this->layout);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the media records
+ $this->media->deleteAssigned();
+ $this->media2->deleteAssigned();
+
+ // Delete stat records
+ self::$container->get('timeSeriesStore')
+ ->deleteStats(Carbon::now(), Carbon::now()->startOfDay()->subDays(10));
+ }
+
+ /**
+ * Test the method call with default values
+ */
+ public function testListAll()
+ {
+ $this->getXmdsWrapper()->SubmitStats($this->display->license, '
+
+
+ ');
+
+ $response = $this->sendRequest('GET', '/stats');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(1, $object->data->recordsTotal);
+ $this->assertCount(1, $object->data->data);
+ }
+
+ /**
+ * Check if proof of play statistics can be exported
+ */
+ public function testExport()
+ {
+ $this->getXmdsWrapper()->SubmitStats($this->display->license, '
+
+
+
+
+ ');
+
+
+ $response = $this->sendRequest('GET', '/stats/export', [
+ 'fromDt' => Carbon::now()->startOfDay()->subDays(5)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::now()->startOfDay()->subDays(2)->format(DateFormatHelper::getSystemFormat())
+ ]);
+
+ // Check 200
+ $this->assertSame(200, $response->getStatusCode());
+
+ // We're expecting a send file header as we're testing within Docker
+ $this->assertTrue($response->hasHeader('X-Sendfile'));
+ $this->assertSame('text/csv', $response->getHeader('Content-Type')[0] ?? '');
+ $this->assertGreaterThan(0, $response->getHeader('Content-Length')[0] ?? 0);
+
+ // We can't test the body, because there isn't any web server involved with this request.
+ }
+
+ public function testProofOldStats()
+ {
+ $hardwareId = $this->display->license;
+
+ // Attempt to insert stat data older than 30 days
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+
+ $this->assertSame(true, $response);
+
+ // Default max stat age is 30 days, therefore we expect to get no results after the attempted inserts.
+ $stats = (new XiboStats($this->getEntityProvider()))->get([
+ 'fromDt' => Carbon::now()->startOfDay()->subDays(41)->format('Y-m-d H:i:s'),
+ 'toDt' => Carbon::now()->startOfDay()->subDays(31)->format('Y-m-d H:i:s'),
+ 'layoutId' => [$this->layout->layoutId],
+ 'type' => 'layout'
+ ]);
+ $this->assertEquals(0, count($stats));
+ }
+}
diff --git a/tests/integration/StatisticsWidgetTest.php b/tests/integration/StatisticsWidgetTest.php
new file mode 100644
index 0000000..80efce3
--- /dev/null
+++ b/tests/integration/StatisticsWidgetTest.php
@@ -0,0 +1,173 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Carbon\Carbon;
+use Xibo\Helper\DateFormatHelper;
+use Xibo\OAuth2\Client\Entity\XiboDisplay;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\DisplayHelperTrait;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class StatisticsWidgetTest
+ * @package Xibo\Tests\Integration
+ */
+class StatisticsWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait, DisplayHelperTrait;
+
+ /** @var XiboLayout */
+ protected $layout;
+
+ /** @var XiboDisplay */
+ protected $display;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboWidget */
+ private $widget;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+ $this->displaySetLicensed($this->display);
+
+ // Checkout our Layout and add some Widgets to it.
+ $layout = $this->getDraft($this->layout);
+
+ // Create and assign new text widget
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Publish the Layout
+ $this->layout = $this->publish($this->layout);
+
+ $this->getLogger()->debug('Finished Setup');
+
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete stat records
+ self::$container->get('timeSeriesStore')->deleteStats(Carbon::now(), Carbon::now()->startOfDay()->subDays(10));
+ }
+
+ /**
+ * Check if proof of play statistics are correct
+ */
+ public function testProof()
+ {
+ $type = 'widget';
+
+ $hardwareId = $this->display->license;
+
+ // First insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Second insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Third insert
+ $response = $this->getXmdsWrapper()->SubmitStats(
+ $hardwareId,
+ '
+
+ '
+ );
+ $this->assertSame(true, $response);
+
+ // Get stats and see if they match with what we expect
+ $response = $this->sendRequest('GET', '/stats', [
+ 'fromDt' => Carbon::now()->startOfDay()->subDays(5)->format(DateFormatHelper::getSystemFormat()),
+ 'toDt' => Carbon::now()->startOfDay()->format(DateFormatHelper::getSystemFormat()),
+ 'displayId' => $this->display->displayId,
+ 'layoutId' => [$this->layout->layoutId],
+ 'type' => $type
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertEquals(3, $object->data->recordsTotal);
+ $this->assertCount(3, $object->data->data);
+ }
+}
diff --git a/tests/integration/TemplateTest.php b/tests/integration/TemplateTest.php
new file mode 100644
index 0000000..eea904f
--- /dev/null
+++ b/tests/integration/TemplateTest.php
@@ -0,0 +1,89 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLayout;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class TemplateTest
+ * @package Xibo\Tests
+ */
+class TemplateTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /**
+ * Show Templates
+ */
+ public function testListAll()
+ {
+ $response = $this->sendRequest('GET','/template');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ }
+
+ /**
+ * Add Template
+ */
+ public function testAdd()
+ {
+ # Create random name and new layout
+ $layout = $this->createLayout();
+
+ # Generate second random name
+ $name2 = Random::generateString(8, 'phpunit');
+
+ # Create template using our layout and new name
+ $response = $this->sendRequest('POST','/template/' . $layout->layoutId, [
+ 'name' => $name2,
+ 'includeWidgets' => 1,
+ 'tags' => 'phpunit',
+ 'description' => $layout->description
+ ]);
+
+ # Check if successful
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertObjectHasAttribute('id', $object);
+
+ # Check if it has edited name
+ $this->assertSame($name2, $object->data->layout);
+ // Expect 2 tags phpunit added in this request and template tag.
+ $this->assertSame(2,count($object->data->tags));
+
+ $templateId = $object->id;
+
+ # delete template as we no longer need it
+ $template = (new XiboLayout($this->getEntityProvider()))->getByTemplateId($templateId);
+ $template->delete();
+
+ # delete layout as we no longer need it
+ $layout->delete();
+ }
+}
diff --git a/tests/integration/UserGroupTest.php b/tests/integration/UserGroupTest.php
new file mode 100644
index 0000000..e98dcfa
--- /dev/null
+++ b/tests/integration/UserGroupTest.php
@@ -0,0 +1,63 @@
+.
+ */
+
+namespace Xibo\Tests\integration;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Exception\XiboApiException;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class UserGroupTest
+ * @package Xibo\Tests\integration
+ */
+class UserGroupTest extends LocalWebTestCase
+{
+ /**
+ * Add a new group and then check it was added correctly.
+ */
+ public function testAdd()
+ {
+ $params = [
+ 'group' => Random::generateString(),
+ 'description' => Random::generateString()
+ ];
+ $response = $this->sendRequest('POST', '/group', $params);
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertObjectHasAttribute('id', $object, $response->getBody());
+
+ // Use the API to get it out again
+ try {
+ //$group = (new XiboUserGroup($this->getEntityProvider()))->getById($object->id);
+ $group = $this->getEntityProvider()->get('/group', ['userGroupId' => $object->id])[0];
+
+ // Check our key parts match.
+ $this->assertSame($params['group'], $group['group'], 'Name does not match');
+ $this->assertSame($params['description'], $group['description'], 'Description does not match');
+ } catch (XiboApiException $e) {
+ $this->fail('Group not found. e = ' . $e->getMessage());
+ }
+ }
+}
diff --git a/tests/integration/UserTest.php b/tests/integration/UserTest.php
new file mode 100644
index 0000000..b8d9193
--- /dev/null
+++ b/tests/integration/UserTest.php
@@ -0,0 +1,114 @@
+.
+ */
+
+namespace Xibo\Tests\Integration;
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboUser;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class UserTest
+ * @package Xibo\Tests
+ */
+class UserTest extends LocalWebTestCase
+{
+ /**
+ * Show me
+ */
+ public function testGetMe()
+ {
+ $response = $this->sendRequest('GET','/user/me');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object);
+ $this->assertSame('phpunit', $object->data->userName);
+ }
+
+ /**
+ * Show all users
+ */
+ public function testGetUsers()
+ {
+ $response = $this->sendRequest('GET','/user');
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object);
+ }
+
+ /**
+ * Add new user
+ */
+ public function testAdd()
+ {
+ $group = $this->getEntityProvider()->get('/group', ['userGroup' => 'Users'])[0];
+ $userName = Random::generateString();
+
+ $response = $this->sendRequest('POST','/user', [
+ 'userName' => $userName,
+ 'userTypeId' => 3,
+ 'homePageId' => 'icondashboard.view',
+ 'homeFolderId' => 1,
+ 'password' => 'newUserPassword',
+ 'groupId' => $group['groupId'],
+ 'libraryQuota' => 0
+ ]);
+
+ $this->assertSame(200, $response->getStatusCode(), $response->getBody());
+
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+ $this->assertObjectHasAttribute('id', $object, $response->getBody());
+
+ $this->assertSame($userName, $object->data->userName);
+ $this->assertSame(3, $object->data->userTypeId);
+ $this->assertSame('icondashboard.view', $object->data->homePageId);
+
+ $userCheck = (new XiboUser($this->getEntityProvider()))->getById($object->id);
+ $userCheck->delete();
+ }
+
+ public function testAddEmptyPassword()
+ {
+ $group = $this->getEntityProvider()->get('/group', ['userGroup' => 'Users'])[0];
+
+ $response = $this->sendRequest('POST', '/user', [
+ 'userName' => Random::generateString(),
+ 'userTypeId' => 3,
+ 'homePageId' => 'icondashboard.view',
+ 'homeFolderId' => 1,
+ 'password' => null,
+ 'groupId' => $group['groupId'],
+ 'libraryQuota' => 0
+ ]);
+
+ $this->assertSame(422, $response->getStatusCode(), $response->getBody());
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Widget/AudioWidgetTest.php b/tests/integration/Widget/AudioWidgetTest.php
new file mode 100644
index 0000000..256c415
--- /dev/null
+++ b/tests/integration/Widget/AudioWidgetTest.php
@@ -0,0 +1,182 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboImage;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class AudioWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class AudioWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var XiboLibrary */
+ protected $audio;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create some media to upload
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/cc0_f1_gp_cars_pass_crash.mp3');
+ $this->audio = (new XiboLibrary($this->getEntityProvider()))->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/cc0_f1_gp_cars_pass_crash.mp3');
+
+ // Assign the media we've created to our regions playlist.
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ // Tidy up the media
+ $this->media->delete();
+ $this->audio->delete();
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * @throws \Xibo\OAuth2\Client\Exception\XiboApiException
+ */
+ public function testEdit()
+ {
+ $this->getLogger()->debug('testEdit ' . get_class($this) .' Test');
+
+ // Now try to edit our assigned Media Item.
+ $name = 'Edited Name ' . Random::generateString(5);
+ $duration = 80;
+ $useDuration = 1;
+ $mute = 0;
+ $loop = 0;
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'duration' => $duration,
+ 'useDuration' => $useDuration,
+ 'mute' => $mute,
+ 'loop' => $loop,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']
+ );
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboImage $widgetOptions */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $widgetOptions = (new XiboImage($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($name, $widgetOptions->name);
+ $this->assertSame($duration, $widgetOptions->duration);
+
+ foreach ($widgetOptions->widgetOptions as $option) {
+ if ($option['option'] == 'mute') {
+ $this->assertSame($mute, intval($option['value']));
+ }
+ if ($option['option'] == 'loop') {
+ $this->assertSame($loop, intval($option['value']));
+ }
+ if ($option['option'] == 'useDuration') {
+ $this->assertSame($useDuration, intval($option['value']));
+ }
+ }
+
+ $this->getLogger()->debug('testEdit finished');
+ }
+
+ /**
+ * Test to edit and assign an auto item to a widget
+ */
+ public function testAssign()
+ {
+ $this->getLogger()->debug('testAssign');
+
+ $volume = 80;
+ $loop = 1;
+
+ // Add audio to image assigned to a playlist
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId . '/audio', [
+ 'mediaId' => $this->audio->mediaId,
+ 'volume' => $volume,
+ 'loop' => $loop,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']
+ );
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+
+ /** @var XiboImage $widgetOptions */
+ $widgetOptions = (new XiboImage($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($this->media->name, $widgetOptions->name);
+ $this->assertSame($this->media->mediaId, intval($widgetOptions->mediaIds[0]));
+ $this->assertSame($this->audio->mediaId, intval($widgetOptions->mediaIds[1]));
+ $this->assertSame($volume, intval($widgetOptions->audio[0]['volume']));
+ $this->assertSame($loop, intval($widgetOptions->audio[0]['loop']));
+
+ $this->getLogger()->debug('testAssign finished');
+ }
+}
diff --git a/tests/integration/Widget/ClockWidgetTest.php b/tests/integration/Widget/ClockWidgetTest.php
new file mode 100644
index 0000000..ffe47b2
--- /dev/null
+++ b/tests/integration/Widget/ClockWidgetTest.php
@@ -0,0 +1,138 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\OAuth2\Client\Entity\XiboClock;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ClockWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class ClockWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/clock/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $duration, $useDuration, $theme, $clockTypeId, $offset, $format, $showSeconds, $clockFace)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ # Sets of data used in testAdd
+ return [
+ 'Analogue' => ['Api Analogue clock', 20, 1, 1, 1, null, null, 0, 'TwentyFourHourClock'],
+ 'Digital' => ['API digital clock', 20, 1, 0, 2, null, '[HH:mm]', 0, 'TwentyFourHourClock'],
+ 'Flip 24h' => ['API Flip clock 24h', 5, 1, 0, 3, null, null, 1, 'TwentyFourHourClock'],
+ 'Flip counter' => ['API Flip clock Minute counter', 50, 1, 0, 3, null, null, 1, 'MinuteCounter']
+ ];
+ }
+
+ /**
+ * @param $name
+ * @param $duration
+ * @param $useDuration
+ * @param $theme
+ * @param $clockTypeId
+ * @param $offset
+ * @param $format
+ * @param $showSeconds
+ * @param $clockFace
+ * @dataProvider provideSuccessCases
+ */
+ public function testEdit($name, $duration, $useDuration, $theme, $clockTypeId, $offset, $format, $showSeconds, $clockFace)
+ {
+ $response = $this->sendRequest('PUT', '/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'useDuration' => $useDuration,
+ 'duration' => $duration,
+ 'themeId' => $theme,
+ 'clockTypeId' => $clockTypeId,
+ 'offset' => $offset,
+ 'format' => $format,
+ 'showSeconds' => $showSeconds,
+ 'clockFace' => $clockFace
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboClock $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboClock($this->getEntityProvider()))->hydrate($response[0]);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'clockTypeId') {
+ $this->assertSame($clockTypeId, intval($option['value']));
+ } else {
+ if ($option['option'] == 'name') {
+ $this->assertSame($name, $option['value']);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/CurrenciesWidgetTest.php b/tests/integration/Widget/CurrenciesWidgetTest.php
new file mode 100644
index 0000000..f030623
--- /dev/null
+++ b/tests/integration/Widget/CurrenciesWidgetTest.php
@@ -0,0 +1,144 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\OAuth2\Client\Entity\XiboCurrencies;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class CurrenciesWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class CurrenciesWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/currencies/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+
+
+ /**
+ * Each array is a test run
+ * Format ($overrideTemplate, $templateId, $name, $duration, $useDuration, $base, $items, $reverseConversion, $effect, $speed, $backgroundColor, $noRecordsMessage, $dateFormat, $updateInterval, $durationIsPerItem, $widgetOriginalWidth, $widgetOriginalHeight, $itemsPerPage, $mainTemplate, $itemTemplate, $styleSheet, $javaScript)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ # Sets of data used in testAdd
+ return [
+ 'No override template 1' => [false, 'currencies1', 'template 1', 6, 1, 'GBP', 'PLN', 0, NULL, NULL, NULL, 'No messages', NULL, 12, 1, null, null, 5, null, null, null, null],
+ 'No override template 2 reverse' => [false, 'currencies2', 'template 2', 120, 1, 'GBP', 'EUR', 1, NULL, NULL, NULL, 'No messages', NULL, 120, 0, null, null, 2, null, null, null, null],
+ 'Override' => [true, 'currencies1', 'override template', 12, 1, 'GBP', 'EUR', 0, NULL, NULL, NULL, 'No messages', NULL, 60, 1, 1000, 800, 5, '
BUY
SELL
[itemsTemplate]
', '
[NameShort]
[Bid]
[Ask]
','body { font-family: "Helvetica", "Arial", sans-serif; line-height: 1; } .container-main {height: 420px !important;width: 820px !important; } .container { height: 420px !important; width: 820px !important; float: left; margin-top: 20px; } .row-finance { height: 60px; background: rgba(0, 0, 0, 0.87); margin-bottom: 20px; } .row {margin-right: 0; margin-left: 0; } .row-header { margin-right: -15px; margin-left: -15px; margin-bottom: 20px; } #cycle-container { margin-left: -15px; margin-right: -15px; } .value { font-size: 20px; padding-top: 20px; font-weight: bold; color: #fff; } .down-arrow { font-size: 20px; color: red; padding-top: 17px; } .up-arrow { font-size: 20px;color: green; padding-top: 17px; } .variant { font-size: 20px; padding-top: 17px; } .flags { padding-top: 4px; } .center-block { width: 50px; height: 50px; }', NULL]
+ ];
+ }
+
+ /**
+ * This test works correctly, it's marked as broken because we don't have this widget installed by default
+ * @group broken
+ * @dataProvider provideSuccessCases
+ */
+ public function testEdit($isOverride, $templateId, $name, $duration, $useDuration, $base, $items, $reverseConversion, $effect, $speed, $backgroundColor, $noRecordsMessage, $dateFormat, $updateInterval, $durationIsPerItem, $widgetOriginalWidth, $widgetOriginalHeight, $itemsPerPage, $mainTemplate, $itemTemplate, $styleSheet, $javaScript)
+ {
+ # Edit currency widget and change name, duration, template, reverseConversion and items
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'templateId' => $templateId,
+ 'name' => $name,
+ 'duration' => $duration,
+ 'useDuration' => $useDuration,
+ 'base' => $base,
+ 'items' => $items,
+ 'reverseConversion' => $reverseConversion,
+ 'effect' => $effect,
+ 'speed' => $speed,
+ 'backgroundColor' => $backgroundColor,
+ 'noRecordsMessage' => $noRecordsMessage,
+ 'dateFormat' => $dateFormat,
+ 'updateInterval' => $updateInterval,
+ 'durationIsPerItem' => $durationIsPerItem,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboCurrencies $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboCurrencies($this->getEntityProvider()))->hydrate($response[0]);
+
+ # check if changes were correctly saved
+ $this->assertSame($name, $checkWidget->name);
+ $this->assertSame($duration, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'templateId') {
+ $this->assertSame($templateId, $option['value']);
+ }
+ if ($option['option'] == 'items') {
+ $this->assertSame($items, $option['value']);
+ }
+ if ($option['option'] == 'reverseConversion') {
+ $this->assertSame($reverseConversion, intval($option['value']));
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/DataSetTickerWidgetTest.php b/tests/integration/Widget/DataSetTickerWidgetTest.php
new file mode 100644
index 0000000..baafde1
--- /dev/null
+++ b/tests/integration/Widget/DataSetTickerWidgetTest.php
@@ -0,0 +1,168 @@
+.
+ */
+
+namespace Xibo\Tests\integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDataSet;
+use Xibo\OAuth2\Client\Entity\XiboTicker;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class DataSetTickerWidgetTest
+ * @package Xibo\Tests\integration\Widget
+ */
+class DataSetTickerWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /** @var XiboDataSet */
+ protected $dataSet;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Add a DataSet
+ $this->dataSet = (new XiboDataSet($this->getEntityProvider()))->create(Random::generateString(), 'Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/datasetticker/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'step' => 1,
+ 'dataSetId' => $this->dataSet->dataSetId
+ ]);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ // Delete the DataSet
+ $this->dataSet->deleteWData();
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Edit dataSet ticker
+ */
+ public function testEditDataset()
+ {
+ $this->getLogger()->debug('testEdit ' . get_class($this) .' Test');
+
+ // Edit ticker
+ $noDataMessage = 'no records found';
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => 'Edited widget',
+ 'duration' => 90,
+ 'useDuration' => 1,
+ 'updateInterval' => 100,
+ 'effect' => 'fadeout',
+ 'speed' => 500,
+ 'template' => '[Col1]',
+ 'durationIsPerItem' => 1,
+ 'itemsSideBySide' => 1,
+ 'upperLimit' => 0,
+ 'lowerLimit' => 0,
+ 'itemsPerPage' => 5,
+ 'noDataMessage' => $noDataMessage
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode(), 'Incorrect status: ' . $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ $this->getLogger()->debug('Request successful, double check contents.');
+
+ /** @var XiboTicker $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboTicker($this->getEntityProvider()))->hydrate($response[0]);
+
+ // check if changes were correctly saved
+ $this->assertSame('Edited widget', $checkWidget->name);
+ $this->assertSame(90, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'updateInterval') {
+ $this->assertSame(100, intval($option['value']));
+ }
+ if ($option['option'] == 'effect') {
+ $this->assertSame('fadeout', $option['value']);
+ }
+ if ($option['option'] == 'speed') {
+ $this->assertSame(500, intval($option['value']));
+ }
+ if ($option['option'] == 'template') {
+ $this->assertSame('[Col1]', $option['value']);
+ }
+ if ($option['option'] == 'durationIsPerItem') {
+ $this->assertSame(1, intval($option['value']));
+ }
+ if ($option['option'] == 'itemsSideBySide') {
+ $this->assertSame(1, intval($option['value']));
+ }
+ if ($option['option'] == 'upperLimit') {
+ $this->assertSame(0, intval($option['value']));
+ }
+ if ($option['option'] == 'lowerLimit') {
+ $this->assertSame(0, intval($option['value']));
+ }
+ if ($option['option'] == 'itemsPerPage') {
+ $this->assertSame(5, intval($option['value']));
+ }
+ if ($option['option'] == 'noDataMessage') {
+ $this->assertSame($noDataMessage, $option['value']);
+ }
+ }
+
+ $this->getLogger()->debug('testEdit finished');
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/Widget/DataSetViewWidgetTest.php b/tests/integration/Widget/DataSetViewWidgetTest.php
new file mode 100644
index 0000000..152b296
--- /dev/null
+++ b/tests/integration/Widget/DataSetViewWidgetTest.php
@@ -0,0 +1,139 @@
+.
+ */
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboDataSet;
+use Xibo\OAuth2\Client\Entity\XiboDataSetColumn;
+use Xibo\OAuth2\Client\Entity\XiboDataSetView;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+class DataSetViewWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /** @var XiboDataSet */
+ protected $dataSet;
+
+ /** @var XiboDataSetColumn */
+ protected $dataSetColumn;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Add a DataSet
+ $this->dataSet = (new XiboDataSet($this->getEntityProvider()))->create(Random::generateString(), 'Test');
+
+ // Create a Column for our DataSet
+ $this->dataSetColumn = (new XiboDataSetColumn($this->getEntityProvider()))->create($this->dataSet->dataSetId, Random::generateString(8, 'phpunit'),'', 2, 1, 1, '');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/datasetview/' . $layout->regions[0]->regionPlaylist->playlistId);
+ $response = $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'step' => 1,
+ 'dataSetId' => $this->dataSet->dataSetId
+ ]);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ // Delete the DataSet
+ $this->dataSet->deleteWData();
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ public function testEdit()
+ {
+ $nameNew = 'Edited Name';
+ $durationNew = 80;
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'dataSetColumnId' => [$this->dataSetColumn->dataSetColumnId],
+ 'name' => $nameNew,
+ 'duration' => $durationNew,
+ 'updateInterval' => 100,
+ 'rowsPerPage' => 2,
+ 'showHeadings' => 0,
+ 'upperLimit' => 0,
+ 'lowerLimit' => 0,
+ 'filter' => null,
+ 'ordering' => null,
+ 'templateId' => 'light-green',
+ 'overrideTemplate' => 0,
+ 'useOrderingClause' => 0,
+ 'useFilteringClause' => 0,
+ 'noDataMessage' => 'No Data returned',
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboDataSetView $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboDataSetView($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($nameNew, $checkWidget->name);
+ $this->assertSame($durationNew, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'templateId') {
+ $this->assertSame('light-green', $option['value']);
+ } else if ($option['option'] == 'updateInterval') {
+ $this->assertSame(100, intval($option['value']));
+ } else if ($option['option'] == 'name') {
+ $this->assertSame($nameNew, $option['value']);
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/EmbeddedWidgetTest.php b/tests/integration/Widget/EmbeddedWidgetTest.php
new file mode 100644
index 0000000..b187967
--- /dev/null
+++ b/tests/integration/Widget/EmbeddedWidgetTest.php
@@ -0,0 +1,108 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboEmbedded;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class EmbeddedWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class EmbeddedWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/embedded/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * @throws \Xibo\OAuth2\Client\Exception\XiboApiException
+ */
+ public function testEdit()
+ {
+ $name = Random::generateString();
+ $durationNew = 80;
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'duration' => $durationNew,
+ 'transparency' => 1,
+ 'scaleContent' => 1,
+ 'embedHtml' => null,
+ 'embedScript' => null,
+ 'embedStyle' => ''
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboEmbedded $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboEmbedded($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($name, $checkWidget->name);
+ $this->assertSame($durationNew, $checkWidget->duration);
+ }
+}
diff --git a/tests/integration/Widget/GoogleTrafficWidgetTest.php b/tests/integration/Widget/GoogleTrafficWidgetTest.php
new file mode 100644
index 0000000..f8609f1
--- /dev/null
+++ b/tests/integration/Widget/GoogleTrafficWidgetTest.php
@@ -0,0 +1,149 @@
+.
+ */
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\OAuth2\Client\Entity\XiboGoogleTraffic;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class GoogleTrafficWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class GoogleTrafficWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ public static function setUpBeforeClass()
+ {
+ parent::setUpBeforeClass();
+
+ parent::installModuleIfNecessary('googletraffic', '\Xibo\Widget\GoogleTraffic');
+ }
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/googletraffic/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $duration, $useDisplayLocation, $longitude, $latitude, $zoom)
+ * @return array
+ */
+ public function provideEditCases()
+ {
+ # Sets of data used in testAdd
+ return [
+ 'Use Display location' => [200, 'Traffic with display location', 2000, 1, null, null, 100],
+ 'Custom location 1' => [200, 'Traffic with custom location - Italy', 4500, 0, 7.640974, 45.109612, 80],
+ 'Custom location 2' => [200, 'Traffic with custom location - Japan', 4500, 0, 35.7105, 139.7336, 50],
+ 'No zoom provided' => [422, 'no zoom', 2000, 1, null, null, null],
+ 'no lat/long' => [422, 'no lat/long provided with useDisplayLocation 0', 3000, 0, null, null, 20],
+ 'low min duration' => [422, 'Traffic with display location', 20, 1, null, null, 100],
+ ];
+ }
+
+ /**
+ * Edit
+ * @dataProvider provideEditCases
+ * This test works correctly, it's marked as broken because we don't have this widget installed by default
+ * @group broken
+ */
+ public function testEdit($statusCode, $name, $duration, $useDisplayLocation, $lat, $long, $zoom)
+ {
+ $this->getLogger()->debug('testEdit ' . get_class($this) .' Test');
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'duration' => $duration,
+ 'useDuration' => 1,
+ 'useDisplayLocation' => $useDisplayLocation,
+ 'longitude' => $long,
+ 'latitude' => $lat,
+ 'zoom' => $zoom,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame($statusCode, $response->getStatusCode(), 'Incorrect status code.', var_export($response, true));
+
+ if ($statusCode == 422)
+ return;
+
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboGoogleTraffic $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboGoogleTraffic($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($name, $checkWidget->name);
+ $this->assertSame($duration, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'longitude' && $long !== null) {
+ $this->assertSame($long, floatval($option['value']));
+ }
+ if ($option['option'] == 'latitude' && $lat !== null) {
+ $this->assertSame($lat, floatval($option['value']));
+ }
+ if ($option['option'] == 'zoom') {
+ $this->assertSame($zoom, intval($option['value']));
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/HlsWidgetTest.php b/tests/integration/Widget/HlsWidgetTest.php
new file mode 100644
index 0000000..141cfe2
--- /dev/null
+++ b/tests/integration/Widget/HlsWidgetTest.php
@@ -0,0 +1,139 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\OAuth2\Client\Entity\XiboHls;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class HlsWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class HlsWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ public static function setUpBeforeClass()
+ {
+ parent::setUpBeforeClass();
+
+ parent::installModuleIfNecessary('hls', '\Xibo\Widget\Hls');
+ }
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/hls/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+
+ $this->getLogger()->debug('Setup Finished');
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $useDuration, $duration, $uri, $mute, $transparency)
+ * @return array
+ */
+ public function provideEditCases()
+ {
+ # Sets of data used in testAdd
+ return [
+ 'HLS stream' => [200, 'HLS stream', 1, 20, 'http://ceu.xibo.co.uk/hls/big_buck_bunny_adaptive_master.m3u8', 0, 0],
+ 'HLS stream 512' => [200, 'HLS stream with transparency', 1, 20, 'http://ceu.xibo.co.uk/hls/big_buck_bunny_adaptive_512.m3u8', 0, 1],
+ 'No url provided' => [422, 'no uri', 1, 10, '', 0, 0],
+ 'No duration provided' => [422, 'no duration with useDuration 1', 1, 0, 'http://ceu.xibo.co.uk/hls/big_buck_bunny_adaptive_512.m3u8', 0, 0],
+ ];
+ }
+
+ /**
+ * Edit
+ * @dataProvider provideEditCases
+ */
+ public function testEdit($statusCode, $name, $useDuration, $duration, $uri, $mute, $transparency)
+ {
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'useDuration' => $useDuration,
+ 'duration' => $duration,
+ 'uri' => $uri,
+ 'mute' => $mute,
+ 'transparency' => $transparency,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame($statusCode, $response->getStatusCode());
+
+ if ($statusCode == 422)
+ return;
+
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboHls $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboHls($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($name, $checkWidget->name);
+ $this->assertSame($duration, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'uri') {
+ $this->assertSame($uri, urldecode(($option['value'])));
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/ImageWidgetTest.php b/tests/integration/Widget/ImageWidgetTest.php
new file mode 100644
index 0000000..e0afb60
--- /dev/null
+++ b/tests/integration/Widget/ImageWidgetTest.php
@@ -0,0 +1,135 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboImage;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ImageWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class ImageWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create some media to upload
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/xts-night-001.jpg');
+
+ // Assign the media we've created to our regions playlist.
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ // Tidy up the media
+ $this->media->delete();
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ public function testEdit()
+ {
+ $this->getLogger()->debug('testEdit ' . get_class($this) .' Test');
+
+ // Edit properties
+ $nameNew = 'Edited Name: ' . Random::generateString(5);
+ $durationNew = 80;
+ $scaleTypeIdNew = 'stretch';
+ $alignIdNew = 'center';
+ $valignIdNew = 'top';
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $nameNew,
+ 'duration' => $durationNew,
+ 'useDuration' => 1,
+ 'scaleTypeId' => $scaleTypeIdNew,
+ 'alignId' => $alignIdNew,
+ 'valignId' => $valignIdNew,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboImage $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboImage($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($nameNew, $checkWidget->name);
+ $this->assertSame($durationNew, $checkWidget->duration);
+ $this->assertSame($this->media->mediaId, intval($checkWidget->mediaIds[0]));
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'scaleTypeId') {
+ $this->assertSame($scaleTypeIdNew, $option['value']);
+ }
+ if ($option['option'] == 'alignId') {
+ $this->assertSame($alignIdNew, $option['value']);
+ }
+ if ($option['option'] == 'valignId') {
+ $this->assertSame($valignIdNew, $option['value']);
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/LocalVideoWidgetTest.php b/tests/integration/Widget/LocalVideoWidgetTest.php
new file mode 100644
index 0000000..811371a
--- /dev/null
+++ b/tests/integration/Widget/LocalVideoWidgetTest.php
@@ -0,0 +1,128 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\OAuth2\Client\Entity\XiboLocalVideo;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LocalVideoWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class LocalVideoWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/localvideo/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $duration, $theme, $clockTypeId, $offset, $format, $showSeconds, $clockFace)
+ * @return array
+ */
+ public function provideEditCases()
+ {
+ # Sets of data used in testAdd
+ return [
+ 'Aspect' => ['rtsp://184.72.239.149/vod/mp4:BigBuckBunny_115k.mov', 30, 1, 'aspect', 0],
+ 'Stretch muted' => ['rtsp://184.72.239.149/vod/mp4:BigBuckBunny_115k.mov', 100, 1, ' stretch', 1],
+ ];
+ }
+
+ /**
+ * @throws \Xibo\OAuth2\Client\Exception\XiboApiException
+ * @dataProvider provideEditCases
+ */
+ public function testEdit($uri, $duration, $useDuration, $scaleTypeId, $mute)
+ {
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'uri' => $uri,
+ 'duration' => $duration,
+ 'useDuration' => $useDuration,
+ 'scaleTypeId' => $scaleTypeId,
+ 'mute' => $mute,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboLocalVideo $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboLocalVideo($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($duration, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'uri') {
+ $this->assertSame($uri, urldecode($option['value']));
+ }
+ if ($option['option'] == 'scaleTypeId') {
+ $this->assertSame($scaleTypeId, $option['value']);
+ }
+ if ($option['option'] == 'mute') {
+ $this->assertSame($mute, intval($option['value']));
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/MenuBoardWidgetTest.php b/tests/integration/Widget/MenuBoardWidgetTest.php
new file mode 100644
index 0000000..a2d3e62
--- /dev/null
+++ b/tests/integration/Widget/MenuBoardWidgetTest.php
@@ -0,0 +1,137 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class LocalVideoWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class MenuBoardWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ private $menuBoard;
+ private $menuBoardCategory;
+ private $menuBoardProduct;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/menuboard/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->menuBoard = $this->getEntityProvider()->post('/menuboard', [
+ 'name' => 'phpunit Menu board',
+ 'description' => 'Description for test Menu Board'
+ ]);
+
+ $this->menuBoardCategory = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoard['menuId'] . '/category', [
+ 'name' => 'phpunit Menu Board Category'
+ ]);
+
+ $this->menuBoardProduct = $this->getEntityProvider()->post('/menuboard/' . $this->menuBoardCategory['menuCategoryId'] . '/product', [
+ 'name' => 'phpunit Menu Board Product',
+ 'price' => '$11.11'
+ ]);
+
+ $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'step' => 1,
+ 'menuId' => $this->menuBoard['menuId'],
+ 'templateId' => 'menuboard1'
+ ]);
+
+ $this->getEntityProvider()->put('/playlist/widget/' . $response['widgetId'], [
+ 'step' => 2,
+ 'menuBoardCategories_1' => [$this->menuBoardCategory['menuCategoryId']]
+ ]);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ if ($this->menuBoard['menuId'] !== null) {
+ $this->getEntityProvider()->delete('/menuboard/' . $this->menuBoard['menuId']);
+ }
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ public function testEdit()
+ {
+ $response = $this->sendRequest('PUT', '/playlist/widget/' . $this->widgetId, [
+ 'name' => 'Test Menu Board Widget',
+ 'duration' => 60,
+ 'useDuration' => 1,
+ 'showUnavailable' => 0,
+ 'productsHighlight' => [$this->menuBoardProduct['menuProductId']]
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ $widget = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId])[0];
+
+ $this->assertSame(60, $widget['duration']);
+ foreach ($widget['widgetOptions'] as $option) {
+ if ($option['option'] == 'showUnavailable') {
+ $this->assertSame(0, intval($option['value']));
+ } elseif ($option['option'] == 'name') {
+ $this->assertSame('Test Menu Board Widget', $option['value']);
+ } elseif ($option['option'] == 'productsHighlight') {
+ $this->assertSame([$this->menuBoardProduct['menuBoardProductId']], $option['value']);
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/PdfWidgetTest.php b/tests/integration/Widget/PdfWidgetTest.php
new file mode 100644
index 0000000..541ec67
--- /dev/null
+++ b/tests/integration/Widget/PdfWidgetTest.php
@@ -0,0 +1,122 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboLibrary;
+use Xibo\OAuth2\Client\Entity\XiboPdf;
+use Xibo\OAuth2\Client\Entity\XiboPlaylist;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class PdfWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class PdfWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var XiboLibrary */
+ protected $media;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create some media to upload
+ $this->media = (new XiboLibrary($this->getEntityProvider()))->create(Random::generateString(), PROJECT_ROOT . '/tests/resources/sampleDocument.pdf');
+
+ // Assign the media we've created to our regions playlist.
+ $playlist = (new XiboPlaylist($this->getEntityProvider()))->assign([$this->media->mediaId], 10, $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Store the widgetId
+ $this->widgetId = $playlist->widgets[0]->widgetId;
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ // Tidy up the media
+ $this->media->delete();
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Test Edit
+ */
+ public function testEdit()
+ {
+ $this->getLogger()->debug('testEdit ' . get_class($this) .' Test');
+
+ $name = 'Edited Name: ' . Random::generateString(5);
+ $duration = 80;
+
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'duration' => $duration,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->getLogger()->debug('testEdit Finished');
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboPdf $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboPdf($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($name, $checkWidget->name);
+ $this->assertSame($duration, $checkWidget->duration);
+ $this->assertSame($this->media->mediaId, intval($checkWidget->mediaIds[0]));
+
+ $this->getLogger()->debug('testEdit Finished');
+ }
+}
diff --git a/tests/integration/Widget/ShellCommandWidgetTest.php b/tests/integration/Widget/ShellCommandWidgetTest.php
new file mode 100644
index 0000000..f7ebcf6
--- /dev/null
+++ b/tests/integration/Widget/ShellCommandWidgetTest.php
@@ -0,0 +1,139 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\Helper\Random;
+use Xibo\OAuth2\Client\Entity\XiboCommand;
+use Xibo\OAuth2\Client\Entity\XiboShellCommand;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class ShellCommandWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class ShellCommandWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var XiboCommand */
+ protected $command;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/shellcommand/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ // Create a command
+ $this->command = (new XiboCommand($this->getEntityProvider()))->create(Random::generateString(), 'phpunit description', 'phpunitcode');
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ // Delete the commant
+ $this->command->delete();
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $duration, $windowsCommand, $linuxCommand, $launchThroughCmd, $terminateCommand, $useTaskkill, $commandCode)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ # Sets of data used in testAdd
+ return [
+ 'Windows new command' => ['Api Windows command', 20, 1,'reboot', NULL, 1, null, 1, null],
+ 'Android new command' => ['Api Android command', 30, 1, null, 'reboot', null, 1, null, null],
+ 'Previously created command' => ['Api shell command', 50, 1, null, null, 1, 1, 1, 'phpunit code']
+ ];
+ }
+
+ /**
+ * @throws \Xibo\OAuth2\Client\Exception\XiboApiException
+ * @dataProvider provideSuccessCases
+ */
+ public function testEdit($name, $duration, $useDuration, $windowsCommand, $linuxCommand, $launchThroughCmd, $terminateCommand, $useTaskkill, $commandCode)
+ {
+ $response = $this->sendRequest('PUT','/playlist/widget/' . $this->widgetId, [
+ 'name' => $name,
+ 'duration' => $duration,
+ 'useDuration' => $useDuration,
+ 'windowsCommand' => $windowsCommand,
+ 'linuxCommand' => $linuxCommand,
+ 'launchThroughCmd' => $launchThroughCmd,
+ 'terminateCommand' => $terminateCommand,
+ 'useTaskkill' => $useTaskkill,
+ 'commandCode' => $commandCode,
+ ], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertNotEmpty($response->getBody());
+ $object = json_decode($response->getBody());
+ $this->assertObjectHasAttribute('data', $object, $response->getBody());
+
+ /** @var XiboShellCommand $checkWidget */
+ $response = $this->getEntityProvider()->get('/playlist/widget', ['widgetId' => $this->widgetId]);
+ $checkWidget = (new XiboShellCommand($this->getEntityProvider()))->hydrate($response[0]);
+
+ $this->assertSame($name, $checkWidget->name);
+ $this->assertSame($duration, $checkWidget->duration);
+
+ foreach ($checkWidget->widgetOptions as $option) {
+ if ($option['option'] == 'commandCode') {
+ $this->assertSame($commandCode, $option['value']);
+ }
+ }
+ }
+}
diff --git a/tests/integration/Widget/TextWidgetTest.php b/tests/integration/Widget/TextWidgetTest.php
new file mode 100644
index 0000000..3311591
--- /dev/null
+++ b/tests/integration/Widget/TextWidgetTest.php
@@ -0,0 +1,128 @@
+.
+ */
+
+namespace Xibo\Tests\Integration\Widget;
+
+use Xibo\OAuth2\Client\Entity\XiboText;
+use Xibo\Tests\Helper\LayoutHelperTrait;
+use Xibo\Tests\LocalWebTestCase;
+
+/**
+ * Class TextWidgetTest
+ * @package Xibo\Tests\Integration\Widget
+ */
+class TextWidgetTest extends LocalWebTestCase
+{
+ use LayoutHelperTrait;
+
+ /** @var \Xibo\OAuth2\Client\Entity\XiboLayout */
+ protected $publishedLayout;
+
+ /** @var int */
+ protected $widgetId;
+
+ /**
+ * setUp - called before every test automatically
+ */
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup for ' . get_class($this) .' Test');
+
+ // Create a Layout
+ $this->publishedLayout = $this->createLayout();
+
+ // Checkout
+ $layout = $this->getDraft($this->publishedLayout);
+
+ // Create a Widget for us to edit.
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $layout->regions[0]->regionPlaylist->playlistId);
+
+ $this->widgetId = $response['widgetId'];
+ }
+
+ /**
+ * tearDown - called after every test automatically
+ */
+ public function tearDown()
+ {
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->publishedLayout);
+
+ parent::tearDown();
+
+ $this->getLogger()->debug('Tear down for ' . get_class($this) .' Test');
+ }
+
+ /**
+ * Each array is a test run
+ * Format ($name, $duration, $useDuration, $effect, $speed, $backgroundColor, $marqueeInlineSelector, $text, $javaScript)
+ * @return array
+ */
+ public function provideSuccessCases()
+ {
+ // Sets of data used in testAdd
+ return [
+ 'text 1' => ['Text item', 10, 1, 'marqueeRight', 5, null, null, 'TEST API TEXT', null],
+ 'text with formatting' => ['Text item 2', 20, 1, 'marqueeLeft', 3, null, null, '