diff --git a/.gitignore b/.gitignore index 5092954fe..6a75d6466 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Ignore custom templates folder. /assets/shared/custom-templates/* +# Ignore custom screen layouts folder. +/assets/shared/custom-screen-layouts/* + # Ignore the public/fixtures folder. /public/fixtures diff --git a/CHANGELOG.md b/CHANGELOG.md index d169fce18..f0dfc492c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. - Removed themes. - Added command to migrate config.json files. - Fix data fetching bug +- Refactored screen layout commands. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/README.md b/README.md index 3c9d9ce57..dcd91eb93 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,30 @@ ## Table of Contents -- [Description](#description) -- [ADR - Architectural Decision Records](#adr---architectural-decision-records) -- [Technologies](#technologies) -- [Versioning](#versioning) -- [Taskfile](#taskfile) -- [Development setup](#development-setup) -- [Production setup](#production-setup) -- [Coding standards](#coding-standards) -- [Stateless](#stateless) -- [OIDC providers](#oidc-providers) -- [JWT Auth](#jwt-auth) -- [Test](#test) -- [API specification and generated code](#api-specification-and-generated-code) -- [Configuration](#configuration) -- [Rest API & Relationships](#rest-api--relationships) -- [Error codes in the Client](#error-codes-in-the-client) -- [Preview mode in the Client](#preview-mode-in-the-client) -- [Feeds](#feeds) -- [Custom Templates](#custom-templates) -- [Upgrade Guide](#upgrade-guide) +1. [Description](#description) +2. [ADR - Architectural Decision Records](#adr---architectural-decision-records) +3. [Technologies](#technologies) +4. [Versioning](#versioning) +5. [Taskfile](#taskfile) +6. [Development setup](#development-setup) +7. [Production setup](#production-setup) +8. [Coding standards](#coding-standards) +9. [Stateless](#stateless) +10. [OIDC providers](#oidc-providers) +11. [JWT Auth](#jwt-auth) +12. [Test](#test) +13. [API specification and generated code](#api-specification-and-generated-code) +14. [Configuration](#configuration) +15. [Rest API & Relationships](#rest-api--relationships) +16. [Error codes in the Client](#error-codes-in-the-client) +17. [Preview mode in the Client](#preview-mode-in-the-client) +18. [Feeds](#feeds) +19. [Custom Templates](#custom-templates) +20. [Static Analysis](#static-analysis) +21. [Upgrade Guide](#upgrade-guide) +22. [Tenants](#tenants) +23. [Screen layouts](#screen-layouts) +24. [Templates](#templates) ## Description @@ -31,22 +35,58 @@ At the core of OS2Display is an API that clients communicate with. All data runs It includes an Admin for creating content and a Client for displaying the content. -The structure is that slides are the content element of the system. Each slide is based on a template with content -added. The slides are gathered into a playlist. Playlists are then added to screens. +The structure is that slides are the content element of the system. Each slide is based on a Template with content +added. The slides are gathered into playlists. Playlists are then added to screens. A screen is the connection between a physical device and the content. +```mermaid +flowchart LR + A[Admin] <-->B(API) + B <--> C(Client) +``` + Further documentation can be found in the [https://os2display.github.io/display-docs/](https://os2display.github.io/display-docs/). -## ADR - Architectural Decision Records +## Content Structure -Architectural decisions are recorded in the `docs/adr` folder. +| Component | Description | Accessible by | +|-----------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------| +| Slide | A slide is the visible content on a screen. | Admin, editor | +| Media | Media is either images or videos used as content for slides. | Admin, editor | +| Theme | A theme has css, that can override the slide css. | Admin | +| Template | The template is how the slide looks, and which content is on the slide. Templates are accessible to choose on Slides. | Admin, editor | +| Playlist | A playlist arranges the order of the slides, and the playlist is scheduled. | Admin, editor | +| Campaign | A campaign is a playlist, that takes precedence over all other playlists on the screen. If there a multiple campaigns, they are queued. A campaign is either directly attached to a screen, or attached to a group affecting the screens that are members of that group. If a campaign applies to a screen it fills the whole screen, not just a region of the screen. | Admin | +| Group | A group is a collection of screens. | Admin | +| Layout | A layout consists of different regions, and each region can have a number of playlists connected. A layout is connected to a screen. | Admin | +| Screen | A screen is connected to an actual screen, and has a layout with different playlists in. | Admin | -## Technologies +```mermaid +flowchart LR + Slide -->|1| D[Theme] + Slide -->|1| E[Template] + Slide -->|fa:fa-asterisk| F[Media] +``` + +```mermaid +flowchart LR + Screen-->|fa:fa-asterisk|Layout + Layout -->|fa:fa-asterisk|Playlist + Playlist -->|fa:fa-asterisk|Slide + + Screen-->|fa:fa-asterisk|G[Campaign] + G -->|fa:fa-asterisk|H[Slide] + + Screen-->|fa:fa-asterisk|Group + + Group-->|fa:fa-asterisk|L[Campaign] + L -->|fa:fa-asterisk| M[Slide] +``` -The API is written in PHP with Symfony and API Platform as frameworks. +## ADR - Architectural Decision Records -The Admin and Client are written in javascript and React. +Architectural decisions are recorded in `docs/adr`. ## Versioning @@ -54,6 +94,13 @@ We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/os2display/display-api-service/tags). +## Technologies + +The API is written in PHP project, built with [Symfony](https://symfony.com/) and +[API Platform](https://api-platform.com/). + +The Admin and Client are written in javascript and [React](https://react.dev/) and built with [Vite](https://vite.dev/). + ## Taskfile The project includes a [taskfile](https://taskfile.dev/) for executing common commands. @@ -137,6 +184,31 @@ task coding-standards:apply The API is stateless except for the `/v2/authentication` routes. +## Authentication + +Authentication is achieved through `/v2/authentication/token` for the `/admin` +and through `/v2/authentication/screen` for the `/client`. + +## Tenants + +Content is connected to a Tenant. A user is in x tenants. +This allows for maintaining multiple content silos in the same installation. + +You can add a new tenant: + +```shell +docker compose exec phpfpm bin/console app:tenant:add +``` + +A tenant can be configured with + +```shell +docker compose exec phpfpm bin/console app:tenant:configure +``` + +At the monment, it is possible to configure the fallback image to be shown in the tenant when a screen shows no content. +It is also possible to configure if a tenants should support interactive slides. + ## OIDC providers At the present two possible oidc providers are implemented: 'internal' and 'external'. @@ -276,6 +348,10 @@ In interactive mode: task test:frontend-local-ui ``` +### Manual tests + +A manual test guide is included with the project: [docs/test-guide/test-guide.md](docs/test-guide/test-guide.md). + ## API specification and generated code When the API is changed a new OpenAPI specification should be generated that reflects the changes to the API. @@ -549,11 +625,66 @@ The preview will use the token and tenant for accessing the data from the api. This feature is used in the Admin for displaying previews of slides, playlists and screens. +## Screen status + +Screen status consists of 2 elements. Tracking latest request from a screen client. +This data is collected and exposed through the API. + +The other part is in the admin where the data can be exposed to the user. + +To enable screen status information tracking and showing this information in the admin requires setting these .env +variables: + +```dotenv +# Enable tracking screen information. +TRACK_SCREEN_INFO=true +# Data will only be updated with this frequency +TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 +# Enable screen information in Admin. +ADMIN_SHOW_SCREEN_STATUS=true +``` + +### List view + +In the list view of screens, there is a column called "Status". + +This column shows the status of the connection of a "screen" in the administration and an +actual "machine" running the screen data. + +This status can be: + +- "+ Tilkobl": The screen is not connected to a machine. +- ✓ (green): The machine is connected and running the latest code. +- i (yellow circle): The machine is not running the newest released code. +- ! (red triangle): The machine has not called the API within the last hour or the access token is expired. + +### Screen edit view + +In the screen edit view, the "Tilkobling" section shows the status of the connection between the +screen entity and a machine running the screen data. + +The status can be: + +- "Skærmen er tilkoblet" (green): The machine is connected and running the latest code. +- "Skærmen kører ikke seneste udgivelse" (yellow circle): The machine is not running the newest released code. +- "Skærmen har ikke kommunikeret i mere end en time" (red triangle): The machine has not called the API the latest hour. + +Furthermore, the section "Tilkobling" will show the following data: + +```text +* Seneste kommunikation: 14/12 2024 11:35 +* Version: 1.0.9 +* Kodeudgivelsestidspunkt: 17/6 2024 17:26 +``` + +This shows when the latest communication has occured, what client version the machine is running, +and the time of client code release. + ## Feeds "Feeds" in OS2display are external data sources that can provide up-to-data to slides. The idea is that if you can set -up slide based on a feed and publish it. The Screen Client will then fetch new data from the feed whenever the Slide is -shown on screen. +up a slide based on a feed and publish it. The Screen Client will then fetch new data from the feed whenever the Slide +is shown on screen. The simplest example is a classic RSS news feed. You can set up a slide based on the RSS slide template, configure the RSS source URL, and whenever the slide is on screen it will show the latest entries from the RSS feed. @@ -577,6 +708,38 @@ For example: booking system you can implement a "FeedSource" that fetches booking data from your source and normalizes it to match the calendar output model. +## Create a new FeedType + +To implement a new FeedType, create a class that implements `src/Feed/FeedTypeInterface`. + +## List installed Feed Sources + +```shell +docker compose exec phpfpm bin/console app:feed:list-feed-source +``` + +## Create a Feed Source + +To create a feed source use the following command: + +```shell +docker compose exec phpfpm bin/console app:feed:create-feed-source +``` + +This will start an interactive session where the secrets and configuration for the feed source can be set. + +To override an existing feed source, use the ulid in the command above, eg.: + +```shell +docker compose exec phpfpm bin/console app:feed:create-feed-source 01FYRMSGGHG4VXS3Z0WACG6BX8 +``` + +## Remove a Feed Source + +```shell +docker compose exec phpfpm bin/console app:feed:remove-feed-source 01FYRMSGGHG4VXS3Z0WACG6BX8 +``` + ## Themes It is possible to create themes that can apply to select templates. See `/admin/themes` in the Admin. @@ -584,6 +747,42 @@ It is possible to create themes that can apply to select templates. See `/admin/ The theme css has to follow som rules. See [docs/themes/themes.md](docs/themes/themes.md) for instructions on writing custom themes. +## Templates + +A list of installed and available templates can be seen with: + +```shell +docker compose exec phpfpm bin/console app:templates:list +``` + +Templates can be installed with the + +```shell +docker compose exec phpfpm bin/console app:templates:install +``` + +or all templates with + +```shell +docker compose exec phpfpm bin/console app:templates:install --all +``` + +To remove a template: + +```shell +docker compose exec phpfpm bin/console app:templates:remove +``` + +When running in dev mode, the route `/template` can be visited to preview how templates are rendered with different +fixtures. + +### Video + +When using the video template the video will not autoplay in the `/client` unless the autoplay flag is enabled in the +browser configuration. For Chrome see: + +[https://developer.chrome.com/blog/autoplay#developer_switches](https://developer.chrome.com/blog/autoplay#developer_switches) + ## Custom Templates OS2Display ships with some standard templates. These are located in `assets/shared/templates`. @@ -619,9 +818,46 @@ The `.jsx` should expose the following functions: - config() - Should contain the following keys: id (as above), title (the titel displayed in the admin), options, adminForm. - renderSlide(slide, run, slideDone) - Should return the JSX for the template. + - slide: The slide data from the API. + - run: A date string that will be set when the slide should start executing. + - slideDone: A function that is called when the slide is done. For an example of a custom template see `assets/shared/custom-templates-example/`. +The slide is responsible for signaling that it is done executing. +This is done by calling the slideDone() function. If the slide should just run for X milliseconds then you can use the +BaseSlideExecution class to handle this. See the example for this approach. + +##### Admin Form + +To get content into the slide the config.adminForm field should be set. This should be an array of objects with the +following attributes: + +- input: The type of the input field. Supported types: + - input: Regular html5 input. + - header: Headline. + - header-h3: Sub-headline. + - select: Select. + - checkbox: Checkbox. + - rich-text-input: Text field with support for rich text html. + - image: Upload image(s) or select from media archive. + - video: Upload video(s) or select from media archive. + - file: Upload file(s) or select from media archive. + - duration: Slide duration field. + - contacts: Create contacts entries + - feed: Configure a feed for the slide. + - table: Create table content. + - textarea: Textarea. +- name: A name, should be unique. This is the field in slide.content what will be set. +- type: text, number or email, for input type. +- label: Label for the input +- helpText: A helptext for the input +- required: Whether it is required data +- formGroupClasses: For styling, bootstrap, e.g. mb-3 +- options: An array of options {name,id} for the select + +Look at the existing templates in `assets/shared/templates/` for examples. + In production, these custom templates need to be built together with the normal templates with the `npm run build` command. @@ -636,7 +872,54 @@ If you think the template could be used by other, consider contributing the temp `assets/shared/custom-templates/` folder to the `assets/shared/templates/` folder. - Create a PR to `os2display/display` repository. -### Static analysis +## Screen Layouts + +A screen layout is a setting that defines how a screen is divided into different regions. +A layout consists of a grid. + +The grid regions are created from the number of rows and columns selected for the given layout. The regions are named + +`[a-z][aa-zz][aaa-zzz]` + +Core layouts are stored in `assets/shared/screen-layouts` and custom layouts can be placed in +`assets/shared/custom-screen-layouts`. + +To see status of screen layouts: + +```shell +docker compose exec phpfpm bin/console app:screen-layouts:list +``` + +To install a layout: + +```shell +docker compose exec phpfpm bin/console app:screen-layouts:install +``` + +or all with + +```shell +docker compose exec phpfpm bin/console app:screen-layouts:install --all +``` + +To remove a layout: + +```shell +docker compose exec phpfpm bin/console app:screen-layouts:remove +``` + +### Touch regions in layouts + +A region can be rendered as buttons. In this scenario each slide that is present in a region is added as a button that +can be opened in full screen. It will close when the slide has run or if the user presses the close button. + +To make a layout region into a touch button region, add the following to the region in the layout `.json` file: + +```text +"type": "touch-buttons" +``` + +## Static analysis [Psalm](https://psalm.dev/) is used for static analysis: diff --git a/Taskfile.yml b/Taskfile.yml index 011281bc9..06badb6a5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -143,12 +143,12 @@ tasks: coding-standards:markdown:apply: desc: "Apply coding standards for Markdown." cmds: - - docker compose run --rm --volume "$PWD:/md" markdownlint markdownlint --ignore '**/node_modules/**' --ignore '**/vendor/**' '*.md' 'documentation/*.md' --fix + - docker compose run --rm --volume "$PWD:/md" markdownlint markdownlint --ignore '**/node_modules/**' --ignore '**/vendor/**' '*.md' 'docs/**/*.md' --fix coding-standards:markdown:check: desc: "Check coding standards for Markdown." cmds: - - docker compose run --rm --volume "$PWD:/md" markdownlint markdownlint --ignore '**/node_modules/**' --ignore '**/vendor/**' '*.md' 'documentation/*.md' + - docker compose run --rm --volume "$PWD:/md" markdownlint markdownlint --ignore '**/node_modules/**' --ignore '**/vendor/**' '*.md' 'docs/**/*.md' coding-standards:php:apply: desc: "Apply coding standards for PHP." diff --git a/UPGRADE.md b/UPGRADE.md index 8346af6ef..ab622b548 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -82,3 +82,19 @@ docker compose exec phpfpm bin/console app:templates:install - Use `--all` option for installing all available templates. - Use `--update` option for updating existing templates. + +#### 6 - Run screen layout list command to see status for installed screen layouts + +```shell +docker compose exec phpfpm bin/console app:screen-layouts:list +``` + +#### 7 - Run screen layout install for enabling screen layouts + +```shell +docker compose exec phpfpm bin/console app:screen-layouts:install +``` + +- Use `--all` option for installing all available templates. +- Use `--update` option for updating existing templates. +- Use `--cleanupRegions` option for cleaning up regions that are no longer connected to a layout. diff --git a/assets/shared/custom-templates-example/custom-template-example.jsx b/assets/shared/custom-templates-example/custom-template-example.jsx index 96cf5e06c..0ea8a45fd 100644 --- a/assets/shared/custom-templates-example/custom-template-example.jsx +++ b/assets/shared/custom-templates-example/custom-template-example.jsx @@ -3,14 +3,29 @@ import templateConfig from "./custom-template-example.json"; import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; +/** + * Get the ULID of the template. + * @return {string} The ULID of the template. + */ function id() { return templateConfig.id; } +/** + * Get the config object of the template. + * @return {{title: string, id: string, options: {}, adminForm: {}}} + */ function config() { return templateConfig; } +/** + * Render the slide. + * @param {object} slide The slide data. + * @param {string} run A date string set when the slide should start running. + * @param slideDone A function to invoke when the slide is done playing. + * @return {JSX.Element} The component. + */ function renderSlide(slide, run, slideDone) { return ( {title} - + {slide?.theme?.cssStyles && ( + + )} ); } diff --git a/assets/template/screen-layouts/four-areas.json b/assets/shared/screen-layouts/four-areas.json similarity index 100% rename from assets/template/screen-layouts/four-areas.json rename to assets/shared/screen-layouts/four-areas.json diff --git a/assets/template/screen-layouts/full-screen.json b/assets/shared/screen-layouts/full-screen.json similarity index 100% rename from assets/template/screen-layouts/full-screen.json rename to assets/shared/screen-layouts/full-screen.json diff --git a/assets/template/screen-layouts/six-areas.json b/assets/shared/screen-layouts/six-areas.json similarity index 100% rename from assets/template/screen-layouts/six-areas.json rename to assets/shared/screen-layouts/six-areas.json diff --git a/assets/template/screen-layouts/three-boxes-horizontal.json b/assets/shared/screen-layouts/three-boxes-horizontal.json similarity index 100% rename from assets/template/screen-layouts/three-boxes-horizontal.json rename to assets/shared/screen-layouts/three-boxes-horizontal.json diff --git a/assets/template/screen-layouts/three-boxes.json b/assets/shared/screen-layouts/three-boxes.json similarity index 100% rename from assets/template/screen-layouts/three-boxes.json rename to assets/shared/screen-layouts/three-boxes.json diff --git a/assets/template/screen-layouts/touch-template.json b/assets/shared/screen-layouts/touch-template.json similarity index 100% rename from assets/template/screen-layouts/touch-template.json rename to assets/shared/screen-layouts/touch-template.json diff --git a/assets/template/screen-layouts/two-boxes-vertical-reversed.json b/assets/shared/screen-layouts/two-boxes-vertical-reversed.json similarity index 100% rename from assets/template/screen-layouts/two-boxes-vertical-reversed.json rename to assets/shared/screen-layouts/two-boxes-vertical-reversed.json diff --git a/assets/template/screen-layouts/two-boxes-vertical.json b/assets/shared/screen-layouts/two-boxes-vertical.json similarity index 100% rename from assets/template/screen-layouts/two-boxes-vertical.json rename to assets/shared/screen-layouts/two-boxes-vertical.json diff --git a/assets/template/screen-layouts/two-boxes.json b/assets/shared/screen-layouts/two-boxes.json similarity index 100% rename from assets/template/screen-layouts/two-boxes.json rename to assets/shared/screen-layouts/two-boxes.json diff --git a/assets/template/fixtures/screen-fixtures.js b/assets/template/fixtures/screen-fixtures.js index fcbdc8f86..38eb14f66 100644 --- a/assets/template/fixtures/screen-fixtures.js +++ b/assets/template/fixtures/screen-fixtures.js @@ -1,12 +1,12 @@ -import twoBoxes from "../screen-layouts/two-boxes.json"; -import threeBoxes from "../screen-layouts/three-boxes.json"; -import threeBoxesHorizontal from "../screen-layouts/three-boxes-horizontal.json"; -import twoBoxesVertical from "../screen-layouts/two-boxes-vertical.json"; -import touchTemplate from "../screen-layouts/touch-template.json"; -import sixAreas from "../screen-layouts/six-areas.json"; -import fullScreen from "../screen-layouts/full-screen.json"; -import fourAreas from "../screen-layouts/four-areas.json"; -import twoBoxesVerticalReversed from "../screen-layouts/two-boxes-vertical-reversed.json"; +import twoBoxes from "../../shared/screen-layouts/two-boxes.json"; +import threeBoxes from "../../shared/screen-layouts/three-boxes.json"; +import threeBoxesHorizontal from "../../shared/screen-layouts/three-boxes-horizontal.json"; +import twoBoxesVertical from "../../shared/screen-layouts/two-boxes-vertical.json"; +import touchTemplate from "../../shared/screen-layouts/touch-template.json"; +import sixAreas from "../../shared/screen-layouts/six-areas.json"; +import fullScreen from "../../shared/screen-layouts/full-screen.json"; +import fourAreas from "../../shared/screen-layouts/four-areas.json"; +import twoBoxesVerticalReversed from "../../shared/screen-layouts/two-boxes-vertical-reversed.json"; const screenFixtures = [ { diff --git a/composer.json b/composer.json index 69958df2d..3108dd75c 100644 --- a/composer.json +++ b/composer.json @@ -121,7 +121,7 @@ "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd" }, - "code-analysis": "vendor/bin/psalm", + "code-analysis": "vendor/bin/psalm --no-cache", "coding-standards-apply": [ "vendor/bin/php-cs-fixer fix" ], diff --git a/docs/test-guide/assets/anmeldelse.png b/docs/test-guide/assets/anmeldelse.png new file mode 100644 index 000000000..5d4f583fd Binary files /dev/null and b/docs/test-guide/assets/anmeldelse.png differ diff --git a/docs/test-guide/assets/billede-og-tekst.png b/docs/test-guide/assets/billede-og-tekst.png new file mode 100644 index 000000000..7f3adbc31 Binary files /dev/null and b/docs/test-guide/assets/billede-og-tekst.png differ diff --git a/docs/test-guide/assets/iframe.png b/docs/test-guide/assets/iframe.png new file mode 100644 index 000000000..196fa9d82 Binary files /dev/null and b/docs/test-guide/assets/iframe.png differ diff --git a/docs/test-guide/assets/rejseplanen.png b/docs/test-guide/assets/rejseplanen.png new file mode 100644 index 000000000..62cc102f7 Binary files /dev/null and b/docs/test-guide/assets/rejseplanen.png differ diff --git a/docs/test-guide/test-guide.md b/docs/test-guide/test-guide.md new file mode 100644 index 000000000..9e9b6d759 --- /dev/null +++ b/docs/test-guide/test-guide.md @@ -0,0 +1,518 @@ +# Test Guide + +## Overview + +This guide explains how to test the different parts of the system. This can be used to confirm that the system works +following upgrades, new features, etc. + +This guide can also be used as an introduction to how the system can be used. + +The following test scenarios are explained: + +* [T1 Basic use](#t1-basic-use) +* [T2 Templates](#t2-templates) +* [T3 Publishing and planning](#t3-publishing-and-planning) +* [T4 Themes](#t4-themes) +* [T5 Screen layouts](#t5-screen-layouts) +* [T6 External users](#t6-external-users) +* [T7 Campaigns](#t7-campaigns) +* [T8 Shared playlists](#t8-shared-playlists) +* [T9 Feed sources](#t9-feed-sources) + +--- + +## T1 Basic use + +### Description + +This guide tests that the user can log in, create content and show the content on a screen. +The user will create a slide, a playlist and a screen and then make the slide be displayed +on the screen. + +This guide requires a user that has the `ROLE_ADMIN` role. + +### Steps + +* Open the admin at `/admin`. +* Log in. +* If more than one tenant has been set up, choose one. +* You should now be on the page `/admin/slide/list` (slide list). +* Create a slide + * Click "Opret nyt slide". + * You should now be on `/admin/slide/create`. + * Fill "Slidets navn" with a name you can recognize. + * Select "Billede og tekst" in "Vælg en skabelon til dit slide". + * Fill "Overskrift på slide". + * Fill "Tekst på slide". + * Select "l" in "Tekststørrelse". + * Upload an image under "Billeder". + * Scroll to the bottom and click "Gem slide". + * You should be redirected to the slide list at `/admin/slide/list`. + * You should be able to find the new slide in the list. +* Create a playlist + * Navigate to `/admin/playlist/list` by clicking `Spillelister` in the navigation. + * Click "Opret ny spilleliste" + * You should now be on `/admin/playlist/create`. + * Fill "Spillelistens navn" with a name you can recognize. + * Select the slide created above in "Vælg en eller flere slides". + * Scroll to the bottom and click "Gem". + * You should be redirected to the playlist list at `/admin/playlist/list`. + * You should be able to find the new playlist in the list. +* Create a screen + * Navigate to `/admin/screen/list` by clicking `Skærme` in the navigation. + * Click "Opret ny skærm" + * Fill "Skærmens navn" with a name you can recognize. + * Select "Fuld skærm" in "Skærmens layout". + * Select the playlist created above in "Vælg en eller flere spillelister" + * Scroll to the bottom and click "Gem skærm". + * You should be redirected to the screen list at `/admin/screen/list`. + * You should be able to find the new screen in the list. +* Set up the screen + * Open the screen client at `/client` in another tab in the browser. + * You should see an empty screen with an activation code in the bottom right corner. + * If already logged in from earlier you can log out the screen with `shift+ctrl+i`. + * Copy the activation code. +* Activate the screen + * Open the the screen in the admin by clicking "Rediger". + * You should be at a route like `/admin/screen/edit/[ID]`. + * Input the activation code in "Tilkoblingskode". + * Click "Tilkobl skærm". +* Check the content is displayed in the screen + * Open the screen tab again at `/client`. + * See that the slide is displayed. + +--- + +## T2 Templates + +### Description + +This guide tests that the different supported templates can be created. +This guide assumes that all templates from have been added to the installation. + +### Steps + +* "Anmeldelse" (expected result [anmeldelse.png](./assets/anmeldelse.png)) + * `Use Case: Show a review of book, film, etc.` + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Anmeldelse" from "Vælg en skabelon til dit slide". + * Fill "Teksten til anmeldelsen". Use different kinds of formatting available in the text editor. + * Upload an image under "Billede". + * Fill "Forfattertekst" + * Upload an image under "Billede". + * Click "Gem slide" +* "Billede og tekst" (expected result [billede-og-tekst.png](./assets/billede-og-tekst.png)) + * `Use Case: Show image(s) and/or text.` + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Billede og tekst" from "Vælg en skabelon til dit slide". + * Fill content into the "Indhold" fields. Start with a single image in "Billeder". + * Test different setting under "Opsætning". + * To see the result of the different settings use the "Åben preview i fuld skærm". + * Add an extra image under "Billeder". + * Open the preview to see that the background image changes. + * Enable "Deaktiver fade ved flere billeder" and see in the preview that the fade effect gone between images. + * NB! The settings regarding logo are dependent on setting a theme with a theme logo. This will be tested in T4. + * Click "Gem slide". +* "Iframe" (expected result [iframe.png](./assets/iframe.png)) + * `Use Case: Show content from an external url.` + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Iframe" from "Vælg en skabelon til dit slide". + * Fill "URL til iframe". E.g. . + * Open the preview and see that the webpage pointed to is shown in the slide. + * Click "Gem slide". +* "Instagram feed" + * `Use Case: Show data from an instagram feed.` + * NB! This guide assumes that a feed source supplying "instagram" data being installed for the selected tenant. + * NB! The feed will not supply data until after the slide has been saved, so it cannot be previewed before saving the + slide. + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Instagram feed" from "Vælg en skabelon til dit slide". + * Select the feed source as "Vælg datakilde" and the feed under "Vælg feed". + * Select a duration in "Varighed pr. billede/video (i sekunder)". + * Fill "Hashtag-tekst". + * Click "Gem slide". +* "Kalender" + * `Use Case: Show calendar events in different layouts.` + * NB! This guide assumes that a feed source supplying "calendar" data has been installed for the selected tenant. + * NB! The feed will not supply data until after the slide has been saved, so it cannot be previewed before saving the + slide. + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Kalender" from "Vælg en skabelon til dit slide". + * Select the feed source as "Vælg datakilde" and the feed under "Vælg feed". + * Select a resource in "Vælg resurser". + * This template support different layouts. Try different options under "Vælg layout". + * Some of the options under "Konfigurér slide" only apply to specific layouts. + * Click "Gem slide". + * Open the slide again. + * Test different layouts and settings. See the effects in the preview. +* "Kontakter" + * `Use Case: Display a list of people available for contact.` + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Kontakter" from "Vælg en skabelon til dit slide". + * Click "Tilføj kontakt" + * Add an image. + * Fill the fields: "Titel", "Navn", "Telefonnummer", "E-mail". + * Add a few more contacts. + * Save the slide. + * See that the data is displayed correctly. +* "Plakat" + * `Use Case: Display a poster for an event` + * NB! This guide assumes that a feed source supplying "poster" data has been installed for the selected tenant. + * The slide can be configured to show a single occurrence of an event, or subscribe to events for given selections. + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Plakat" from "Vælg en skabelon til dit slide". + * Select the data source. + * Click "Enkelt" in "Vælg visningstype". + * Search for an event. + * Click the event. + * If more than one occurrence appears, select one of them. + * Save the slide. + * See that the event is displayed. + * Try to override the different fields with "Vis overskrivningsmuligheder". + * Create a new "Plakat" slide. + * Click "Abonnement" in "Vælg visningstype". + * Test the different filters. Find a case where multiple events appear. + * Save the slide. + * See that the expected events are shown in the slide. +* "Rejseplanen" (expected result [rejseplanen.png](./assets/rejseplanen.png)) + * `Use Case: Show which public transportation is available from a given location with rejseplanen.dk.` + * NB! This guide assumes that the admin has been configured with a valid rejseplanen api key. + * NB! The part that displays departures is an iframe from rejseplanen from the stops selected. + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Rejseplanen" from "Vælg en skabelon til dit slide". + * Fill the different text fields. + * Add a map of how to get to the stop. + * Find a stop with "Vælg stoppested". + * Fill "Antal afgange der skal vises" and "Er det valgte stoppested bus eller letbane". + * Save the slide. + * See that the slide displays the expected content. +* "RSS" + * `Use Case: Display latest news from an RSS feed.` + * NB! This guide assumes that a feed source supplying "rss" data has been installed for the selected tenant. + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Rss" from "Vælg en skabelon til dit slide". + * Select the data source from the dropdown. + * Fill an rss feed url into "Kilde". E.g. + * Fill 5 into "Antal indgange" and "Varighed pr. indgang". + * Select a background image and a text size. + * Save the slide. + * See that data is displayed in the slide. +* "Slideshow" + * `Use Case: Display multiple images after each other with transitions between.` + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Slideshow" from "Vælg en skabelon til dit slide". + * Upload multiple images. + * Select "Varighed pr. billede". + * Choose "Zoom mod midten" from "Vælg animation". + * Choose "Cross fade" from "Vælg overgang". + * Save the slide. + * See that the expected result is displayed in the slide. + * Experiment with different animations and transitions. +* "Tabel" + * `Use Case: Display tabel data, which is manually created or supplied by an API endpoint.` + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Tabel" from "Vælg en skabelon til dit slide". + * Create multiple columns and rows with the tool under "Indtast tabeldata". + * See that the data is presented correctly. +* "Video" + * `Use Case: Show an uploaded video.` + * NB! For the video to autoplay with sound the browser has to be configured to allow this. + See . + * Navigate to `/admin/slide/create`. + * Fill "Slidets navn". + * Select "Video" from "Vælg en skabelon til dit slide". + * Upload a video. + * Save the slide. + * See that the video is played back without sound. + * Activate "Afspil lyd" and save. + * See that the video is played back with sound. +* Follow the steps from T1 for adding the slides to a playlist and screen. +* See that the templates display correctly. + +--- + +## T3 Publishing and planning + +### Description + +This guide tests the different publishing and planning options available. + +A slide/playlist can be restricted in publishing period. +If no publishing is set the default is that the slide/playlist is published. +If you wish to limit the period the slide/playlist is published you can set publishing from,to dates. + +A playlist can also use planning rules. +These resemble the recurrence rules you can set for a calendar event in most calendar programs. +An example of planning can be "every monday at 12:00" with a duration of 1 hour. + +### Steps + +* Create a slide, playlist, screen. See T1. +* Slide publishing + * Confirm that the slide is displayed in the client `/client`. + * Set "Udgivet fra" to a future date. Save the change. + * Confirm that the slide is NOT displayed in the client `/client`. + * Set "Udgivet fra" to a past date. Save the change. + * Confirm sure the slide is displayed in the client `/client`. + * Set "Udgivet til" to a past date. Save the change. + * Confirm that the slide is NOT displayed in the client `/client`. + * Set "Udgivet til" to a future date. Save the change. + * Confirm sure the slide is displayed in the client `/client`. +* Playlist publishing + * Repeat the steps from "Slide publishing" for the playlist. +* Playlist slide ordering. + * Add several slides with different publishing dates set, to the playlist. + Try different scenarios, where the slide is published now, in the future, in the past. + * See how the fields "Udgivelse fra", "Udgivelse til", "Status" reflect the publishing dates set for the slides in the + list "Afspilningsrækkefølge". + * Try to change order of the slides in "Afspilningsrækkefølge" by clicking the "↓" arrows. + * See that the order is sorted according to "Udgivelse til" or "Status". +* Playlist planning + * Click "Tilføj" under "Planning" in the playlist (`/admin/playlist/edit/[ID]`). + * NB! Planning is handled by setting up a RRule. + * Test different setups for "Gentag". + * For each scenario: + * Choose "Start" and "Slut" so the occurrence will have a duration of 1 hour. + * Click "Gentag planlægning" to set up repetition rules. + * Test that the rule applies to the playlist by creating a scenario where the playlist should be shown. + * Save the playlist. + * Open the client at `/client` and see that playlist is shown. + * Change the planning to a scenario where it should not be shown. + * Save the playlist. + * Open the client at `/client` and see that playlist is NOT shown. + * Select "År" for "Gentag". + * See that the occurrences will be the every year at the given "Start" in the list of coming occurrences. + * Select "Valgte ugedage" + * Select "År" for "Gentag". + * Select "Mandag" in "Valgte ugedage". + * Select 5 in "Ugenummer". + * See that the occurrences will be every year on monday in week 5. + * Select "Måned" for "Gentag". + * Select some months in "Valgte måneder" + * See that the occurrences will be the selected months at the given time from "Start" in the list of coming + occurrences. + * Select "Uge" for "Gentag". + * Select a couple of days in "Valgte ugedage". + * See that the occurrences are weekly occurrences on the selected days. + +--- + +## T4 Themes + +### Description + +This guide tests setting up a theme and applying it to a slide. + +A theme modifies that look of slides. A theme can also supply a logo to slides. + +Creating a theme requires that the user understands css. + +### Steps + +* Navigate to "Temaer" (`/admin/themes/list`). +* Click "Opret nyt tema". +* Fill "Temaets navn", "Temaets beskrivelse". +* Fill "Temaets CSS" med + + ```css + #SLIDE_ID h1 { + color: red; + } + ``` + +* Upload a logo in "Tilføj logo til temaet". +* Create a slide with the template "Billede og tekst". See T1. +* Edit the slide and set "Slidets tema". +* See that the theme colors the "Overskrift på slide" red in the preview. +* Select "Vis logo fra tema". +* Change "Logostørrelse", "Logoposition" and "Margin om logo". +* See that the logo is added to the slide in the preview. +* Click "Gem slide". +* Test that the theme applies for the slide at `/client`. + +--- + +## T5 Screen layouts + +### Description + +This guide tests setting up screens with different layouts. + +A screen can have different layouts. It can be split into different regions. + +A special case of screen layouts is "touch-buttons" type for a region. See the [README.md](../../README.md) for a +description of this. +This converts slides shown in the screen region to buttons that display the slide when pressed. The button text is set +in the slide in the section "Touch region". This requires that touch button is enabled in the installation. + +### Steps + +* NB! This guide assumes that the layouts in `asset/shared/screen-layouts` are installed in the test installation. +* Create a screen. +* Fill "Skærmens navn". +* Select "2-delt" in "Skærmens layout". +* In "Spillelister tilknyttet regionen" two regions should appear. +* Select different playlists for the two regions. +* Save the screen. +* Open the screen in `/client` as described in T1. +* See that the screen is split in the middle and displays the playlists selected. + +--- + +## T6 External users + +### Description + +This guide tests inviting external users to the system using the OIDC external setup. + +This guide assumes that the test installation has been configured to allow external users through OIDC login. + +An external user can be allowed to create content in a tenant by using an activation code, created by an admin user +in the given tenant. + +In the following two different users will take part: + +* The admin user that has access to create activation code for a given tenant (USER_ADMIN). +* The external user that should be allowed to work in the tenant (USER_EXTERNAL). + +### Steps + +* Log in as USER_ADMIN in a tenant using "Medarbejder" login. +* Navigate to "Aktiveringskoder" (`/admin/activation/list`). +* Click "Opret ny". +* Choose a name for the external user when they log in. +* Choose the role for the user: "Ekstern bruger" +* Save the activation code. +* You should see a list of created activation code. +* Copy the activation code. +* Log out of the system +* Login as external using MitID. +* Use the activation code. +* You should be logged into the tenant with `ROLE_EXTERNAL_USER` permissions. + +NB! If you are already registered as an external user in the system, +you will retain the name you were given with the first activation code. +If you are already added to a tenant, you can be use an activation code to be added to +another tenant in the top menu: "Tilføj" -> "Område". + +--- + +## T7 Campaigns + +### Description + +This guide tests applying campaigns to screens and screen groups. Screens can be added to a screen group. + +Consider the following scenario: A group of screens normally contains some standard content. For one week they should +show some different content (e.g. a festival for one week). Instead of manually removing the normal content and adding +different content when the week starts and manually change back when the week is done, a campaign can be set up. +The campaign will take over the screen for the selected period, and yield control when the period does not apply. + +The campaign is a list a slides to show, start and end dates, and a list of screens or screen groups it should +apply to. + +Please note that a campaign applies the full area of a screen. It ignores the layout of the screen and applies as if +the screen is running the full screen layout. + +### Steps + +* Create a slide (SLIDE_1), playlist and screen (SCREEN_1). This should contain default content. See T1. +* Create another slide (SLIDE_2) that is different from the first. +* Campaign + * Navigate to "Kampagner" (`/admin/campaign/list`). + * Click "Opret ny kampagne" (`/admin/campaign/create`). + * Choose a name and description. + * Add SLIDE_2 in "Tilknyttede slides". + * Add SCREEN_1 in "Skærme". + * Select the campaign period in "Udgivelse". + * Save the campaign. + * Test that the campaign applies/does not apply to SCREEN_1 according to the campaign period select. + * See T1 for setting up the screen SCREEN_1. +* Screen group + * Create a screen group (GROUP_1) under "Grupper" (`/admin/group/list`). + * Create a new screen (SCREEN_2) + * Select GROUP_1 under "Grupper". + * Save the screen. + * Edit the campaign created below + * Set GROUP_1 in "Skærmgrupper". + * Save the campaign + * Test that SCREEN_2 displays the campaign. + * See T1 for setting up the screen. + +--- + +## T8 Shared playlists + +### Description + +This guide tests sharing playlists across tenants. + +The system consists of different tenants and content is not shared between the different tenants. + +The exception to the rule is shared playlists. +A playlist can be configured to be shared between tenants. +When it is shared it can be displayed in another tenant, but not edited. + +### Steps + +* NB! This guide assumes that more than one tenant is created the tenant and that the tester has access to both tenants. +* Create slide, playlist in one tenant (TENANT_1). +* Edit the playlist and select the other tenant (TENANT_2) in "Del denne spilleliste". +* Save the playlist. +* Change tenant in the top left corner to TENANT_2. +* Navigate to "Delte spillelister" (`/admin/shared/list`). +* See that the playlist is in the list. +* Create a screen in TENANT_2. +* Select "fuld skærm" in "Layout". +* In "Spillelister tilknyttet regionen" tick the box "Vis delte spillelister". +* Attach the shared playlist. +* Save the screen. +* Test that the playlist is displayed on the screen. See T1 for how to activate the screen. + +--- + +## T9 Feed sources + +### Description + +Feed sources are the link between external data source and slides. An example is the RSS feed. +When creating a slide with the RSS template a feed source ("datakilde") needs to be selected. +After the feed source has been selected it is possible to input the url to the RSS feed to show data from. +The feed source contains the code that calls the external source and converts data to a format the RSS template +understands. + +Feed sources are connected to a tenant. New feed sources can be connected to a tenant in through the +"Datakilde" link in the navigation. + +The following guide describes how to add a "calendar api" feed source to a tenant. + +NB! The calendar api feed source has some installation requirements that are assumed to have been set up. +See the [documentation](../../docs/configuration/calender-api-feed.md) for further information. + +With the calendar api feed source the administrator should select which locations are connected with the given tenant. +When creating a slide with the given feed source, the resources that belong to the given locations are available +to deliver data to the slide. + +### Steps + +* Go to `/admin/feed-sources/list`. +* Click "Opret ny datakilde" (`/admin/feed-sources/create`). +* Select a name (e.g. "My calendar feed") and add a description of the feed source. +* Select "Kalender feed" for "Type". +* It will display "Bemærk! Datakilden skal gemmes før der kan tilkobles lokationer. Gem og åbn datakilden igen.". +* Save with "Gem datakilde". +* Reopen the feed source from the list. +* Select locations that should be connected with the given tenant. +* Save with "Gem datakilde". +* Now the feed source should be available in the feed selector, when setting up a "calendar" slide. diff --git a/docs/themes/themes.md b/docs/themes/themes.md index d4d83411d..6fb4b9caa 100644 --- a/docs/themes/themes.md +++ b/docs/themes/themes.md @@ -1,5 +1,9 @@ # Theme development +## Description + +In the admin it is possible to create custom themes for templates. + For the styles to have effect you will need to use the `#SLIDE_ID` to all styling. `#SLIDE_ID` will be replaced with an actual id in the Client to isolate the styling on the current slide. diff --git a/fixtures/screen_layout.yaml b/fixtures/screen_layout.yaml index 0c8d0640f..36265b0fd 100644 --- a/fixtures/screen_layout.yaml +++ b/fixtures/screen_layout.yaml @@ -3,14 +3,15 @@ App\Entity\ScreenLayout: screen_layout (template): createdAt (unique): '' modifiedAt: '' - id: "" screen_layout_id_two_boxes (extends screen_layout): + id: "" title: "2 boxes" gridRows: 2 gridColumns: 1 regions: ["@screen_layout_region_a", "@screen_layout_region_b"] screen_layout_id_full (extends screen_layout): + id: "" title: "Full screen" gridRows: 1 gridColumns: 1 diff --git a/package-lock.json b/package-lock.json index 370688e34..d959cccb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7226,20 +7226,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/src/Command/Screen/LoadScreenLayoutsCommand.php b/src/Command/Screen/LoadScreenLayoutsCommand.php deleted file mode 100644 index 9805c8ba8..000000000 --- a/src/Command/Screen/LoadScreenLayoutsCommand.php +++ /dev/null @@ -1,152 +0,0 @@ -addArgument('filename', InputArgument::REQUIRED, 'Json file to load. Can be a local file or a URL'); - $this->addOption('update', null, InputOption::VALUE_NONE, 'Update existing entities.'); - $this->addOption('cleanup-regions', null, InputOption::VALUE_NONE, 'Remove unused regions and their links to playlists.'); - } - - final protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - try { - $updating = false; - - $filename = $input->getArgument('filename'); - $content = json_decode(file_get_contents($filename), false, 512, JSON_THROW_ON_ERROR); - - $update = $input->getOption('update'); - $cleanupRegions = $input->getOption('cleanup-regions'); - - $io->writeln($update ? 'update' : 'no update'); - - if (isset($content->id) && Ulid::isValid($content->id)) { - $screenLayout = $this->screenLayoutRepository->findOneBy(['id' => Ulid::fromString($content->id)]); - - if (!$screenLayout) { - $screenLayout = new ScreenLayout(); - $metadata = $this->entityManager->getClassMetaData($screenLayout::class); - $metadata->setIdGenerator(new AssignedGenerator()); - - $ulid = Ulid::fromString($content->id); - - $screenLayout->setId($ulid); - - $this->entityManager->persist($screenLayout); - } else { - if (!$update) { - $io->error('Screen layout already exists. Use --update to update existing entities.'); - - return Command::INVALID; - } - - $updating = true; - } - } else { - $io->error('The screen layout should have an id (ulid)'); - - return Command::INVALID; - } - - $screenLayout->setTitle($content->title); - $screenLayout->setGridColumns($content->grid->columns); - $screenLayout->setGridRows($content->grid->rows); - - $existingRegions = $screenLayout->getRegions(); - - $processedRegionIds = []; - - foreach ($content->regions as $localRegion) { - $region = $this->layoutRegionsRepository->findOneBy(['id' => Ulid::fromString($localRegion->id)]); - - if (!$region) { - $region = new ScreenLayoutRegions(); - - $metadata = $this->entityManager->getClassMetaData($region::class); - $metadata->setIdGenerator(new AssignedGenerator()); - - $ulid = Ulid::fromString($localRegion->id); - - $region->setId($ulid); - - $this->entityManager->persist($region); - - $screenLayout->addRegion($region); - } - - $region->setGridArea($localRegion->gridArea); - $region->setTitle($localRegion->title); - - if (isset($localRegion->type)) { - $region->setType($localRegion->type); - } - - $processedRegionIds[] = $region->getId(); - } - - foreach ($existingRegions as $existingRegion) { - // Remove all regions that are not present in the json. - if (!in_array($existingRegion->getId(), $processedRegionIds)) { - if (!$cleanupRegions) { - $io->error('Removing not permitted. Playlists linked to the removed regions will be unlinked. Use --cleanup-regions option to remove regions not in json.'); - - return Command::INVALID; - } else { - foreach ($existingRegion->getPlaylistScreenRegions() as $playlistScreenRegion) { - $this->entityManager->remove($playlistScreenRegion); - } - - $this->entityManager->remove($existingRegion); - } - } - } - - $this->entityManager->flush(); - - $updating ? - $io->success('Screen layout updated.') : - $io->success('Screen layout added.'); - - return Command::SUCCESS; - } catch (\JsonException) { - $io->error('Invalid json'); - - return Command::INVALID; - } - } -} diff --git a/src/Command/Screen/RemoveScreenLayoutCommand.php b/src/Command/Screen/RemoveScreenLayoutCommand.php deleted file mode 100644 index 7c393b83d..000000000 --- a/src/Command/Screen/RemoveScreenLayoutCommand.php +++ /dev/null @@ -1,92 +0,0 @@ -addArgument('filename', InputArgument::REQUIRED, 'json file to load. Can be a local file or a URL'); - } - - final protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - try { - $filename = $input->getArgument('filename'); - $content = json_decode(file_get_contents($filename), false, 512, JSON_THROW_ON_ERROR); - - if (isset($content->id) && Ulid::isValid($content->id)) { - $screenLayout = $this->screenLayoutRepository->findOneBy(['id' => Ulid::fromString($content->id)]); - - if (!$screenLayout) { - $io->error('Screen layout not installed. Aborting.'); - - return self::INVALID; - } - - $screens = $this->screenRepository->findBy(['screenLayout' => $screenLayout]); - $numberOfScreens = count($screens); - - if ($numberOfScreens > 0) { - $message = "Aborting. Screen layout is bound to $numberOfScreens following screens:\n\n"; - - foreach ($screens as $screen) { - $id = $screen->getId(); - $message .= "$id\n"; - } - - $io->error($message); - - return self::INVALID; - } - - foreach ($screenLayout->getRegions() as $region) { - $this->entityManager->remove($region); - } - - $this->entityManager->remove($screenLayout); - } else { - $io->error('The screen layout should have an id (ulid)'); - - return Command::INVALID; - } - - $this->entityManager->flush(); - - $io->success('Screen layout removed'); - - return Command::SUCCESS; - } catch (\JsonException) { - $io->error('Invalid json'); - - return Command::INVALID; - } - } -} diff --git a/src/Command/ScreenLayout/ScreenLayoutsInstallCommand.php b/src/Command/ScreenLayout/ScreenLayoutsInstallCommand.php new file mode 100644 index 000000000..f7daa3e98 --- /dev/null +++ b/src/Command/ScreenLayout/ScreenLayoutsInstallCommand.php @@ -0,0 +1,73 @@ +addOption('all', 'a', InputOption::VALUE_NONE, 'Install all available screen layouts'); + $this->addOption('update', 'u', InputOption::VALUE_NONE, 'Update already installed screen layouts'); + $this->addOption('cleanupRegions', 'c', InputOption::VALUE_NONE, 'Remove regions that are no longer used'); + $this->addArgument('screenLayoutUlid', InputArgument::OPTIONAL, 'Install the screen layout with the given ULID'); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $all = $input->getOption('all'); + $update = $input->getOption('update'); + $cleanupRegions = $input->getOption('cleanupRegions'); + + if ($all) { + $this->screenLayoutService->installAll($update, $cleanupRegions); + + $io->success('Installed all available screen layouts'); + + return Command::SUCCESS; + } + + $screenLayoutUlid = $input->getArgument('screenLayoutUlid'); + + if (null === $screenLayoutUlid) { + $io->warning('Screen layout ULID not supplied.'); + + return Command::INVALID; + } + + try { + $this->screenLayoutService->installById($screenLayoutUlid, $update, $cleanupRegions); + } catch (NotFoundException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success('Screen layout with ULID: '.$screenLayoutUlid.' installed'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/ScreenLayout/ScreenLayoutsListCommand.php b/src/Command/ScreenLayout/ScreenLayoutsListCommand.php new file mode 100644 index 000000000..1ec3c4ff3 --- /dev/null +++ b/src/Command/ScreenLayout/ScreenLayoutsListCommand.php @@ -0,0 +1,64 @@ +addOption('status', 's', InputOption::VALUE_NONE, 'Get status of installed screen layouts.'); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $status = $input->getOption('status'); + + try { + if ($status) { + $installStatus = $this->screenLayoutService->getInstallStatus(); + + $text = $installStatus->installed.' / '.$installStatus->available.' templates installed.'; + + $io->success($text); + } else { + $screenLayouts = $this->screenLayoutService->getAll(); + + $io->table(['ID', 'Title', 'Status', 'Type'], array_map(fn (ScreenLayoutData $screenLayout) => [ + $screenLayout->id, + $screenLayout->title, + $screenLayout->installed ? 'Installed' : 'Not Installed', + $screenLayout->type->value, + ], $screenLayouts)); + } + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error($e->getMessage()); + + return Command::INVALID; + } + } +} diff --git a/src/Command/ScreenLayout/ScreenLayoutsRemoveCommand.php b/src/Command/ScreenLayout/ScreenLayoutsRemoveCommand.php new file mode 100644 index 000000000..5a47e7c88 --- /dev/null +++ b/src/Command/ScreenLayout/ScreenLayoutsRemoveCommand.php @@ -0,0 +1,58 @@ +addArgument('ulid', InputArgument::REQUIRED, 'The ulid of the screen layout to remove'); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $ulid = $input->getArgument('ulid'); + + if (!$ulid) { + $io->error('No ulid supplied'); + + return Command::INVALID; + } + + try { + $this->screenLayoutService->remove($ulid); + } catch (NotFoundException|NotAcceptableException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success('Screen layout removed.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/ScreenLayout/ScreenLayoutsUpdateCommand.php b/src/Command/ScreenLayout/ScreenLayoutsUpdateCommand.php new file mode 100644 index 000000000..8d6291464 --- /dev/null +++ b/src/Command/ScreenLayout/ScreenLayoutsUpdateCommand.php @@ -0,0 +1,36 @@ +screenLayoutService->updateAll(); + + $io->success('Updated all installed screen layouts'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 73e82847f..911d741d2 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -49,7 +49,19 @@ final protected function execute(InputInterface $input, OutputInterface $output) ]); $application->doRun($command, $output); - $io->info('Run app:update to update migrations and templates.'); + $io->writeln(''); + $io->writeln(''); + $io->writeln(''); + $io->title('Screen layout status'); + + // List status for templates. + $command = new ArrayInput([ + 'command' => 'app:screen-layouts:list', + '--status' => true, + ]); + $application->doRun($command, $output); + + $io->info('Run app:update to update migrations, templates and screen layouts.'); return Command::SUCCESS; } diff --git a/src/Command/TemplatesInstallCommand.php b/src/Command/Template/TemplatesInstallCommand.php similarity index 71% rename from src/Command/TemplatesInstallCommand.php rename to src/Command/Template/TemplatesInstallCommand.php index c25fe1292..e29747be9 100644 --- a/src/Command/TemplatesInstallCommand.php +++ b/src/Command/Template/TemplatesInstallCommand.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace App\Command; +namespace App\Command\Template; -use App\Model\TemplateData; +use App\Exceptions\NotFoundException; use App\Service\TemplateService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -40,12 +40,8 @@ final protected function execute(InputInterface $input, OutputInterface $output) $all = $input->getOption('all'); $update = $input->getOption('update'); - $templates = $this->templateService->getAllTemplates(); - if ($all) { - foreach ($templates as $templateToInstall) { - $this->templateService->installTemplate($templateToInstall, $update); - } + $this->templateService->installAll($update); $io->success('Installed all available templates'); @@ -60,18 +56,15 @@ final protected function execute(InputInterface $input, OutputInterface $output) return Command::INVALID; } - $templatesFound = array_find($templates, fn (TemplateData $templateData): bool => $templateData->id === $templateUlid); - - if (1 !== count($templatesFound)) { - $io->error('Template not found.'); + try { + $this->templateService->installById($templateUlid, $update); + } catch (NotFoundException $e) { + $io->error($e->getMessage()); return Command::FAILURE; } - $templateToInstall = $templatesFound[0]; - - $this->templateService->installTemplate($templateToInstall); - $io->success('Template '.$templateToInstall->title.' installed'); + $io->success('Template with ULID: '.$templateUlid.' installed'); return Command::SUCCESS; } diff --git a/src/Command/TemplatesListCommand.php b/src/Command/Template/TemplatesListCommand.php similarity index 67% rename from src/Command/TemplatesListCommand.php rename to src/Command/Template/TemplatesListCommand.php index 849758fa7..c062b1e0d 100644 --- a/src/Command/TemplatesListCommand.php +++ b/src/Command/Template/TemplatesListCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Command; +namespace App\Command\Template; use App\Model\TemplateData; use App\Service\TemplateService; @@ -37,31 +37,21 @@ final protected function execute(InputInterface $input, OutputInterface $output) $status = $input->getOption('status'); try { - $templates = $this->templateService->getCoreTemplates(); - - if (0 === count($templates)) { - $io->error('No core templates found.'); - - return Command::INVALID; - } - - $customTemplates = $this->templateService->getCustomTemplates(); - - $allTemplates = array_merge($templates, $customTemplates); - if ($status) { - $numberOfTemplates = count($allTemplates); - $numberOfInstallledTemplates = count(array_filter($allTemplates, fn ($entry): bool => $entry->installed)); - $text = $numberOfInstallledTemplates.' / '.$numberOfTemplates.' templates installed.'; + $installStatus = $this->templateService->getInstallStatus(); + + $text = $installStatus->installed.' / '.$installStatus->available.' templates installed.'; $io->success($text); } else { + $templates = $this->templateService->getAll(); + $io->table(['ID', 'Title', 'Status', 'Type'], array_map(fn (TemplateData $templateData) => [ $templateData->id, $templateData->title, $templateData->installed ? 'Installed' : 'Not Installed', - $templateData->type, - ], $allTemplates)); + $templateData->type->value, + ], $templates)); } return Command::SUCCESS; diff --git a/src/Command/Template/TemplatesRemoveCommand.php b/src/Command/Template/TemplatesRemoveCommand.php new file mode 100644 index 000000000..3918b8aac --- /dev/null +++ b/src/Command/Template/TemplatesRemoveCommand.php @@ -0,0 +1,64 @@ +addArgument('ulid', InputArgument::REQUIRED, 'The ulid of the template to remove'); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $ulid = $input->getArgument('ulid'); + + if (!$ulid) { + $io->error('No ulid supplied'); + + return Command::INVALID; + } + + try { + $this->templateService->remove($ulid); + } catch (NotFoundException|NotAcceptableException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success('Template removed.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/TemplatesUpdateCommand.php b/src/Command/Template/TemplatesUpdateCommand.php similarity index 79% rename from src/Command/TemplatesUpdateCommand.php rename to src/Command/Template/TemplatesUpdateCommand.php index eb5207411..06a2347a1 100644 --- a/src/Command/TemplatesUpdateCommand.php +++ b/src/Command/Template/TemplatesUpdateCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Command; +namespace App\Command\Template; use App\Service\TemplateService; use Symfony\Component\Console\Attribute\AsCommand; @@ -27,11 +27,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); - $templates = $this->templateService->getAllTemplates(); - - foreach ($templates as $templateToUpdate) { - $this->templateService->updateTemplate($templateToUpdate); - } + $this->templateService->updateAll(); $io->success('Updated all installed templates'); diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index f2dd2227d..55ba3c352 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -4,6 +4,7 @@ namespace App\Command; +use App\Service\ScreenLayoutService; use App\Service\TemplateService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -21,6 +22,7 @@ class UpdateCommand extends Command { public function __construct( private readonly TemplateService $templateService, + private readonly ScreenLayoutService $screenLayoutService, ?string $name = null, ) { parent::__construct($name); @@ -51,7 +53,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) return Command::FAILURE; } - $allTemplates = $this->templateService->getAllTemplates(); + $allTemplates = $this->templateService->getAll(); $installedTemplates = array_filter($allTemplates, fn ($entry): bool => $entry->installed); // If no installed templates, we assume that this is a new installation and offer to install all templates. @@ -59,7 +61,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) $question = new ConfirmationQuestion('No templates are installed. Install all '.count($allTemplates).'?'); $installAll = $io->askQuestion($question); - if ('yes' === $installAll) { + if ($installAll) { $io->info('Installing all templates...'); $command = new ArrayInput([ 'command' => 'app:templates:install', @@ -75,6 +77,30 @@ final protected function execute(InputInterface $input, OutputInterface $output) $application->doRun($command, $output); } + $allScreenLayouts = $this->screenLayoutService->getAll(); + $installedScreenLayouts = array_filter($allScreenLayouts, fn ($entry): bool => $entry->installed); + + // If no installed screen layouts, we assume that this is a new installation and offer to install all screen layouts. + if ($isInteractive && 0 === count($installedScreenLayouts)) { + $question = new ConfirmationQuestion('No screen layouts are installed. Install all '.count($allScreenLayouts).'?'); + $installAll = $io->askQuestion($question); + + if ($installAll) { + $io->info('Installing all screen layouts...'); + $command = new ArrayInput([ + 'command' => 'app:screen-layouts:install', + '--all' => true, + ]); + $application->doRun($command, $output); + } + } else { + $io->info('Updating existing screen layouts...'); + $command = new ArrayInput([ + 'command' => 'app:screen-layouts:update', + ]); + $application->doRun($command, $output); + } + return Command::SUCCESS; } } diff --git a/src/Enum/ResourceTypeEnum.php b/src/Enum/ResourceTypeEnum.php new file mode 100644 index 000000000..6bf04e5c3 --- /dev/null +++ b/src/Enum/ResourceTypeEnum.php @@ -0,0 +1,11 @@ +loader->getResourceInDirectory($this::CORE_SCREEN_LAYOUTS_PATH, ScreenLayoutData::class, ResourceTypeEnum::CORE); + $custom = $this->loader->getResourceInDirectory($this::CUSTOM_SCREEN_LAYOUTS_PATH, ScreenLayoutData::class, ResourceTypeEnum::CUSTOM); + + return array_merge($core, $custom); + } + + public function installAll(bool $update = false, bool $cleanupRegions = false): void + { + $screenLayouts = $this->getAll(); + + foreach ($screenLayouts as $screenLayoutToInstall) { + $this->install($screenLayoutToInstall, $update, $cleanupRegions); + } + } + + public function installById(string $ulidString, bool $update = false, bool $cleanupRegions = false): void + { + $screenLayoutToInstall = array_find($this->getAll(), fn (ScreenLayoutData $screenLayoutData): bool => $screenLayoutData->id === $ulidString); + + if (null === $screenLayoutToInstall) { + throw new NotFoundException(); + } + + $this->install($screenLayoutToInstall, $update, $cleanupRegions); + } + + public function install(ScreenLayoutData $screenLayoutData, bool $update = false, bool $cleanupRegions = false): void + { + $screenLayout = $screenLayoutData->screenLayoutEntity; + + if (null === $screenLayout) { + $screenLayout = new ScreenLayout(); + + $metadata = $this->entityManager->getClassMetaData(ScreenLayout::class); + $metadata->setIdGenerator(new AssignedGenerator()); + + $ulid = Ulid::fromString($screenLayoutData->id); + $screenLayout->setId($ulid); + + $this->entityManager->persist($screenLayout); + } + + if ($update) { + $screenLayout->setTitle($screenLayoutData->title); + } + + $screenLayout->setGridColumns($screenLayoutData->gridColumns); + $screenLayout->setGridRows($screenLayoutData->gridRows); + + $existingRegions = $screenLayout->getRegions(); + + $processedRegionIds = []; + + foreach ($screenLayoutData->regions as $localRegion) { + $region = $this->layoutRegionsRepository->findOneBy(['id' => Ulid::fromString($localRegion->id)]); + + if (!$region) { + $region = new ScreenLayoutRegions(); + + $metadata = $this->entityManager->getClassMetaData($region::class); + $metadata->setIdGenerator(new AssignedGenerator()); + + $ulid = Ulid::fromString($localRegion->id); + + $region->setId($ulid); + + $this->entityManager->persist($region); + + $screenLayout->addRegion($region); + } + + $region->setGridArea($localRegion->gridArea); + $region->setTitle($localRegion->title); + + if (isset($localRegion->type)) { + $region->setType($localRegion->type); + } + + $processedRegionIds[] = $region->getId(); + } + + if ($cleanupRegions) { + foreach ($existingRegions as $existingRegion) { + // Remove all regions that are not present in the json. + if (!in_array($existingRegion->getId(), $processedRegionIds)) { + foreach ($existingRegion->getPlaylistScreenRegions() as $playlistScreenRegion) { + $this->entityManager->remove($playlistScreenRegion); + } + + $this->entityManager->remove($existingRegion); + } + } + } + + $this->entityManager->flush(); + } + + public function updateAll(): void + { + $screenLayouts = $this->getAll(); + + foreach ($screenLayouts as $screenLayoutToUpdate) { + $this->update($screenLayoutToUpdate); + } + } + + public function update(ScreenLayoutData $screenLayoutToUpdate): void + { + // This only handles screen layouts that have an entity in the database. + if (null !== $screenLayoutToUpdate->screenLayoutEntity) { + $this->install($screenLayoutToUpdate, true); + } + } + + public function remove(string $ulidString): void + { + $screenLayout = $this->screenLayoutRepository->findOneBy(['id' => Ulid::fromString($ulidString)]); + + if (!$screenLayout) { + throw new NotFoundException('Screen layout not installed. Aborting.'); + } + + $screens = $this->screenRepository->findBy(['screenLayout' => $screenLayout]); + $numberOfScreens = count($screens); + + if ($numberOfScreens > 0) { + $message = "Aborting. Screen layout is bound to $numberOfScreens following screens:\n\n"; + + foreach ($screens as $screen) { + $id = $screen->getId(); + $message .= "$id\n"; + } + + throw new NotAcceptableException($message); + } + + foreach ($screenLayout->getRegions() as $region) { + $this->entityManager->remove($region); + } + + $this->entityManager->remove($screenLayout); + + $this->entityManager->flush(); + } + + public function getInstallStatus(): InstallStatus + { + $screenLayouts = $this->getAll(); + $numberOfScreenLayouts = count($screenLayouts); + $numberOfInstalledScreenLayouts = count(array_filter($screenLayouts, fn ($entry): bool => $entry->installed)); + + return new InstallStatus($numberOfInstalledScreenLayouts, $numberOfScreenLayouts); + } +} diff --git a/src/Service/TemplateService.php b/src/Service/TemplateService.php index d99286dd7..123a2b29c 100644 --- a/src/Service/TemplateService.php +++ b/src/Service/TemplateService.php @@ -5,22 +5,59 @@ namespace App\Service; use App\Entity\Template; +use App\Enum\ResourceTypeEnum; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\NotFoundException; +use App\Model\InstallStatus; use App\Model\TemplateData; +use App\Repository\SlideRepository; +use App\Repository\TemplateRepository; +use App\Utils\ResourceLoader; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Id\AssignedGenerator; -use JsonSchema\Constraints\Factory; -use JsonSchema\SchemaStorage; -use JsonSchema\Validator; -use Symfony\Component\Finder\Finder; use Symfony\Component\Uid\Ulid; class TemplateService { + public const string CORE_TEMPLATES_PATH = 'assets/shared/templates'; + public const string CUSTOM_TEMPLATES_PATH = 'assets/shared/custom-templates'; + public function __construct( private readonly EntityManagerInterface $entityManager, + private readonly TemplateRepository $templateRepository, + private readonly SlideRepository $slideRepository, + private readonly ResourceLoader $loader, ) {} - public function installTemplate(TemplateData $templateData, bool $update = false): void + public function getAll(): array + { + $core = $this->loader->getResourceInDirectory($this::CORE_TEMPLATES_PATH, TemplateData::class, ResourceTypeEnum::CORE); + $custom = $this->loader->getResourceInDirectory($this::CUSTOM_TEMPLATES_PATH, TemplateData::class, ResourceTypeEnum::CUSTOM); + + return array_merge($core, $custom); + } + + public function installAll(bool $update): void + { + $templates = $this->getAll(); + + foreach ($templates as $templateToInstall) { + $this->install($templateToInstall, $update); + } + } + + public function installById(string $ulidString, bool $update = false): void + { + $templateToInstall = array_find($this->getAll(), fn (TemplateData $templateData): bool => $templateData->id === $ulidString); + + if (null === $templateToInstall) { + throw new NotFoundException(); + } + + $this->install($templateToInstall, $update); + } + + public function install(TemplateData $templateData, bool $update = false): void { $template = $templateData->templateEntity; @@ -43,7 +80,16 @@ public function installTemplate(TemplateData $templateData, bool $update = false $this->entityManager->flush(); } - public function updateTemplate(TemplateData $templateData): void + public function updateAll(): void + { + $templates = $this->getAll(); + + foreach ($templates as $templateToUpdate) { + $this->update($templateToUpdate); + } + } + + public function update(TemplateData $templateData): void { $template = $templateData->templateEntity; @@ -57,128 +103,39 @@ public function updateTemplate(TemplateData $templateData): void $this->entityManager->flush(); } - public function getAllTemplates(): array + public function remove(string $ulidString): void { - return array_merge($this->getCoreTemplates(), $this->getCustomTemplates()); - } + $template = $this->templateRepository->findOneBy(['id' => Ulid::fromString($ulidString)]); - public function getCoreTemplates(): array - { - $finder = new Finder(); - - if (is_dir('assets/shared/templates')) { - $finder->files()->followLinks()->ignoreUnreadableDirs()->in('assets/shared/templates')->depth('== 0')->name('*.json'); - - if ($finder->hasResults()) { - return $this->getTemplates($finder); - } - } - - return []; - } - - public function getCustomTemplates(): array - { - $finder = new Finder(); - - if (is_dir('assets/shared/custom-templates')) { - $finder->files()->followLinks()->ignoreUnreadableDirs()->in('assets/shared/custom-templates')->depth('== 0')->name('*.json'); - - if ($finder->hasResults()) { - return $this->getTemplates($finder, true); - } + if (!$template) { + throw new NotFoundException('Template not installed. Aborting.'); } - return []; - } - - public function getTemplates(iterable $finder, bool $customTemplates = false): array - { - $templates = []; + $slides = $this->slideRepository->findBy(['template' => $template]); + $numberOfSlides = count($slides); - // Validate template json. - $schemaStorage = new SchemaStorage(); - $jsonSchemaObject = $this->getSchema(); - $schemaStorage->addSchema('file://contentSchema', $jsonSchemaObject); - $validator = new Validator(new Factory($schemaStorage)); + if ($numberOfSlides > 0) { + $message = "Aborting. Template is bound to $numberOfSlides following slides:\n\n"; - foreach ($finder as $file) { - $content = json_decode((string) $file->getContents()); - $validator->validate($content, $jsonSchemaObject); - - if (!$validator->isValid()) { - $message = 'JSON file '.$file->getFilename()." does not validate. Violations:\n"; - foreach ($validator->getErrors() as $error) { - $message .= sprintf("\n[%s] %s", $error['property'], $error['message']); - } - - throw new \Exception($message); + foreach ($slides as $slide) { + $id = $slide->getId(); + $message .= "$id\n"; } - if (!Ulid::isValid($content->id)) { - throw new \Exception('The Ulid is not valid'); - } - - $repository = $this->entityManager->getRepository(Template::class); - $template = $repository->findOneBy(['id' => Ulid::fromString($content->id)]); - - $templates[] = new TemplateData( - $content->id, - $content->title, - $content->adminForm, - $content->options, - $template, - null !== $template, - $customTemplates ? 'Custom' : 'Core', - ); + throw new NotAcceptableException($message); } - return $templates; + $this->entityManager->remove($template); + + $this->entityManager->flush(); } - /** - * Supplies json schema for validation. - * - * @return mixed - * Json schema - * - * @throws \JsonException - */ - public function getSchema(): object + public function getInstallStatus(): InstallStatus { - $jsonSchema = <<<'JSON' - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://os2display.dk/config-schema.json", - "title": "Config file schema", - "description": "Schema for defining config files for templates", - "type": "object", - "properties": { - "id": { - "description": "Ulid", - "type": "string" - }, - "title": { - "description": "The title of the template", - "type": "string" - }, - "options": { - "description": "Template options", - "type": "object" - }, - "adminForm": { - "description": "The admin form description", - "type": "array", - "items": { - "type": "object", - "description": "Form element" - } - } - }, - "required": ["id", "title", "options", "adminForm"] - } - JSON; + $templates = $this->getAll(); + $numberOfTemplates = count($templates); + $numberOfInstalledTemplates = count(array_filter($templates, fn ($entry): bool => $entry->installed)); - return json_decode($jsonSchema, false, 512, JSON_THROW_ON_ERROR); + return new InstallStatus($numberOfTemplates, $numberOfInstalledTemplates); } } diff --git a/src/Utils/ResourceLoader.php b/src/Utils/ResourceLoader.php new file mode 100644 index 000000000..220754d56 --- /dev/null +++ b/src/Utils/ResourceLoader.php @@ -0,0 +1,224 @@ +files()->followLinks()->ignoreUnreadableDirs()->in($path)->depth('== 0')->name('*.json'); + + if ($finder->hasResults()) { + return match ($resourceType) { + ScreenLayoutData::class, TemplateData::class => $this->getResourceData($finder, $resourceType, $type), + default => throw new NotImplementedException(), + }; + } + } + + return []; + } + + private function getResourceData(iterable $finder, string $resourceType, ResourceTypeEnum $type): array + { + // Validate template json. + $schemaStorage = new SchemaStorage(); + + $jsonSchemaObject = match ($resourceType) { + ScreenLayoutData::class => $this->getScreenLayoutJsonSchema(), + TemplateData::class => $this->getTemplateJsonSchema(), + default => throw new NotImplementedException(), + }; + + $schemaStorage->addSchema('file://contentSchema', $jsonSchemaObject); + $validator = new Validator(new Factory($schemaStorage)); + + $results = []; + + foreach ($finder as $file) { + $content = json_decode((string) $file->getContents()); + $validator->validate($content, $jsonSchemaObject); + + if (!$validator->isValid()) { + $message = 'JSON file '.$file->getFilename()." does not validate. Violations:\n"; + foreach ($validator->getErrors() as $error) { + $message .= sprintf("\n[%s] %s", $error['property'], $error['message']); + } + + throw new \Exception($message); + } + + if (!Ulid::isValid($content->id)) { + throw new \Exception('The Ulid is not valid'); + } + + switch ($resourceType) { + case ScreenLayoutData::class: + $entity = $this->screenLayoutRepository->find(Ulid::fromString($content->id)); + + $results[] = new ScreenLayoutData( + $content->id, + $content->title, + $type, + $content->grid->rows, + $content->grid->columns, + $entity, + null !== $entity, + $content->regions, + ); + + break; + case TemplateData::class: + $entity = $this->templateRepository->find(Ulid::fromString($content->id)); + + $results[] = new TemplateData( + $content->id, + $content->title, + $content->adminForm, + $content->options, + $entity, + null !== $entity, + $type, + ); + break; + } + } + + return $results; + } + + /** + * Supplies json schema for validation of template data. + * + * @throws \JsonException + */ + public function getTemplateJsonSchema(): object + { + $jsonSchema = <<<'JSON' + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://os2display.dk/config-schema.json", + "title": "Config file schema", + "description": "Schema for defining config files for templates", + "type": "object", + "properties": { + "id": { + "description": "Ulid", + "type": "string" + }, + "title": { + "description": "The title of the template", + "type": "string" + }, + "options": { + "description": "Template options", + "type": "object" + }, + "adminForm": { + "description": "The admin form description", + "type": "array", + "items": { + "type": "object", + "description": "Form element" + } + } + }, + "required": ["id", "title", "options", "adminForm"] + } + JSON; + + return json_decode($jsonSchema, false, 512, JSON_THROW_ON_ERROR); + } + + /** + * Supplies json schema for validation of screen layout data. + * + * @throws \JsonException + */ + private function getScreenLayoutJsonSchema(): object + { + $jsonSchema = <<<'JSON' + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://os2display.dk/config-schema.json", + "title": "Config file schema", + "description": "Schema for defining config files for screen layouts", + "type": "object", + "properties": { + "id": { + "description": "Ulid of the screen layout", + "type": "string" + }, + "title": { + "description": "The title of the screen layout", + "type": "string" + }, + "grid": { + "description": "Grid properties", + "type": "object", + "properties": { + "rows": { + "type": "integer", + "description": "Number of rows" + }, + "columns": { + "type": "integer", + "description": "Number of columns" + } + } + }, + "regions": { + "description": "The regions of the screen layout", + "type": "array", + "items": { + "type": "object", + "description": "Region", + "properties": { + "id": { + "description": "Ulid of the region", + "type": "string" + }, + "title": { + "description": "The title of the region", + "type": "string" + }, + "gridArea": { + "description": "Grid area value", + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "required": ["id", "title", "grid", "regions"] + } + JSON; + + return json_decode($jsonSchema, false, 512, JSON_THROW_ON_ERROR); + } +}