diff --git a/README.md b/README.md index e62eb5b..21771b6 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ ## About Use a RaspberryPi to show a full-screen slideshow (Kiosk-mode) +[![Docker Pulls](https://badgen.net/docker/pulls/jierka/obscreen?icon=docker&label=docker%20pulls)](https://hub.docker.com/r/jierka/obscreen/) + ### Features: - Dead simple chromium webview - Clear GUI @@ -18,7 +20,7 @@ Use a RaspberryPi to show a full-screen slideshow (Kiosk-mode) ![Obscreen Screenshot](https://github.com/jr-k/obscreen/blob/master/docs/screenshot.png "Obscreen Screenshot") -## Installation and configure (docker) +## Installation and configuration (docker) ```bash git clone https://github.com/jr-k/obscreen.git cd obscreen diff --git a/data/db/slideshow.json.dist b/data/db/slideshow.json.dist index c2acc4a..7371348 100755 --- a/data/db/slideshow.json.dist +++ b/data/db/slideshow.json.dist @@ -6,7 +6,8 @@ "location", "name", "position", - "type" + "type", + "cron_schedule" ], "data": { "0": { @@ -15,7 +16,8 @@ "type": "picture", "enabled": true, "name": "Picture Sample", - "position": 0 + "position": 0, + "cron_schedule": null }, "1": { "location": "https://unix.org", @@ -23,7 +25,8 @@ "type": "url", "enabled": true, "name": "URL Sample", - "position": 1 + "position": 1, + "cron_schedule": null } } } \ No newline at end of file diff --git a/data/www/css/main.css b/data/www/css/main.css index 1b4cd4d..ff2f41a 100644 --- a/data/www/css/main.css +++ b/data/www/css/main.css @@ -512,6 +512,14 @@ form .form-group textarea { width: auto; } +form .form-group input[type=checkbox] { + flex: 0; +} + +form .form-group input[type=checkbox].trigger { + margin-right: 10px; +} + form .form-group span { margin-left: 10px; } diff --git a/data/www/js/is-cron-now.js b/data/www/js/is-cron-now.js new file mode 100755 index 0000000..f5857e8 --- /dev/null +++ b/data/www/js/is-cron-now.js @@ -0,0 +1,101 @@ +const cron = (() => { + const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + const DAY_ABBRS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + const MINUTE_CHOICES = Array.from({length: 60}, (_, i) => [`${i}`, `${i}`]); + const HOUR_CHOICES = Array.from({length: 24}, (_, i) => [`${i}`, `${i}`]); + const DOM_CHOICES = Array.from({length: 31}, (_, i) => [`${i + 1}`, `${i + 1}`]); + const MONTH_CHOICES = Array.from({length: 12}, (_, i) => [`${i + 1}`, new Date(0, i).toLocaleString('en', { month: 'long' })]); + const DOW_CHOICES = DAY_NAMES.map((day, index) => [`${index}`, day]); + + function toInt(value, allowDaynames = false) { + if (typeof value === 'number' || (typeof value === 'string' && !isNaN(value))) { + return parseInt(value, 10); + } else if (allowDaynames && typeof value === 'string') { + value = value.toLowerCase(); + const dayIndex = DAY_NAMES.indexOf(value); + if (dayIndex !== -1) return dayIndex; + const abbrIndex = DAY_ABBRS.indexOf(value); + if (abbrIndex !== -1) return abbrIndex; + } + throw new Error('Failed to parse string to integer'); + } + + function parseArg(value, target, allowDaynames = false) { + value = value.trim(); + if (value === '*') return true; + let values = value.split(',').map(v => v.trim()).filter(v => v); + + for (let val of values) { + try { + if (toInt(val, allowDaynames) === target) { + return true; + } + } catch (error) {} + + if (val.includes('-')) { + let step = 1; + let start, end; + if (val.includes('/')) { + [start, end] = val.split('-').map(part => part.trim()); + [end, step] = end.split('/').map(part => toInt(part.trim(), allowDaynames)); + start = toInt(start, allowDaynames); + } else { + [start, end] = val.split('-').map(part => toInt(part.trim(), allowDaynames)); + } + + for (let i = start; i <= end; i += step) { + if (i === target) return true; + } + if (allowDaynames && start > end) { + for (let i = start; i < start + 7; i += step) { + if (i % 7 === target) return true; + } + } + } + + if (val.includes('/')) { + let [v, interval] = val.split('/').map(part => part.trim()); + if (v !== '*') continue; + if (target % toInt(interval, allowDaynames) === 0) { + return true; + } + } + } + + return false; + } + + function hasBeen(s, since, dt = new Date()) { + since = new Date(since); + dt = new Date(dt); + + if (dt < since) { + throw new Error("The 'since' datetime must be before the current datetime."); + } + + while (since <= dt) { + if (isActive(s, since)) { + return true; + } + since = new Date(since.getTime() + 60000); + } + + return false; + } + + function isActive(s, dt = new Date()) { + let [minute, hour, dom, month, dow, year] = s.split(' '); + let weekday = dt.getDay(); + + return parseArg(minute, dt.getMinutes()) && + parseArg(hour, dt.getHours()) && + parseArg(dom, dt.getDate()) && + parseArg(month, dt.getMonth() + 1) && + parseArg(dow, weekday === 0 ? 6 : weekday - 1, true) && + (!year || (year && parseArg(year, dt.getFullYear()))); + } + + return { + isActive + }; +})(); \ No newline at end of file diff --git a/data/www/js/slideshow.js b/data/www/js/slideshow.js index 68bf91f..3cd6ea0 100644 --- a/data/www/js/slideshow.js +++ b/data/www/js/slideshow.js @@ -49,7 +49,7 @@ jQuery(document).ready(function ($) { }); }; - $(document).on('change', 'input[type=checkbox]', function () { + $(document).on('change', '.slide-item input[type=checkbox]', function () { $.ajax({ url: '/slideshow/slide/toggle', headers: {'Content-Type': 'application/json'}, @@ -68,6 +68,16 @@ jQuery(document).ready(function ($) { updateTable(); }); + $(document).on('change', '.modal-slide input[type=checkbox]', function () { + const $target = $('#'+ $(this).attr('id').replace('-trigger', '')); + const hide = !$(this).is(':checked'); + $target.toggleClass('hidden', hide); + + if (hide) { + $target.val(''); + } + }); + $(document).on('change', '#slide-add-type', function () { const value = $(this).val(); const inputType = $(this).find('option').filter(function (i, el) { @@ -95,11 +105,14 @@ jQuery(document).ready(function ($) { $(document).on('click', '.slide-edit', function () { const slide = JSON.parse($(this).parents('tr:eq(0)').attr('data-entity')); showModal('modal-slide-edit'); + const hasCron = slide.cron_schedule && slide.cron_schedule.length > 0; $('.modal-slide-edit input:visible:eq(0)').focus().select(); $('#slide-edit-name').val(slide.name); $('#slide-edit-type').val(slide.type); $('#slide-edit-location').val(slide.location); $('#slide-edit-duration').val(slide.duration); + $('#slide-edit-cron-schedule').val(slide.cron_schedule).toggleClass('hidden', !hasCron); + $('#slide-edit-cron-schedule-trigger').prop('checked', hasCron); $('#slide-edit-id').val(slide.id); }); diff --git a/docs/screenshot.png b/docs/screenshot.png old mode 100644 new mode 100755 index 0841636..335c218 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/lang/en.json b/lang/en.json index 0b4f76d..3c9857f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,7 +8,10 @@ "slideshow_slide_panel_th_duration": "Duration", "slideshow_slide_panel_th_duration_unit": "sec", "slideshow_slide_panel_th_enabled": "Enabled", + "slideshow_slide_panel_th_cron_scheduled": "Scheduled", "slideshow_slide_panel_th_activity": "Activity", + "slideshow_slide_panel_td_cron_scheduled_loop": "Loop", + "slideshow_slide_panel_td_cron_scheduled_bad_cron": "Bad cron value", "slideshow_slide_form_add_title": "Add Slide", "slideshow_slide_form_add_submit": "Add", "slideshow_slide_form_edit_title": "Edit Slide", @@ -22,6 +25,8 @@ "slideshow_slide_form_label_object": "Object", "slideshow_slide_form_label_duration": "Duration", "slideshow_slide_form_label_duration_unit": "seconds", + "slideshow_slide_form_label_cron_scheduled": "Scheduled", + "slideshow_slide_form_widget_cron_scheduled_placeholder": "Use crontab format: * * * * *", "slideshow_slide_form_button_cancel": "Cancel", "js_slideshow_slide_delete_confirmation": "Are you sure?", diff --git a/lang/fr.json b/lang/fr.json index 1af5037..1cc575c 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -8,7 +8,10 @@ "slideshow_slide_panel_th_duration": "Durée", "slideshow_slide_panel_th_duration_unit": "sec", "slideshow_slide_panel_th_enabled": "Activé", + "slideshow_slide_panel_th_cron_scheduled": "Programmation", "slideshow_slide_panel_th_activity": "Options", + "slideshow_slide_panel_td_cron_scheduled_loop": "En boucle", + "slideshow_slide_panel_td_cron_scheduled_bad_cron": "Mauvaise valeur cron", "slideshow_slide_form_add_title": "Ajouter d'une slide", "slideshow_slide_form_add_submit": "Ajouter", "slideshow_slide_form_edit_title": "Modification d'une slide", @@ -22,6 +25,8 @@ "slideshow_slide_form_label_object": "Objet", "slideshow_slide_form_label_duration": "Durée", "slideshow_slide_form_label_duration_unit": "secondes", + "slideshow_slide_form_label_cron_scheduled": "Programmer", + "slideshow_slide_form_widget_cron_scheduled_placeholder": "Utiliser le format crontab: * * * * *", "slideshow_slide_form_button_cancel": "Annuler", "js_slideshow_slide_delete_confirmation": "Êtes-vous sûr ?", diff --git a/requirements.txt b/requirements.txt index 8af2c5c..7460098 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ flask==2.3.3 pysondb-v2==2.1.0 python-dotenv +cron-descriptor + diff --git a/src/controller/PlayerController.py b/src/controller/PlayerController.py index 44823e1..1f2a2ce 100644 --- a/src/controller/PlayerController.py +++ b/src/controller/PlayerController.py @@ -3,18 +3,31 @@ import json from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify from src.service.ModelStore import ModelStore from src.interface.ObController import ObController -from src.utils import get_ip_address +from src.utils import get_ip_address, get_safe_cron_descriptor class PlayerController(ObController): def _get_playlist(self) -> dict: - slides = self._model_store.slide().to_dict(self._model_store.slide().get_enabled_slides()) + enabled_slides = self._model_store.slide().get_enabled_slides() + slides = self._model_store.slide().to_dict(enabled_slides) - if len(slides) == 1: - return [slides[0], slides[0]] + playlist_loop = [] + playlist_cron = [] - return slides + for slide in slides: + if 'cron_schedule' in slide and slide['cron_schedule']: + if get_safe_cron_descriptor(slide['cron_schedule']): + playlist_cron.append(slide) + else: + playlist_loop.append(slide) + + playlists = { + 'loop': playlist_loop, + 'cron': playlist_cron + } + + return playlists def register(self): self._app.add_url_rule('/', 'player', self.player, methods=['GET']) diff --git a/src/controller/SlideshowController.py b/src/controller/SlideshowController.py index 259aa3b..5c82f2a 100644 --- a/src/controller/SlideshowController.py +++ b/src/controller/SlideshowController.py @@ -8,7 +8,7 @@ from src.service.ModelStore import ModelStore from src.model.entity.Slide import Slide from src.model.enum.SlideType import SlideType from src.interface.ObController import ObController -from src.utils import str_to_enum +from src.utils import str_to_enum, get_optional_string class SlideshowController(ObController): @@ -40,6 +40,7 @@ class SlideshowController(ObController): name=request.form['name'], type=str_to_enum(request.form['type'], SlideType), duration=request.form['duration'], + cron_schedule=get_optional_string(request.form['cron_schedule']), ) if slide.has_file(): @@ -65,7 +66,7 @@ class SlideshowController(ObController): return redirect(url_for('slideshow_slide_list')) def slideshow_slide_edit(self): - self._model_store.slide().update_form(request.form['id'], request.form['name'], request.form['duration']) + self._model_store.slide().update_form(request.form['id'], request.form['name'], request.form['duration'], request.form['cron_schedule']) self._post_update() return redirect(url_for('slideshow_slide_list')) diff --git a/src/manager/LangManager.py b/src/manager/LangManager.py index 7aca3f9..1db0dbe 100644 --- a/src/manager/LangManager.py +++ b/src/manager/LangManager.py @@ -8,7 +8,7 @@ class LangManager: def __init__(self, lang: str): self._map = {} - self._lang = lang + self._lang = lang.lower() self.load() def load(self, directory: str = "", prefix: str = ""): @@ -23,3 +23,6 @@ class LangManager: def map(self) -> dict: return self._map + + def get_locale(self, local_with_country: bool = False) -> str: + return "{}_{}".format(self._lang, self._lang.upper()) if local_with_country else self._lang \ No newline at end of file diff --git a/src/manager/SlideManager.py b/src/manager/SlideManager.py index 7944af9..31fa397 100644 --- a/src/manager/SlideManager.py +++ b/src/manager/SlideManager.py @@ -2,7 +2,7 @@ import os from typing import Dict, Optional, List, Tuple, Union from src.model.entity.Slide import Slide -from src.utils import str_to_enum +from src.utils import str_to_enum, get_optional_string from pysondb import PysonDB from pysondb.errors import IdDoesNotExistError @@ -69,8 +69,12 @@ class SlideManager: for slide_id, slide_position in positions.items(): self._db.update_by_id(slide_id, {"position": slide_position}) - def update_form(self, id: str, name: str, duration: int) -> None: - self._db.update_by_id(id, {"name": name, "duration": duration}) + def update_form(self, id: str, name: str, duration: int, cron_schedule: Optional[str] = '') -> None: + self._db.update_by_id(id, { + "name": name, + "duration": duration, + "cron_schedule": get_optional_string(cron_schedule) + }) def add_form(self, slide: Union[Slide, Dict]) -> None: db_slide = slide diff --git a/src/model/entity/Slide.py b/src/model/entity/Slide.py index ff723a8..3ba35cd 100644 --- a/src/model/entity/Slide.py +++ b/src/model/entity/Slide.py @@ -7,7 +7,7 @@ from src.utils import str_to_enum class Slide: - def __init__(self, location: str = '', duration: int = 3, type: Union[SlideType, str] = SlideType.URL, enabled: bool = False, name: str = 'Untitled', position: int = 999, id: Optional[str] = None): + def __init__(self, location: str = '', duration: int = 3, type: Union[SlideType, str] = SlideType.URL, enabled: bool = False, name: str = 'Untitled', position: int = 999, id: Optional[str] = None, cron_schedule: Optional[str] = None): self._id = id if id else None self._location = location self._duration = duration @@ -15,6 +15,7 @@ class Slide: self._enabled = enabled self._name = name self._position = position + self._cron_schedule = cron_schedule @property def id(self) -> Optional[str]: @@ -36,6 +37,14 @@ class Slide: def type(self, value: SlideType): self._type = value + @property + def cron_schedule(self) -> Optional[str]: + return self._cron_schedule + + @cron_schedule.setter + def cron_schedule(self, value: Optional[str]): + self._cron_schedule = value + @property def duration(self) -> int: return self._duration @@ -77,6 +86,7 @@ class Slide: f"duration='{self.duration}',\n" \ f"position='{self.position}',\n" \ f"location='{self.location}',\n" \ + f"cron_schedule='{self.cron_schedule}',\n" \ f")" def to_json(self) -> str: @@ -91,9 +101,11 @@ class Slide: "type": self.type.value, "duration": self.duration, "location": self.location, + "cron_schedule": self.cron_schedule, } def has_file(self) -> bool: - return (self.type == SlideType.VIDEO + return ( + self.type == SlideType.VIDEO or self.type == SlideType.PICTURE - ) \ No newline at end of file + ) diff --git a/src/service/TemplateRenderer.py b/src/service/TemplateRenderer.py index 6146242..fda1da3 100644 --- a/src/service/TemplateRenderer.py +++ b/src/service/TemplateRenderer.py @@ -9,6 +9,7 @@ from src.model.hook.HookRegistration import HookRegistration from src.model.hook.StaticHookRegistration import StaticHookRegistration from src.model.hook.FunctionalHookRegistration import FunctionalHookRegistration from src.constant.WebDirConstant import WebDirConstant +from src.utils import get_safe_cron_descriptor class TemplateRenderer: @@ -18,6 +19,9 @@ class TemplateRenderer: self._model_store = model_store self._render_hook = render_hook + def cron_descriptor(self, expression: str, use_24hour_time_format=True) -> str: + return get_safe_cron_descriptor(expression, use_24hour_time_format, self._model_store.lang().get_locale(local_with_country=True)) + def get_view_globals(self) -> dict: globals = dict( STATIC_PREFIX="/{}/{}/".format(WebDirConstant.FOLDER_STATIC, WebDirConstant.FOLDER_STATIC_WEB_ASSETS), @@ -25,6 +29,7 @@ class TemplateRenderer: VERSION=self._model_store.config().map().get('version'), LANG=self._model_store.variable().map().get('lang').as_string(), HOOK=self._render_hook, + cron_descriptor=self.cron_descriptor ) for hook in HookType: diff --git a/src/service/WebServer.py b/src/service/WebServer.py index 04c3aa0..f4b4948 100644 --- a/src/service/WebServer.py +++ b/src/service/WebServer.py @@ -33,7 +33,6 @@ class WebServer: def setup(self) -> None: self._setup_flask_app() self._setup_web_globals() - self._setup_web_extensions() self._setup_web_errors() self._setup_web_controllers() @@ -73,12 +72,8 @@ class WebServer: def inject_global_vars() -> dict: return self._template_renderer.get_view_globals() - def _setup_web_extensions(self) -> None: - @self._app.template_filter('ctime') - def time_ctime(s): - return time.ctime(s) - def _setup_web_errors(self) -> None: @self._app.errorhandler(404) def not_found(e): return send_from_directory(self._get_template_folder(), 'core/error404.html'), 404 + diff --git a/src/utils.py b/src/utils.py index 48720c7..90af616 100644 --- a/src/utils.py +++ b/src/utils.py @@ -4,6 +4,38 @@ import platform from typing import Optional, List from enum import Enum +from cron_descriptor import ExpressionDescriptor +from cron_descriptor.Exception import FormatException, WrongArgumentException, MissingFieldException + + +def get_safe_cron_descriptor(expression: str, use_24hour_time_format=True, locale_code: Optional[str] = None) -> str: + options = { + "expression": expression, + "use_24hour_time_format": use_24hour_time_format + } + + if locale_code: + options["locale_code"] = locale_code + try: + return str(ExpressionDescriptor(**options)) + except FormatException: + return '' + except WrongArgumentException: + return '' + except MissingFieldException: + return '' + + +def get_optional_string(var: Optional[str]) -> Optional[str]: + if var is None: + return None + + var = var.strip() + + if var: + return var + + return None def get_keys(dict_or_object, key_list_name: str, key_attr_name: str = 'key') -> Optional[List]: @@ -27,12 +59,14 @@ def get_keys(dict_or_object, key_list_name: str, key_attr_name: str = 'key') -> return None + def str_to_enum(str_val: str, enum_class) -> Enum: for enum_item in enum_class: if enum_item.value == str_val: return enum_item raise ValueError(f"{str_val} is not a valid {enum_class.__name__} item") + def get_ip_address() -> Optional[str]: try: os_name = platform.system().lower() diff --git a/version.txt b/version.txt index 400122e..810ee4e 100755 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5 \ No newline at end of file +1.6 diff --git a/views/player/player.jinja.html b/views/player/player.jinja.html index a32d9a9..d0a10fd 100755 --- a/views/player/player.jinja.html +++ b/views/player/player.jinja.html @@ -12,19 +12,20 @@ .slide iframe { background: white; } .slide img { height: 100%; } +
-
+