diff --git a/changelog.md b/changelog.md
index 89ee336ae..995a7641c 100755
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,69 @@
+## 3.0.a
+### New / Changed Widgets
+- new weather service API met.no for deprecated yr.no
+- new widget status.activelist to display json messages as active listview
+- device.uzsuicon can be displayed as button with additional "type" parameter (micro, mini or midi)
+- basic.symbol provides button design as additional options btn-micro, btn-mini or btn-midi with additional text on icon
+- basic.slider provides a new silent mode. Live mode (default) sends changed values constantly, silent mode only if change is completed.
+- plot.period provides stacked plots for line, area and column
+- image / data exporting via context menu in plot.period
+- status.collapse supports a list of multiple values for control of collapsing / hiding
+- device.rtrslider supports offset temperature for MDT RTR and supplements (like device.rtr)
+- multimedia.slideshow now refreshes available images in a defineable time
+- basic.offset rounds result to the count of decimals given by "step" attribute
+- weather service openweathermap.org accepts location by ID (id=...), postal code (zip=...) or latidude&longitude (lat=...&lon=...) in adddition to city name
+
+### Other New Features
+- php8 compatibility (mainly solved by new management of warnings and 'nullsafe' programming)
+- parameter [debug = "1"] in config.ini enables error reporting for php warnings
+- new public functions in weather.php plus new language category [weather] to centrally determine verbal wind direction and strength
+- weather services use humidity and air pressure as additional data (has been max. one out of both)
+- demoseries in offline driver have been synchronized to the minute in order to enable stacking of demoseries
+- new function Number.prototype.decimals() to determine count of decimals of a number
+- new page ./pages/base/widget_docu.html displays parameter info for all widgets (tool to optimize custom widgets docstrings)
+
+### Improvements
+- error reporting for weather services and CalDav / iCloud calendars shows answers from remote
+- error reporting for phone service improved
+- error notification avoids duplicate messages (weather and phone services)
+- error notification is cleared if service is running again (weather and phone services)
+- complete review of all parameter definitions in order to improve results in template checker
+- improved autocomplete lists and styling in widget assistent
+
+### Updated Libraries
+- Highcharts updated to v9.1
+- ICal ICS Parser updated to v2.2.2
+
+### Deprecated
+- weather service yr.no (use met.no as replacement)
+- weather service wunderground (use weather.com as replacement)
+- fritz!box services other than TR-064
+- custom widgets using sliders ( ) must use attributes "data-orientation" and "data-handleinfo" instead of "orientation" and "handleinfo"
+
+### Removed Features
+- support for older widgets (non jQuery mobile types) has been finally removed
+- unsued Google Closure compiler has been removed
+- deprecated ov.colordisc and ov.rgb removed from example3.graphics. Use ovbasic.color instead
+
+### Fixed Bugs
+- plot.pie did not show series titles as labels / legend
+- some weather services did not use correct language if user defined language extension file was used
+- some weather services did not use the units specified in the language file
+- default repeat interval for phone services was 15 months. Corrected to 15 minutes.
+- design colors where not defined in 'pages' and 'device' options of the config page
+- config options selectable with flip switches where not stored properly in "device" tab (cookie mode)
+- cache folders where deleted completely regardless of source (global / cookie)
+- met.no weather service showed no icon if started directly after midnight and had problems with chages to summer time
+- when leaving a page via the "back" button, widgets exit method and cancellation of plot data subscriptions didn't work.
+- conflicts between exit method and older versions of back-to-home functions
+- templatechecker did not consider widgets in the pages subfolder
+- plot.gauge threw warnings due to faulty "data-axis" parameter.
+- 100% check of docu pages and widgets with W3C validator revealed some issues - fixed.
+- widget assistant threw errors with nested curly brackets (e.g. in plot options)
+
+### Known Bugs
+
+
## 3.0.1
### New / Changed Widgets
@@ -19,6 +85,10 @@
- php errors thrown in calendar service due to usage of deprecated join() statement
- outline render page for widget assistant has been fixed, also for Apple devices
+### Known Bugs
+- when leaving a page via the "back" button, widgets exit method and cancellation of plot data subscriptions won't work.
+ (root cause documented in base.js line 1804)
+
## 3.0
diff --git a/driver/io_offline.js b/driver/io_offline.js
index 124a28fe9..8d8f8579c 100644
--- a/driver/io_offline.js
+++ b/driver/io_offline.js
@@ -261,8 +261,15 @@ var io = {
max = 1;
}
- tmin = new Date().getTime() - new Date().duration(tmin);
- tmax = new Date().getTime() - new Date().duration(tmax);
+ //tmin = new Date().getTime() - new Date().duration(tmin);
+ //tmax = new Date().getTime() - new Date().duration(tmax);
+
+ //synchronize timestamps for demoseries to the minute in order to allow stacked plots
+ var actualTime = new Date()
+ var actualMinute = Math.round(actualTime/60000) * 60000;
+ tmin = new Date (actualMinute) - new Date().duration(tmin);
+ tmax = new Date (actualMinute) - new Date().duration(tmax);
+
var step = Math.round((tmax - tmin) / (cnt-1));
if(step == 0)
step = 1;
diff --git a/driver/io_offline.php b/driver/io_offline.php
index a627215b2..879c4c288 100644
--- a/driver/io_offline.php
+++ b/driver/io_offline.php
@@ -33,7 +33,7 @@ class driver_offline
public function __construct($request)
{
$this->item = explode(",", $request['item']);
- $this->val = $request['val'];
+ $this->val = (isset($request['val'])) ? $request['val'] : '';
$this->filename = const_path.'temp/offline_'.config_pages.'.var';
}
@@ -113,7 +113,7 @@ public function json()
$data = $this->fileread();
// write if a value is given
- if ($this->val != '' or $this->writeall)
+ if ((isset($this->val) && $this->val != '') or $this->writeall)
{
foreach ($this->item as $item)
{
@@ -125,7 +125,7 @@ public function json()
foreach ($this->item as $item)
{
- $ret[$item] = $data[$item];
+ $ret[$item] = isset($data[$item]) ? $data[$item] : null;
}
return json_encode($ret);
diff --git a/index.php b/index.php
index 690aa4807..ddee6097f 100644
--- a/index.php
+++ b/index.php
@@ -21,10 +21,10 @@
$request = array_merge($_GET, $_POST);
// override configured pages if a corresponding request parameter is passed
-$config_pages = ($request['pages'] != '') ? $request['pages'] : config_pages;
+$config_pages = (isset($request['pages']) && $request['pages'] != '') ? $request['pages'] : config_pages;
// if page is not in $request use default index page defined in defaults.ini
-if ($request['page'] == '')
+if (!isset($request['page']) || $request['page'] == '')
$request['page'] = config_index;
// Caching
diff --git a/lang/de.ini b/lang/de.ini
index 5eb8ed835..138e4349a 100644
--- a/lang/de.ini
+++ b/lang/de.ini
@@ -9,6 +9,7 @@ extends = "en"
[format]
int = "%d"
float = "%01,2f"
+weathertemp = "%01,0f °C"
temp = "%01,1f °C"
° = "%01,1f °"
°c = "%01,1f °C"
@@ -110,6 +111,50 @@ axis = "Temperatur, Feuchtigkeit"
; -----------------------------------------------------------------------------
; S E R V I C E S
; -----------------------------------------------------------------------------
+[weather]
+lang = "de"
+
+n = "Nord"
+nne = "Nordnordost"
+ne = "Nordost"
+ene = "Ostnordost"
+e = "Ost"
+ese = "Ostsüdost"
+se = "Südost"
+sse = "Südsüdost"
+s = "Süd"
+ssw = "Südsüdwest"
+sw = "Südwest"
+wsw = "Westsüdwest"
+w = "West"
+wnw = "Westnordwest"
+nw = "Nordwest"
+nnw = "Nordnordwest"
+
+calm = "still"
+light air = "leiser Zug"
+light breeze = "leichte Brise"
+gentle breeze = "schwache Brise"
+moderate breeze = "mäßige Brise"
+fresh breeze = "frische Brise"
+strong breeze = "starker Wind"
+near gale = "steifer Wind"
+strong gale = "Sturm"
+gale = "stürmischer Wind"
+violent storm = "orkanartiger Sturm"
+storm = "schwerer Sturm"
+hurricane = "Orkan"
+
+from = "aus"
+at = "mit"
+
+humidity = "rel. Luftfeuchte"
+air pressure = "Luftdruck"
+precipitation = "Niederschlag"
+wind_gust = "Böen"
+wind_speed = "Geschwindigkeit"
+wind = "Wind"
+
[yr.no]
n = "Nord"
nne = "Nordnordost"
@@ -148,7 +193,7 @@ snow = "Schnee"
fog = "Nebel"
calm = "still"
-light air = "leichter Zug"
+light air = "leiser Zug"
light breeze = "leichte Brise"
gentle breeze = "schwache Brise"
moderate breeze = "mäßige Brise"
@@ -163,6 +208,50 @@ hurricane = "Orkan"
from = "aus"
+[met.no]
+clearsky = "klarer Himmel"
+fair = "Schönwetter"
+partlycloudy = "leicht bewölkt"
+cloudy = "bewölkt"
+lightrainshowersandthunder = "leichte Regenschauer mit Gewitter"
+heavyrainshowersandthunder = "starke Regenschauer mit Gewitter"
+rainshowersandthunder = "Regenschauer mit Gewitter"
+lightrainshowers = "leichte Regenschauer"
+heavyrainshowers = "starke Regenschauer"
+rainshowers = "Regenschauer"
+lightrainandthunder = "leichter Regen mit Gewitter"
+heavyrainandthunder = "starker Regen mit Gewitter"
+rainandthunder = "Regen mit Gewitter"
+lightrain = "leichter Regen"
+heavyrain = "starker Regen"
+rain = "Regen"
+lightsnowshowersandthunder = "leichte Schneeschauer mit Gewitter"
+heavysnowshowersandthunder = "starke Schneeschauer mit Gewitter"
+snowshowersandthunder = "Schneeschauer mit Gewitter"
+lightsnowshowers = "leichte Schneeschauer"
+heavysnowshowers = "starke Schneeschauer"
+snowshowers = "Schneeschauer"
+lightsnowandthunder = "leichter Schneefall mit Gewitter"
+heavysnowandthunder = "starker Schneefall mit Gewitter"
+snowandthunder = "Schneefall mit Gewitter"
+lightsnow = "leichter Schneefall"
+heavysnow = "starker Schneefall"
+snow = "Schneefall"
+lightsleetshowersandthunder = "leichte Graupelschauer mit Gewitter"
+heavysleetshowersandthunder = "starke Graupelschauer mit Gewitter"
+sleetshowersandthunder = "Graupelschauer mit Gewitter"
+lightsleetshowers = "leichte Graupelschauer"
+heavysleetshowers = "starke Graupelschauer"
+sleetshowers = "Graupelschauer"
+lightsleetandthunder = "leichter Graupel mit Gewitter"
+heavysleetandthunder = "starker Graupel mit Gewitter"
+sleetandthunder = "Graupel mit Gewitter"
+lightsleet = "leichter Graupel"
+heavysleet = "starker Graupel"
+sleet = "Graupel"
+fog = "Nebel"
+
+
[wunderground]
lang = "DL"
@@ -195,33 +284,12 @@ from = "aus"
lang = "de"
units = "si"
-humidity = "Luftfeuchte"
-wind_gust = "Böen"
-wind_speed = "Geschwindigkeit"
-wind = "Wind"
-from = "aus"
-
-dir_n = "Nord"
-dir_nne = "Nordnordost"
-dir_ne = "Nordost"
-dir_ene = "Ostnordost"
-dir_e = "Ost"
-dir_ese = "Ostsüdost"
-dir_se = "Südost"
-dir_sse = "Südsüdost"
-dir_s = "Süd"
-dir_ssw = "Südsüdwest"
-dir_sw = "Südwest"
-dir_wsw = "Westsüdwest"
-dir_w = "West"
-dir_wnw = "Westnordwest"
-dir_nw = "Nordwest"
-dir_nnw = "Nordnordwest"
-
[openweathermap]
lang = "de"
-humidity = "Luftfeuchte"
-wind = "Wind"
+units = "metric"
+
+[weather.com]
+units = "m"
[phone]
phonelist = "Telefonliste"
@@ -286,6 +354,16 @@ default_img_list[color] = "rgb(32, 178, 170)"
default_img_waste[icon] = ''
default_img_waste[color] = "rgb(32, 178, 170)"
+[status_event_format]
+info[icon] = info_info
+info[color] = green
+warning[icon] = info_warning
+warning[color] = gold
+error[icon] = info_error
+error[color] = red
+default_img_status[icon] = ''
+default_img_status[color] = "rgb(32, 178, 170)"
+
[configuration_page]
configuration[label] = "Einstellungen"
globaltab[label] = "Global"
diff --git a/lang/en-gb.ini b/lang/en-gb.ini
index d30f2604a..1ce782ec0 100644
--- a/lang/en-gb.ini
+++ b/lang/en-gb.ini
@@ -5,6 +5,7 @@
extends = "en"
[format]
+weathertemp = "%01.0f °C"
temp = "%01.1f °C"
° = "%01.1f °"
°c = "%01.1f °C"
@@ -17,3 +18,6 @@ long = "d/m/Y H:i:s"
[darksky]
units = "uk2"
+
+[weather.com]
+units = "h"
diff --git a/lang/en.ini b/lang/en.ini
index 80575abf6..1993a9973 100644
--- a/lang/en.ini
+++ b/lang/en.ini
@@ -8,6 +8,7 @@
[format]
int = "%d"
float = "%01.2f"
+weathertemp ="%01,0f °F"
temp = "%01.1f °F"
° = "%01.1f °"
°c = "%01.1f °C"
@@ -115,6 +116,50 @@ axis = "Temperature, Humidity"
; -----------------------------------------------------------------------------
; S E R V I C E S
; -----------------------------------------------------------------------------
+
+[weather]
+lang = "en"
+
+n = "North"
+nne = "North-northeast"
+ne = "Northeast"
+ene = "East-northeast"
+e = "East"
+ese = "East-southeast"
+se = "South-east"
+sse = "South-southeast"
+s = "South"
+ssw = "South-southwest"
+sw = "Southwest"
+wsw = "West-southwest"
+w = "West"
+wnw = "West-northwest"
+nw = "Northwest"
+nnw = "North-northwest"
+
+calm = "calm"
+light air = "light air"
+light breeze = "light breeze"
+gentle breeze = "gentle breeze"
+moderate breeze = "moderate breeze"
+fresh breeze = "fresh breeze"
+strong breeze = "strong breeze"
+near gale = "near gale"
+strong gale = "strong gale"
+gale = "gale"
+violent storm = "violent storm"
+storm = "storm"
+hurricane = "hurricane"
+
+from = "from"
+at = "at"
+
+humidity = "rel. humidity"
+air pressure = "air pressure"
+precipitation = "precipitation"
+wind_gust = "wind gust"
+wind_speed = "wind speed"
+
[yr.no]
n = "North"
nne = "North-northeast"
@@ -167,6 +212,48 @@ hurricane = "hurricane"
from = "from"
+[met.no]
+clearsky = "clear sky"
+fair = "fair"
+partlycloudy = "partly cloudy"
+cloudy = "cloudy"
+lightrainshowersandthunder = "light rain showers and thunder"
+heavyrainshowersandthunder = "heavy rain showers and thunder"
+rainshowersandthunder = "rain showers and thunder"
+lightrainshowers = "light rain showers"
+heavyrainshowers = "heavy rain showers"
+rainshowers = "rain showers"
+lightrainandthunder = "light rain and thunder"
+heavyrainandthunder = "heavy rain and thunder"
+rainandthunder = "rain and thunder"
+lightrain = "light rain"
+heavyrain = "heavy rain"
+rain = "rain"
+lightsnowshowersandthunder = "light snow showers and thunder"
+heavysnowshowersandthunder = "heavy snow showers and thunder"
+snowshowersandthunder = "snow showers and thunder"
+lightsnowshowers = "light snow showers"
+heavysnowshowers = "heavy snow showers"
+snowshowers = "snow showers"
+lightsnowandthunder = "light snow and thunder"
+heavysnowandthunder = "heavy snow and thunder"
+snowandthunder = "snow and thunder"
+lightsnow = "light snow"
+heavysnow = "heavy snow"
+snow = "snow"
+lightsleetshowersandthunder = "light sleet showers and thunder"
+heavysleetshowersandthunder = "heavy sleet showers and thunder"
+sleetshowersandthunder = "sleet showers and thunder"
+lightsleetshowers = "light sleet showers"
+heavysleetshowers = "heavy sleet showers"
+sleetshowers = "sleet showers"
+lightsleetandthunder = "light sleet and thunder"
+heavysleetandthunder = "heavy sleet and thunder"
+sleetandthunder = "sleet and thunder"
+lightsleet = "light sleet"
+heavysleet = "heavy sleet"
+sleet = "sleet"
+fog = "fog"
[wunderground]
lang = "EN"
@@ -200,33 +287,12 @@ from = "from"
lang = "en"
units = "us"
-humidity = "Humidity"
-wind_gust = "Gusts"
-wind_speed = "Speed"
-wind = "Wind"
-from = "from"
-
-dir_n = "North"
-dir_nne = "North-northeast"
-dir_ne = "Northeast"
-dir_ene = "East-northeast"
-dir_e = "East"
-dir_ese = "East-southeast"
-dir_se = "South-east"
-dir_sse = "South-southeast"
-dir_s = "South"
-dir_ssw = "South-southwest"
-dir_sw = "Southwest"
-dir_wsw = "West-southwest"
-dir_w = "West"
-dir_wnw = "West-northwest"
-dir_nw = "Northwest"
-dir_nnw = "North-northwest"
-
[openweathermap]
lang = "en"
-humidity = "Humidity"
-wind = "Wind"
+units = "imperial"
+
+[weather.com]
+units = "e"
[phone]
phonelist = "Call list"
@@ -279,6 +345,17 @@ default_img_list[icon] = ''
default_img_list[color] = "rgb(32, 178, 170)"
default_img_waste[icon] = ''
default_img_waste[color] = "rgb(32, 178, 170)"
+
+[status_event_format]
+info[icon] = info_info
+info[color] = green
+warning[icon] = info_warning
+warning[color] = gold
+error[icon] = info_error
+error[color] = red
+default_img_status[icon] = ''
+default_img_status[color] = "rgb(32, 178, 170)"
+
[configuration_page]
configuration[label] = "Configuration"
globaltab[label] = "Global"
@@ -288,7 +365,6 @@ pagestab[hint] = "Settings for the selected page only (in /pages/xyz/config.ini)
cookietab[label] = "Client (Cookie)"
cookietab[hint] = "Settings for the current client only (stored as cookie)."
-
userinterface[label] = "User Interface"
interface[label] = "General Settings"
diff --git a/lang/fr.ini b/lang/fr.ini
index a3b69fef4..5132b294d 100644
--- a/lang/fr.ini
+++ b/lang/fr.ini
@@ -10,6 +10,7 @@ extends = "en"
[format]
int = "%d"
float = "%01,2f"
+weathertemp = "%01,0f °C"
temp = "%01,1f °C"
° = "%01,1f °"
°c = "%01,1f °C"
@@ -110,6 +111,51 @@ axis = "Température, Humidité"
; -----------------------------------------------------------------------------
; S E R V I C E S
; -----------------------------------------------------------------------------
+
+[weather]
+lang = "fr"
+
+n = "nord"
+nne = "nord-nord-est"
+ne = "nord-est"
+ene = "est-nord-est"
+e = "est"
+ese = "est-sud-est"
+se = "sud-est"
+sse = "sud-sud-est"
+s = "sud"
+ssw = "sud-sud-ouest"
+sw = "sud-ouest"
+wsw = "ouest-sud-ouest"
+w = "ouest"
+wnw = "ouest-nord-ouest"
+nw = "nord-ouest"
+nnw = "nord-nord-ouest"
+
+calm = "calme"
+light air = "très légère brise"
+light breeze = "légère brise"
+gentle breeze = "petite brise"
+moderate breeze = "brise modérée"
+fresh breeze = "brise fraîche"
+strong breeze = "forte brise"
+near gale = "vent fort"
+strong gale = "fort coup de vent"
+gale = "coup de vent"
+violent storm = "violente tempête"
+storm = "tempête"
+hurricane = "ouragan"
+
+from = "de"
+at = "à"
+
+humidity = "humidité rel."
+air pressure = "pression d' air"
+precipitation = "pluviosité"
+wind_gust = "rafales"
+wind_speed = "vitesse"
+wind = "vent"
+
[yr.no]
n = "nord"
nne = "nord-nord-est"
@@ -162,6 +208,49 @@ hurricane = "ouragan"
from = "de"
+[met.no]
+clearsky = "ciel dégagé"
+fair = "beau temps"
+partlycloudy = "partiellement couvert"
+cloudy = "nuageux"
+lightrainshowersandthunder = "averses legères et orage"
+heavyrainshowersandthunder = "averses fortes et orage"
+rainshowersandthunder = "averses et orage"
+lightrainshowers = "averses legères"
+heavyrainshowers = "averses fortes"
+rainshowers = "averses"
+lightrainandthunder = "pluie legère et orage"
+heavyrainandthunder = "pluie forte et orage"
+rainandthunder = "pluie et orage"
+lightrain = "pluie légère"
+heavyrain = "pluie forte"
+rain = "pluie"
+lightsnowshowersandthunder = "averses legères de neige et orage"
+heavysnowshowersandthunder = "averses fortes de neige et orage"
+snowshowersandthunder = "averses de neige et orage"
+lightsnowshowers = "averses legères de neige"
+heavysnowshowers = "averses fortes de neige"
+snowshowers = "averses de neige"
+lightsnowandthunder = "neige legère et orage"
+heavysnowandthunder = "neige forte et orage"
+snowandhunder = "neige et orage"
+lightsnow = "neige legère"
+heavysnow = "neige forte"
+snow = "neige"
+lightsleetshowersandthunder = "averses legères de grésil et orage"
+heavysleetshowersandthunder = "averses fortes de grésil et orage"
+sleetshowersandthunder = "averses de grésil et orage"
+lightsleetshowers = "averses legères de grésil"
+heavysleetshowers = "averses fortes de grésil"
+sleetshowers = "averses de grésil"
+lightsleetandthunder = "neige fondue legère et orage"
+heavysleetandthunder = "neige fondue forte et orage"
+sleetandthunder = "neige fondue et orage"
+lightsleet = "neige fondue legère"
+heavysleet = "neige fondue forte"
+sleet = "neige fondue"
+fog = "brouillard"
+
[wunderground]
lang = "FR"
@@ -197,28 +286,12 @@ from = "de"
lang = "fr"
units = "si"
-humidity = "Humidité"
-wind_gust = "Rafales"
-wind_speed = "Vitesse"
-wind = "Vent"
-from = "de"
+[openweathermap]
+lang = "fr"
+units = "metric"
-dir_n = "nord"
-dir_nne = "nord-nord-est"
-dir_ne = "nord-est"
-dir_ene = "est-nord-est"
-dir_e = "est"
-dir_ese = "est-sud-est"
-dir_se = "sud-est"
-dir_sse = "sud-sud-est"
-dir_s = "sud"
-dir_ssw = "sud-sud-ouest"
-dir_sw = "sud-ouest"
-dir_wsw = "ouest-sud-ouest"
-dir_w = "ouest"
-dir_wnw = "ouest-nord-ouest"
-dir_nw = "nord-ouest"
-dir_nnw = "nord-nord-ouest"
+[weather.com]
+units = "m"
[phone]
phonelist = "Liste des coups de fil"
@@ -266,6 +339,17 @@ default_img_list[icon] = ''
default_img_list[color] = "rgb(32, 178, 170)"
default_img_waste[icon] = ''
default_img_waste[color] = "rgb(32, 178, 170)"
+
+[status_event_format]
+info[icon] = info_info
+info[color] = green
+warning[icon] = info_warning
+warning[color] = gold
+error[icon] = info_error
+error[color] = red
+default_img_status[icon] = ''
+default_img_status[color] = "rgb(32, 178, 170)"
+
[uzsu]
weekday = "jour de la semaine"
su = "di"
diff --git a/lang/nl.ini b/lang/nl.ini
index c54b66230..5ab9c96d3 100644
--- a/lang/nl.ini
+++ b/lang/nl.ini
@@ -10,6 +10,7 @@ extends = "en"
[format]
int = "%d"
float = "%01,2f"
+weathertemp = "%01,0f °C"
temp = "%01,1f °C"
° = "%01,1f °"
°c = "%01,1f °C"
@@ -92,6 +93,49 @@ axis = "Temperatuur, Vochtigheid"
; -----------------------------------------------------------------------------
; S E R V I C E S
; -----------------------------------------------------------------------------
+[weather]
+lang = "nl"
+
+n = "Noord"
+nne = "Noordnoordoost"
+ne = "Noordoost"
+ene = "Oostnoordoost"
+e = "Oost"
+ese = "Oostzuidoost"
+se = "Zuidoost"
+sse = "Zuidzuidoost"
+s = "Zuid"
+ssw = "Zuidzuidwest"
+sw = "Zuidwest"
+wsw = "Westzuidwest"
+w = "West"
+wnw = "Westnoordwest"
+nw = "Noordwest"
+nnw = "Noordnoordwest"
+
+calm = "windstil"
+light air = "flauwe bries"
+light breeze = "lichte bries"
+gentle breeze = "zwakke bries"
+moderate breeze = "matige wind"
+fresh breeze = "frisse wind"
+strong breeze = "harde wind"
+near gale = "stormachtig"
+strong gale = "sterke storm"
+gale = "storm"
+violent storm = "orkaan-achtige storm"
+storm = "zware storm"
+hurricane = "orkaan"
+
+from = "uit"
+at = "met"
+
+humidity = "Luchtvochtigheid"
+air pressure = "Luchtdruk"
+wind_gust = "Windstoten"
+wind_speed = "Snelheid"
+wind = "Wind"
+
[yr.no]
n = "Noord"
nne = "Noordnoordoost"
@@ -144,6 +188,49 @@ hurricane = "orkaan"
from = "uit"
+[met.no]
+clearsky = "heldere hemel"
+fair = "goed weer"
+partlycloudy = "licht bewolkt"
+cloudy = "bewolkt"
+lightrainshowersandthunder = "lichte regenbuien en onweer"
+heavyrainshowersandthunder = "zware regenbuien en onweer"
+rainshowersandthunder = "regenbuien en onweer"
+lightrainshowers = "lichte regenbuien"
+heavyrainshowers = "zware regenbuien"
+rainshowers = "regenbuien"
+lightrainandthunder = "lichte regen en onweer"
+heavyrainandthunder = "zware regen en onweer"
+rainandthunder = "regen en onweer"
+lightrain = "lichte regenval"
+heavyrain = "zware regenval"
+rain = "regen"
+lightsnowshowersandthunder = "lichte sneeuwbuien en onweer"
+heavysnowshowersandthunder = "zware sneeuwbuien en onweer"
+snowshowersandthunder = "sneeuwbuien en onweer"
+lightsnowshowers = "lichte sneeuwbuien"
+heavysnowshowers = "zware sneeuwbuien"
+snowshowers = "sneeuwbuien"
+lightsnowandthunder = "lichte sneeuw en onweer"
+heavysnowandthunder = "zware sneeuw en onweer"
+snowandthunder = "sneeuw en onweer"
+lightsnow = "lichte sneeuw"
+heavysnow = "zware sneeuw"
+snow = "sneeuw"
+lightsleetshowersandthunder = "lichte natte sneeuwbuien en onweer"
+heavysleetshowersandthunder = "zware natte sneeuwbuien en onweer"
+sleetshowersandthunder = "natte sneeuwbuien en onweer"
+lightsleetshowers = "lichte natte sneeuwbuien"
+heavysleetshowers = "zware natte sneeuwbuien"
+sleetshowers = "natte sneeuwbuien"
+lightsleetandthunder = "lichte natte sneeuw en onweer"
+heavysleetandthunder = "zware natte sneeuw en onweer"
+sleetandthunder = "natte sneeuw en onweer"
+lightsleet = "lichte natte sneeuw"
+heavysleet = "zware natte sneeuw"
+sleet = "natte sneeuw"
+fog = "mist"
+
[wunderground]
lang = "NL"
@@ -175,28 +262,12 @@ from = "uit"
lang = "nl"
units = "si"
-humidity = "Luchtvochtigheid"
-wind_gust = "Windstoten"
-wind_speed = "Snelheid"
-wind = "Wind"
-from = "uit"
+[openweathermap]
+lang = "nl"
+units = "metric"
-dir_n = "Noord"
-dir_nne = "Noordnoordoost"
-dir_ne = "Noordoost"
-dir_ene = "Oostnoordoost"
-dir_e = "Oost"
-dir_ese = "Oostzuidoost"
-dir_se = "Zuidoost"
-dir_sse = "Zuidzuidoost"
-dir_s = "Zuid"
-dir_ssw = "Zuidzuidwest"
-dir_sw = "Zuidwest"
-dir_wsw = "Westzuidwest"
-dir_w = "West"
-dir_wnw = "Westnoordwest"
-dir_nw = "Noordwest"
-dir_nnw = "Noordnoordwest"
+[weather.com]
+units = "m"
[phone]
unknown = "onbekend"
diff --git a/lib/appliance/enertex.iprouter.php b/lib/appliance/enertex.iprouter.php
index fe879af7a..6567b213b 100644
--- a/lib/appliance/enertex.iprouter.php
+++ b/lib/appliance/enertex.iprouter.php
@@ -27,10 +27,13 @@ class enertex_iprouter extends service
*/
public function init($request)
{
- $this->debug = ($request['debug'] == 1);
-
- $this->server = (trim($request['server']) != "" ? trim($request['server']) : config_appliance_iprouter_server);
- $this->pass = (trim($request['pass']) != "" ? trim($request['pass']) : config_appliance_iprouter_pass);
+ $this->debug = (isset($request['debug']) && $request['debug'] == 1);
+
+ $defaultServer = defined('config_appliance_iprouter_server') ? config_appliance_iprouter_server : null;
+ $defaultPass = defined('config_appliance_iprouter_pass') ? config_appliance_iprouter_pass : null;
+
+ $this->server = (isset($request['server']) && trim($request['server']) != "") ? trim($request['server']) : $defaultServer;
+ $this->pass = (isset($request['pass']) && trim($request['pass']) != "") ? trim($request['pass']) : $defaultPass;
}
/**
diff --git a/lib/base/base.js b/lib/base/base.js
index cf035f5a0..6ad9b015c 100755
--- a/lib/base/base.js
+++ b/lib/base/base.js
@@ -161,6 +161,19 @@ Number.prototype.limit = function (min, max, step) {
return ret;
};
+/**
+ * Determine count of decimals of a number
+ */
+Number.prototype.decimals = function (){
+ if (parseInt(this) == this)
+ return 0;
+ else {
+ var parts = Array();
+ parts = String(this).split('.');
+ return parts[1].length;
+ }
+};
+
/**
* Splits a string into parts
*/
@@ -1090,12 +1103,16 @@ var notify = {
* Add a new message
*/
add: function (level, signal, title, text, ackitem, ackval) {
+ if (text.toLowerCase().trim().substr(-16 ) == 'operation failed')
+ text += '
Notice: The message "operation failed" is thrown if a connect fails before the request can be executed. '
+ +'Very often, this is due to failed ssl communication. Try calling the service directly in debug mode for more information.
'
+ +'Example: YourIP/YourSmartvisuDir/lib/weather/service/YourService.php?debug=1';
var message = {level: level, signal: signal, title: title, text: text, time: Date.now(), id: ++notify.i, ackitem: ackitem, ackval: ackval}
notify.messagesIndexed[message.id] = message;
notify.messagesPerLevel[level].push(message);
// log to console
- console.log('[notify.' + level + '] ' + title + ' - ' + text);
+ console.log('[notify.' + level + '] ' + 'id = '+ message.id +': ' + title + ' - ' + text);
// return id
return message.id;
@@ -1158,8 +1175,9 @@ var notify = {
var message = (jqXHR.responseJSON != null) ? jqXHR.responseJSON[0] : { "title": "Unknown Error", "text": jqXHR.responseText };
- notify.add('error', 'ERROR', message.title, message.text);
+ var id = notify.add('error', 'ERROR', message.title, message.text);
notify.display();
+ return id;
},
/**
@@ -1513,55 +1531,9 @@ var widget = {
$(":mobile-pagecontainer").pagecontainer( "getActivePage" ).find('[data-item*="' + item + '"]').filter(':data("sv-widget")').widget('update', widget.get(item), item) // new jQuery Mobile style widgets
- //.invert().each(function (idx) { // Old-style widgets, DEPRECATED as of 2.9
-
- // console.warn('Plain old smartVISU widgets are deprecated. Use a jQuery widget based on $.sv.widget instead.', this);
-
- // var items = widget.explode($(this).attr('data-item'));
-
- // update to a plot: only add a point
- // if ($(this).attr('data-widget').substr(0, 5) == 'plot.' && $(this).highcharts()) { // alternative: jQuery._data( this, "events" )['point'] !== undefined;
- // if (value !== undefined) {
- // var values = [];
- // if more than one item, only that with the value
- // for (var j = 0; j < items.length; j++) {
- // values.push(items[j] == item ? value : null);
- // }
- // DEBUG:
- // console.log("[" + $(this).attr('data-widget') + "] point '" + this.id + "':", values);
- // $(this).trigger('point', [values]);
- // }
- // }
-
- // regular update to the widget with all items
- // else {
- // values = widget.get(items);
- // if (widget.check(values)) {
- // DEBUG:
- // console.log("[" + $(this).attr('data-widget') + "] update '" + this.id + "':", values);
- // $(this).trigger('update', [values]);
- // }
- // }
-
- //});
}
},
- /**
- * Prepares some widgets on the current page.
- * Bind to jquery mobile 'pagebeforeshow'.
- *
- * DEPRECATED as of 2.9
- */
- prepare: function () {
-// // all plots on the current page.
-// $(":mobile-pagecontainer").pagecontainer( "getActivePage" ).find('[data-widget^="plot."][data-item]:not(:data("sv-widget"))').each(function (idx) {
-// console.warn('Plain old smartVISU widgets are deprecated. Use a jQuery widget based on $.sv.widget instead.', this);
-// if ($(this).highcharts()) {
-// $(this).highcharts().destroy();
-// }
-// });
- },
/**
* Refreshes all widgets on the current page. Used to put the values to the
@@ -1569,26 +1541,16 @@ var widget = {
*/
refresh: function () {
$(":mobile-pagecontainer").pagecontainer( "getActivePage" ).find('[data-item]').filter(':data("sv-widget")').widget('update') // new jQuery Mobile style widgets
-// .invert().each(function (idx) { // Old-style widgets, DEPRECATED as of 2.9
-// console.warn('Plain old smartVISU widgets are deprecated. Use a jQuery widget based on $.sv.widget instead.', this);
-//
-// var values = widget.get(widget.explode($(this).attr('data-item')));
-//
-// if (widget.check(values)) {
-// $(this).trigger('update', [values]);
-// console.log("[" + $(this).attr('data-widget') + "] update '" + this.id + "':", values);
-// }
-// });
-
+
//TODO: call io.run here instead of this in io.run and io.run in root.html
// config_driver_realtime needs to be evaluated
-
- //widget.getUrlData();
+
},
+
/**
- * Get data from
- * Called by widget.refresh and in widgets repeat events.
+ * Get data from url
+ * Called in widgets repeat events, e.g. calendar.
*/
getUrlData: function (jqWidgets) {
$.each(widget.urls(jqWidgets), function(idx, url) {
@@ -1796,19 +1758,13 @@ $(document).on("pagebeforeload", function(event, data) {
/**
* stop series subscriptions and trigger 'exit' method in all widgets on current page
- * before new page is being loaded.
- * pagecontainerbeforechange event is triggered twice but with different attributes.
- * at first occurrence ui.toPage only contains the URL of target pages as a string.
- * to avoid triggering on popup open / close, ui.options.role and ui.options.reverse is evaluated
- *
- * known bug: changing a page via the browsers "back" button can not be distinguished from closing a popup, yet.
- * hence, the exit method is not triggered if back button is used to change pages
+ * before new page is being loaded. Trigger only if page is different (not just a return to same page)
*/
-$(document).on('pagecontainerbeforechange', function(event,ui) {
- if(typeof(ui.toPage)== 'string' && ui.options.role == undefined && ui.options.reverse != true ){
+ $(document).on('pagecontainerbeforetransition', function(event,ui) {
+ if (ui.prevPage!= undefined && ui.toPage[0].id != ui.prevPage[0].id) {
io.stopseries ();
$(":mobile-pagecontainer").pagecontainer( "getActivePage" ).find('[data-widget]').filter(':data("sv-widget")').widget('exit');
- };
+ }
});
/**
@@ -1861,8 +1817,13 @@ function setMobileWidgetValue(field, value) {
// click on row enables input
$(document).on('click', '#config .ui-field-contain',function(event) {
- if(!$(event.target).closest('.ui-field-contain label.ui-btn').length && !$(event.target).closest('.ui-field-contain .ui-help-icon').length)
+ if(!$(event.target).closest('.ui-field-contain label.ui-btn').length && !$(event.target).closest('.ui-field-contain .ui-help-icon').length) {
changeDisabledState($(this).closest('.ui-field-contain'), false).find('label.ui-btn').addClass('ui-btn-active');
+ // fill missing meta data after activation
+ if(event.target.id != undefined && event.target.id != '' ){
+ $('#'+event.target.id).trigger('change');
+ }
+ }
});
// click on label disables input
$(document).on('click', '#config .ui-field-contain label.ui-btn',function(event) {
diff --git a/lib/base/check_update.php b/lib/base/check_update.php
index 00b588037..0d4ed6740 100644
--- a/lib/base/check_update.php
+++ b/lib/base/check_update.php
@@ -12,7 +12,7 @@
// get config-variables
require_once '../../lib/includes.php';
-if (empty($_COOKIE['updchk']))
+if (empty($_COOKIE['updchk']) && config_updatecheck)
{
// get contents from smartvisu.de (main version only)
$request = array_merge($_GET, $_POST);
diff --git a/lib/base/jquery.mobile.slider.js b/lib/base/jquery.mobile.slider.js
index c2ab2f1cc..e5242cdc9 100644
--- a/lib/base/jquery.mobile.slider.js
+++ b/lib/base/jquery.mobile.slider.js
@@ -12,10 +12,12 @@ $.widget( "mobile.slider", $.mobile.slider, {
_create: function() {
this._super();
- this.handleinfo = this.element.attr("handleinfo");
+ //wvhn @v3.1: rename attr 'handleindo' to 'data-handleinfo' after w3c syntax check / set 'handleinfo' to deprecated
+ this.handleinfo = this.element.attr("data-handleinfo") || this.element.attr("handleinfo");
this.noInput = this.element.hasClass('ui-slider-no-input');
- var orientation = this.orientation = this.element.attr("orientation"); // orientation (horizontal, vertical, bottomup, semicircle)
+ //wvhn @v3.1: rename attr 'orientation' to 'data-orientation' after w3c syntax check / set 'orientation' to deprecated
+ var orientation = this.orientation = this.element.attr("data-orientation") || this.element.attr("orientation"); // orientation (horizontal, vertical, bottomup, semicircle)
if (orientation == 'semicircle') {
var trackTheme = this.options.trackTheme || $.mobile.getAttribute( this.element[ 0 ], "theme" ) || 'inherit';
diff --git a/lib/calendar/ICal/Event.php b/lib/calendar/ICal/Event.php
index 54329c533..6e477699b 100644
--- a/lib/calendar/ICal/Event.php
+++ b/lib/calendar/ICal/Event.php
@@ -2,110 +2,126 @@
namespace ICal;
-class Event extends ICal
+class Event
{
+ // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
+
const HTML_TEMPLATE = '
%s: %s
';
/**
- * http://www.kanzaki.com/docs/ical/summary.html
+ * https://www.kanzaki.com/docs/ical/summary.html
*
* @var $summary
*/
public $summary;
/**
- * http://www.kanzaki.com/docs/ical/dtstart.html
+ * https://www.kanzaki.com/docs/ical/dtstart.html
*
* @var $dtstart
*/
public $dtstart;
/**
- * http://www.kanzaki.com/docs/ical/dtend.html
+ * https://www.kanzaki.com/docs/ical/dtend.html
*
* @var $dtend
*/
public $dtend;
/**
- * http://www.kanzaki.com/docs/ical/duration.html
+ * https://www.kanzaki.com/docs/ical/duration.html
*
* @var $duration
*/
public $duration;
/**
- * http://www.kanzaki.com/docs/ical/dtstamp.html
+ * https://www.kanzaki.com/docs/ical/dtstamp.html
*
* @var $dtstamp
*/
public $dtstamp;
/**
- * http://www.kanzaki.com/docs/ical/uid.html
+ * When the event starts, represented as a timezone-adjusted string
+ *
+ * @var $dtstart_tz
+ */
+ public $dtstart_tz;
+
+ /**
+ * When the event ends, represented as a timezone-adjusted string
+ *
+ * @var $dtend_tz
+ */
+ public $dtend_tz;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/uid.html
*
* @var $uid
*/
public $uid;
/**
- * http://www.kanzaki.com/docs/ical/created.html
+ * https://www.kanzaki.com/docs/ical/created.html
*
* @var $created
*/
public $created;
/**
- * http://www.kanzaki.com/docs/ical/lastModified.html
+ * https://www.kanzaki.com/docs/ical/lastModified.html
*
* @var $lastmodified
*/
public $lastmodified;
/**
- * http://www.kanzaki.com/docs/ical/description.html
+ * https://www.kanzaki.com/docs/ical/description.html
*
* @var $description
*/
public $description;
/**
- * http://www.kanzaki.com/docs/ical/location.html
+ * https://www.kanzaki.com/docs/ical/location.html
*
* @var $location
*/
public $location;
/**
- * http://www.kanzaki.com/docs/ical/sequence.html
+ * https://www.kanzaki.com/docs/ical/sequence.html
*
* @var $sequence
*/
public $sequence;
/**
- * http://www.kanzaki.com/docs/ical/status.html
+ * https://www.kanzaki.com/docs/ical/status.html
*
* @var $status
*/
public $status;
/**
- * http://www.kanzaki.com/docs/ical/transp.html
+ * https://www.kanzaki.com/docs/ical/transp.html
*
* @var $transp
*/
public $transp;
/**
- * http://www.kanzaki.com/docs/ical/organizer.html
+ * https://www.kanzaki.com/docs/ical/organizer.html
*
* @var $organizer
*/
public $organizer;
/**
- * http://www.kanzaki.com/docs/ical/attendee.html
+ * https://www.kanzaki.com/docs/ical/attendee.html
*
* @var $attendee
*/
@@ -119,11 +135,9 @@ class Event extends ICal
*/
public function __construct(array $data = array())
{
- if (!empty($data)) {
- foreach ($data as $key => $value) {
- $variable = self::snakeCase($key);
- $this->{$variable} = self::prepareData($value);
- }
+ foreach ($data as $key => $value) {
+ $variable = self::snakeCase($key);
+ $this->{$variable} = self::prepareData($value);
}
}
@@ -173,7 +187,9 @@ public function printData($html = self::HTML_TEMPLATE)
'ATTENDEE(S)' => $this->attendee,
);
- $data = array_filter($data); // Remove any blank values
+ // Remove any blank values
+ $data = array_filter($data);
+
$output = '';
foreach ($data as $key => $value) {
@@ -194,9 +210,9 @@ public function printData($html = self::HTML_TEMPLATE)
protected static function snakeCase($input, $glue = '_', $separator = '-')
{
$input = preg_split('/(?<=[a-z])(?=[A-Z])/x', $input);
- $input = join($glue, $input);
+ $input = implode($glue, $input);
$input = str_replace($separator, $glue, $input);
return strtolower($input);
}
-}
\ No newline at end of file
+}
diff --git a/lib/calendar/ICal/ICal.php b/lib/calendar/ICal/ICal.php
index cb1142eeb..5c6496d8a 100644
--- a/lib/calendar/ICal/ICal.php
+++ b/lib/calendar/ICal/ICal.php
@@ -1,28 +1,40 @@
, John Grogg , Martin Thoma
+ * @author Jonathan Goode
* @license https://opensource.org/licenses/mit-license.php MIT License
- * @version 2.0.6
+ * @version 2.2.2
*/
namespace ICal;
class ICal
{
+ // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
+
const DATE_TIME_FORMAT = 'Ymd\THis';
- const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
const DATE_TIME_FORMAT_PRETTY = 'F Y H:i:s';
+ const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
+ const ISO_8601_WEEK_START = 'MO';
const RECURRENCE_EVENT = 'Generated recurrence event';
const SECONDS_IN_A_WEEK = 604800;
const TIME_FORMAT = 'His';
+ const TIME_ZONE_UTC = 'UTC';
const UNIX_FORMAT = 'U';
const UNIX_MIN_YEAR = 1970;
+ /**
+ * Tracks the number of alarms in the current iCal feed
+ *
+ * @var integer
+ */
+ public $alarmCount = 0;
+
/**
* Tracks the number of events in the current iCal feed
*
@@ -63,7 +75,7 @@ class ICal
*
* @var string
*/
- public $defaultWeekStart = 'MO';
+ public $defaultWeekStart = self::ISO_8601_WEEK_START;
/**
* Toggles whether to skip the parsing of recurrence rules
@@ -73,11 +85,25 @@ class ICal
public $skipRecurrence = false;
/**
- * Toggles whether to use time zone info when parsing recurrence rules
+ * Toggles whether to disable all character replacement.
*
* @var boolean
*/
- public $useTimeZoneWithRRules = false;
+ public $disableCharacterReplacement = false;
+
+ /**
+ * With this being non-null the parser will ignore all events more than roughly this many days after now.
+ *
+ * @var integer
+ */
+ public $filterDaysBefore;
+
+ /**
+ * With this being non-null the parser will ignore all events more than roughly this many days before now.
+ *
+ * @var integer
+ */
+ public $filterDaysAfter;
/**
* The parsed calendar
@@ -101,83 +127,68 @@ class ICal
protected $lastKeyword;
/**
- * Event recurrence instances that have been altered
+ * Cache valid IANA time zone IDs to avoid unnecessary lookups
*
* @var array
*/
- protected $alteredRecurrenceInstances = array();
+ protected $validIanaTimeZones = array();
/**
- * An associative array containing ordinal data
+ * Event recurrence instances that have been altered
*
* @var array
*/
- protected $dayOrdinals = array(
- 1 => 'first',
- 2 => 'second',
- 3 => 'third',
- 4 => 'fourth',
- 5 => 'fifth',
- );
+ protected $alteredRecurrenceInstances = array();
/**
* An associative array containing weekday conversion data
*
+ * The order of the days in the array follow the ISO-8601 specification of a week.
+ *
* @var array
*/
protected $weekdays = array(
- 'SU' => 'sunday',
'MO' => 'monday',
'TU' => 'tuesday',
'WE' => 'wednesday',
'TH' => 'thursday',
'FR' => 'friday',
'SA' => 'saturday',
+ 'SU' => 'sunday',
);
/**
- * An associative array containing week conversion data
- * (UK = SU, Europe = MO)
+ * An associative array containing frequency conversion terms
*
* @var array
*/
- protected $weeks = array(
- 'SA' => array('SA', 'SU', 'MO', 'TU', 'WE', 'TH', 'FR'),
- 'SU' => array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'),
- 'MO' => array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'),
+ protected $frequencyConversion = array(
+ 'DAILY' => 'day',
+ 'WEEKLY' => 'week',
+ 'MONTHLY' => 'month',
+ 'YEARLY' => 'year',
);
/**
- * An associative array containing month names
+ * Holds the username and password for HTTP basic authentication
*
* @var array
*/
- protected $monthNames = array(
- 1 => 'January',
- 2 => 'February',
- 3 => 'March',
- 4 => 'April',
- 5 => 'May',
- 6 => 'June',
- 7 => 'July',
- 8 => 'August',
- 9 => 'September',
- 10 => 'October',
- 11 => 'November',
- 12 => 'December',
- );
+ protected $httpBasicAuth = array();
/**
- * An associative array containing frequency conversion terms
+ * Holds the custom User Agent string header
*
- * @var array
+ * @var string
*/
- protected $frequencyConversion = array(
- 'DAILY' => 'day',
- 'WEEKLY' => 'week',
- 'MONTHLY' => 'month',
- 'YEARLY' => 'year',
- );
+ protected $httpUserAgent;
+
+ /**
+ * Holds the custom Accept Language string header
+ *
+ * @var string
+ */
+ protected $httpAcceptLanguage;
/**
* Define which variables can be configured
@@ -188,16 +199,300 @@ class ICal
'defaultSpan',
'defaultTimeZone',
'defaultWeekStart',
+ 'disableCharacterReplacement',
+ 'filterDaysAfter',
+ 'filterDaysBefore',
'skipRecurrence',
- 'useTimeZoneWithRRules',
);
+ /**
+ * CLDR time zones mapped to IANA time zones.
+ *
+ * @var array
+ */
+ private static $cldrTimeZonesMap = array(
+ '(UTC-12:00) International Date Line West' => 'Etc/GMT+12',
+ '(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11',
+ '(UTC-10:00) Hawaii' => 'Pacific/Honolulu',
+ '(UTC-09:00) Alaska' => 'America/Anchorage',
+ '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles',
+ '(UTC-07:00) Arizona' => 'America/Phoenix',
+ '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua',
+ '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver',
+ '(UTC-06:00) Central America' => 'America/Guatemala',
+ '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago',
+ '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City',
+ '(UTC-06:00) Saskatchewan' => 'America/Regina',
+ '(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota',
+ '(UTC-05:00) Chetumal' => 'America/Cancun',
+ '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York',
+ '(UTC-05:00) Indiana (East)' => 'America/Indianapolis',
+ '(UTC-04:00) Asuncion' => 'America/Asuncion',
+ '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax',
+ '(UTC-04:00) Caracas' => 'America/Caracas',
+ '(UTC-04:00) Cuiaba' => 'America/Cuiaba',
+ '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz',
+ '(UTC-04:00) Santiago' => 'America/Santiago',
+ '(UTC-03:30) Newfoundland' => 'America/St_Johns',
+ '(UTC-03:00) Brasilia' => 'America/Sao_Paulo',
+ '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne',
+ '(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires',
+ '(UTC-03:00) Greenland' => 'America/Godthab',
+ '(UTC-03:00) Montevideo' => 'America/Montevideo',
+ '(UTC-03:00) Salvador' => 'America/Bahia',
+ '(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2',
+ '(UTC-01:00) Azores' => 'Atlantic/Azores',
+ '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde',
+ '(UTC) Coordinated Universal Time' => 'Etc/GMT',
+ '(UTC+00:00) Casablanca' => 'Africa/Casablanca',
+ '(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London',
+ '(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik',
+ '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin',
+ '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest',
+ '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris',
+ '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw',
+ '(UTC+01:00) West Central Africa' => 'Africa/Lagos',
+ '(UTC+02:00) Amman' => 'Asia/Amman',
+ '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest',
+ '(UTC+02:00) Beirut' => 'Asia/Beirut',
+ '(UTC+02:00) Cairo' => 'Africa/Cairo',
+ '(UTC+02:00) Chisinau' => 'Europe/Chisinau',
+ '(UTC+02:00) Damascus' => 'Asia/Damascus',
+ '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg',
+ '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev',
+ '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem',
+ '(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad',
+ '(UTC+02:00) Tripoli' => 'Africa/Tripoli',
+ '(UTC+02:00) Windhoek' => 'Africa/Windhoek',
+ '(UTC+03:00) Baghdad' => 'Asia/Baghdad',
+ '(UTC+03:00) Istanbul' => 'Europe/Istanbul',
+ '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh',
+ '(UTC+03:00) Minsk' => 'Europe/Minsk',
+ '(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow',
+ '(UTC+03:00) Nairobi' => 'Africa/Nairobi',
+ '(UTC+03:30) Tehran' => 'Asia/Tehran',
+ '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai',
+ '(UTC+04:00) Baku' => 'Asia/Baku',
+ '(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara',
+ '(UTC+04:00) Port Louis' => 'Indian/Mauritius',
+ '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi',
+ '(UTC+04:00) Yerevan' => 'Asia/Yerevan',
+ '(UTC+04:30) Kabul' => 'Asia/Kabul',
+ '(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent',
+ '(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg',
+ '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi',
+ '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta',
+ '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo',
+ '(UTC+05:45) Kathmandu' => 'Asia/Katmandu',
+ '(UTC+06:00) Astana' => 'Asia/Almaty',
+ '(UTC+06:00) Dhaka' => 'Asia/Dhaka',
+ '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon',
+ '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok',
+ '(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk',
+ '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk',
+ '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai',
+ '(UTC+08:00) Irkutsk' => 'Asia/Irkutsk',
+ '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore',
+ '(UTC+08:00) Perth' => 'Australia/Perth',
+ '(UTC+08:00) Taipei' => 'Asia/Taipei',
+ '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar',
+ '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo',
+ '(UTC+09:00) Pyongyang' => 'Asia/Pyongyang',
+ '(UTC+09:00) Seoul' => 'Asia/Seoul',
+ '(UTC+09:00) Yakutsk' => 'Asia/Yakutsk',
+ '(UTC+09:30) Adelaide' => 'Australia/Adelaide',
+ '(UTC+09:30) Darwin' => 'Australia/Darwin',
+ '(UTC+10:00) Brisbane' => 'Australia/Brisbane',
+ '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney',
+ '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby',
+ '(UTC+10:00) Hobart' => 'Australia/Hobart',
+ '(UTC+10:00) Vladivostok' => 'Asia/Vladivostok',
+ '(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk',
+ '(UTC+11:00) Magadan' => 'Asia/Magadan',
+ '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal',
+ '(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka',
+ '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland',
+ '(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12',
+ '(UTC+12:00) Fiji' => 'Pacific/Fiji',
+ "(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu',
+ '(UTC+13:00) Samoa' => 'Pacific/Apia',
+ '(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati',
+ );
+
+ /**
+ * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID
+ * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though.
+ *
+ * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
+ *
+ * @var array
+ */
+ private static $windowsTimeZonesMap = array(
+ 'AUS Central Standard Time' => 'Australia/Darwin',
+ 'AUS Eastern Standard Time' => 'Australia/Sydney',
+ 'Afghanistan Standard Time' => 'Asia/Kabul',
+ 'Alaskan Standard Time' => 'America/Anchorage',
+ 'Aleutian Standard Time' => 'America/Adak',
+ 'Altai Standard Time' => 'Asia/Barnaul',
+ 'Arab Standard Time' => 'Asia/Riyadh',
+ 'Arabian Standard Time' => 'Asia/Dubai',
+ 'Arabic Standard Time' => 'Asia/Baghdad',
+ 'Argentina Standard Time' => 'America/Buenos_Aires',
+ 'Astrakhan Standard Time' => 'Europe/Astrakhan',
+ 'Atlantic Standard Time' => 'America/Halifax',
+ 'Aus Central W. Standard Time' => 'Australia/Eucla',
+ 'Azerbaijan Standard Time' => 'Asia/Baku',
+ 'Azores Standard Time' => 'Atlantic/Azores',
+ 'Bahia Standard Time' => 'America/Bahia',
+ 'Bangladesh Standard Time' => 'Asia/Dhaka',
+ 'Belarus Standard Time' => 'Europe/Minsk',
+ 'Bougainville Standard Time' => 'Pacific/Bougainville',
+ 'Canada Central Standard Time' => 'America/Regina',
+ 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
+ 'Caucasus Standard Time' => 'Asia/Yerevan',
+ 'Cen. Australia Standard Time' => 'Australia/Adelaide',
+ 'Central America Standard Time' => 'America/Guatemala',
+ 'Central Asia Standard Time' => 'Asia/Almaty',
+ 'Central Brazilian Standard Time' => 'America/Cuiaba',
+ 'Central Europe Standard Time' => 'Europe/Budapest',
+ 'Central European Standard Time' => 'Europe/Warsaw',
+ 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
+ 'Central Standard Time (Mexico)' => 'America/Mexico_City',
+ 'Central Standard Time' => 'America/Chicago',
+ 'Chatham Islands Standard Time' => 'Pacific/Chatham',
+ 'China Standard Time' => 'Asia/Shanghai',
+ 'Cuba Standard Time' => 'America/Havana',
+ 'Dateline Standard Time' => 'Etc/GMT+12',
+ 'E. Africa Standard Time' => 'Africa/Nairobi',
+ 'E. Australia Standard Time' => 'Australia/Brisbane',
+ 'E. Europe Standard Time' => 'Europe/Chisinau',
+ 'E. South America Standard Time' => 'America/Sao_Paulo',
+ 'Easter Island Standard Time' => 'Pacific/Easter',
+ 'Eastern Standard Time (Mexico)' => 'America/Cancun',
+ 'Eastern Standard Time' => 'America/New_York',
+ 'Egypt Standard Time' => 'Africa/Cairo',
+ 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
+ 'FLE Standard Time' => 'Europe/Kiev',
+ 'Fiji Standard Time' => 'Pacific/Fiji',
+ 'GMT Standard Time' => 'Europe/London',
+ 'GTB Standard Time' => 'Europe/Bucharest',
+ 'Georgian Standard Time' => 'Asia/Tbilisi',
+ 'Greenland Standard Time' => 'America/Godthab',
+ 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
+ 'Haiti Standard Time' => 'America/Port-au-Prince',
+ 'Hawaiian Standard Time' => 'Pacific/Honolulu',
+ 'India Standard Time' => 'Asia/Calcutta',
+ 'Iran Standard Time' => 'Asia/Tehran',
+ 'Israel Standard Time' => 'Asia/Jerusalem',
+ 'Jordan Standard Time' => 'Asia/Amman',
+ 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
+ 'Korea Standard Time' => 'Asia/Seoul',
+ 'Libya Standard Time' => 'Africa/Tripoli',
+ 'Line Islands Standard Time' => 'Pacific/Kiritimati',
+ 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
+ 'Magadan Standard Time' => 'Asia/Magadan',
+ 'Magallanes Standard Time' => 'America/Punta_Arenas',
+ 'Marquesas Standard Time' => 'Pacific/Marquesas',
+ 'Mauritius Standard Time' => 'Indian/Mauritius',
+ 'Middle East Standard Time' => 'Asia/Beirut',
+ 'Montevideo Standard Time' => 'America/Montevideo',
+ 'Morocco Standard Time' => 'Africa/Casablanca',
+ 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
+ 'Mountain Standard Time' => 'America/Denver',
+ 'Myanmar Standard Time' => 'Asia/Rangoon',
+ 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
+ 'Namibia Standard Time' => 'Africa/Windhoek',
+ 'Nepal Standard Time' => 'Asia/Katmandu',
+ 'New Zealand Standard Time' => 'Pacific/Auckland',
+ 'Newfoundland Standard Time' => 'America/St_Johns',
+ 'Norfolk Standard Time' => 'Pacific/Norfolk',
+ 'North Asia East Standard Time' => 'Asia/Irkutsk',
+ 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
+ 'North Korea Standard Time' => 'Asia/Pyongyang',
+ 'Omsk Standard Time' => 'Asia/Omsk',
+ 'Pacific SA Standard Time' => 'America/Santiago',
+ 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
+ 'Pacific Standard Time' => 'America/Los_Angeles',
+ 'Pakistan Standard Time' => 'Asia/Karachi',
+ 'Paraguay Standard Time' => 'America/Asuncion',
+ 'Romance Standard Time' => 'Europe/Paris',
+ 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
+ 'Russia Time Zone 11' => 'Asia/Kamchatka',
+ 'Russia Time Zone 3' => 'Europe/Samara',
+ 'Russian Standard Time' => 'Europe/Moscow',
+ 'SA Eastern Standard Time' => 'America/Cayenne',
+ 'SA Pacific Standard Time' => 'America/Bogota',
+ 'SA Western Standard Time' => 'America/La_Paz',
+ 'SE Asia Standard Time' => 'Asia/Bangkok',
+ 'Saint Pierre Standard Time' => 'America/Miquelon',
+ 'Sakhalin Standard Time' => 'Asia/Sakhalin',
+ 'Samoa Standard Time' => 'Pacific/Apia',
+ 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
+ 'Saratov Standard Time' => 'Europe/Saratov',
+ 'Singapore Standard Time' => 'Asia/Singapore',
+ 'South Africa Standard Time' => 'Africa/Johannesburg',
+ 'Sri Lanka Standard Time' => 'Asia/Colombo',
+ 'Sudan Standard Time' => 'Africa/Tripoli',
+ 'Syria Standard Time' => 'Asia/Damascus',
+ 'Taipei Standard Time' => 'Asia/Taipei',
+ 'Tasmania Standard Time' => 'Australia/Hobart',
+ 'Tocantins Standard Time' => 'America/Araguaina',
+ 'Tokyo Standard Time' => 'Asia/Tokyo',
+ 'Tomsk Standard Time' => 'Asia/Tomsk',
+ 'Tonga Standard Time' => 'Pacific/Tongatapu',
+ 'Transbaikal Standard Time' => 'Asia/Chita',
+ 'Turkey Standard Time' => 'Europe/Istanbul',
+ 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
+ 'US Eastern Standard Time' => 'America/Indianapolis',
+ 'US Mountain Standard Time' => 'America/Phoenix',
+ 'UTC' => 'Etc/GMT',
+ 'UTC+12' => 'Etc/GMT-12',
+ 'UTC+13' => 'Etc/GMT-13',
+ 'UTC-02' => 'Etc/GMT+2',
+ 'UTC-08' => 'Etc/GMT+8',
+ 'UTC-09' => 'Etc/GMT+9',
+ 'UTC-11' => 'Etc/GMT+11',
+ 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
+ 'Venezuela Standard Time' => 'America/Caracas',
+ 'Vladivostok Standard Time' => 'Asia/Vladivostok',
+ 'W. Australia Standard Time' => 'Australia/Perth',
+ 'W. Central Africa Standard Time' => 'Africa/Lagos',
+ 'W. Europe Standard Time' => 'Europe/Berlin',
+ 'W. Mongolia Standard Time' => 'Asia/Hovd',
+ 'West Asia Standard Time' => 'Asia/Tashkent',
+ 'West Bank Standard Time' => 'Asia/Hebron',
+ 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
+ 'Yakutsk Standard Time' => 'Asia/Yakutsk',
+ );
+
+ /**
+ * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
+ * by this field and `$windowMaxTimestamp`.
+ *
+ * @var integer
+ */
+ private $windowMinTimestamp;
+
+ /**
+ * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
+ * by this field and `$windowMinTimestamp`.
+ *
+ * @var integer
+ */
+ private $windowMaxTimestamp;
+
+ /**
+ * `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set.
+ *
+ * @var boolean
+ */
+ private $shouldFilterByWindow = false;
+
/**
* Creates the ICal object
*
- * @param mixed $files The path or URL to each ICS file to parse
- * or iCal content provided as an array
- * @param array $options Default options to be used by the parser
+ * @param mixed $files
+ * @param array $options
* @return void
*/
public function __construct($files = false, array $options = array())
@@ -215,11 +510,19 @@ public function __construct($files = false, array $options = array())
$this->defaultTimeZone = date_default_timezone_get();
}
+ // Ideally you would use `PHP_INT_MIN` from PHP 7
+ $php_int_min = -2147483648;
+
+ $this->windowMinTimestamp = is_null($this->filterDaysBefore) ? $php_int_min : (new \DateTime('now'))->sub(new \DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp();
+ $this->windowMaxTimestamp = is_null($this->filterDaysAfter) ? PHP_INT_MAX : (new \DateTime('now'))->add(new \DateInterval('P' . $this->filterDaysAfter . 'D'))->getTimestamp();
+
+ $this->shouldFilterByWindow = !is_null($this->filterDaysBefore) || !is_null($this->filterDaysAfter);
+
if ($files !== false) {
$files = is_array($files) ? $files : array($files);
foreach ($files as $file) {
- if ($this->isFileOrUrl($file)) {
+ if (!is_array($file) && $this->isFileOrUrl($file)) {
$lines = $this->fileOrUrl($file);
} else {
$lines = is_array($file) ? $file : array($file);
@@ -233,13 +536,15 @@ public function __construct($files = false, array $options = array())
/**
* Initialises lines from a string
*
- * @param string $string The contents of the ICS file to initialise
+ * @param string $string
* @return ICal
*/
public function initString($string)
{
+ $string = str_replace(array("\r\n", "\n\r", "\r"), "\n", $string);
+
if (empty($this->cal)) {
- $lines = explode(PHP_EOL, $string);
+ $lines = explode("\n", $string);
$this->initLines($lines);
} else {
@@ -252,7 +557,7 @@ public function initString($string)
/**
* Initialises lines from a file
*
- * @param string $file The file path or URL of the ICS to use
+ * @param string $file
* @return ICal
*/
public function initFile($file)
@@ -271,11 +576,28 @@ public function initFile($file)
/**
* Initialises lines from a URL
*
- * @param string $url The url of the ICS file to download and initialise from
+ * @param string $url
+ * @param string $username
+ * @param string $password
+ * @param string $userAgent
+ * @param string $acceptLanguage
* @return ICal
*/
- public function initUrl($url)
+ public function initUrl($url, $username = null, $password = null, $userAgent = null, $acceptLanguage = null)
{
+ if (!is_null($username) && !is_null($password)) {
+ $this->httpBasicAuth['username'] = $username;
+ $this->httpBasicAuth['password'] = $password;
+ }
+
+ if (!is_null($userAgent)) {
+ $this->httpUserAgent = $userAgent;
+ }
+
+ if (!is_null($acceptLanguage)) {
+ $this->httpAcceptLanguage = $acceptLanguage;
+ }
+
$this->initFile($url);
return $this;
@@ -285,7 +607,7 @@ public function initUrl($url)
* Initialises the parser using an array
* containing each line of iCal content
*
- * @param array $lines The lines to initialise
+ * @param array $lines
* @return void
*/
protected function initLines(array $lines)
@@ -297,17 +619,29 @@ protected function initLines(array $lines)
foreach ($lines as $line) {
$line = rtrim($line); // Trim trailing whitespace
$line = $this->removeUnprintableChars($line);
- $line = $this->cleanData($line);
- $add = $this->keyValueFromString($line);
+
+ if (empty($line)) {
+ continue;
+ }
+
+ if (!$this->disableCharacterReplacement) {
+ $line = $this->cleanData($line);
+ }
+
+ $add = $this->keyValueFromString($line);
+
+ if ($add === false) {
+ continue;
+ }
$keyword = $add[0];
$values = $add[1]; // May be an array containing multiple values
if (!is_array($values)) {
if (!empty($values)) {
- $values = array($values); // Make an array as not already
+ $values = array($values); // Make an array as not one already
$blankArray = array(); // Empty placeholder array
- array_push($values, $blankArray);
+ $values[] = $blankArray;
} else {
$values = array(); // Use blank array to ignore this line
}
@@ -320,52 +654,81 @@ protected function initLines(array $lines)
foreach ($values as $value) {
switch ($line) {
- // http://www.kanzaki.com/docs/ical/vtodo.html
+ // https://www.kanzaki.com/docs/ical/vtodo.html
case 'BEGIN:VTODO':
if (!is_array($value)) {
$this->todoCount++;
}
+
$component = 'VTODO';
- break;
- // http://www.kanzaki.com/docs/ical/vevent.html
+ break;
+
+ // https://www.kanzaki.com/docs/ical/vevent.html
case 'BEGIN:VEVENT':
if (!is_array($value)) {
$this->eventCount++;
}
+
$component = 'VEVENT';
- break;
- // http://www.kanzaki.com/docs/ical/vfreebusy.html
+ break;
+
+ // https://www.kanzaki.com/docs/ical/vfreebusy.html
case 'BEGIN:VFREEBUSY':
if (!is_array($value)) {
$this->freeBusyIndex++;
}
+
$component = 'VFREEBUSY';
- break;
+
+ break;
+
+ case 'BEGIN:VALARM':
+ if (!is_array($value)) {
+ $this->alarmCount++;
+ }
+
+ $component = 'VALARM';
+
+ break;
+
+ case 'END:VALARM':
+ $component = 'VEVENT';
+
+ break;
case 'BEGIN:DAYLIGHT':
case 'BEGIN:STANDARD':
- case 'BEGIN:VALARM':
case 'BEGIN:VCALENDAR':
case 'BEGIN:VTIMEZONE':
$component = $value;
- break;
+
+ break;
case 'END:DAYLIGHT':
case 'END:STANDARD':
- case 'END:VALARM':
case 'END:VCALENDAR':
- case 'END:VEVENT':
case 'END:VFREEBUSY':
case 'END:VTIMEZONE':
case 'END:VTODO':
$component = 'VCALENDAR';
- break;
+
+ break;
+
+ case 'END:VEVENT':
+ if ($this->shouldFilterByWindow) {
+ $this->removeLastEventIfOutsideWindowAndNonRecurring();
+ }
+
+ $component = 'VCALENDAR';
+
+ break;
default:
$this->addCalendarComponentWithKeyAndValue($component, $keyword, $value);
- break;
+
+ break;
}
}
}
@@ -374,24 +737,125 @@ protected function initLines(array $lines)
if (!$this->skipRecurrence) {
$this->processRecurrences();
+
+ // Apply changes to altered recurrence instances
+ if (!empty($this->alteredRecurrenceInstances)) {
+ $events = $this->cal['VEVENT'];
+
+ foreach ($this->alteredRecurrenceInstances as $alteredRecurrenceInstance) {
+ if (isset($alteredRecurrenceInstance['altered-event'])) {
+ $alteredEvent = $alteredRecurrenceInstance['altered-event'];
+ $key = key($alteredEvent);
+ $events[$key] = $alteredEvent[$key];
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ if ($this->shouldFilterByWindow) {
+ $this->reduceEventsToMinMaxRange();
}
$this->processDateConversions();
}
}
+ /**
+ * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by
+ * `$windowMinTimestamp` / `$windowMaxTimestamp`.
+ *
+ * @return void
+ */
+ protected function removeLastEventIfOutsideWindowAndNonRecurring()
+ {
+ $events = $this->cal['VEVENT'];
+
+ if (!empty($events)) {
+ $lastIndex = count($events) - 1;
+ $lastEvent = $events[$lastIndex];
+
+ if ((!isset($lastEvent['RRULE']) || $lastEvent['RRULE'] === '') && $this->doesEventStartOutsideWindow($lastEvent)) {
+ $this->eventCount--;
+
+ unset($events[$lastIndex]);
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Reduces the number of events to the defined minimum and maximum range
+ *
+ * @return void
+ */
+ protected function reduceEventsToMinMaxRange()
+ {
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ if (!empty($events)) {
+ foreach ($events as $key => $anEvent) {
+ if ($anEvent === null) {
+ unset($events[$key]);
+
+ continue;
+ }
+
+ if ($this->doesEventStartOutsideWindow($anEvent)) {
+ $this->eventCount--;
+
+ unset($events[$key]);
+
+ continue;
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp`.
+ * Returns `true` for invalid dates.
+ *
+ * @param array $event
+ * @return boolean
+ */
+ protected function doesEventStartOutsideWindow(array $event)
+ {
+ return !$this->isValidDate($event['DTSTART']) || $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp);
+ }
+
+ /**
+ * Determines whether a valid iCalendar date is within a given range
+ *
+ * @param string $calendarDate
+ * @param integer $minTimestamp
+ * @param integer $maxTimestamp
+ * @return boolean
+ */
+ protected function isOutOfRange($calendarDate, $minTimestamp, $maxTimestamp)
+ {
+ $timestamp = strtotime(explode('T', $calendarDate)[0]);
+
+ return $timestamp < $minTimestamp || $timestamp > $maxTimestamp;
+ }
+
/**
* Unfolds an iCal file in preparation for parsing
* (https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html)
*
- * @param array $lines The contents of the iCal string to unfold
- * @return string
+ * @param array $lines
+ * @return array
*/
protected function unfold(array $lines)
{
$string = implode(PHP_EOL, $lines);
$string = preg_replace('/' . PHP_EOL . '[ \t]/', '', $string);
- $lines = explode(PHP_EOL, $string);
+
+ $lines = explode(PHP_EOL, $string);
return $lines;
}
@@ -399,9 +863,9 @@ protected function unfold(array $lines)
/**
* Add one key and value pair to the `$this->cal` array
*
- * @param string $component This could be VTODO, VEVENT, VCALENDAR, ...
- * @param string|boolean $keyword The keyword, for example DTSTART
- * @param string $value The value, for example 20110105T090000Z
+ * @param string $component
+ * @param string|boolean $keyword
+ * @param string $value
* @return void
*/
protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value)
@@ -411,67 +875,99 @@ protected function addCalendarComponentWithKeyAndValue($component, $keyword, $va
}
switch ($component) {
- case 'VTODO':
- $this->cal[$component][$this->todoCount - 1][$keyword] = $value;
- break;
+ case 'VALARM':
+ $key1 = 'VEVENT';
+ $key2 = ($this->eventCount - 1);
+ $key3 = $component;
+
+ if (!isset($this->cal[$key1][$key2][$key3]["{$keyword}_array"])) {
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"] = array();
+ }
+
+ if (is_array($value)) {
+ // Add array of properties to the end
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"][] = $value;
+ } else {
+ if (!isset($this->cal[$key1][$key2][$key3][$keyword])) {
+ $this->cal[$key1][$key2][$key3][$keyword] = $value;
+ }
+
+ if ($this->cal[$key1][$key2][$key3][$keyword] !== $value) {
+ $this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value;
+ }
+ }
+ break;
case 'VEVENT':
- if (!isset($this->cal[$component][$this->eventCount - 1][$keyword . '_array'])) {
- $this->cal[$component][$this->eventCount - 1][$keyword . '_array'] = array();
+ $key1 = $component;
+ $key2 = ($this->eventCount - 1);
+
+ if (!isset($this->cal[$key1][$key2]["{$keyword}_array"])) {
+ $this->cal[$key1][$key2]["{$keyword}_array"] = array();
}
if (is_array($value)) {
// Add array of properties to the end
- array_push($this->cal[$component][$this->eventCount - 1][$keyword . '_array'], $value);
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
} else {
- if (!isset($this->cal[$component][$this->eventCount - 1][$keyword])) {
- $this->cal[$component][$this->eventCount - 1][$keyword] = $value;
+ if (!isset($this->cal[$key1][$key2][$keyword])) {
+ $this->cal[$key1][$key2][$keyword] = $value;
}
if ($keyword === 'EXDATE') {
if (trim($value) === $value) {
$array = array_filter(explode(',', $value));
- $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][] = $array;
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $array;
} else {
- $value = explode(',', implode(',', $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][1]) . trim($value));
- $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][1] = $value;
+ $value = explode(',', implode(',', $this->cal[$key1][$key2]["{$keyword}_array"][1]) . trim($value));
+ $this->cal[$key1][$key2]["{$keyword}_array"][1] = $value;
}
} else {
- $this->cal[$component][$this->eventCount - 1][$keyword . '_array'][] = $value;
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
if ($keyword === 'DURATION') {
$duration = new \DateInterval($value);
- array_push($this->cal[$component][$this->eventCount - 1][$keyword . '_array'], $duration);
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $duration;
}
}
- if ($this->cal[$component][$this->eventCount - 1][$keyword] !== $value) {
- $this->cal[$component][$this->eventCount - 1][$keyword] .= ',' . $value;
+ if ($this->cal[$key1][$key2][$keyword] !== $value) {
+ $this->cal[$key1][$key2][$keyword] .= ',' . $value;
}
}
- break;
+ break;
case 'VFREEBUSY':
+ $key1 = $component;
+ $key2 = ($this->freeBusyIndex - 1);
+ $key3 = $keyword;
+
if ($keyword === 'FREEBUSY') {
if (is_array($value)) {
- $this->cal[$component][$this->freeBusyIndex - 1][$keyword][][] = $value;
+ $this->cal[$key1][$key2][$key3][][] = $value;
} else {
$this->freeBusyCount++;
- end($this->cal[$component][$this->freeBusyIndex - 1][$keyword]);
- $key = key($this->cal[$component][$this->freeBusyIndex - 1][$keyword]);
+ end($this->cal[$key1][$key2][$key3]);
+ $key = key($this->cal[$key1][$key2][$key3]);
$value = explode('/', $value);
- $this->cal[$component][$this->freeBusyIndex - 1][$keyword][$key][] = $value;
+ $this->cal[$key1][$key2][$key3][$key][] = $value;
}
} else {
- $this->cal[$component][$this->freeBusyIndex - 1][$keyword][] = $value;
+ $this->cal[$key1][$key2][$key3][] = $value;
}
- break;
+ break;
+
+ case 'VTODO':
+ $this->cal[$component][$this->todoCount - 1][$keyword] = $value;
+
+ break;
default:
$this->cal[$component][$keyword] = $value;
- break;
+
+ break;
}
$this->lastKeyword = $keyword;
@@ -481,7 +977,7 @@ protected function addCalendarComponentWithKeyAndValue($component, $keyword, $va
* Gets the key value pair from an iCal string
*
* @param string $text
- * @return array
+ * @return array|boolean
*/
protected function keyValueFromString($text)
{
@@ -500,7 +996,7 @@ protected function keyValueFromString($text)
$matches = str_getcsv($text, ':');
$combinedValue = '';
- foreach ($matches as $key => $match) {
+ foreach (array_keys($matches) as $key) {
if ($key === 0) {
if (!empty($before)) {
$matches[$key] = $before . '"' . $matches[$key] . '"';
@@ -513,6 +1009,7 @@ protected function keyValueFromString($text)
$combinedValue .= $matches[$key];
}
}
+
$matches = array_slice($matches, 0, 2);
$matches[1] = $combinedValue;
array_unshift($matches, $before . $text);
@@ -538,7 +1035,7 @@ protected function keyValueFromString($text)
// Match semicolon separator outside of quoted substrings
preg_match_all('~[^' . PHP_EOL . '";]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '";]*)*~', $property, $attributes);
// Remove multi-dimensional array and use the first key
- $attributes = (sizeof($attributes) === 0) ? array($property) : reset($attributes);
+ $attributes = (count($attributes) === 0) ? array($property) : reset($attributes);
if (is_array($attributes)) {
foreach ($attributes as $attribute) {
@@ -549,7 +1046,7 @@ protected function keyValueFromString($text)
$values
);
// Remove multi-dimensional array and use the first key
- $value = (sizeof($values) === 0) ? null : reset($values);
+ $value = (count($values) === 0) ? null : reset($values);
if (is_array($value) && isset($value[1])) {
// Remove double quotes from beginning and end only
@@ -576,17 +1073,14 @@ protected function keyValueFromString($text)
/**
* Returns a `DateTime` object from an iCal date time format
*
- * @param string $icalDate A Date in the format YYYYMMDD[T]HHMMSS[Z],
- * YYYYMMDD[T]HHMMSS or
- * TZID={Time Zone}:YYYYMMDD[T]HHMMSS
- * @param boolean $forceTimeZone Whether to force the time zone; the event's or the default
- * @param boolean $forceUtc Whether to force the time zone as UTC
- * @return DateTime
+ * @param string $icalDate
+ * @return \DateTime
+ * @throws \Exception
*/
- public function iCalDateToDateTime($icalDate, $forceTimeZone = false, $forceUtc = false)
+ public function iCalDateToDateTime($icalDate)
{
/**
- * iCal times may be in 3 formats, (http://www.kanzaki.com/docs/ical/dateTime.html)
+ * iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html)
*
* UTC: Has a trailing 'Z'
* Floating: No time zone reference specified, no trailing 'Z', use local time
@@ -595,118 +1089,86 @@ public function iCalDateToDateTime($icalDate, $forceTimeZone = false, $forceUtc
* Use DateTime class objects to get around limitations with `mktime` and `gmmktime`.
* Must have a local time zone set to process floating times.
*/
- $pattern = '/\AT?Z?I?D?=?(.*):?'; // [1]: Time zone
- $pattern .= '([0-9]{4})'; // [2]: YYYY
- $pattern .= '([0-9]{2})'; // [3]: MM
- $pattern .= '([0-9]{2})'; // [4]: DD
- $pattern .= 'T?'; // Time delimiter
- $pattern .= '([0-9]{0,2})'; // [5]: HH
- $pattern .= '([0-9]{0,2})'; // [6]: MM
- $pattern .= '([0-9]{0,2})'; // [7]: SS
- $pattern .= '(Z?)/'; // [8]: UTC flag
+ $pattern = '/^(?:TZID=)?([^:]*|".*")'; // [1]: Time zone
+ $pattern .= ':?'; // Time zone delimiter
+ $pattern .= '([0-9]{8})'; // [2]: YYYYMMDD
+ $pattern .= 'T?'; // Time delimiter
+ $pattern .= '(?(?<=T)([0-9]{6}))'; // [3]: HHMMSS (filled if delimiter present)
+ $pattern .= '(Z?)/'; // [4]: UTC flag
preg_match($pattern, $icalDate, $date);
if (empty($date)) {
- // Default to the initial
- $dateTime = $icalDate;
- } else {
- // A Unix timestamp cannot represent a date prior to 1 Jan 1970
- $year = $date[2];
- if ($year <= self::UNIX_MIN_YEAR) {
- $dateTime = new \DateTime($icalDate, new \DateTimeZone($this->defaultTimeZone));
- } else {
- if ($forceTimeZone) {
- // TZID={Time Zone}:
- if (isset($date[1])) {
- $eventTimeZone = rtrim($date[1], ':');
- }
+ throw new \Exception('Invalid iCal date format.');
+ }
- if ($date[8] === 'Z') {
- $dateTime = new \DateTime('now', new \DateTimeZone('UTC'));
- } elseif (isset($eventTimeZone) && $this->isValidTimeZoneId($eventTimeZone)) {
- $dateTime = new \DateTime('now', new \DateTimeZone($eventTimeZone));
- } else {
- $dateTime = new \DateTime('now', new \DateTimeZone($this->defaultTimeZone));
- }
- } else {
- $dateTime = new \DateTime('now');
- }
+ // A Unix timestamp usually cannot represent a date prior to 1 Jan 1970.
+ // PHP, on the other hand, uses negative numbers for that. Thus we don't
+ // need to special case them.
- $dateTime->setDate((int) $date[2], (int) $date[3], (int) $date[4]);
- $dateTime->setTime((int) $date[5], (int) $date[6], (int) $date[7]);
- }
+ if ($date[4] === 'Z') {
+ $dateTimeZone = new \DateTimeZone(self::TIME_ZONE_UTC);
+ } elseif (!empty($date[1])) {
+ $dateTimeZone = $this->timeZoneStringToDateTimeZone($date[1]);
+ } else {
+ $dateTimeZone = new \DateTimeZone($this->defaultTimeZone);
+ }
- if ($forceUtc) {
- $dateTime->setTimezone(new \DateTimeZone('UTC'));
- }
+ // The exclamation mark at the start of the format string indicates that if a
+ // time portion is not included, the time in the returned DateTime should be
+ // set to 00:00:00. Without it, the time would be set to the current system time.
+ $dateFormat = '!Ymd';
+ $dateBasic = $date[2];
+ if (!empty($date[3])) {
+ $dateBasic .= "T{$date[3]}";
+ $dateFormat .= '\THis';
}
- return $dateTime;
+ return \DateTime::createFromFormat($dateFormat, $dateBasic, $dateTimeZone);
}
/**
* Returns a Unix timestamp from an iCal date time format
*
- * @param string $icalDate A Date in the format YYYYMMDD[T]HHMMSS[Z],
- * YYYYMMDD[T]HHMMSS or
- * TZID={Time Zone}:YYYYMMDD[T]HHMMSS
- * @param boolean $forceTimeZone Whether to force the time zone; the event's or the default
- * @param boolean $forceUtc Whether to force the time zone as UTC
+ * @param string $icalDate
* @return integer
*/
- public function iCalDateToUnixTimestamp($icalDate, $forceTimeZone = false, $forceUtc = false)
+ public function iCalDateToUnixTimestamp($icalDate)
{
- $dateTime = $this->iCalDateToDateTime($icalDate, $forceTimeZone, $forceUtc);
-
- return $dateTime->getTimestamp();
+ return $this->iCalDateToDateTime($icalDate)->getTimestamp();
}
/**
* Returns a date adapted to the calendar time zone depending on the event `TZID`
*
- * @param array $event An event
- * @param string $key An event property (`DTSTART` or `DTEND`)
- * @param string $format The date format to apply
+ * @param array $event
+ * @param string $key
+ * @param string $format
* @return string|boolean
*/
public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TIME_FORMAT)
{
- if (!isset($event[$key . '_array']) || !isset($event[$key])) {
+ if (!isset($event["{$key}_array"]) || !isset($event[$key])) {
return false;
}
- $dateArray = $event[$key . '_array'];
- $date = $event[$key];
+ $dateArray = $event["{$key}_array"];
if ($key === 'DURATION') {
- $duration = end($dateArray);
- $dateTime = $this->parseDuration($event['DTSTART'], $duration, null);
+ $dateTime = $this->parseDuration($event['DTSTART'], $dateArray[2], null);
} else {
- $dateTime = new \DateTime($dateArray[1], new \DateTimeZone('UTC'));
- $dateTime->setTimezone(new \DateTimeZone($this->calendarTimeZone()));
+ // When constructing from a Unix Timestamp, no time zone needs passing.
+ $dateTime = new \DateTime("@{$dateArray[2]}");
}
- // Force time zone
- if (isset($dateArray[0]['TZID'])) {
- if ($this->isValidTimeZoneId($dateArray[0]['TZID'])) {
- $dateTime->setTimezone(new \DateTimeZone($dateArray[0]['TZID']));
- } else {
- $dateTime->setTimezone(new \DateTimeZone($this->defaultTimeZone));
- }
- }
+ // Set the time zone we wish to use when running `$dateTime->format`.
+ $dateTime->setTimezone(new \DateTimeZone($this->calendarTimeZone()));
if (is_null($format)) {
- $output = $dateTime;
- } else {
- if ($format === self::UNIX_FORMAT) {
- $output = $dateTime->getTimestamp();
- } else {
- $output = $dateTime->format($format);
- }
+ return $dateTime;
}
- return $output;
+ return $dateTime->format($format);
}
/**
@@ -714,824 +1176,856 @@ public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TI
* Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays
* Tracks modified recurrence instances
*
- * @return boolean|void
+ * @return void
*/
protected function processEvents()
{
+ $checks = null;
$events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
- if (empty($events)) {
- return false;
- }
+ if (!empty($events)) {
+ foreach ($events as $key => $anEvent) {
+ foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
+ if (isset($anEvent[$type])) {
+ $date = $anEvent["{$type}_array"][1];
- foreach ($events as $key => $anEvent) {
- foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
- if (isset($anEvent[$type])) {
- $date = $anEvent[$type . '_array'][1];
- if (isset($anEvent[$type . '_array'][0]['TZID'])) {
- $date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent[$type . '_array'][0]['TZID']) . $date;
+ if (isset($anEvent["{$type}_array"][0]['TZID'])) {
+ $timeZone = $this->escapeParamText($anEvent["{$type}_array"][0]['TZID']);
+ $date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone) . $date;
+ }
+
+ $anEvent["{$type}_array"][2] = $this->iCalDateToUnixTimestamp($date);
+ $anEvent["{$type}_array"][3] = $date;
+ }
+ }
+
+ if (isset($anEvent['RECURRENCE-ID'])) {
+ $uid = $anEvent['UID'];
+
+ if (!isset($this->alteredRecurrenceInstances[$uid])) {
+ $this->alteredRecurrenceInstances[$uid] = array();
}
- $anEvent[$type . '_array'][2] = $this->iCalDateToUnixTimestamp($date);
- $anEvent[$type . '_array'][3] = $date;
+
+ $recurrenceDateUtc = $this->iCalDateToUnixTimestamp($anEvent['RECURRENCE-ID_array'][3]);
+ $this->alteredRecurrenceInstances[$uid][$key] = $recurrenceDateUtc;
}
+
+ $events[$key] = $anEvent;
}
- if (isset($anEvent['RECURRENCE-ID'])) {
- $uid = $anEvent['UID'];
- if (!isset($this->alteredRecurrenceInstances[$uid])) {
- $this->alteredRecurrenceInstances[$uid] = array();
+ $eventKeysToRemove = array();
+
+ foreach ($events as $key => $event) {
+ $checks[] = !isset($event['RECURRENCE-ID']);
+ $checks[] = isset($event['UID']);
+ $checks[] = isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);
+
+ if ((bool) array_product($checks)) {
+ $eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]);
+
+ // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
+ if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']])) !== false) {
+ $eventKeysToRemove[] = $alteredEventKey;
+
+ $alteredEvent = array_replace_recursive($events[$key], $events[$alteredEventKey]);
+ $this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent);
+ }
}
- $recurrenceDateUtc = $this->iCalDateToUnixTimestamp($anEvent['RECURRENCE-ID_array'][3], true, true);
- $this->alteredRecurrenceInstances[$uid][] = $recurrenceDateUtc;
+
+ unset($checks);
}
- $events[$key] = $anEvent;
- }
+ foreach ($eventKeysToRemove as $eventKeyToRemove) {
+ $events[$eventKeyToRemove] = null;
+ }
- $this->cal['VEVENT'] = $events;
+ $this->cal['VEVENT'] = $events;
+ }
}
/**
* Processes recurrence rules
*
- * @return boolean|void
+ * @return void
*/
protected function processRecurrences()
{
$events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
- $recurrenceEvents = array();
- $allRecurrenceEvents = array();
-
+ // If there are no events, then we have nothing to process.
if (empty($events)) {
- return false;
+ return;
}
- foreach ($events as $anEvent) {
- if (isset($anEvent['RRULE']) && $anEvent['RRULE'] !== '') {
- // Tag as generated by a recurrence rule
- $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT;
-
- $isAllDayEvent = (strlen($anEvent['DTSTART_array'][1]) === 8) ? true : false;
+ $allEventRecurrences = array();
+ $eventKeysToRemove = array();
- $initialStart = new \DateTime($anEvent['DTSTART_array'][1]);
- $initialStartOffset = $initialStart->getOffset();
- $initialStartTimeZoneName = $initialStart->getTimezone()->getName();
+ foreach ($events as $key => $anEvent) {
+ if (!isset($anEvent['RRULE']) || $anEvent['RRULE'] === '') {
+ continue;
+ }
- if (isset($anEvent['DTEND'])) {
- $initialEnd = new \DateTime($anEvent['DTEND_array'][1]);
- $initialEndOffset = $initialEnd->getOffset();
- $initialEndTimeZoneName = $initialEnd->getTimezone()->getName();
- } else {
- $initialEndTimeZoneName = $initialStartTimeZoneName;
- }
+ // Tag as generated by a recurrence rule
+ $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT;
- // Recurring event, parse RRULE and add appropriate duplicate events
- $rrules = array();
- $rruleStrings = explode(';', $anEvent['RRULE']);
- foreach ($rruleStrings as $s) {
- list($k, $v) = explode('=', $s);
- $rrules[$k] = $v;
- }
- // Get frequency
- $frequency = $rrules['FREQ'];
- // Get Start timestamp
- $startTimestamp = $initialStart->getTimestamp();
- if (isset($anEvent['DTEND'])) {
- $endTimestamp = $initialEnd->getTimestamp();
- } elseif (isset($anEvent['DURATION'])) {
- $duration = end($anEvent['DURATION_array']);
- $endTimestamp = $this->parseDuration($anEvent['DTSTART'], $duration);
+ // Create new initial starting point.
+ $initialEventDate = $this->icalDateToDateTime($anEvent['DTSTART_array'][3]);
+
+ // Separate the RRULE stanzas, and explode the values that are lists.
+ $rrules = array();
+ foreach (explode(';', $anEvent['RRULE']) as $s) {
+ list($k, $v) = explode('=', $s);
+ if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYYEARDAY', 'BYWEEKNO'))) {
+ $rrules[$k] = explode(',', $v);
} else {
- $endTimestamp = $anEvent['DTSTART_array'][2];
+ $rrules[$k] = $v;
}
- $eventTimestampOffset = $endTimestamp - $startTimestamp;
- // Get Interval
- $interval = (isset($rrules['INTERVAL']) && $rrules['INTERVAL'] !== '') ? $rrules['INTERVAL'] : 1;
-
- $dayNumber = null;
- $weekday = null;
-
- if (in_array($frequency, array('MONTHLY', 'YEARLY')) && isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') {
- // Deal with BYDAY
- $byDay = $rrules['BYDAY'];
- $dayNumber = intval($byDay);
-
- if (empty($dayNumber)) { // Returns 0 when no number defined in BYDAY
- if (!isset($rrules['BYSETPOS'])) {
- $dayNumber = 1; // Set first as default
- } elseif (is_numeric($rrules['BYSETPOS'])) {
- $dayNumber = $rrules['BYSETPOS'];
- }
+ }
+
+ // Get frequency
+ $frequency = $rrules['FREQ'];
+
+ // Reject RRULE if BYDAY stanza is invalid:
+ // > The BYDAY rule part MUST NOT be specified with a numeric value
+ // > when the FREQ rule part is not set to MONTHLY or YEARLY.
+ // > Furthermore, the BYDAY rule part MUST NOT be specified with a
+ // > numeric value with the FREQ rule part set to YEARLY when the
+ // > BYWEEKNO rule part is specified.
+ if (isset($rrules['BYDAY'])) {
+ $checkByDays = function ($carry, $weekday) {
+ return $carry && substr($weekday, -2) === $weekday;
+ };
+ if (!in_array($frequency, array('MONTHLY', 'YEARLY'))) {
+ if (!array_reduce($rrules['BYDAY'], $checkByDays, true)) {
+ error_log("ICal::ProcessRecurrences: A {$frequency} RRULE may not contain BYDAY values with numeric prefixes");
+
+ continue;
}
+ } elseif ($frequency === 'YEARLY' && !empty($rrules['BYWEEKNO'])) {
+ if (!array_reduce($rrules['BYDAY'], $checkByDays, true)) {
+ error_log('ICal::ProcessRecurrences: A YEARLY RRULE with a BYWEEKNO part may not contain BYDAY values with numeric prefixes');
- $weekday = substr($byDay, -2);
+ continue;
+ }
}
+ }
- $untilDefault = date_create('now');
- $untilDefault->modify($this->defaultSpan . ' year');
- $untilDefault->setTime(23, 59, 59); // End of the day
-
- // Compute EXDATEs
- $exdates = $this->parseExdates($anEvent);
-
- if (isset($rrules['UNTIL'])) {
- // Get Until
- $until = strtotime($rrules['UNTIL']);
- } elseif (isset($rrules['COUNT'])) {
- $countOrig = (is_numeric($rrules['COUNT']) && $rrules['COUNT'] > 1) ? $rrules['COUNT'] : 0;
-
- // Increment count by the number of excluded dates
- $countOrig += sizeof($exdates);
-
- // Remove one to exclude the occurrence that initialises the rule
- $count = ($countOrig - 1);
+ // Get Interval
+ $interval = (empty($rrules['INTERVAL'])) ? 1 : $rrules['INTERVAL'];
- if ($interval >= 2) {
- $count += ($count > 0) ? ($count * $interval) : 0;
- }
+ // Throw an error if this isn't an integer.
+ if (!is_int($this->defaultSpan)) {
+ trigger_error('ICal::defaultSpan: User defined value is not an integer', E_USER_NOTICE);
+ }
- $countNb = 1;
- $offset = "+{$count} " . $this->frequencyConversion[$frequency];
- $until = strtotime($offset, $startTimestamp);
+ // Compute EXDATEs
+ $exdates = $this->parseExdates($anEvent);
- if (in_array($frequency, array('MONTHLY', 'YEARLY'))
- && isset($rrules['BYDAY']) && $rrules['BYDAY'] !== ''
- ) {
- $dtstart = date_create($anEvent['DTSTART']);
+ // Determine if the initial date is also an EXDATE
+ $initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) {
+ return $carry || $exdate->getTimestamp() == $initialEventDate->getTimestamp();
+ }, false);
- if (!$dtstart) {
- continue;
- }
+ if ($initialDateIsExdate) {
+ $eventKeysToRemove[] = $key;
+ }
- for ($i = 1; $i <= $count; $i++) {
- $dtstartClone = clone $dtstart;
- $dtstartClone->modify('next ' . $this->frequencyConversion[$frequency]);
- $offset = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $dtstartClone)} {$this->weekdays[$weekday]} of " . $dtstartClone->format('F Y H:i:01');
- $dtstart->modify($offset);
- }
+ /**
+ * Determine at what point we should stop calculating recurrences
+ * by looking at the UNTIL or COUNT rrule stanza, or, if neither
+ * if set, using a fallback.
+ *
+ * If the initial date is also an EXDATE, it shouldn't be included
+ * in the count.
+ *
+ * Syntax:
+ * UNTIL={enddate}
+ * COUNT=
+ *
+ * Where:
+ * enddate = ||
+ */
+ $count = 1;
+ $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : 0;
+ $until = date_create()->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp();
+
+ if (isset($rrules['UNTIL'])) {
+ $until = min($until, $this->iCalDateToUnixTimestamp($rrules['UNTIL']));
+ }
- // Jumping X months forwards doesn't mean
- // the end date will fall on the same day defined in BYDAY
- // Use the largest of these to ensure we are going far enough
- // in the future to capture our final end day
- $until = max($until, $dtstart->format(self::UNIX_FORMAT));
- }
+ $eventRecurrences = array();
- unset($offset);
- } else {
- $until = $untilDefault->getTimestamp();
- }
+ $frequencyRecurringDateTime = clone $initialEventDate;
+ while ($frequencyRecurringDateTime->getTimestamp() <= $until) {
+ $candidateDateTimes = array();
- // Decide how often to add events and do so
+ // phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault
switch ($frequency) {
case 'DAILY':
- // Simply add a new event each interval of days until UNTIL is reached
- $offset = "+{$interval} day";
- $recurringTimestamp = strtotime($offset, $startTimestamp);
-
- while ($recurringTimestamp <= $until) {
- $dayRecurringTimestamp = $recurringTimestamp;
-
- // Adjust time zone from initial event
- $dayRecurringOffset = 0;
- if ($this->useTimeZoneWithRRules) {
- $recurringTimeZone = \DateTime::createFromFormat(self::UNIX_FORMAT, $dayRecurringTimestamp);
- $recurringTimeZone->setTimezone($initialStart->getTimezone());
- $dayRecurringOffset = $recurringTimeZone->getOffset();
- $dayRecurringTimestamp += $dayRecurringOffset;
+ if (!empty($rrules['BYMONTHDAY'])) {
+ if (!isset($monthDays)) {
+ // This variable is unset when we change months (see below)
+ $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
}
- // Add event
- $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $dayRecurringTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $dayRecurringTimestamp;
- $anEvent['DTEND_array'] = $anEvent['DTSTART_array'];
- $anEvent['DTEND_array'][2] += $eventTimestampOffset;
- $anEvent['DTEND'] = date(
- self::DATE_TIME_FORMAT,
- $anEvent['DTEND_array'][2]
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
-
- // Exclusions
- $searchDate = $anEvent['DTSTART'];
- if (isset($anEvent['DTSTART_array'][0]['TZID'])) {
- $searchDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent['DTSTART_array'][0]['TZID']) . $searchDate;
- }
- $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $dayRecurringOffset) {
- $a = $this->iCalDateToUnixTimestamp($searchDate);
- $b = ($exdate + $dayRecurringOffset);
-
- return $a === $b;
- });
-
- if (isset($anEvent['UID'])) {
- if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $searchDateUtc = $this->iCalDateToUnixTimestamp($searchDate, true, true);
- if (in_array($searchDateUtc, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $isExcluded = true;
- }
- }
+ if (!in_array($frequencyRecurringDateTime->format('j'), $monthDays)) {
+ break;
}
+ }
- if (!$isExcluded) {
- $anEvent = $this->processEventIcalDateTime($anEvent);
- $recurrenceEvents[] = $anEvent;
- $this->eventCount++;
+ $candidateDateTimes[] = clone $frequencyRecurringDateTime;
- // If RRULE[COUNT] is reached then break
- if (isset($rrules['COUNT'])) {
- $countNb++;
+ break;
- if ($countNb >= $countOrig) {
- break;
- }
+ case 'WEEKLY':
+ $initialDayOfWeek = $frequencyRecurringDateTime->format('N');
+ $matchingDays = array($initialDayOfWeek);
+
+ if (!empty($rrules['BYDAY'])) {
+ // setISODate() below uses the ISO-8601 specification of weeks: start on
+ // a Monday, end on a Sunday. However, RRULEs (or the caller of the
+ // parser) may state an alternate WeeKSTart.
+ $wkstTransition = 7;
+
+ if (empty($rrules['WKST'])) {
+ if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) {
+ $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays));
}
+ } elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) {
+ $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays));
}
- // Move forwards
- $recurringTimestamp = strtotime($offset, $recurringTimestamp);
- }
+ $matchingDays = array_map(
+ function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) {
+ $day = array_search($weekday, array_keys($this->weekdays));
- $recurrenceEvents = $this->trimToRecurrenceCount($rrules, $recurrenceEvents);
- $allRecurrenceEvents = array_merge($allRecurrenceEvents, $recurrenceEvents);
- $recurrenceEvents = array(); // Reset
+ if ($day < $initialDayOfWeek) {
+ $day += 7;
+ }
- break;
+ if ($day >= $wkstTransition) {
+ $day += 7 * ($interval - 1);
+ }
- case 'WEEKLY':
- // Create offset
- $offset = "+{$interval} week";
+ // Ignoring alternate week starts, $day at this point will have a
+ // value between 0 and 6. But setISODate() expects a value of 1 to 7.
+ // Even with alternate week starts, we still need to +1 to set the
+ // correct weekday.
+ $day++;
- $wkst = (isset($rrules['WKST']) && in_array($rrules['WKST'], array('SA', 'SU', 'MO'))) ? $rrules['WKST'] : $this->defaultWeekStart;
- $aWeek = $this->weeks[$wkst];
- $days = array('SA' => 'Saturday', 'SU' => 'Sunday', 'MO' => 'Monday');
+ return $day;
+ },
+ $rrules['BYDAY']
+ );
+ }
- // Build list of days of week to add events
- $weekdays = $aWeek;
+ sort($matchingDays);
- if (isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') {
- $byDays = explode(',', $rrules['BYDAY']);
- } else {
- // A textual representation of a day, two letters (e.g. SU)
- $byDays = array(mb_substr(strtoupper($initialStart->format('D')), 0, 2));
+ foreach ($matchingDays as $day) {
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setISODate(
+ $frequencyRecurringDateTime->format('o'),
+ $frequencyRecurringDateTime->format('W'),
+ $day
+ );
}
+ break;
- // Get timestamp of first day of start week
- $weekRecurringTimestamp = (strcasecmp($initialStart->format('l'), $this->weekdays[$wkst]) === 0)
- ? $startTimestamp
- : strtotime("last {$days[$wkst]} " . $initialStart->format('H:i:s'), $startTimestamp);
-
- // Step through weeks
- while ($weekRecurringTimestamp <= $until) {
- $dayRecurringTimestamp = $weekRecurringTimestamp;
-
- // Adjust time zone from initial event
- $dayRecurringOffset = 0;
- if ($this->useTimeZoneWithRRules) {
- $dayRecurringTimeZone = \DateTime::createFromFormat(self::UNIX_FORMAT, $dayRecurringTimestamp);
- $dayRecurringTimeZone->setTimezone($initialStart->getTimezone());
- $dayRecurringOffset = $dayRecurringTimeZone->getOffset();
- $dayRecurringTimestamp += $dayRecurringOffset;
+ case 'MONTHLY':
+ $matchingDays = array();
+
+ if (!empty($rrules['BYMONTHDAY'])) {
+ $matchingDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
+ if (!empty($rrules['BYDAY'])) {
+ $matchingDays = array_filter(
+ $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime),
+ function ($monthDay) use ($matchingDays) {
+ return in_array($monthDay, $matchingDays);
+ }
+ );
}
+ } elseif (!empty($rrules['BYDAY'])) {
+ $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
+ }
- foreach ($weekdays as $day) {
- // Check if day should be added
- if (in_array($day, $byDays) && $dayRecurringTimestamp > $startTimestamp
- && $dayRecurringTimestamp <= $until
- ) {
- // Add event
- $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $dayRecurringTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $dayRecurringTimestamp;
- $anEvent['DTEND_array'] = $anEvent['DTSTART_array'];
- $anEvent['DTEND_array'][2] += $eventTimestampOffset;
- $anEvent['DTEND'] = date(
- self::DATE_TIME_FORMAT,
- $anEvent['DTEND_array'][2]
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
-
- // Exclusions
- $searchDate = $anEvent['DTSTART'];
- if (isset($anEvent['DTSTART_array'][0]['TZID'])) {
- $searchDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent['DTSTART_array'][0]['TZID']) . $searchDate;
- }
- $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $dayRecurringOffset) {
- $a = $this->iCalDateToUnixTimestamp($searchDate);
- $b = ($exdate + $dayRecurringOffset);
-
- return $a === $b;
- });
-
- if (isset($anEvent['UID'])) {
- if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $searchDateUtc = $this->iCalDateToUnixTimestamp($searchDate, true, true);
- if (in_array($searchDateUtc, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $isExcluded = true;
- }
- }
- }
+ if (!empty($rrules['BYSETPOS'])) {
+ $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
+ }
- if (!$isExcluded) {
- $anEvent = $this->processEventIcalDateTime($anEvent);
- $recurrenceEvents[] = $anEvent;
- $this->eventCount++;
+ foreach ($matchingDays as $day) {
+ // Skip invalid dates (e.g. 30th February)
+ if ($day > $frequencyRecurringDateTime->format('t')) {
+ continue;
+ }
- // If RRULE[COUNT] is reached then break
- if (isset($rrules['COUNT'])) {
- $countNb++;
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ $frequencyRecurringDateTime->format('m'),
+ $day
+ );
+ }
+ break;
- if ($countNb >= $countOrig) {
- break 2;
- }
- }
- }
+ case 'YEARLY':
+ $matchingDays = array();
+
+ if (!empty($rrules['BYMONTH'])) {
+ $bymonthRecurringDatetime = clone $frequencyRecurringDateTime;
+ foreach ($rrules['BYMONTH'] as $byMonth) {
+ $bymonthRecurringDatetime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ $byMonth,
+ $frequencyRecurringDateTime->format('d')
+ );
+
+ // Determine the days of the month affected
+ // (The interaction between BYMONTHDAY and BYDAY is resolved later.)
+ $monthDays = array();
+ if (!empty($rrules['BYMONTHDAY'])) {
+ $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $bymonthRecurringDatetime);
+ } elseif (!empty($rrules['BYDAY'])) {
+ $monthDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $bymonthRecurringDatetime);
+ } else {
+ $monthDays[] = $bymonthRecurringDatetime->format('d');
}
- // Move forwards a day
- $dayRecurringTimestamp = strtotime('+1 day', $dayRecurringTimestamp);
+ // And add each of them to the list of recurrences
+ foreach ($monthDays as $day) {
+ $matchingDays[] = $bymonthRecurringDatetime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ $bymonthRecurringDatetime->format('m'),
+ $day
+ )->format('z') + 1;
+ }
}
+ } elseif (!empty($rrules['BYWEEKNO'])) {
+ $matchingDays = $this->getDaysOfYearMatchingByWeekNoRRule($rrules['BYWEEKNO'], $frequencyRecurringDateTime);
+ } elseif (!empty($rrules['BYYEARDAY'])) {
+ $matchingDays = $this->getDaysOfYearMatchingByYearDayRRule($rrules['BYYEARDAY'], $frequencyRecurringDateTime);
+ } elseif (!empty($rrules['BYMONTHDAY'])) {
+ $matchingDays = $this->getDaysOfYearMatchingByMonthDayRRule($rrules['BYMONTHDAY'], $frequencyRecurringDateTime);
+ }
- // Move forwards $interval weeks
- $weekRecurringTimestamp = strtotime($offset, $weekRecurringTimestamp);
+ if (!empty($rrules['BYDAY'])) {
+ if (!empty($rrules['BYYEARDAY']) || !empty($rrules['BYMONTHDAY']) || !empty($rrules['BYWEEKNO'])) {
+ $matchingDays = array_filter(
+ $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime),
+ function ($yearDay) use ($matchingDays) {
+ return in_array($yearDay, $matchingDays);
+ }
+ );
+ } elseif (count($matchingDays) === 0) {
+ $matchingDays = $this->getDaysOfYearMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
+ }
}
- $recurrenceEvents = $this->trimToRecurrenceCount($rrules, $recurrenceEvents);
- $allRecurrenceEvents = array_merge($allRecurrenceEvents, $recurrenceEvents);
- $recurrenceEvents = array(); // Reset
+ if (count($matchingDays) === 0) {
+ $matchingDays = array($frequencyRecurringDateTime->format('z') + 1);
+ } else {
+ sort($matchingDays);
+ }
- break;
+ if (!empty($rrules['BYSETPOS'])) {
+ $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
+ }
- case 'MONTHLY':
- // Create offset
- $recurringTimestamp = $startTimestamp;
- $offset = "+{$interval} month";
-
- if (isset($rrules['BYMONTHDAY']) && $rrules['BYMONTHDAY'] !== '') {
- // Deal with BYMONTHDAY
- $monthdays = explode(',', $rrules['BYMONTHDAY']);
-
- while ($recurringTimestamp <= $until) {
- foreach ($monthdays as $key => $monthday) {
- if ($key === 0) {
- // Ensure original event conforms to monthday rule
- $anEvent['DTSTART'] = gmdate(
- 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT,
- strtotime($anEvent['DTSTART'])
- ) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
-
- $anEvent['DTEND'] = gmdate(
- 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT,
- isset($anEvent['DURATION'])
- ? $this->parseDuration($anEvent['DTSTART'], end($anEvent['DURATION_array']))
- : strtotime($anEvent['DTEND'])
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
-
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $this->iCalDateToUnixTimestamp($anEvent['DTSTART']);
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
- $anEvent['DTEND_array'][2] = $this->iCalDateToUnixTimestamp($anEvent['DTEND']);
-
- // Ensure recurring timestamp confirms to BYMONTHDAY rule
- $monthRecurringTimestamp = $this->iCalDateToUnixTimestamp(
- gmdate(
- 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT,
- $recurringTimestamp
- ) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '')
- );
- }
+ foreach ($matchingDays as $day) {
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ 1,
+ $day
+ );
+ }
+ break;
+ }
- // Adjust time zone from initial event
- $monthRecurringOffset = 0;
- if ($this->useTimeZoneWithRRules) {
- $recurringTimeZone = \DateTime::createFromFormat(self::UNIX_FORMAT, $monthRecurringTimestamp);
- $recurringTimeZone->setTimezone($initialStart->getTimezone());
- $monthRecurringOffset = $recurringTimeZone->getOffset();
- $monthRecurringTimestamp += $monthRecurringOffset;
- }
+ foreach ($candidateDateTimes as $candidate) {
+ $timestamp = $candidate->getTimestamp();
+ if ($timestamp <= $initialEventDate->getTimestamp()) {
+ continue;
+ }
- // Add event
- $anEvent['DTSTART'] = date(
- 'Ym' . sprintf('%02d', $monthday) . '\T' . self::TIME_FORMAT,
- $monthRecurringTimestamp
- ) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $monthRecurringTimestamp;
- $anEvent['DTEND_array'] = $anEvent['DTSTART_array'];
- $anEvent['DTEND_array'][2] += $eventTimestampOffset;
- $anEvent['DTEND'] = date(
- self::DATE_TIME_FORMAT,
- $anEvent['DTEND_array'][2]
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
-
- // Exclusions
- $searchDate = $anEvent['DTSTART'];
- if (isset($anEvent['DTSTART_array'][0]['TZID'])) {
- $searchDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent['DTSTART_array'][0]['TZID']) . $searchDate;
- }
- $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $monthRecurringOffset) {
- $a = $this->iCalDateToUnixTimestamp($searchDate);
- $b = ($exdate + $monthRecurringOffset);
-
- return $a === $b;
- });
-
- if (isset($anEvent['UID'])) {
- if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $searchDateUtc = $this->iCalDateToUnixTimestamp($searchDate, true, true);
- if (in_array($searchDateUtc, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $isExcluded = true;
- }
- }
- }
+ if ($timestamp > $until) {
+ break;
+ }
- if (!$isExcluded) {
- $anEvent = $this->processEventIcalDateTime($anEvent);
- $recurrenceEvents[] = $anEvent;
- $this->eventCount++;
+ // Exclusions
+ $isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) {
+ return $exdate->getTimestamp() == $timestamp;
+ });
- // If RRULE[COUNT] is reached then break
- if (isset($rrules['COUNT'])) {
- $countNb++;
+ if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
+ if (in_array($timestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
+ $isExcluded = true;
+ }
+ }
- if ($countNb >= $countOrig) {
- break 2;
- }
- }
- }
- }
+ if (!$isExcluded) {
+ $eventRecurrences[] = $candidate;
+ $this->eventCount++;
+ }
- // Move forwards
- $recurringTimestamp = strtotime($offset, $recurringTimestamp);
- }
- } elseif (isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') {
- while ($recurringTimestamp <= $until) {
- $monthRecurringTimestamp = $recurringTimestamp;
-
- // Adjust time zone from initial event
- $monthRecurringOffset = 0;
- if ($this->useTimeZoneWithRRules) {
- $recurringTimeZone = \DateTime::createFromFormat(self::UNIX_FORMAT, $monthRecurringTimestamp);
- $recurringTimeZone->setTimezone($initialStart->getTimezone());
- $monthRecurringOffset = $recurringTimeZone->getOffset();
- $monthRecurringTimestamp += $monthRecurringOffset;
- }
+ // Count all evaluated candidates including excluded ones
+ if (isset($rrules['COUNT'])) {
+ $count++;
- $eventStartDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $monthRecurringTimestamp)} {$this->weekdays[$weekday]} of "
- . date(self::DATE_TIME_FORMAT_PRETTY, $monthRecurringTimestamp);
- $eventStartTimestamp = strtotime($eventStartDesc);
+ // If RRULE[COUNT] is reached then break
+ if ($count >= $countLimit) {
+ break 2;
+ }
+ }
+ }
- if (intval($rrules['BYDAY']) === 0) {
- $lastDayDesc = "last {$this->weekdays[$weekday]} of "
- . date(self::DATE_TIME_FORMAT_PRETTY, $monthRecurringTimestamp);
- } else {
- $lastDayDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $monthRecurringTimestamp)} {$this->weekdays[$weekday]} of "
- . date(self::DATE_TIME_FORMAT_PRETTY, $monthRecurringTimestamp);
- }
- $lastDayTimestamp = strtotime($lastDayDesc);
+ // Move forwards $interval $frequency.
+ $monthPreMove = $frequencyRecurringDateTime->format('m');
+ $frequencyRecurringDateTime->modify("{$interval} {$this->frequencyConversion[$frequency]}");
- do {
- // Prevent 5th day of a month from showing up on the next month
- // If BYDAY and the event falls outside the current month, skip the event
+ // As noted in Example #2 on https://www.php.net/manual/en/datetime.modify.php,
+ // there are some occasions where adding months doesn't give the month you might
+ // expect. For instance: January 31st + 1 month == March 3rd (March 2nd on a leap
+ // year.) The following code crudely rectifies this.
+ if ($frequency === 'MONTHLY') {
+ $monthDiff = $frequencyRecurringDateTime->format('m') - $monthPreMove;
- $compareCurrentMonth = date('F', $monthRecurringTimestamp);
- $compareEventMonth = date('F', $eventStartTimestamp);
+ if (($monthDiff > 0 && $monthDiff > $interval) || ($monthDiff < 0 && $monthDiff > $interval - 12)) {
+ $frequencyRecurringDateTime->modify('-1 month');
+ }
+ }
- if ($compareCurrentMonth !== $compareEventMonth) {
- $monthRecurringTimestamp = strtotime($offset, $monthRecurringTimestamp);
- continue;
- }
+ // $monthDays is set in the DAILY frequency if the BYMONTHDAY stanza is present in
+ // the RRULE. The variable only needs to be updated when we change months, so we
+ // unset it here, prompting a recreation next iteration.
+ if (isset($monthDays) && $frequencyRecurringDateTime->format('m') !== $monthPreMove) {
+ unset($monthDays);
+ }
+ }
- if ($eventStartTimestamp > $startTimestamp && $eventStartTimestamp < $until) {
- $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $eventStartTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $eventStartTimestamp;
- $anEvent['DTEND_array'] = $anEvent['DTSTART_array'];
- $anEvent['DTEND_array'][2] += $eventTimestampOffset;
- $anEvent['DTEND'] = date(
- self::DATE_TIME_FORMAT,
- $anEvent['DTEND_array'][2]
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
-
- // Exclusions
- $searchDate = $anEvent['DTSTART'];
- if (isset($anEvent['DTSTART_array'][0]['TZID'])) {
- $searchDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent['DTSTART_array'][0]['TZID']) . $searchDate;
- }
- $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $monthRecurringOffset) {
- $a = $this->iCalDateToUnixTimestamp($searchDate);
- $b = ($exdate + $monthRecurringOffset);
-
- return $a === $b;
- });
-
- if (isset($anEvent['UID'])) {
- if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $searchDateUtc = $this->iCalDateToUnixTimestamp($searchDate, true, true);
- if (in_array($searchDateUtc, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $isExcluded = true;
- }
- }
- }
-
- if (!$isExcluded) {
- $anEvent = $this->processEventIcalDateTime($anEvent);
- $recurrenceEvents[] = $anEvent;
- $this->eventCount++;
-
- // If RRULE[COUNT] is reached then break
- if (isset($rrules['COUNT'])) {
- $countNb++;
-
- if ($countNb >= $countOrig) {
- break 2;
- }
- }
- }
- }
+ unset($monthDays); // Unset it here as well, so it doesn't bleed into the calculation of the next recurring event.
- if (isset($rrules['BYSETPOS'])) {
- // BYSETPOS is defined so skip
- // looping through each week
- $lastDayTimestamp = $eventStartTimestamp;
- }
+ // Determine event length
+ $eventLength = 0;
+ if (isset($anEvent['DURATION'])) {
+ $clonedDateTime = clone $initialEventDate;
+ $endDate = $clonedDateTime->add($anEvent['DURATION_array'][2]);
+ $eventLength = $endDate->getTimestamp() - $anEvent['DTSTART_array'][2];
+ } elseif (isset($anEvent['DTEND_array'])) {
+ $eventLength = $anEvent['DTEND_array'][2] - $anEvent['DTSTART_array'][2];
+ }
- $eventStartTimestamp += self::SECONDS_IN_A_WEEK;
- } while ($eventStartTimestamp <= $lastDayTimestamp);
+ // Whether or not the initial date was UTC
+ $initialDateWasUTC = substr($anEvent['DTSTART'], -1) === 'Z';
- // Move forwards
- $recurringTimestamp = strtotime($offset, $recurringTimestamp);
- }
- }
+ // Build the param array
+ $dateParamArray = array();
+ if (
+ !$initialDateWasUTC
+ && isset($anEvent['DTSTART_array'][0]['TZID'])
+ && $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID'])
+ ) {
+ $dateParamArray['TZID'] = $anEvent['DTSTART_array'][0]['TZID'];
+ }
- $recurrenceEvents = $this->trimToRecurrenceCount($rrules, $recurrenceEvents);
- $allRecurrenceEvents = array_merge($allRecurrenceEvents, $recurrenceEvents);
- $recurrenceEvents = array(); // Reset
+ // Populate the `DT{START|END}[_array]`s
+ $eventRecurrences = array_map(
+ function ($recurringDatetime) use ($anEvent, $eventLength, $initialDateWasUTC, $dateParamArray) {
+ $tzidPrefix = (isset($dateParamArray['TZID'])) ? 'TZID=' . $this->escapeParamText($dateParamArray['TZID']) . ':' : '';
- break;
+ foreach (array('DTSTART', 'DTEND') as $dtkey) {
+ $anEvent[$dtkey] = $recurringDatetime->format(self::DATE_TIME_FORMAT) . (($initialDateWasUTC) ? 'Z' : '');
- case 'YEARLY':
- // Create offset
- $recurringTimestamp = $startTimestamp;
- $offset = "+{$interval} year";
+ $anEvent["{$dtkey}_array"] = array(
+ $dateParamArray, // [0] Array of params (incl. TZID)
+ $anEvent[$dtkey], // [1] ICalDateTime string w/o TZID
+ $recurringDatetime->getTimestamp(), // [2] Unix Timestamp
+ "{$tzidPrefix}{$anEvent[$dtkey]}", // [3] Full ICalDateTime string
+ );
- // Deal with BYMONTH
- if (isset($rrules['BYMONTH']) && $rrules['BYMONTH'] !== '') {
- $bymonths = explode(',', $rrules['BYMONTH']);
+ if ($dtkey !== 'DTEND') {
+ $recurringDatetime->modify("{$eventLength} seconds");
}
+ }
- // Check if BYDAY rule exists
- if (isset($rrules['BYDAY']) && $rrules['BYDAY'] !== '') {
- while ($recurringTimestamp <= $until) {
- $yearRecurringTimestamp = $recurringTimestamp;
-
- // Adjust time zone from initial event
- $yearRecurringOffset = 0;
- if ($this->useTimeZoneWithRRules) {
- $recurringTimeZone = \DateTime::createFromFormat(self::UNIX_FORMAT, $yearRecurringTimestamp);
- $recurringTimeZone->setTimezone($initialStart->getTimezone());
- $yearRecurringOffset = $recurringTimeZone->getOffset();
- $yearRecurringTimestamp += $yearRecurringOffset;
- }
+ return $anEvent;
+ },
+ $eventRecurrences
+ );
- foreach ($bymonths as $bymonth) {
- $eventStartDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $yearRecurringTimestamp)} {$this->weekdays[$weekday]}"
- . " of {$this->monthNames[$bymonth]} "
- . gmdate('Y H:i:s', $yearRecurringTimestamp);
- $eventStartTimestamp = strtotime($eventStartDesc);
-
- if (intval($rrules['BYDAY']) === 0) {
- $lastDayDesc = "last {$this->weekdays[$weekday]}"
- . " of {$this->monthNames[$bymonth]} "
- . gmdate('Y H:i:s', $yearRecurringTimestamp);
- } else {
- $lastDayDesc = "{$this->convertDayOrdinalToPositive($dayNumber, $weekday, $yearRecurringTimestamp)} {$this->weekdays[$weekday]}"
- . " of {$this->monthNames[$bymonth]} "
- . gmdate('Y H:i:s', $yearRecurringTimestamp);
- }
- $lastDayTimestamp = strtotime($lastDayDesc);
-
- do {
- if ($eventStartTimestamp > $startTimestamp && $eventStartTimestamp < $until) {
- $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $eventStartTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $eventStartTimestamp;
- $anEvent['DTEND_array'] = $anEvent['DTSTART_array'];
- $anEvent['DTEND_array'][2] += $eventTimestampOffset;
- $anEvent['DTEND'] = date(
- self::DATE_TIME_FORMAT,
- $anEvent['DTEND_array'][2]
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
-
- // Exclusions
- $searchDate = $anEvent['DTSTART'];
- if (isset($anEvent['DTSTART_array'][0]['TZID'])) {
- $searchDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent['DTSTART_array'][0]['TZID']) . $searchDate;
- }
- $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $yearRecurringOffset) {
- $a = $this->iCalDateToUnixTimestamp($searchDate);
- $b = ($exdate + $yearRecurringOffset);
-
- return $a === $b;
- });
-
- if (isset($anEvent['UID'])) {
- if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $searchDateUtc = $this->iCalDateToUnixTimestamp($searchDate, true, true);
- if (in_array($searchDateUtc, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $isExcluded = true;
- }
- }
- }
-
- if (!$isExcluded) {
- $anEvent = $this->processEventIcalDateTime($anEvent);
- $recurrenceEvents[] = $anEvent;
- $this->eventCount++;
-
- // If RRULE[COUNT] is reached then break
- if (isset($rrules['COUNT'])) {
- $countNb++;
-
- if ($countNb >= $countOrig) {
- break 3;
- }
- }
- }
- }
-
- $eventStartTimestamp += self::SECONDS_IN_A_WEEK;
- } while ($eventStartTimestamp <= $lastDayTimestamp);
- }
+ $allEventRecurrences = array_merge($allEventRecurrences, $eventRecurrences);
+ }
- // Move forwards
- $recurringTimestamp = strtotime($offset, $recurringTimestamp);
- }
- } else {
- $day = $initialStart->format('d');
-
- // Step through years
- while ($recurringTimestamp <= $until) {
- $yearRecurringTimestamp = $recurringTimestamp;
-
- // Adjust time zone from initial event
- $yearRecurringOffset = 0;
- if ($this->useTimeZoneWithRRules) {
- $recurringTimeZone = \DateTime::createFromFormat(self::UNIX_FORMAT, $yearRecurringTimestamp);
- $recurringTimeZone->setTimezone($initialStart->getTimezone());
- $yearRecurringOffset = $recurringTimeZone->getOffset();
- $yearRecurringTimestamp += $yearRecurringOffset;
- }
+ // Nullify the initial events that are also EXDATEs
+ foreach ($eventKeysToRemove as $eventKeyToRemove) {
+ $events[$eventKeyToRemove] = null;
+ }
- $eventStartDescs = array();
- if (isset($rrules['BYMONTH']) && $rrules['BYMONTH'] !== '') {
- foreach ($bymonths as $bymonth) {
- array_push($eventStartDescs, "$day {$this->monthNames[$bymonth]} " . gmdate('Y H:i:s', $yearRecurringTimestamp));
- }
- } else {
- array_push($eventStartDescs, $day . gmdate(self::DATE_TIME_FORMAT_PRETTY, $yearRecurringTimestamp));
- }
+ $events = array_merge($events, $allEventRecurrences);
- foreach ($eventStartDescs as $eventStartDesc) {
- $eventStartTimestamp = strtotime($eventStartDesc);
-
- if ($eventStartTimestamp > $startTimestamp && $eventStartTimestamp < $until) {
- $anEvent['DTSTART'] = date(self::DATE_TIME_FORMAT, $eventStartTimestamp) . ($isAllDayEvent || ($initialStartTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTSTART_array'][1] = $anEvent['DTSTART'];
- $anEvent['DTSTART_array'][2] = $eventStartTimestamp;
- $anEvent['DTEND_array'] = $anEvent['DTSTART_array'];
- $anEvent['DTEND_array'][2] += $eventTimestampOffset;
- $anEvent['DTEND'] = date(
- self::DATE_TIME_FORMAT,
- $anEvent['DTEND_array'][2]
- ) . ($isAllDayEvent || ($initialEndTimeZoneName === 'Z') ? 'Z' : '');
- $anEvent['DTEND_array'][1] = $anEvent['DTEND'];
-
- // Exclusions
- $searchDate = $anEvent['DTSTART'];
- if (isset($anEvent['DTSTART_array'][0]['TZID'])) {
- $searchDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $anEvent['DTSTART_array'][0]['TZID']) . $searchDate;
- }
- $isExcluded = array_filter($exdates, function ($exdate) use ($searchDate, $yearRecurringOffset) {
- $a = $this->iCalDateToUnixTimestamp($searchDate);
- $b = ($exdate + $yearRecurringOffset);
-
- return $a === $b;
- });
-
- if (isset($anEvent['UID'])) {
- if (isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $searchDateUtc = $this->iCalDateToUnixTimestamp($searchDate, true, true);
- if (in_array($searchDateUtc, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
- $isExcluded = true;
- }
- }
- }
-
- if (!$isExcluded) {
- $anEvent = $this->processEventIcalDateTime($anEvent);
- $recurrenceEvents[] = $anEvent;
- $this->eventCount++;
-
- // If RRULE[COUNT] is reached then break
- if (isset($rrules['COUNT'])) {
- $countNb++;
-
- if ($countNb >= $countOrig) {
- break 2;
- }
- }
- }
- }
- }
+ $this->cal['VEVENT'] = $events;
+ }
- // Move forwards
- $recurringTimestamp = strtotime($offset, $recurringTimestamp);
- }
- }
+ /**
+ * Resolves values from indices of the range 1 -> $limit.
+ *
+ * For instance, if passed [1, 4, -16] and 28, this will return [1, 4, 13].
+ *
+ * @param array $indexes
+ * @param integer $limit
+ * @return array
+ */
+ protected function resolveIndicesOfRange(array $indexes, $limit)
+ {
+ $matching = array();
+ foreach ($indexes as $index) {
+ if ($index > 0 && $index <= $limit) {
+ $matching[] = $index;
+ } elseif ($index < 0 && -$index <= $limit) {
+ $matching[] = $index + $limit + 1;
+ }
+ }
- $recurrenceEvents = $this->trimToRecurrenceCount($rrules, $recurrenceEvents);
- $allRecurrenceEvents = array_merge($allRecurrenceEvents, $recurrenceEvents);
- $recurrenceEvents = array(); // Reset
+ sort($matching);
- break;
+ return $matching;
+ }
+
+ /**
+ * Find all days of a month that match the BYDAY stanza of an RRULE.
+ *
+ * With no {ordwk}, then return the day number of every {weekday}
+ * within the month.
+ *
+ * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
+ * month.
+ *
+ * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
+ * within the month.
+ *
+ * RRule Syntax:
+ * BYDAY={bywdaylist}
+ *
+ * Where:
+ * bywdaylist = {weekdaynum}[,{weekdaynum}...]
+ * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
+ * ordwk = 1 to 53
+ * weekday = SU || MO || TU || WE || TH || FR || SA
+ *
+ * @param array $byDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfMonthMatchingByDayRRule(array $byDays, $initialDateTime)
+ {
+ $matchingDays = array();
+
+ foreach ($byDays as $weekday) {
+ $bydayDateTime = clone $initialDateTime;
+
+ $ordwk = intval(substr($weekday, 0, -2));
+
+ // Quantise the date to the first instance of the requested day in a month
+ // (Or last if we have a -ve {ordwk})
+ $bydayDateTime->modify(
+ (($ordwk < 0) ? 'Last' : 'First') .
+ ' ' .
+ $this->weekdays[substr($weekday, -2)] . // e.g. "Monday"
+ ' of ' .
+ $initialDateTime->format('F') // e.g. "June"
+ );
+
+ if ($ordwk < 0) { // -ve {ordwk}
+ $bydayDateTime->modify((++$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('j');
+ } elseif ($ordwk > 0) { // +ve {ordwk}
+ $bydayDateTime->modify((--$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('j');
+ } else { // No {ordwk}
+ while ($bydayDateTime->format('n') === $initialDateTime->format('n')) {
+ $matchingDays[] = $bydayDateTime->format('j');
+ $bydayDateTime->modify('+1 week');
}
}
}
- $events = array_merge($events, $allRecurrenceEvents);
+ // Sort into ascending order
+ sort($matchingDays);
- $this->cal['VEVENT'] = $events;
+ return $matchingDays;
}
/**
- * Processes date conversions using the time zone
+ * Find all days of a month that match the BYMONTHDAY stanza of an RRULE.
*
- * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
- * These keys contain dates adapted to the calendar
- * time zone depending on the event `TZID`.
+ * RRUle Syntax:
+ * BYMONTHDAY={bymodaylist}
+ *
+ * Where:
+ * bymodaylist = {monthdaynum}[,{monthdaynum}...]
+ * monthdaynum = ([+] || -) {ordmoday}
+ * ordmoday = 1 to 31
*
- * @return boolean|void
+ * @param array $byMonthDays
+ * @param \DateTime $initialDateTime
+ * @return array
*/
- protected function processDateConversions()
+ protected function getDaysOfMonthMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime)
{
- $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+ return $this->resolveIndicesOfRange($byMonthDays, $initialDateTime->format('t'));
+ }
- if (empty($events)) {
- return false;
+ /**
+ * Find all days of a year that match the BYDAY stanza of an RRULE.
+ *
+ * With no {ordwk}, then return the day number of every {weekday}
+ * within the year.
+ *
+ * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
+ * year.
+ *
+ * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
+ * within the year.
+ *
+ * RRule Syntax:
+ * BYDAY={bywdaylist}
+ *
+ * Where:
+ * bywdaylist = {weekdaynum}[,{weekdaynum}...]
+ * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
+ * ordwk = 1 to 53
+ * weekday = SU || MO || TU || WE || TH || FR || SA
+ *
+ * @param array $byDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByDayRRule(array $byDays, $initialDateTime)
+ {
+ $matchingDays = array();
+
+ foreach ($byDays as $weekday) {
+ $bydayDateTime = clone $initialDateTime;
+
+ $ordwk = intval(substr($weekday, 0, -2));
+
+ // Quantise the date to the first instance of the requested day in a year
+ // (Or last if we have a -ve {ordwk})
+ $bydayDateTime->modify(
+ (($ordwk < 0) ? 'Last' : 'First') .
+ ' ' .
+ $this->weekdays[substr($weekday, -2)] . // e.g. "Monday"
+ ' of ' . (($ordwk < 0) ? 'December' : 'January') .
+ ' ' . $initialDateTime->format('Y') // e.g. "2018"
+ );
+
+ if ($ordwk < 0) { // -ve {ordwk}
+ $bydayDateTime->modify((++$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('z') + 1;
+ } elseif ($ordwk > 0) { // +ve {ordwk}
+ $bydayDateTime->modify((--$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('z') + 1;
+ } else { // No {ordwk}
+ while ($bydayDateTime->format('Y') === $initialDateTime->format('Y')) {
+ $matchingDays[] = $bydayDateTime->format('z') + 1;
+ $bydayDateTime->modify('+1 week');
+ }
+ }
}
- foreach ($events as $key => $anEvent) {
- if (!$this->isValidDate($anEvent['DTSTART'])) {
- unset($events[$key]);
- $this->eventCount--;
+ // Sort into ascending order
+ sort($matchingDays);
- continue;
+ return $matchingDays;
+ }
+
+ /**
+ * Find all days of a year that match the BYYEARDAY stanza of an RRULE.
+ *
+ * RRUle Syntax:
+ * BYYEARDAY={byyrdaylist}
+ *
+ * Where:
+ * byyrdaylist = {yeardaynum}[,{yeardaynum}...]
+ * yeardaynum = ([+] || -) {ordyrday}
+ * ordyrday = 1 to 366
+ *
+ * @param array $byYearDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByYearDayRRule(array $byYearDays, $initialDateTime)
+ {
+ // `\DateTime::format('L')` returns 1 if leap year, 0 if not.
+ $daysInThisYear = $initialDateTime->format('L') ? 366 : 365;
+
+ return $this->resolveIndicesOfRange($byYearDays, $daysInThisYear);
+ }
+
+ /**
+ * Find all days of a year that match the BYWEEKNO stanza of an RRULE.
+ *
+ * Unfortunately, the RFC5545 specification does not specify exactly
+ * how BYWEEKNO should expand on the initial DTSTART when provided
+ * without any other stanzas.
+ *
+ * A comparison of expansions used by other ics parsers may be found
+ * at https://github.com/s0600204/ics-parser-1/wiki/byweekno
+ *
+ * This method uses the same expansion as the python-dateutil module.
+ *
+ * RRUle Syntax:
+ * BYWEEKNO={bywknolist}
+ *
+ * Where:
+ * bywknolist = {weeknum}[,{weeknum}...]
+ * weeknum = ([+] || -) {ordwk}
+ * ordwk = 1 to 53
+ *
+ * @param array $byWeekNums
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByWeekNoRRule(array $byWeekNums, $initialDateTime)
+ {
+ // `\DateTime::format('L')` returns 1 if leap year, 0 if not.
+ $isLeapYear = $initialDateTime->format('L');
+ $firstDayOfTheYear = date_create("first day of January {$initialDateTime->format('Y')}")->format('D');
+ $weeksInThisYear = ($firstDayOfTheYear === 'Thu' || $isLeapYear && $firstDayOfTheYear === 'Wed') ? 53 : 52;
+
+ $matchingWeeks = $this->resolveIndicesOfRange($byWeekNums, $weeksInThisYear);
+ $matchingDays = array();
+ $byweekDateTime = clone $initialDateTime;
+ foreach ($matchingWeeks as $weekNum) {
+ $dayNum = $byweekDateTime->setISODate(
+ $initialDateTime->format('Y'),
+ $weekNum,
+ 1
+ )->format('z') + 1;
+ for ($x = 0; $x < 7; ++$x) {
+ $matchingDays[] = $x + $dayNum;
}
+ }
- if ($this->useTimeZoneWithRRules && isset($anEvent['RRULE_array'][2]) && $anEvent['RRULE_array'][2] === self::RECURRENCE_EVENT) {
- $events[$key]['DTSTART_tz'] = $anEvent['DTSTART'];
- $events[$key]['DTEND_tz'] = $anEvent['DTEND'];
- } else {
- $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART');
+ sort($matchingDays);
- if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) {
- $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND');
- } elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) {
- $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION');
- }
+ return $matchingDays;
+ }
+
+ /**
+ * Find all days of a year that match the BYMONTHDAY stanza of an RRULE.
+ *
+ * RRule Syntax:
+ * BYMONTHDAY={bymodaylist}
+ *
+ * Where:
+ * bymodaylist = {monthdaynum}[,{monthdaynum}...]
+ * monthdaynum = ([+] || -) {ordmoday}
+ * ordmoday = 1 to 31
+ *
+ * @param array $byMonthDays
+ * @param \DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfYearMatchingByMonthDayRRule(array $byMonthDays, $initialDateTime)
+ {
+ $matchingDays = array();
+ $monthDateTime = clone $initialDateTime;
+ for ($month = 1; $month < 13; $month++) {
+ $monthDateTime->setDate(
+ $initialDateTime->format('Y'),
+ $month,
+ 1
+ );
+
+ $monthDays = $this->getDaysOfMonthMatchingByMonthDayRRule($byMonthDays, $monthDateTime);
+ foreach ($monthDays as $day) {
+ $matchingDays[] = $monthDateTime->setDate(
+ $initialDateTime->format('Y'),
+ $monthDateTime->format('m'),
+ $day
+ )->format('z') + 1;
}
}
- $this->cal['VEVENT'] = $events;
+ return $matchingDays;
}
/**
- * Extends the `{DTSTART|DTEND|RECURRENCE-ID}_array`
- * array to include an iCal date time for each event
- * (`TZID=Timezone:YYYYMMDD[T]HHMMSS`)
+ * Filters a provided values-list by applying a BYSETPOS RRule.
+ *
+ * Where a +ve {daynum} is provided, the {ordday} position'd value as
+ * measured from the start of the list of values should be retained.
*
- * @param array $event
- * @param integer $index
+ * Where a -ve {daynum} is provided, the {ordday} position'd value as
+ * measured from the end of the list of values should be retained.
+ *
+ * RRule Syntax:
+ * BYSETPOS={bysplist}
+ *
+ * Where:
+ * bysplist = {setposday}[,{setposday}...]
+ * setposday = {daynum}
+ * daynum = [+ || -] {ordday}
+ * ordday = 1 to 366
+ *
+ * @param array $bySetPos
+ * @param array $valuesList
* @return array
*/
- protected function processEventIcalDateTime(array $event, $index = 3)
+ protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList)
{
- $calendarTimeZone = $this->calendarTimeZone(true);
+ $filteredMatches = array();
- foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
- if (isset($event["{$type}_array"])) {
- $timeZone = (isset($event["{$type}_array"][0]['TZID'])) ? $event["{$type}_array"][0]['TZID'] : $calendarTimeZone;
- $event["{$type}_array"][$index] = ((is_null($timeZone)) ? '' : sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone)) . $event["{$type}_array"][1];
+ foreach ($bySetPos as $setPosition) {
+ if ($setPosition < 0) {
+ $setPosition = count($valuesList) + ++$setPosition;
+ }
+
+ // Positioning starts at 1, array indexes start at 0
+ if (isset($valuesList[$setPosition - 1])) {
+ $filteredMatches[] = $valuesList[$setPosition - 1];
}
}
- return $event;
+ return $filteredMatches;
+ }
+
+ /**
+ * Processes date conversions using the time zone
+ *
+ * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
+ * These keys contain dates adapted to the calendar
+ * time zone depending on the event `TZID`.
+ *
+ * @return void
+ * @throws \Exception
+ */
+ protected function processDateConversions()
+ {
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ if (!empty($events)) {
+ foreach ($events as $key => $anEvent) {
+ if (is_null($anEvent) || !$this->isValidDate($anEvent['DTSTART'])) {
+ unset($events[$key]);
+ $this->eventCount--;
+
+ continue;
+ }
+
+ $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART');
+
+ if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) {
+ $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND');
+ } elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) {
+ $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION');
+ } else {
+ $events[$key]['DTEND_tz'] = $events[$key]['DTSTART_tz'];
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
}
/**
@@ -1545,6 +2039,7 @@ public function events()
{
$array = $this->cal;
$array = isset($array['VEVENT']) ? $array['VEVENT'] : array();
+
$events = array();
if (!empty($array)) {
@@ -1592,12 +2087,10 @@ public function calendarTimeZone($ignoreUtc = false)
$timeZone = $this->defaultTimeZone;
}
- // Use default time zone if the calendar's is invalid
- if (!$this->isValidTimeZoneId($timeZone)) {
- $timeZone = $this->defaultTimeZone;
- }
+ // Validate the time zone, falling back to the time zone set in the PHP environment.
+ $timeZone = $this->timeZoneStringToDateTimeZone($timeZone)->getName();
- if ($ignoreUtc && strtoupper($timeZone) === 'UTC') {
+ if ($ignoreUtc && strtoupper($timeZone) === self::TIME_ZONE_UTC) {
return null;
}
@@ -1615,7 +2108,7 @@ public function freeBusyEvents()
{
$array = $this->cal;
- return isset($array['VFREEBUSY']) ? $array['VFREEBUSY'] : '';
+ return isset($array['VFREEBUSY']) ? $array['VFREEBUSY'] : array();
}
/**
@@ -1647,15 +2140,15 @@ public function hasEvents()
* problem for events on, during, or after 29 Jan 2038.
* See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number
*
- * @param string $rangeStart Start date of the search range.
- * @param string $rangeEnd End date of the search range.
+ * @param string|null $rangeStart
+ * @param string|null $rangeEnd
* @return array
- * @throws Exception
+ * @throws \Exception
*/
- public function eventsFromRange($rangeStart = false, $rangeEnd = false)
+ public function eventsFromRange($rangeStart = null, $rangeEnd = null)
{
// Sort events before processing range
- $events = $this->sortEventsWithOrder($this->events(), SORT_ASC);
+ $events = $this->sortEventsWithOrder($this->events());
if (empty($events)) {
return array();
@@ -1663,10 +2156,10 @@ public function eventsFromRange($rangeStart = false, $rangeEnd = false)
$extendedEvents = array();
- if ($rangeStart) {
+ if (!is_null($rangeStart)) {
try {
$rangeStart = new \DateTime($rangeStart, new \DateTimeZone($this->defaultTimeZone));
- } catch (\Exception $e) {
+ } catch (\Exception $exception) {
error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})");
$rangeStart = false;
}
@@ -1674,10 +2167,10 @@ public function eventsFromRange($rangeStart = false, $rangeEnd = false)
$rangeStart = new \DateTime('now', new \DateTimeZone($this->defaultTimeZone));
}
- if ($rangeEnd) {
+ if (!is_null($rangeEnd)) {
try {
$rangeEnd = new \DateTime($rangeEnd, new \DateTimeZone($this->defaultTimeZone));
- } catch (\Exception $e) {
+ } catch (\Exception $exception) {
error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})");
$rangeEnd = false;
}
@@ -1687,7 +2180,7 @@ public function eventsFromRange($rangeStart = false, $rangeEnd = false)
}
// If start and end are identical and are dates with no times...
- if ($rangeEnd->format('His') == 0 && $rangeStart->getTimestamp() == $rangeEnd->getTimestamp()) {
+ if ($rangeEnd->format('His') == 0 && $rangeStart->getTimestamp() === $rangeEnd->getTimestamp()) {
$rangeEnd->modify('+1 day');
}
@@ -1698,7 +2191,8 @@ public function eventsFromRange($rangeStart = false, $rangeEnd = false)
$eventStart = $anEvent->dtstart_array[2];
$eventEnd = (isset($anEvent->dtend_array[2])) ? $anEvent->dtend_array[2] : null;
- if (($eventStart >= $rangeStart && $eventStart < $rangeEnd) // Event start date contained in the range
+ if (
+ ($eventStart >= $rangeStart && $eventStart < $rangeEnd) // Event start date contained in the range
|| ($eventEnd !== null
&& (
($eventEnd > $rangeStart && $eventEnd <= $rangeEnd) // Event end date contained in the range
@@ -1738,7 +2232,7 @@ public function eventsFromInterval($interval)
/**
* Sorts events based on a given sort order
*
- * @param array $events An array of Events
+ * @param array $events
* @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING
* @return array
*/
@@ -1758,13 +2252,30 @@ public function sortEventsWithOrder(array $events, $sortOrder = SORT_ASC)
}
/**
- * Checks if a time zone is valid
+ * Checks if a time zone is valid (IANA, CLDR, or Windows)
*
* @param string $timeZone
* @return boolean
*/
protected function isValidTimeZoneId($timeZone)
{
+ return $this->isValidIanaTimeZoneId($timeZone) !== false
+ || $this->isValidCldrTimeZoneId($timeZone) !== false
+ || $this->isValidWindowsTimeZoneId($timeZone) !== false;
+ }
+
+ /**
+ * Checks if a time zone is a valid IANA time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ protected function isValidIanaTimeZoneId($timeZone)
+ {
+ if (in_array($timeZone, $this->validIanaTimeZones)) {
+ return true;
+ }
+
$valid = array();
$tza = timezone_abbreviations_list();
@@ -1777,6 +2288,8 @@ protected function isValidTimeZoneId($timeZone)
unset($valid['']);
if (isset($valid[$timeZone]) || in_array($timeZone, timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC))) {
+ $this->validIanaTimeZones[] = $timeZone;
+
return true;
}
@@ -1784,96 +2297,54 @@ protected function isValidTimeZoneId($timeZone)
}
/**
- * Parses a duration and applies it to a date
+ * Checks if a time zone is a valid CLDR time zone
*
- * @param string $date A date to add a duration to
- * @param string $duration A duration to parse
- * @param string $format The format to apply to the DateTime object
- * @return integer|DateTime
+ * @param string $timeZone
+ * @return boolean
*/
- protected function parseDuration($date, $duration, $format = self::UNIX_FORMAT)
+ public function isValidCldrTimeZoneId($timeZone)
{
- $dateTime = date_create($date);
- $dateTime->modify($duration->y . ' year');
- $dateTime->modify($duration->m . ' month');
- $dateTime->modify($duration->d . ' day');
- $dateTime->modify($duration->h . ' hour');
- $dateTime->modify($duration->i . ' minute');
- $dateTime->modify($duration->s . ' second');
-
- if (is_null($format)) {
- $output = $dateTime;
- } else {
- if ($format === self::UNIX_FORMAT) {
- $output = $dateTime->getTimestamp();
- } else {
- $output = $dateTime->format($format);
- }
- }
-
- return $output;
+ return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap);
}
/**
- * Gets the number of days between a start and end date
+ * Checks if a time zone is a recognised Windows (non-CLDR) time zone
*
- * @param integer $days
- * @param integer $start
- * @param integer $end
- * @return integer
+ * @param string $timeZone
+ * @return boolean
*/
- protected function numberOfDays($days, $start, $end)
+ public function isValidWindowsTimeZoneId($timeZone)
{
- $w = array(date('w', $start), date('w', $end));
- $oneWeek = self::SECONDS_IN_A_WEEK;
- $x = floor(($end - $start) / $oneWeek);
- $sum = 0;
-
- for ($day = 0; $day < 7; ++$day) {
- if ($days & pow(2, $day)) {
- $sum += $x + (($w[0] > $w[1]) ? $w[0] <= $day || $day <= $w[1] : $w[0] <= $day && $day <= $w[1]);
- }
- }
-
- return $sum;
+ return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap);
}
/**
- * Converts a negative day ordinal to
- * its equivalent positive form
+ * Parses a duration and applies it to a date
*
- * @param integer $dayNumber
- * @param integer $weekday
- * @param integer $timestamp
- * @return string
+ * @param string $date
+ * @param string $duration
+ * @param string $format
+ * @return integer|\DateTime
*/
- protected function convertDayOrdinalToPositive($dayNumber, $weekday, $timestamp)
+ protected function parseDuration($date, $duration, $format = self::UNIX_FORMAT)
{
- $dayNumber = empty($dayNumber) ? 1 : $dayNumber; // Returns 0 when no number defined in BYDAY
-
- $dayOrdinals = $this->dayOrdinals;
+ $dateTime = date_create($date);
+ $dateTime->modify("{$duration->y} year");
+ $dateTime->modify("{$duration->m} month");
+ $dateTime->modify("{$duration->d} day");
+ $dateTime->modify("{$duration->h} hour");
+ $dateTime->modify("{$duration->i} minute");
+ $dateTime->modify("{$duration->s} second");
- // We only care about negative BYDAY values
- if ($dayNumber >= 1) {
- return $dayOrdinals[$dayNumber];
+ if (is_null($format)) {
+ $output = $dateTime;
+ } elseif ($format === self::UNIX_FORMAT) {
+ $output = $dateTime->getTimestamp();
+ } else {
+ $output = $dateTime->format($format);
}
- $timestamp = (is_object($timestamp)) ? $timestamp : \DateTime::createFromFormat(self::UNIX_FORMAT, $timestamp);
- $start = strtotime('first day of ' . $timestamp->format(self::DATE_TIME_FORMAT_PRETTY));
- $end = strtotime('last day of ' . $timestamp->format(self::DATE_TIME_FORMAT_PRETTY));
-
- // Used with pow(2, X) so pow(2, 4) is THURSDAY
- $weekdays = array_flip(array_keys($this->weekdays));
-
- $numberOfDays = $this->numberOfDays(pow(2, $weekdays[$weekday]), $start, $end);
-
- // Create subset
- $dayOrdinals = array_slice($dayOrdinals, 0, $numberOfDays, true);
-
- // Reverse only the values
- $dayOrdinals = array_combine(array_keys($dayOrdinals), array_reverse(array_values($dayOrdinals)));
-
- return $dayOrdinals[$dayNumber * -1];
+ return $output;
}
/**
@@ -1894,16 +2365,16 @@ protected function removeUnprintableChars($data)
* @param integer $code
* @return string
*/
- protected function mb_chr($code)
+ protected function mb_chr($code) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
if (function_exists('mb_chr')) {
return mb_chr($code);
} else {
- if (0x80 > $code %= 0x200000) {
+ if (($code %= 0x200000) < 0x80) {
$s = chr($code);
- } elseif (0x800 > $code) {
+ } elseif ($code < 0x800) {
$s = chr(0xc0 | $code >> 6) . chr(0x80 | $code & 0x3f);
- } elseif (0x10000 > $code) {
+ } elseif ($code < 0x10000) {
$s = chr(0xe0 | $code >> 12) . chr(0x80 | $code >> 6 & 0x3f) . chr(0x80 | $code & 0x3f);
} else {
$s = chr(0xf0 | $code >> 18) . chr(0x80 | $code >> 12 & 0x3f) . chr(0x80 | $code >> 6 & 0x3f) . chr(0x80 | $code & 0x3f);
@@ -1914,39 +2385,68 @@ protected function mb_chr($code)
}
/**
- * Replaces all occurrences of a search string with a given replacement string.
+ * Replace all occurrences of the search string with the replacement string.
* Multibyte safe.
*
- * @param string|array $search The value being searched for, otherwise known as the needle. An array may be used to designate multiple needles.
- * @param string|array $replace The replacement value that replaces found search values. An array may be used to designate multiple replacements.
- * @param string|array $subject The string or array being searched and replaced on, otherwise known as the haystack.
- * If subject is an array, then the search and replace is performed with every entry of subject, and the return value is an array as well.
- * @param integer $count If passed, this will be set to the number of replacements performed.
+ * @param string|array $search
+ * @param string|array $replace
+ * @param string|array $subject
+ * @param string $encoding
+ * @param integer $count
* @return array|string
*/
- protected function mb_str_replace($search, $replace, $subject, &$count = 0)
+ protected static function mb_str_replace($search, $replace, $subject, $encoding = null, &$count = 0) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
- if (!is_array($subject)) {
- // Normalize `$search` and `$replace` so they are both arrays of the same length
- $searches = is_array($search) ? array_values($search) : array($search);
+ if (is_array($subject)) {
+ // Call `mb_str_replace()` for each subject in the array, recursively
+ foreach ($subject as $key => $value) {
+ $subject[$key] = self::mb_str_replace($search, $replace, $value, $encoding, $count);
+ }
+ } else {
+ // Normalize $search and $replace so they are both arrays of the same length
+ $searches = is_array($search) ? array_values($search) : array($search);
$replacements = is_array($replace) ? array_values($replace) : array($replace);
$replacements = array_pad($replacements, count($searches), '');
foreach ($searches as $key => $search) {
- $parts = mb_split(preg_quote($search), $subject);
- $count += count($parts) - 1;
- $subject = implode($replacements[$key], $parts);
- }
- } else {
- // Call `mb_str_replace` for each subject in array, recursively
- foreach ($subject as $key => $value) {
- $subject[$key] = $this->mb_str_replace($search, $replace, $value, $count);
+ if (is_null($encoding)) {
+ $encoding = mb_detect_encoding($search, 'UTF-8', true);
+ }
+
+ $replace = $replacements[$key];
+ $searchLen = mb_strlen($search, $encoding);
+
+ $sb = array();
+ while (($offset = mb_strpos($subject, $search, 0, $encoding)) !== false) {
+ $sb[] = mb_substr($subject, 0, $offset, $encoding);
+ $subject = mb_substr($subject, $offset + $searchLen, null, $encoding);
+ ++$count;
+ }
+
+ $sb[] = $subject;
+ $subject = implode($replace, $sb);
}
}
return $subject;
}
+ /**
+ * Places double-quotes around texts that have characters not permitted
+ * in parameter-texts, but are permitted in quoted-texts.
+ *
+ * @param string $candidateText
+ * @return string
+ */
+ protected function escapeParamText($candidateText)
+ {
+ if (strpbrk($candidateText, ':;,') !== false) {
+ return '"' . $candidateText . '"';
+ }
+
+ return $candidateText;
+ }
+
/**
* Replaces curly quotes and other special characters
* with their standard equivalents
@@ -1998,22 +2498,27 @@ public function parseExdates(array $event)
}
$output = array();
- $currentTimeZone = $this->defaultTimeZone;
+ $currentTimeZone = new \DateTimeZone($this->defaultTimeZone);
foreach ($exdates as $subArray) {
end($subArray);
$finalKey = key($subArray);
- foreach ($subArray as $key => $value) {
+ foreach (array_keys($subArray) as $key) {
if ($key === 'TZID') {
- $currentTimeZone = $subArray[$key];
+ $currentTimeZone = $this->timeZoneStringToDateTimeZone($subArray[$key]);
} elseif (is_numeric($key)) {
- $icalDate = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $currentTimeZone) . $subArray[$key];
- $output[] = $this->iCalDateToUnixTimestamp($icalDate);
+ $icalDate = $subArray[$key];
+
+ if (substr($icalDate, -1) === 'Z') {
+ $currentTimeZone = new \DateTimeZone(self::TIME_ZONE_UTC);
+ }
+
+ $output[] = new \DateTime($icalDate, $currentTimeZone);
if ($key === $finalKey) {
// Reset to default
- $currentTimeZone = $this->defaultTimeZone;
+ $currentTimeZone = new \DateTimeZone($this->defaultTimeZone);
}
}
}
@@ -2027,7 +2532,7 @@ public function parseExdates(array $event)
*
* @param string $value
* @return boolean
- * @throws Exception
+ * @throws \Exception
*/
public function isValidDate($value)
{
@@ -2039,7 +2544,7 @@ public function isValidDate($value)
new \DateTime($value);
return true;
- } catch (\Exception $e) {
+ } catch (\Exception $exception) {
return false;
}
}
@@ -2060,11 +2565,40 @@ protected function isFileOrUrl($filename)
*
* @param string $filename
* @return array
- * @throws Exception
+ * @throws \Exception
*/
protected function fileOrUrl($filename)
{
- if (!$lines = file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) {
+ $options = array();
+ $options['http'] = array();
+ $options['http']['header'] = array();
+
+ if (!empty($this->httpBasicAuth) || !empty($this->httpUserAgent) || !empty($this->httpAcceptLanguage)) {
+ if (!empty($this->httpBasicAuth)) {
+ $username = $this->httpBasicAuth['username'];
+ $password = $this->httpBasicAuth['password'];
+ $basicAuth = base64_encode("{$username}:{$password}");
+
+ $options['http']['header'][] = "Authorization: Basic {$basicAuth}";
+ }
+
+ if (!empty($this->httpUserAgent)) {
+ $options['http']['header'][] = "User-Agent: {$this->httpUserAgent}";
+ }
+
+ if (!empty($this->httpAcceptLanguage)) {
+ $options['http']['header'][] = "Accept-language: {$this->httpAcceptLanguage}";
+ }
+ }
+
+ $options['http']['protocol_version'] = '1.1';
+
+ $options['http']['header'][] = 'Connection: close';
+
+ $context = stream_context_create($options);
+
+ // phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
+ if (($lines = file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES, $context)) === false) {
throw new \Exception("The file path or URL '{$filename}' does not exist.");
}
@@ -2072,24 +2606,32 @@ protected function fileOrUrl($filename)
}
/**
- * Ensures the recurrence count is enforced against generated recurrence events.
+ * Returns a `DateTimeZone` object based on a string containing a time zone name.
+ * Falls back to the default time zone if string passed not a recognised time zone.
*
- * @param array $rrules
- * @param array $recurrenceEvents
- * @return array
+ * @param string $timeZoneString
+ * @return \DateTimeZone
*/
- protected function trimToRecurrenceCount(array $rrules, array $recurrenceEvents)
+ public function timeZoneStringToDateTimeZone($timeZoneString)
{
- if (isset($rrules['COUNT'])) {
- $recurrenceCount = (intval($rrules['COUNT']) - 1);
- $surplusCount = (sizeof($recurrenceEvents) - $recurrenceCount);
+ // Some time zones contain characters that are not permitted in param-texts,
+ // but are within quoted texts. We need to remove the quotes as they're not
+ // actually part of the time zone.
+ $timeZoneString = trim($timeZoneString, '"');
+ $timeZoneString = html_entity_decode($timeZoneString);
+
+ if ($this->isValidIanaTimeZoneId($timeZoneString)) {
+ return new \DateTimeZone($timeZoneString);
+ }
- if ($surplusCount > 0) {
- $recurrenceEvents = array_slice($recurrenceEvents, 0, $recurrenceCount);
- $this->eventCount -= $surplusCount;
- }
+ if ($this->isValidCldrTimeZoneId($timeZoneString)) {
+ return new \DateTimeZone(self::$cldrTimeZonesMap[$timeZoneString]);
+ }
+
+ if ($this->isValidWindowsTimeZoneId($timeZoneString)) {
+ return new \DateTimeZone(self::$windowsTimeZonesMap[$timeZoneString]);
}
- return $recurrenceEvents;
+ return new \DateTimeZone($this->defaultTimeZone);
}
-}
\ No newline at end of file
+}
diff --git a/lib/calendar/ICal/LICENSE b/lib/calendar/ICal/LICENSE
index ccbd1abd4..0708674b4 100644
--- a/lib/calendar/ICal/LICENSE
+++ b/lib/calendar/ICal/LICENSE
@@ -1,5 +1,5 @@
The MIT License (MIT)
-Copyright (c) 2016
+Copyright (c) 2018
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
@@ -12,4 +12,4 @@ Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
-ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/calendar/service/CalDav.php b/lib/calendar/service/CalDav.php
index 37630a86b..ee205c774 100755
--- a/lib/calendar/service/CalDav.php
+++ b/lib/calendar/service/CalDav.php
@@ -27,6 +27,7 @@ class calendar_caldav extends calendar
*/
private function get_caldav_data($url, $method, $xmlquery, $depth = 0)
{
+ $loadError = '';
$ctxopts = array('http' =>
array(
'method' => $method,
@@ -43,8 +44,13 @@ private function get_caldav_data($url, $method, $xmlquery, $depth = 0)
$caldavresponse = file_get_contents($url, false, $context);
if ($caldavresponse === false) {
- $this->error('Calendar: CalDav', 'Read request to "'.$url.'" failed with message "'.$http_response_header[0].'"');
- $this->debug(implode("\n", $http_response_header));
+ if (substr($this->errorMessage, 0, 17) == 'file_get_contents')
+ $loadError = substr(strrchr($this->errorMessage, ':'), 2);
+ elseif (!empty($http_response_header))
+ $loadError = $http_response_header[0];
+ $this->error('Calendar: CalDav', 'Read request to "'.$url.'" failed with message "'.$loadError.'"');
+ if (!empty($http_response_header))
+ $this->debug(implode("\n", $http_response_header));
echo $this->json();
exit;
}
@@ -62,26 +68,26 @@ private function get_caldav_data($url, $method, $xmlquery, $depth = 0)
private function get_calendar_urls($davbaseurl, $calnames = array('')) {
// extract server root url
$urlparsed = parse_url($davbaseurl);
- $calserver = (isset($urlparsed['scheme']) ? $urlparsed['scheme'] : 'https') . '://' . $urlparsed['host'] . (isset($urlparsed['port']) ? ':'.$urlparsed['port'] : '');
+ $calserver = (isset($urlparsed['scheme']) ? $urlparsed['scheme'] : 'https') . '://' . (isset($urlparsed['host']) ? $urlparsed['host'] : '') . (isset($urlparsed['port']) ? ':'.$urlparsed['port'] : '');
- // Get user pricipal
+ // Get user principal
$xmlquery = '';
$xml = $this->get_caldav_data($davbaseurl, "PROPFIND", $xmlquery);
- $principle_url = $xml->response->propstat->prop->{'current-user-principal'}->href;
- $this->debug((string)$principle_url, 'principle_url');
+ $principal_url = (!$xml ? "" : $xml->response->propstat->prop->{'current-user-principal'}->href);
+ $this->debug((string)$principal_url, 'principal_url');
// use configured url if no current-user-principal returned
- if($principle_url == "")
- $principle_url = $davbaseurl;
- else if(strpos($principle_url, '://') === false)
- $principle_url = $calserver . $principle_url;
+ if($principal_url == "")
+ $principal_url = $davbaseurl;
+ else if(strpos($principal_url, '://') === false)
+ $principal_url = $calserver . $principal_url;
- $urlparsed = parse_url($principle_url);
- $calserver = $urlparsed['scheme'] . '://' . $urlparsed['host'] . (isset($urlparsed['port']) ? ':'.$urlparsed['port'] : '');
+ $urlparsed = parse_url($principal_url);
+ $calserver = (isset($urlparsed['scheme']) ? $urlparsed['scheme'] : 'https') . '://' . (isset($urlparsed['host']) ? $urlparsed['host'] : '') . (isset($urlparsed['port']) ? ':'.$urlparsed['port'] : '');
// Get home url of user's calendars
$xmlquery = '';
- $xml = $this->get_caldav_data($principle_url, "PROPFIND", $xmlquery);
- $calendar_home_url = $xml->response->propstat->prop->children('urn:ietf:params:xml:ns:caldav')->{'calendar-home-set'}->children('DAV:')->href;
+ $xml = $this->get_caldav_data($principal_url, "PROPFIND", $xmlquery);
+ $calendar_home_url = (!$xml ? "" : $xml->response->propstat->prop->children('urn:ietf:params:xml:ns:caldav')->{'calendar-home-set'}->children('DAV:')->href);
$this->debug((string)$calendar_home_url, 'calendar_home_url');
// use configured url if no calendar-home-set returned
if($calendar_home_url == "")
@@ -90,7 +96,7 @@ private function get_calendar_urls($davbaseurl, $calnames = array('')) {
$calendar_home_url = $calserver . $calendar_home_url;
$urlparsed = parse_url($calendar_home_url);
- $calserver = $urlparsed['scheme'] . '://' . $urlparsed['host'] . (isset($urlparsed['port']) ? ':'.$urlparsed['port'] : '');
+ $calserver = (isset($urlparsed['scheme']) ? $urlparsed['scheme'] : 'https') . '://' . (isset($urlparsed['host']) ? $urlparsed['host'] : '') . (isset($urlparsed['port']) ? ':'.$urlparsed['port'] : '');
// Get calendars
// '?' masked by '\x3f' in first line to prevent confusing of syntax highlighters
$xmlquery = <<get_caldav_data($calendar_home_url, 'PROPFIND', $xmlquery, 1);
$calurls = array();
- foreach($xml->response as $response) {
- // check if response is a calendar
- if(!isset($response->propstat->prop->resourcetype->children('urn:ietf:params:xml:ns:caldav')->calendar))
- continue;
- /*
- // check if response may have VEVENT components
- //response sample: c:supported-calendar-component-set>
- if(!isset($response->propstat[0]->prop[0]->children('urn:ietf:params:xml:ns:caldav')->{'supported-calendar-component-set'})
- continue;
- */
- $displayname = strtolower($response->propstat->prop->displayname);
- $this->debug((string)$response->href, 'calendar_url of \''.$displayname.'\'');
- // add only requested (by URL parameter or configuration) calendars or all, if none requested
- if(in_array($displayname, $calnames) || $calnames === array('')) {
- $description = $response->propstat->prop->children('urn:ietf:params:xml:ns:caldav')->{'calendar-description'};
- $color = $response->propstat->prop->children('http://apple.com/ns/ical/')->{'calendar-color'};
- $calendar_url = $response->href;
- if(strpos($calendar_url, '://') === false)
- $calendar_url = $calserver . $calendar_url;
- $calurls[$calendar_url] = array(
- 'calendarname' => $displayname,
- 'calendarcolor' => $color != false ? (string)$color : null,
- 'calendardesc' => $description != false ? (string)$description : null
- );
+ if ($xml != false){
+ foreach($xml->response as $response) {
+ // check if response is a calendar
+ if(!isset($response->propstat->prop->resourcetype->children('urn:ietf:params:xml:ns:caldav')->calendar))
+ continue;
+
+ /*
+ // check if response may have VEVENT components
+ //response sample: c:supported-calendar-component-set>
+ if(!isset($response->propstat[0]->prop[0]->children('urn:ietf:params:xml:ns:caldav')->{'supported-calendar-component-set'})
+ continue;
+ */
+ $displayname = strtolower($response->propstat->prop->displayname);
+ $this->debug((string)$response->href, 'calendar_url of \''.$displayname.'\'');
+ // add only requested (by URL parameter or configuration) calendars or all, if none requested
+ if(in_array($displayname, $calnames) || $calnames === array('')) {
+ $description = $response->propstat->prop->children('urn:ietf:params:xml:ns:caldav')->{'calendar-description'};
+ $color = $response->propstat->prop->children('http://apple.com/ns/ical/')->{'calendar-color'};
+ $calendar_url = $response->href;
+ if(strpos($calendar_url, '://') === false)
+ $calendar_url = $calserver . $calendar_url;
+ $calurls[$calendar_url] = array(
+ 'calendarname' => $displayname,
+ 'calendarcolor' => $color != false ? (string)$color : null,
+ 'calendardesc' => $description != false ? (string)$description : null
+ );
+ }
}
}
-
+ if (!$xml || empty($calurls)) {
+ $calError = $this->errorMessage.'
Calendar URLs could not be identified in remote answer.
Try using ICS calendar service';
+ $this->error('Calendar: CalDav', 'Read request failed with message'.$calError );
+ }
return $calurls;
}
diff --git a/lib/calendar/service/service_disabled.php b/lib/calendar/service/service_disabled.php
index 34bf0608a..f71141077 100644
--- a/lib/calendar/service/service_disabled.php
+++ b/lib/calendar/service/service_disabled.php
@@ -15,5 +15,19 @@
* @hide calendar_name
* @hide calendar_color
*/
+require_once '../../../lib/includes.php';
+require_once const_path_system . 'calendar/calendar.php';
+
+class calendar_disabled extends calendar
+{
+ public function __construct(){
+ parent::__construct(null);
+
+ $this->error('Calendar service', 'Calendar service is disabled. Select a web service or "offline" for a demo.
'.
+ 'Remove the widget from your pages if you don\'t need the service.');
+ }
+}
+$service = new calendar_disabled(array_merge($_GET, $_POST));
+echo $service->json();
?>
\ No newline at end of file
diff --git a/lib/config.php b/lib/config.php
index cd72a30e8..9765fae88 100644
--- a/lib/config.php
+++ b/lib/config.php
@@ -30,7 +30,7 @@ public function __construct()
*/
public function get($source = 'all')
{
- $config = $this->config_by_source[$source];
+ $config = (isset($this->config_by_source[$source]) ? $this->config_by_source[$source] : null);
// only read if source was not read before
if($config == null) {
@@ -53,21 +53,24 @@ public function get($source = 'all')
case 'globalonly':
if (is_file(const_path.'config.ini'))
$config = parse_ini_file(const_path.'config.ini', true, self::INI_SCANNER);
- elseif (is_file(const_path.'config.php')) {
+
+ //drop support for legacy config.php as of v3.1
+ //
+ //elseif (is_file(const_path.'config.php')) {
// read legacy config.php
- $configphp = file_get_contents(const_path.'config.php');
- preg_match_all("/define\s*\s*\('config_(.*?)'\s*,\s*(.*)\s*\)\s*;\s*[\r\n]+/", $configphp, $matches, PREG_SET_ORDER);
-
- $config = array();
- foreach($matches as $match) {
- $config[$match[1]] = eval('return '.$match[2].';');
- }
- }
+ // $configphp = file_get_contents(const_path.'config.php');
+ // preg_match_all("/define\s*\s*\('config_(.*?)'\s*,\s*(.*)\s*\)\s*;\s*[\r\n]+/", $configphp, $matches, PREG_SET_ORDER);
+
+ // $config = array();
+ // foreach($matches as $match) {
+ // $config[$match[1]] = eval('return '.$match[2].';');
+ // }
+ //}
break;
// configuration per pages (config.ini in current pages folder)
case 'pages':
- if ($_REQUEST['pages'] != '') // pages in request
+ if (isset($_REQUEST['pages']) && $_REQUEST['pages'] != '') // pages in request
$config_for_pages['pages'] = $_REQUEST['pages'];
else {
// configuration of pages in cookie
@@ -121,21 +124,29 @@ public function save($target, $options, $pages) {
break;
case 'cookie':
$basepath = substr(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), 0, -strlen(substr($_SERVER['SCRIPT_FILENAME'], strlen(const_path))));
- // generate unique cache folder for cookie (combination of remote IP, forwarded IP and time should be unique)
- if(!isset($config['cachefolder']) || $config['cachefolder'] == 'global')
- $config['cachefolder'] = md5($_SERVER['REMOTE_ADDR'] . ($_SERVER['HTTP_CLIENT_IP'] ?: $_SERVER['HTTP_X_FORWARDED_FOR'] ?: $_SERVER['HTTP_X_FORWARDED'] ?: $_SERVER['HTTP_FORWARDED_FOR'] ?: $_SERVER['HTTP_FORWARDED']) . time());
+ if (isset($config['cache'])) {
+ // generate unique cache folder for cookie (combination of remote IP, forwarded IP and time should be unique)
+ if(!isset($config['cachefolder']) || $config['cachefolder'] == 'global')
+ $config['cachefolder'] = md5($_SERVER['REMOTE_ADDR'] . ($_SERVER['HTTP_CLIENT_IP'] ?: $_SERVER['HTTP_X_FORWARDED_FOR'] ?: $_SERVER['HTTP_X_FORWARDED'] ?: $_SERVER['HTTP_FORWARDED_FOR'] ?: $_SERVER['HTTP_FORWARDED']) . time());
+ } else
+ unset($config['cachefolder']);
+
if(count($config) > 0){ // some options are set
+ foreach ($config as $key=>&$val) {
+ $val = ($val == "true") ? "1" : $val;
+ $val = ($val == "false") ? "" : $val;
+ }
$confexpire = time()+3600*24*364*10; // expires after 10 years
$success = setcookie('config', json_encode($config), ['expires' => $confexpire, 'path' => $basepath, 'samesite' => 'Lax']);
}
else
- $success = setcookie('config', '', time() - 3600, $basepath); // delete cookie
+ $success = setcookie('config', '', time() - 3600, $basepath); // delete cookie
break;
}
if($success)
$this->config_by_source[$target] = $config;
-
+
return $success;
}
diff --git a/lib/functions_twig.php b/lib/functions_twig.php
index c3bdc1b00..b79845f60 100644
--- a/lib/functions_twig.php
+++ b/lib/functions_twig.php
@@ -8,6 +8,7 @@
* -----------------------------------------------------------------------------
*/
+const SmartvisuButtonTypes = array('micro', 'mini', 'midi', 'icon');
// -----------------------------------------------------------------------------
// Filters for Twig
@@ -28,7 +29,7 @@ function twig_bit($val)
function twig_substr($val, $start, $end = null)
{
- if ($end)
+ if (isset($end))
$ret = substr($val, $start, $end);
else
$ret = substr($val, $start);
@@ -221,7 +222,13 @@ function twig_docu($filenames = null)
}
else
$p['valid_values'] = explode(',', substr($tag[5],1,-1));
+
+ if ($p['type'] == 'type')
+ $p['valid_values'] = array_merge(SmartvisuButtonTypes, $p['valid_values']);
}
+ elseif ($p['type'] == 'type')
+ $p['valid_values'] = SmartvisuButtonTypes;
+
$p['optional'] = $tag[6] != '';
if($p['optional'] && $tag[6] != '=')
$p['default'] = substr($tag[6],1);
@@ -229,7 +236,8 @@ function twig_docu($filenames = null)
// valid_values
// array_form must may
}
- $rettmp['param'][trim($params[$param++])] = $p;
+ if (isset($params[$param])) //wvhn@v3.1: hidden parameter starting with "_" must be omitted
+ $rettmp['param'][trim($params[$param++])] = $p;
}
elseif ($tag[1] == 'see')
{
@@ -327,10 +335,11 @@ function twig_lang($subset, $key, $subkey = null)
if (!$lang)
$lang = get_lang();
- if($subkey == null)
+ if(!isset($subkey))
return $lang[$subset][$key];
- else
- return $lang[$subset][$key][$subkey];
+ else
+ if (isset($lang[$subset][$key][$subkey]))
+ return $lang[$subset][$key][$subkey];
}
/**
@@ -422,7 +431,7 @@ function twig_items () {
//
function twig_asset_exists($file) {
$fileExists = 0;
- $requestpages = ($_REQUEST['pages'] != '') ? $_REQUEST['pages'] : config_pages;
+ $requestpages = (isset($_REQUEST['pages']) && $_REQUEST['pages'] != '') ? $_REQUEST['pages'] : config_pages;
if(strpos($file, '/') === false) {
if(is_file(const_path . 'widgets/'. $file)) $fileExists = 1;
if(is_file(const_path . 'dropins/'. $file)) $fileExists = 1;
diff --git a/lib/getdir.php b/lib/getdir.php
new file mode 100644
index 000000000..49e2d9ea7
--- /dev/null
+++ b/lib/getdir.php
@@ -0,0 +1,29 @@
+
+
+
\ No newline at end of file
diff --git a/lib/includes.php b/lib/includes.php
index abbb607b1..979990cd5 100644
--- a/lib/includes.php
+++ b/lib/includes.php
@@ -77,4 +77,18 @@
*/
date_default_timezone_set(config_timezone);
+/**
+* Set Handling of PHP WARNINGS
+* TO DO: make a concept for integrating php error messages into SV notifications
+*/
+set_error_handler(
+ function($errno, $errstr, $errfile, $errline)
+ {
+ if (defined('config_debug') && config_debug == 1)
+ return false; // hand over to standard error reporting
+ else
+ return true; // ignore warnings
+ }
+ ,E_WARNING);
+
?>
\ No newline at end of file
diff --git a/lib/pagecache.php b/lib/pagecache.php
index 033a552b9..b31c0a015 100644
--- a/lib/pagecache.php
+++ b/lib/pagecache.php
@@ -98,7 +98,7 @@ private function filewrite($content)
if (!is_dir($dir))
mkdir($dir, 0775, true);
- $this->tmpFile = tempnam($dir, basename($file));
+ $this->tmpFile = tempnam($dir, basename($this->file));
}
file_put_contents($this->tmpFile, $content, FILE_APPEND);
diff --git a/lib/phone/phone.php b/lib/phone/phone.php
index 7ff8bc76b..d8a186c7e 100644
--- a/lib/phone/phone.php
+++ b/lib/phone/phone.php
@@ -31,7 +31,7 @@ public function __construct($request)
*/
public function init($request)
{
- $this->debug = ($request['debug'] == 1);
+ parent::init($request);
$this->server = config_phone_server;
$this->port = config_phone_port;
@@ -66,7 +66,7 @@ public function prepare()
$ds['text'] = trans('phone', 'unknown');
// combine the infos, if type is present
- if ($ds['type'] != '')
+ if (isset($ds['type']) && $ds['type'] != '')
$ds['text'] = $ds['name'].' ('.$ds['type'].')';
// dir == 0 missed
diff --git a/lib/phone/service/fritz!box.php b/lib/phone/service/fritz!box.php
index b4036f136..1cd9f00ab 100644
--- a/lib/phone/service/fritz!box.php
+++ b/lib/phone/service/fritz!box.php
@@ -6,6 +6,7 @@
* @copyright 2012 - 2015
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
+ * @deprecated true
*/
diff --git a/lib/phone/service/fritz!box_TR-064.php b/lib/phone/service/fritz!box_TR-064.php
index dcf6e4b64..5e3fb8729 100644
--- a/lib/phone/service/fritz!box_TR-064.php
+++ b/lib/phone/service/fritz!box_TR-064.php
@@ -133,41 +133,48 @@ private function TransformCallList()
$url = $this->call_list_url . '&max=' . $this->max_calls_to_fetch;
$this->debug($url, "URL for call_list");
// download xml file and put it to xml parser
- $GetCallListXml = file_get_contents($url, false, stream_context_create(array('http://' => array('ssl' => $self->context_ssl))));
- $simplexml = simplexml_load_string($GetCallListXml);
- $this->debug($GetCallListXml, "GetCallListXml");
- /*
- [Id] => 1767
- [Type] => 1
- [Caller] => 0175000000
- [Called] => Amt ISDN 123456789
- [Name] => Mustermann, Max
- [Numbertype] => isdn
- [Device] => Wohnzimmer
- [Port] => 10
- [Date] => 08.02.14 12:43
- [Duration] => 0:32
- */
- // map fritz box xml values to the smartvisu standard
- foreach ($simplexml->xpath('//Call') as $call) {
- // check if we got german date format and translate to ISO date
- //(smartvisu is using strtotime later on)
- if (preg_match("/[0-3]\d\.[0-1]\d\.\d{2}\s([0-1][0-9]|[2][0-3]):([0-5][0-9])/", $call->Date)) {
- $date = DateTime::createFromFormat('d.m.y H:i', $call->Date);
- $call->Date = $date->format('Y-m-d H:i');
- }
- // bulid data array for smartvisu
- $this->data[] = array(
- 'pos' => (string) $call->Id,
- 'dir' => (string) ($call->Type == 1 ? 1 : ($call->Type == 2 ? 0 : -1)),
- 'date' => (string) $call->Date,
- 'number' => (string) $call->Caller,
- 'name' => (string) $call->Name,
- 'called' => (string) $call->Called,
- 'duration' => (string) $call->Duration
- );
- $call = '';
- }
+ $loadError = '';
+ $GetCallListXml = file_get_contents($url, false, stream_context_create(array('http://' => array('ssl' => $this->context_ssl))));
+ if (substr($this->errorMessage, 0, 17) == 'file_get_contents') {
+ $loadError = substr(strrchr($this->errorMessage, ':'), 2);
+ $this->error('Phone: fritz!box_TR-064', 'Read request failed with message: '.$loadError);
+ }
+ else {
+ $simplexml = simplexml_load_string($GetCallListXml);
+ $this->debug($GetCallListXml, "GetCallListXml");
+ /*
+ [Id] => 1767
+ [Type] => 1
+ [Caller] => 0175000000
+ [Called] => Amt ISDN 123456789
+ [Name] => Mustermann, Max
+ [Numbertype] => isdn
+ [Device] => Wohnzimmer
+ [Port] => 10
+ [Date] => 08.02.14 12:43
+ [Duration] => 0:32
+ */
+ // map fritz box xml values to the smartvisu standard
+ foreach ($simplexml->xpath('//Call') as $call) {
+ // check if we got german date format and translate to ISO date
+ //(smartvisu is using strtotime later on)
+ if (preg_match("/[0-3]\d\.[0-1]\d\.\d{2}\s([0-1][0-9]|[2][0-3]):([0-5][0-9])/", $call->Date)) {
+ $date = DateTime::createFromFormat('d.m.y H:i', $call->Date);
+ $call->Date = $date->format('Y-m-d H:i');
+ }
+ // bulid data array for smartvisu
+ $this->data[] = array(
+ 'pos' => (string) $call->Id,
+ 'dir' => (string) ($call->Type == 1 ? 1 : ($call->Type == 2 ? 0 : -1)),
+ 'date' => (string) $call->Date,
+ 'number' => (string) $call->Caller,
+ 'name' => (string) $call->Name,
+ 'called' => (string) $call->Called,
+ 'duration' => (string) $call->Duration
+ );
+ $call = '';
+ }
+ }
}
public function run()
{
diff --git a/lib/phone/service/fritz!box_v5.20.php b/lib/phone/service/fritz!box_v5.20.php
index cdf3144b9..478f39384 100644
--- a/lib/phone/service/fritz!box_v5.20.php
+++ b/lib/phone/service/fritz!box_v5.20.php
@@ -6,6 +6,7 @@
* @copyright 2012 - 2015
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
+ * @deprecated true
*/
diff --git a/lib/phone/service/fritz!box_v5.20_cable6360.php b/lib/phone/service/fritz!box_v5.20_cable6360.php
index f79eefe1d..c135b19ba 100644
--- a/lib/phone/service/fritz!box_v5.20_cable6360.php
+++ b/lib/phone/service/fritz!box_v5.20_cable6360.php
@@ -6,6 +6,7 @@
* @copyright 2012 - 2015
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
+ * @deprecated true
*/
diff --git a/lib/phone/service/fritz!box_v5.50.php b/lib/phone/service/fritz!box_v5.50.php
index 535667ac1..56a4fde85 100644
--- a/lib/phone/service/fritz!box_v5.50.php
+++ b/lib/phone/service/fritz!box_v5.50.php
@@ -6,6 +6,7 @@
* @copyright 2012 - 2015
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
+ * @deprecated true
*/
diff --git a/lib/phone/service/service_disabled.php b/lib/phone/service/service_disabled.php
index 96813f7b4..c2465aeba 100644
--- a/lib/phone/service/service_disabled.php
+++ b/lib/phone/service/service_disabled.php
@@ -14,4 +14,19 @@
* @hide phone_pass
*/
+require_once '../../../lib/includes.php';
+require_once const_path_system . 'phone/phone.php';
+
+class phone_disabled extends phone
+{
+ public function __construct(){
+ parent::__construct(null);
+
+ $this->error('Phone service', 'Phone service is disabled. Select a device driver or "offline" for a demo.
'
+ .'Remove the widget from your pages if you don\'t need the service.');
+ }
+}
+$service = new phone_disabled(array_merge($_GET, $_POST));
+echo $service->json();
+
?>
diff --git a/lib/service.php b/lib/service.php
index 38e92a9d4..358c9484a 100644
--- a/lib/service.php
+++ b/lib/service.php
@@ -23,6 +23,7 @@ class service
var $data = array();
var $error = array();
+ var $errorMessage = '';
/**
* constructor
@@ -37,8 +38,20 @@ public function __construct($request)
*/
public function init($request)
{
- $this->debug = ($request['debug'] == 1);
- error_reporting($this->debug ? E_ALL : 0);
+ if (isset($request['debug']))
+ $this->debug =($request['debug'] == 1);
+ error_reporting(E_ALL);
+
+ set_error_handler(
+ function($errno, $errstr, $errfile, $errline)
+ {
+ $this->errorMessage = $errstr;
+ if ($this->debug == 1)
+ return false; // hand over to standard error reporting
+ else
+ return true;
+ }
+ ,E_ALL);
// the following section has been deactivated by wvhn
// all services bring their own init functions which define the needed communication parameters
diff --git a/lib/templatechecker/Developers.md b/lib/templatechecker/Developers.md
index e829f36ef..df764058a 100644
--- a/lib/templatechecker/Developers.md
+++ b/lib/templatechecker/Developers.md
@@ -1,4 +1,4 @@
-#SmartVISU Template Checker Developer Documentation
+# SmartVISU Template Checker Developer Documentation
This file describes the general syntax to configure the widget parameter checker
@@ -17,42 +17,48 @@ The widget parameter configuration is a multilevel associative array with the fo
[... more elements for further widgets ...]
);
-`'[variable].[widget]'` has to be all lowercase, independent from the case of the actual variable/widget name.
+The array will be filled by the twig_docu() function in ./lib/functions_twig.php which collects all widgets residing in the widget folders
+which are defined in the loader path for smartVISU.
`[parameter index]` is a zero based numeric value: 0 means the first parameter, 1 the second, 2 the third, ...
-`'[type]'` indicates the type of the parameter. Supported types are described in the next section.
-
-Valid values for `'[additional setting]'` depend on `'[type]'`. They are described in the next section, too.
-
-If you want an widget not to be reported as unknown, but not to be checked either, just assign an empty array to `'[variable].[widget]'`.
+`'[type]'` indicates the type of the parameter. Supported types are described in the next section. Parameter types and all other settings
+are collected from the widgets docstring. See description in the subsequent sections.
# Widget Parameter Configuration
-* `'type' => '[type]'`
- type of parameter: see below for valid values.
-
-## General settings, applicable for all types of parameters
-* `'optional' => [TRUE|FALSE]`
-Optional, Default: FALSE
-Set to TRUE if parameter is optional.
-If unset or FALSE, parameter is considered to be mandatory and will cause an error if parameter is missing or empty.
-
-* `'default' => [value]`
-Optional.
-In case of optional parameters: Default-Value of parameter
-
-##Parameter type 'id'
-Parameter is id of widget. Usually the id is required to be unique within a file. The template checker reports if an id is being used multiple
+The docstring of a widget must contain descriptions for each individual parameter - starting with the identifier `@param`. The parameter
+name is defined in the arguments list of the macro call. The sequence of arguments and parameter descriptions must correspond.
+
+```
+/**
+* example for docstring
+*
+* @param definition for first_argument
+* @param definition for second_argument
+* @param definition for third_argument
+*
+* @author wvhn
+*/
+{% macro example(first_argument, second_argument, third_argument) &}
+```
+
+The parameter description is composed as follows: `* @param {type[array form](valid values)}`, e.g.
+
+ `* @param {text[?](text1,text2)=text1}`
+
+* `type`: parameter type is described in the subsequent section
+* `array form`: `[]` = parameter must be given as array / `[?]` parameter may be given as single value or as array
+* `valid values`: a sequence of valid parameter values in brackets (in some cases additional to predefined values - see below).
+* `optional`: a `=` indicates whether a parameter is optional. If missing, it is mandatory.
+* `default`: a default value can be defined for optional parameters
+
+
+## Parameter type 'id'
+Parameter is the id of the widget. In some cases, an id is required to be unique within a file. The template checker reports if an id is being used multiple
times within a file
-**Additional settings:**
-
-* `'unique' => [TRUE|FALSE]`
-Optional, Default: TRUE
-Set to FALSE if the id given for an parameter is not required to be unique.
-
-##Parameter type 'image'
+## Parameter type 'image'
Parameter is an image. Existence of the image will be checked. Missing images will be reported.
Valid values are:
@@ -61,11 +67,7 @@ Valid values are:
* Local images (paths "icon0" and "icon1" are respected, icons without path are looked up in the "icon0" directory)
* dynamic icons (icon.xyz widgets) and basic.symbol are also valid "images"
-**Additional settings:**
-
-* None
-
-##Parameter type 'text'
+## Parameter type 'text'
Parameter is an arbitrary text.
**Additional settings:**
@@ -74,35 +76,39 @@ Parameter is an arbitrary text.
Optional.
If set, the value of the parameter must be one of the values in `[list of values]`. Case sensitive check!
-##Parameter type 'value'
-Parameter is an (numeric) value. Any non-numeric values will be reported as errors.
+## Parameter type 'value'
+Parameter is a numeric value. Any non-numeric values will be reported as errors.
**Additional settings:**
-* `'min' => [minimum value]`
+* `'valid_values' => array([list of values])`
Optional.
-Minimum value for parameter. Values less than this minimum value will be reported as error
+If set, the value of the parameter must be one of the values in `[list of values]`.
+## Parameter type 'percent'
+Parameter is a percentage value consisting of a numeric value and a '%'. Any non-matching values will be reported as errors.
-* `'max' => [maximum value]`
-Optional.
-Maximum value for parameter. Values greater than this maximum value will be reported as error
+**Additional settings:**
+
+* None
-##Parameter type 'color'
+## Parameter type 'color'
Parameter is a color
-Valid values are:
+Valid predefined values are:
-* "icon0"
-* "icon1"
* any known named html color (e.g. "red", "green", "saddlebrown", "tomato", ...)
* any 3 or 6 digit hexadecimal color value prefixed by "#" (e.g. "#f00", "#0f0", "#8b4513", "#ff6347", ...)
**Additional settings:**
-* None
+* `'valid_values' => array([list of values])`
+Optional. Extends the predefined set of values.
+If set, the value of the parameter must be among the predefined values OR in the list of valid values. Case sensitive check!
+`icon0` and `icon1` are not accepted by some widgets. They need to be defined here.
+Further valid values can be `hidden` or `blank` e.g. in basic.stateswitch or basic.symbol
-##Parameter type 'item'
+## Parameter type 'item'
Parameter indicates an item.
Checks of items and item types are performend based on a file 'masteritem.json' which needs to be provided
either automatically by tha backend or manually by the user. The check vaidates whether the used items are available
@@ -124,7 +130,45 @@ Valid values are
* None
-##Parameter type 'iconseries'
+## Parameter type 'type'
+Parameter is a button type.
+Valid predefined values are
+
+* micro
+* mini
+* midi
+* icon
+
+**Additional settings:**
+
+* `'valid_values' => array([list of values])`
+Optional. Extends the predefined set of values.
+If set, the value of the parameter must be among the predefined values OR in the list of valid values. Case sensitive check!
+basic.symbol e.g. uses 'btn-micro'... which are defined as valid here.
+
+## Parameter type 'duration'
+Parameter requires [duration format](http://docu.smartvisu.de/2.7/index.php?page=misc/fundamentals) .
+
+**Additional settings:**
+
+* `'valid_values' => array([list of values])`
+Optional. Extends the predefined set of values.
+If set, the value of the parameter must be among the predefined duration values OR in the list of valid values. Case sensitive check!
+plot.period e.g. uses 'advanced' for zooming options which is defined as valid here.
+
+## Parameter type 'format'
+Parameter is a display format. Currently the checks for the type 'text' are performed.
+
+## Parameter type 'formula'
+Parameter is a formula e.g. for basic.symbol. Currently the checks for the type 'text' are performed.
+
+## Parameter type 'url'
+Parameter is an url. Currently the checks for the type 'text' are performed.
+
+## Parameter type 'unspecified'
+Parameters of this type will not be checked.
+
+## Parameter type 'iconseries'
Parameter defines an iconseries.
Valid values:
@@ -134,25 +178,8 @@ As parameter "\[filename\]_00.svg" needs to be given.
Files "\[filename\]_10.svg", "\[filename\]_20.svg", "\[filename\]_30.svg", ..., "\[filename\]_90.svg" and "\[filename\]_100.svg" are being used.
Their existence is checked by the template checker
-**Additional settings:**
-
-* None
-
-##Parameter type 'type'
-Parameter is an button type.
-Valid values are
-
-* micro
-* mini
-* midi
+This may be **deprecated** in future versions since basic.shifter is deprecated already.
**Additional settings:**
* None
-
-##Parameter type 'duration'
-Parameter requires [duration format](http://docu.smartvisu.de/2.7/index.php?page=misc/fundamentals) .
-
-**Additional settings:**
-
-* None
\ No newline at end of file
diff --git a/lib/templatechecker/ReadMe.md b/lib/templatechecker/ReadMe.md
index 127207144..43f6fed62 100644
--- a/lib/templatechecker/ReadMe.md
+++ b/lib/templatechecker/ReadMe.md
@@ -1,8 +1,8 @@
-#SmartVISU Template Checker
+# SmartVISU Template Checker
Check SmartVISU template files
-##Functionality##
+## Functionality
Currently the following checks are performed:
* __Basic html checks:__ Issues found while parsing the html file are reported
@@ -11,7 +11,7 @@ Currently the following checks are performed:
* __Widget deprecation check:__ report deprecated widgets and propose a replacement
* __Item check:__ check items and item types according to a masteritem file provided by the backend
-##Requirements for successful usage##
+## Requirements for successful usage
For a successful usage of the template checker, there are some requirements regarding your templates:
* __When using {% import "\[file\]" as \[variable\] %}, name the variable always as in the documentation:__
@@ -23,6 +23,7 @@ In general the variable should reflect the file name, so if you e.g. import "ico
* __When using own widgets:__
Import own widgets to different variable names than standard widgets to avoid name conflicts with the standard widgets.
Use a standard head in your widget code:
+```
/**
* -----------------------------------------------------------------------------
* @package smartVISU
@@ -31,11 +32,12 @@ Use a standard head in your widget code:
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
*/
+```
A docstring declaring the parameters is also necessary for a correct evaluation.
Widgets are checked in the ./widget directory as well as ./dropins, ./dropins/widgets and >yourpages</widgets.
A description of the widget parameter configuration can be found in file [Developers.md](Developers.md)
-##Cases which could not be fully checked##
+## Cases which could not be fully checked
There are some cases in which a template file can not be successfully checked, even if it does not contain any issues.
This is due to the fact that a template files is NOT a "standard HTML" file but an Twig template file.
diff --git a/lib/templatechecker/class.Config.php b/lib/templatechecker/class.Config.php
index bc51bab45..ecc70b2de 100644
--- a/lib/templatechecker/class.Config.php
+++ b/lib/templatechecker/class.Config.php
@@ -52,9 +52,10 @@ class TemplateCheckerConfig {
/**
* Array containing known button types
* @var array
+ *
+ * const SmartvisuButtonTypes = array('micro', 'mini', 'midi', 'icon');
*/
- const SmartvisuButtonTypes = array('micro', 'mini', 'midi', 'icon', 'text');
-
+
/**
* Array containing valid interval values for duration format
*/
diff --git a/lib/templatechecker/class.TemplateChecker.php b/lib/templatechecker/class.TemplateChecker.php
index 1f6c52588..8ae46d766 100644
--- a/lib/templatechecker/class.TemplateChecker.php
+++ b/lib/templatechecker/class.TemplateChecker.php
@@ -62,7 +62,15 @@ public static function run($fileName, MessageCollection $messages, $checkItems)
public function __construct($fileName, MessageCollection $messages, $checkItems) {
$this->messages = $messages;
$this->fileName = $fileName;
- $this->widgets = array_merge( twig_docu(), OldWidgets::getRemoved());
+ $fileFolder = strstr(pathinfo($fileName)["dirname"], 'pages');
+ $widgetFolder = ((strrpos($fileFolder, '/') == 5 ) ? $fileFolder : substr($fileFolder, 0, strrpos($fileFolder, '/'))).'/widgets';
+ if(twig_isdir($widgetFolder, '(.*.\.html)')) {
+ $widgetFiles = twig_dir($widgetFolder, '(.*.\.html)');
+ $this->widgets = array_merge( twig_docu(), twig_docu($widgetFiles), OldWidgets::getRemoved());
+ }
+ else {
+ $this->widgets = array_merge( twig_docu(), OldWidgets::getRemoved());
+ }
$this->items = new Items(pathinfo($fileName)["dirname"]); //new
if ($checkItems == "false")
{ $this->items->setState(FALSE);}
@@ -120,9 +128,9 @@ private function readFile($absFile) {
if (array_key_exists($error->code, $this->ignore_html_error_code)) {
$conditions = $this->ignore_html_error_code[$error->code];
$ignore = true;
- if ($conditions['maxline'] && $error->line > $conditions['maxline'])
+ if (isset($conditions['maxline']) && $error->line > $conditions['maxline'])
$ignore = false;
- if ($conditions['minline'] && $error->line < $conditions['minline'])
+ if (isset($conditions['minline']) && $error->line < $conditions['minline'])
$ignore = false;
if ($ignore)
continue;
@@ -243,6 +251,7 @@ public function checkWidget($node, $macro) {
if(array_key_exists('replacement', $widgetConfig)) {
$messageData['Replacement'] = preg_replace("/(\\s*,\\s*''\\s*)+(\\)\\s*}}\\s*)$/", '$2', vsprintf($widgetConfig['replacement'], $messageParams));
+ $messageData['Replacement'] = str_replace ("['', '']", "''", $messageData['Replacement']);
}
$this->messages->addError('WIDGET DEPRECATION CHECK', 'Removed widget', $widget->getLineNumber(), $widget->getMacro(), $messageData);
} else {
@@ -254,7 +263,7 @@ public function checkWidget($node, $macro) {
if (array_key_exists('deprecated', $widgetConfig)) {
$messageData = $widget->getMessageData();
if(array_key_exists('replacement', $widgetConfig)) {
- $messageData['Replacement'] = preg_replace("/(\\s*,\\s*''\\s*)+(\\)\\s*}}\\s*)$/", '$2', vsprintf($widgetConfig['replacement'], $widget->getParamArray() + array_map(function($element) { return "'" . $element['default'] . "'"; }, $paramConfigs)));
+ $messageData['Replacement'] = preg_replace("/(\\s*,\\s*''\\s*)+(\\)\\s*}}\\s*)$/", '$2', vsprintf($widgetConfig['replacement'], $widget->getParamArray() + array_map(function($element) { return isset($element['default']) ? "'" . $element['default']. "'" : null; }, $paramConfigs)));
}
$this->messages->addWarning('WIDGET DEPRECATION CHECK', 'Deprecated widget', $widget->getLineNumber(), $widget->getMacro(), $messageData);
}
diff --git a/lib/templatechecker/class.Widget.php b/lib/templatechecker/class.Widget.php
index d94fe8426..14b95c195 100644
--- a/lib/templatechecker/class.Widget.php
+++ b/lib/templatechecker/class.Widget.php
@@ -71,7 +71,7 @@ public function getParamString() {
* @return mixed, NULL if no parameter with give index is available
*/
public function getParam($index) {
- return $this->paramCount >= $index ? $this->paramArray[$index] : NULL;
+ return ($this->paramCount >= $index && isset($this->paramArray[$index]))? $this->paramArray[$index] : NULL;
}
/**
diff --git a/lib/templatechecker/class.WidgetParameterChecker.php b/lib/templatechecker/class.WidgetParameterChecker.php
index 13895f8db..eaa67a3db 100644
--- a/lib/templatechecker/class.WidgetParameterChecker.php
+++ b/lib/templatechecker/class.WidgetParameterChecker.php
@@ -69,10 +69,9 @@ function get_arrays($string, $start, $end, $single){
$string = str_replace("'", "", $string);
$string = str_replace('"', '', $string);
$p1 = explode($start, $string);
-
for($i=1; $igetParamConfig('type', 'unknown');
if($type == 'unknown') {
- $this->addWarning('WIDGET PARAM CHECK', 'Parameter type not defined. Check manually!', $value);
+ $this->addWarning('WIDGET PARAM CHECK', 'Parameter type not defined. Check manually!', null);
return;
}
@@ -193,6 +192,9 @@ private function run() {
// for now we perform the same tests than for type "text"
$this->checkParameterTypeText($value);
break;
+ case 'percent':
+ $this->checkParameterTypePercent($value); //new
+ break;
case 'unspecified':
// this type is not validated at all
$this->addInfo('WIDGET UNSPECIFIED PARAM TYPE', 'Parameter can not be checked, check manually', $value);
@@ -216,6 +218,7 @@ private function run() {
*/
private function getParameterValue() {
$value = $this->widget->getParam($this->paramIndex);
+
// parameter not given
if ($value == NULL || $value == '') {
if ($this->getParamConfig('optional', TRUE)) {
@@ -275,7 +278,7 @@ private function checkParameterNotEmpty($value) {
}
private function checkParameterValidValues($value) {
- return in_array($value, $this->paramConfig['valid_values'] ?: array());
+ return in_array($value, $this->paramConfig['valid_values'] ?? array());
}
/**
@@ -426,8 +429,9 @@ private function checkParameterTypeType($value) {
if (!$this->checkParameterNotEmpty($value))
return;
- if (in_array($value, TemplateCheckerConfig::SmartvisuButtonTypes))
- return;
+ // moved to function twig_docu @v3.1
+ //if (in_array($value, TemplateCheckerConfig::SmartvisuButtonTypes))
+ // return;
// additional widget-specific valid values
if ($this->checkParameterValidValues($value))
@@ -445,8 +449,9 @@ private function checkParameterTypeColor($value) {
return;
// these are defined based on the smartvisu layout
- if ($value == 'icon0' || $value == 'icon1')
- return;
+ //wvhn @v3.1 move icon0 / icon1 in valid values of the individual widgets since not all widgets accept them
+ // if ($value == 'icon0' || $value == 'icon1')
+ // return;
// additional widget-specific valid values
if ($this->checkParameterValidValues($value))
@@ -513,7 +518,7 @@ private function checkParameterTypeText($value) {
return;
- if ($this->paramConfig['valid_values']) {
+ if (isset($this->paramConfig['valid_values'])) {
if ($this->checkParameterValidValues($value)) {
if (Settings::SHOW_SUCCESS_TOO)
$this->addInfo('WIDGET TEXT PARAM CHECK', 'Value is valid', $value, array('Valid Values' => $this->paramConfig['valid_values']));
@@ -580,15 +585,29 @@ private function checkParameterTypeValue($value) {
}
$numVal = $value + 0;
- if ($this->paramConfig['min'] && $numVal < $this->paramConfig['min'] + 0) {
+ if (isset($this->paramConfig['min']) && $numVal < $this->paramConfig['min'] + 0) {
$this->addError('WIDGET VALUE PARAM CHECK', 'Value less than allowed minimum value', $value, array('Minimum Value' => $this->paramConfig['min']));
return;
}
- if ($this->paramConfig['max'] && $numVal > $this->paramConfig['max'] + 0) {
+ if (isset($this->paramConfig['max']) && $numVal > $this->paramConfig['max'] + 0) {
$this->addError('WIDGET VALUE PARAM CHECK', 'Value greater than allowed maximum value', $value, array('Maximum Value' => $this->paramConfig['max']));
return;
}
}
+
+ /**
+ * Check widget parameter of type "value"
+ * @param $value mixed parameter value
+ */
+ private function checkParameterTypePercent($value) {
+ if (!$this->checkParameterNotEmpty($value))
+ return;
+ $testPos = strpos($value, '%');
+ if ($testPos === false || !is_numeric(substr($value, 0, $testPos)) || strlen($value) != $testPos + 1) {
+ $this->addError('WIDGET VALUE PARAM CHECK', 'Percent value required', $value);
+ return;
+ }
+ }
/**
* Add widget related error to list of messages
diff --git a/lib/weather/jdigiweather.css b/lib/weather/jdigiweather.css
index 26483b36c..d478cecfa 100644
--- a/lib/weather/jdigiweather.css
+++ b/lib/weather/jdigiweather.css
@@ -45,17 +45,21 @@
.weather {
margin: auto;
width: 100%;
+ min-height: 190px;
background-position: center center;
background-repeat: no-repeat;
- background-size: 60%;
- -webkit-background-size: 60%;
- -moz-background-size: 60%;
- -o-background-size: 60%;
+ background-size: 250px 160px;
+ -webkit-background-size: 250px 160px;
+ -moz-background-size: 250px 160px;
+ -o-background-size: 250px 160px;
}
.weather .wind {
- padding-top: 10px;
+ padding-top: 0;
text-align: right;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.weather .misc,
@@ -65,7 +69,8 @@
}
.weather .temp {
- padding-top: 10px;
+ padding-top: 0;
+ margin-top: -10px;
font-size: 25pt;
text-align: left;
}
diff --git a/lib/weather/service/darksky.net.php b/lib/weather/service/darksky.net.php
index 75bd409b2..f970d84c7 100644
--- a/lib/weather/service/darksky.net.php
+++ b/lib/weather/service/darksky.net.php
@@ -32,9 +32,13 @@ public function run()
if ($cache->hit($this->cache_duration_minutes)) {
$content = $cache->read();
} else {
+ $loadError = '';
$url = 'https://api.darksky.net/forecast/' . config_weather_key . '/' . $this->location . '?exclude=minutely,hourly,alerts&units=auto&lang=' . trans('darksky', 'lang');
$content = file_get_contents($url);
- $cache->write($content);
+ if (substr($this->errorMessage, 0, 17) != 'file_get_contents')
+ $cache->write($content);
+ else
+ $loadError = substr(strrchr($this->errorMessage, ':'), 2);
}
$parsed_json = json_decode($content);
@@ -49,16 +53,17 @@ public function run()
$wind_speed = transunit('speed', (float)$parsed_json->{'currently'}->{'windSpeed'});
$wind_gust = transunit('speed', (float)$parsed_json->{'currently'}->{'windGust'});
- $wind_direction = $this->getDirection((int)$parsed_json->{'currently'}->{'windBearing'});
+ $wind_dir = weather::getDirection((int)$parsed_json->{'currently'}->{'windBearing'});
- $this->data['current']['wind'] = translate('wind', 'darksky') . " " . $wind_speed;
+ $this->data['current']['wind'] = translate('wind', 'weather') . " " . $wind_speed;
// when there is no wind, direction is blank
if ($parsed_json->{'currently'}->{'windSpeed'} != 0)
- $this->data['current']['wind'] .= " " . translate('from', 'darksky') . " " . $wind_direction;
+ $this->data['current']['wind'] .= " " . translate('from', 'weather') . " " . $wind_dir;
if ($wind_gust > 0)
- $this->data['current']['wind'] .= ", " . translate('wind_gust', 'darksky') . " " . $wind_gust;
+ $this->data['current']['wind'] .= ", " . translate('wind_gust', 'weather') . " " . $wind_gust;
- $this->data['current']['more'] = translate('humidity', 'darksky') . " " . transunit('%', 100 * (float)$parsed_json->{'currently'}->{'humidity'});
+ $this->data['current']['more'] = translate('humidity', 'weather') . " " . transunit('%', 100 * (float)$parsed_json->{'currently'}->{'humidity'});
+ $this->data['current']['misc'] = translate('air pressure', 'weather') . " " . $parsed_json->{'currently'}->{'pressure'}.' hPa';
// forecast
$i = 0;
@@ -75,8 +80,11 @@ public function run()
$i++;
}
} else {
- $add = $parsed_json->{'flags'}->{'darksky-unavailable'};
- $this->error('Weather: darksky.net', 'Read request failed' . ($add ? ': ' . $add : '') . '!');
+ if ($loadError != '')
+ $add = $loadError;
+ else
+ $add = $parsed_json->{'flags'}->{'darksky-unavailable'};
+ $this->error('Weather: darksky.net', 'Read request failed'.($add ? ' with message: '.$add : '!'));
}
}
@@ -104,47 +112,6 @@ function icon($name, $sm = 'sun_')
return $ret;
}
- function getDirection($degree)
- {
- $direction = '';
-
- if ($degree > 348 or $degree <= 11) {
- $direction = translate('dir_n', 'darksky');
- } elseif ($degree > 11 and $degree <= 34) {
- $direction = translate('dir_nne', 'darksky');
- } elseif ($degree > 34 and $degree <= 56) {
- $direction = translate('dir_ne', 'darksky');
- } elseif ($degree > 56 and $degree <= 79) {
- $direction = translate('dir_ene', 'darksky');
- } elseif ($degree > 79 and $degree <= 101) {
- $direction = translate('dir_e', 'darksky');
- } elseif ($degree > 101 and $degree <= 123) {
- $direction = translate('dir_ese', 'darksky');
- } elseif ($degree > 123 and $degree <= 146) {
- $direction = translate('dir_se', 'darksky');
- } elseif ($degree > 146 and $degree <= 169) {
- $direction = translate('dir_sse', 'darksky');
- } elseif ($degree > 169 and $degree <= 191) {
- $direction = translate('dir_s', 'darksky');
- } elseif ($degree > 191 and $degree <= 214) {
- $direction = translate('dir_ssw', 'darksky');
- } elseif ($degree > 214 and $degree <= 236) {
- $direction = translate('dir_sw', 'darksky');
- } elseif ($degree > 236 and $degree <= 259) {
- $direction = translate('dir_wsw', 'darksky');
- } elseif ($degree > 259 and $degree <= 281) {
- $direction = translate('dir_w', 'darksky');
- } elseif ($degree > 281 and $degree <= 304) {
- $direction = translate('dir_wnw', 'darksky');
- } elseif ($degree > 304 and $degree <= 326) {
- $direction = translate('dir_nw', 'darksky');
- } elseif ($degree > 326 and $degree <= 348) {
-
- $direction = translate('dir_nnw', 'darksky');
- }
-
- return $direction;
- }
}
diff --git a/lib/weather/service/met.no.md b/lib/weather/service/met.no.md
new file mode 100644
index 000000000..11d810365
--- /dev/null
+++ b/lib/weather/service/met.no.md
@@ -0,0 +1,13 @@
+# met.no weather service for smartVISU
+
+## Installation
+1. in SmartVISU config page
+- choose met.no,
+- enter geographical coordinates for your location, e.g. lat=47.1234&lon=9.1234&altitude=123
+
+## Troubleshooting/Debug:
+1. check for files smartVISU/temp/met.no_lat47.1234lon9.1234altitude123.json .
+ This is the api response from met.no after you have called the service once.
+ View it with a json viewer (i.e. addon to chrome)
+2. debug the service by calling YOURSERVER/smartVISU/lib/weather/service/met.no.php?debug=1
+3. check for 'met.no' entries in var/log/nginx/error.log (or /var/log/apache2/)
diff --git a/lib/weather/service/met.no.php b/lib/weather/service/met.no.php
new file mode 100644
index 000000000..9f400674e
--- /dev/null
+++ b/lib/weather/service/met.no.php
@@ -0,0 +1,313 @@
+location).'.json');
+
+ if ($cache->hit($this->cache_duration_minutes))
+ $content = $cache->read();
+ else
+ {
+ $loadError = '';
+ $opts = array(
+ 'http'=>array(
+ 'method'=>"GET",
+ 'header'=>"User-Agent: smartVISU"
+ )
+ );
+ $context = stream_context_create($opts);
+ $url = 'https://api.met.no/weatherapi/locationforecast/2.0/complete?'.$this->location;
+ $content = file_get_contents($url, false, $context);
+ if (substr($this->errorMessage, 0, 17) != 'file_get_contents')
+ $cache->write($content);
+ else
+ $loadError = substr(strrchr($this->errorMessage, ':'), 2);
+ }
+
+ $parsed_json = json_decode($content);
+ $this->debug($parsed_json);
+
+ if ($parsed_json->{'properties'}->{'timeseries'}['0']) {
+ // night or day symbol of today
+ $actualconditions = $parsed_json->{'properties'}->{'timeseries'}['0']->{'data'}->{'next_1_hours'}->{'summary'}->{'symbol_code'};
+ $actualTimeSymbol = substr(strrchr($actualconditions, '_'),1);
+ if ($actualTimeSymbol != '') {
+ $actualconditions = substr($actualconditions, 0, strpos($actualconditions, '_'));
+ $actualTimeSymbol = ($actualTimeSymbol == 'night') ? 'moon_' : 'sun_';
+ }
+ else
+ $actualTimeSymbol = ($this->icon_sm != '') ? $this->icon_sm : 'sun_';
+
+ $actualdata = $parsed_json->{'properties'}->{'timeseries'}['0']->{'data'}->{'instant'}->{'details'};
+ $wind_dir = weather::getDirection((float)$actualdata->{'wind_from_direction'});
+ $wind_speed = transunit('speed',round(3.6*(float)$actualdata->{'wind_speed'}, 1));
+ if (substr($wind_speed,-3) =='mph')
+ $wind_speed = transunit('speed',round(2.24*(float)$actualdata->{'wind_speed'}, 1));
+ $wind_desc = weather::getWindDescription($wind_speed);
+
+ $this->data['current']['temp'] = transunit('temp', (float)$actualdata->{'air_temperature'});
+ $this->data['current']['wind'] = $wind_desc.' '.translate('from', 'weather').' '.$wind_dir.' '.translate('at', 'weather').' '.$wind_speed;
+ $this->data['current']['more'] = translate('humidity', 'weather')." ".transunit('%', (float)$actualdata->{'relative_humidity'});
+ $this->data['current']['misc'] = translate('air pressure', 'weather')." ".(float)$actualdata->{'air_pressure_at_sea_level'}.' hPa';
+ $this->data['current']['conditions'] = translate($actualconditions, 'met.no');
+ $this->data['current']['icon'] = $this->icon($actualconditions, $actualTimeSymbol);
+
+ // forecast
+ // met.no times are UTC based. We need to sustract timezone from local time in order to fit to the UTC data raster.
+ // next_12_hours tag contains a conditions summary for 12 hours -> we use the forecast of 06.00 for the day
+ // next_6_hours tag contains values for min and max temperatures
+ // -> we use all data from 00.00, 06.00, 12.00 and 18.00 to compute min/max temperature
+ // if actual time is before 13.00 we use the rest of the day as forecast, and conditions from 12.00
+
+ $forecastCondition = 'NA';
+ $timezone = (int)date('Z', strtotime($parsed_json->{'properties'}->{'timeseries'}[0]->{'time'}))/3600;
+ $i = 0;
+ $dayready = 0;
+ $nextday = (int)(date('w', strtotime($parsed_json->{'properties'}->{'timeseries'}[0]->{'time'}) - $timezone * 3600)+1) %7;
+ $startTime = (24+ (int)date('G', strtotime($parsed_json->{'properties'}->{'timeseries'}[0]->{'time'})) - $timezone) %24;
+ $maxtemp = -100;
+ $mintemp = 100;
+
+
+ foreach ($parsed_json->{'properties'}->{'timeseries'} as $dataset) {
+ $timezone = (int)date('Z', strtotime($dataset->{'time'}))/3600;
+ $day = (int)date('w', strtotime($dataset->{'time'})- $timezone * 3600);
+ $actualTime = (24 +(int)date('G', strtotime($dataset->{'time'})) - $timezone) % 24;
+
+ // first day if requested before 12:00
+ if ($startTime <= 12 && $i == 0 && $actualTime <= 23 ) {
+ $temps = (float)$dataset->{'data'}->{'instant'}->{'details'}->{'air_temperature'};
+ if($temps > $maxtemp) $maxtemp = $temps;
+ if($temps < $mintemp) $mintemp = $temps;
+ if ($actualTime == 12)
+ $forecastCondition = $dataset->{'data'}->{'next_1_hours'}->{'summary'}->{'symbol_code'};
+ if ($actualTime == 23 ) {
+ $dayready = 1;
+ $nextday = $day;
+ }
+ }
+
+ $searchTimes = array (0, 6, 12, 18);
+ if ($day == $nextday && $dayready == 0) {
+ if (in_array ($actualTime, $searchTimes)) {
+ $mintemp_6h = (float)$dataset->{'data'}->{'next_6_hours'}->{'details'}->{'air_temperature_min'};
+ $maxtemp_6h = (float)$dataset->{'data'}->{'next_6_hours'}->{'details'}->{'air_temperature_max'};
+ if($maxtemp_6h > $maxtemp) $maxtemp = $maxtemp_6h;
+ if($mintemp_6h < $mintemp) $mintemp = $mintemp_6h;
+ if ($actualTime == 6)
+ $forecastCondition = $dataset->{'data'}->{'next_12_hours'}->{'summary'}->{'symbol_code'};
+ if ($actualTime == 18)
+ $dayready = 1;
+ }
+ }
+
+ if ($dayready == 1) {
+ if (strpos ($forecastCondition,'_') > 0)
+ $forecastCondition = substr($forecastCondition, 0, strpos($forecastCondition, '_'));
+ $this->data['forecast'][$i]['date'] = date('Y-m-d', strtotime($dataset->{'time'})-$timezone*3600);
+ $this->data['forecast'][$i]['conditions'] = translate($forecastCondition, 'met.no');
+ $this->data['forecast'][$i]['icon'] = $this->icon($forecastCondition);
+ $this->data['forecast'][$i]['temp'] = round($maxtemp, 0).'°C'.'/'.round($mintemp, 0).'°C';
+
+ $maxtemp = -100;
+ $mintemp = 100;
+ $dayready = 0;
+ $nextday = ($nextday + 1) %7;
+
+ $i++;
+ if ($i == 4) break;
+ }
+
+ }
+ }
+ else
+ {
+ if ($loadError != '')
+ $add = ' with message: '.$loadError;
+ else
+ $add = ': '.$this->errorMessage;
+
+ $this->error('Weather: met.no', 'Read request failed'.$add );
+ }
+ }
+
+ /*
+ * Icon-Mapper
+ */
+ function icon($name, $sm = 'sun_')
+ {
+ $ret = '';
+
+ $icon['clearsky'] = $sm.'1';
+ $icon['fair'] = $sm.'2';
+ $icon['partlycloudy'] = $sm.'3';
+ $icon['cloudy'] = $sm.'5';
+ $icon['lightrainshowers'] = $sm.'7';
+ $icon['rainshowers'] = $sm.'7';
+ $icon['heavyrainshowers'] = $sm.'8';
+ $icon['lightrainshowersandthunder'] = $sm.'9';
+ $icon['rainshowersandthunder'] = $sm.'9';
+ $icon['heavyrainshowersandthunder'] = $sm.'10';
+ $icon['lightsleetshowersandthunder']= $sm.'10';
+ $icon['sleetshowersandthunder'] = $sm.'10';
+ $icon['heavysleetshowersandthunder']= $sm.'10';
+ $icon['lightsnowshowersandthunder'] = $sm.'10';
+ $icon['snowshowersandthunder'] = $sm.'10';
+ $icon['heavysnowshowersandthunder'] = $sm.'10';
+ $icon['lightsleetshowers'] = $sm.'11';
+ $icon['sleetshowers'] = $sm.'11';
+ $icon['heavysleetshowers'] = $sm.'12';
+ $icon['lightsnowshowers'] = $sm.'13';
+ $icon['snowshowers'] = $sm.'13';
+ $icon['heavysnowshowers'] = $sm.'13';
+ $icon['fog'] = 'cloud_6';
+ $icon['lightrain'] = 'cloud_7';
+ $icon['rain'] = 'cloud_7';
+ $icon['heavyrain'] = 'cloud_8';
+ $icon['lightrainandthunder'] = 'cloud_9';
+ $icon['rainandthunder'] = 'cloud_9';
+ $icon['heavyrainandthunder'] = 'cloud_10';
+ $icon['lightsleet'] = 'cloud_11';
+ $icon['sleet'] = 'cloud_11';
+ $icon['heavysleet'] = 'cloud_11';
+ $icon['lightsnow'] = 'cloud_12';
+ $icon['snow'] = 'cloud_12';
+ $icon['heavysnow'] = 'cloud_13';
+ $icon['lightsnowandthunder'] = 'cloud_15';
+ $icon['snowandthunder'] = 'cloud_15';
+ $icon['heavysnowandthunder'] = 'cloud_15';
+ $icon['lightsleetandthunder'] = 'cloud_17';
+ $icon['sleetandthunder'] = 'cloud_17';
+ $icon['heavysleetandthunder'] = 'cloud_17';
+ $icon['NA'] = 'na';
+ $ret = $icon[$name];
+
+ return $ret;
+ }
+
+}
+
+
+// -----------------------------------------------------------------------------
+// i c o n - f o r m a t
+// -----------------------------------------------------------------------------
+
+/* "clearsky_day",
+ "clearsky_night",
+ "clearsky_polartwilight",
+ "fair_day",
+ "fair_night",
+ "fair_polartwilight",
+ "lightssnowshowersandthunder_day",
+ "lightssnowshowersandthunder_night",
+ "lightssnowshowersandthunder_polartwilight",
+ "lightsnowshowers_day",
+ "lightsnowshowers_night",
+ "lightsnowshowers_polartwilight",
+ "heavyrainandthunder",
+ "heavysnowandthunder",
+ "rainandthunder",
+ "heavysleetshowersandthunder_day",
+ "heavysleetshowersandthunder_night",
+ "heavysleetshowersandthunder_polartwilight",
+ "heavysnow",
+ "heavyrainshowers_day",
+ "heavyrainshowers_night",
+ "heavyrainshowers_polartwilight",
+ "lightsleet",
+ "heavyrain",
+ "lightrainshowers_day",
+ "lightrainshowers_night",
+ "lightrainshowers_polartwilight",
+ "heavysleetshowers_day",
+ "heavysleetshowers_night",
+ "heavysleetshowers_polartwilight",
+ "lightsleetshowers_day",
+ "lightsleetshowers_night",
+ "lightsleetshowers_polartwilight",
+ "snow",
+ "heavyrainshowersandthunder_day",
+ "heavyrainshowersandthunder_night",
+ "heavyrainshowersandthunder_polartwilight",
+ "snowshowers_day",
+ "snowshowers_night",
+ "snowshowers_polartwilight",
+ "fog",
+ "snowshowersandthunder_day",
+ "snowshowersandthunder_night",
+ "snowshowersandthunder_polartwilight",
+ "lightsnowandthunder",
+ "heavysleetandthunder",
+ "lightrain",
+ "rainshowersandthunder_day",
+ "rainshowersandthunder_night",
+ "rainshowersandthunder_polartwilight",
+ "rain",
+ "lightsnow",
+ "lightrainshowersandthunder_day",
+ "lightrainshowersandthunder_night",
+ "lightrainshowersandthunder_polartwilight",
+ "heavysleet",
+ "sleetandthunder",
+ "lightrainandthunder",
+ "sleet",
+ "lightssleetshowersandthunder_day",
+ "lightssleetshowersandthunder_night",
+ "lightssleetshowersandthunder_polartwilight",
+ "lightsleetandthunder",
+ "partlycloudy_day",
+ "partlycloudy_night",
+ "partlycloudy_polartwilight",
+ "sleetshowersandthunder_day",
+ "sleetshowersandthunder_night",
+ "sleetshowersandthunder_polartwilight",
+ "rainshowers_day",
+ "rainshowers_night",
+ "rainshowers_polartwilight",
+ "snowandthunder",
+ "sleetshowers_day",
+ "sleetshowers_night",
+ "sleetshowers_polartwilight",
+ "cloudy",
+ "heavysnowshowersandthunder_day",
+ "heavysnowshowersandthunder_night",
+ "heavysnowshowersandthunder_polartwilight",
+ "heavysnowshowers_day",
+ "heavysnowshowers_night",
+ "heavysnowshowers_polartwilight"
+*/
+
+
+// -----------------------------------------------------------------------------
+// call the service
+// -----------------------------------------------------------------------------
+
+$service = new weather_met(array_merge($_GET, $_POST));
+echo $service->json();
+
+?>
diff --git a/lib/weather/service/openweathermap.md b/lib/weather/service/openweathermap.md
index 80de71362..fa2511b6e 100644
--- a/lib/weather/service/openweathermap.md
+++ b/lib/weather/service/openweathermap.md
@@ -1,11 +1,17 @@
-##Openweathermap.org weather service for smartVISU
+# Openweathermap.org weather service for smartVISU
-##Installation
+## Installation
1. register at openweathermap.org to get an api key
2. in SmartVISU config page 1. choose openweather.org, 2. enter your location (i.e. Köln) 3. enter your api key
+ Location can be entered in the following ways:
+ * use latitude and longitude of your location e.g. `lat=50.93331&lon=6.95`
+ * use the city ID provided by openweathermap.org e.g. `id=2886242`
+ * use the ZIP code of your location, e.g. `zip=50667,DE`
+ * use the city name e.g. `Köln,DE`
-
-##Troubleshooting/Debug:
+## Troubleshooting/Debug:
1. check for file smartVISU/temp/openweathermap_YOURCITY.json
- this is the api response from openweathermap.org. View it with a json viewer (i.e. addon to chrome)
-2. check for 'openweathermaps' entries in Logs/nginx/error.log (or Logs/apache2/)
+ this is the api response from openweathermap.org after you have called the service once.
+ View it with a json viewer (i.e. addon to chrome)
+2. debug the service by calling YOURSERVER/smartVISU/lib/weather/service/openweathermap.org.php?debug=1
+3. check for 'openweathermap.org' entries in var/log/nginx/error.log (or /var/log/apache2/)
diff --git a/lib/weather/service/openweathermap.org.php b/lib/weather/service/openweathermap.org.php
index f452e5f95..454a51cd7 100644
--- a/lib/weather/service/openweathermap.org.php
+++ b/lib/weather/service/openweathermap.org.php
@@ -43,11 +43,19 @@ public function run()
}
else
{
- //error_log('nohit');
- $url_current = 'http://api.openweathermap.org/data/2.5/weather?q='.$this->location.'&lang='.trans('openweathermap', 'lang').'&units=metric&appid='.config_weather_key;
- $url_forecast = 'http://api.openweathermap.org/data/2.5/forecast?q='.$this->location.'&lang='.trans('openweathermap', 'lang').'&units=metric&appid='.config_weather_key;
+ //no cache hit
+ $loadError = '';
+ //if location is given by id=..., lat=...&lon= or zip=... (postal code), a '=' is in the string
+ //otherwise consider it to be the city name
+ if (strpos($this->location,'=') === false)
+ $this->location = 'q='.$this->location;
+ $url_current = 'http://api.openweathermap.org/data/2.5/weather?'.$this->location.'&lang='.trans('openweathermap', 'lang').'&units='.trans('openweathermap','units').'&appid='.config_weather_key;
+ $url_forecast = 'http://api.openweathermap.org/data/2.5/forecast?'.$this->location.'&lang='.trans('openweathermap', 'lang').'&units='.trans('openweathermap','units').'&appid='.config_weather_key;
$content = '{"today":'.file_get_contents($url_current).', "forecast":'.file_get_contents($url_forecast).'}';
- $cache->write($content);
+ if (substr($this->errorMessage, 0, 17) != 'file_get_contents')
+ $cache->write($content);
+ else
+ $loadError = substr(strrchr($this->errorMessage, ':'), 2);
}
$parsed_json = json_decode($content);
@@ -59,8 +67,12 @@ public function run()
$this->data['current']['temp'] = transunit('temp', (float)$parsed_json->{'today'}->{'main'}->{'temp'});
$this->data['current']['icon'] = $this->icon(substr((string)$parsed_json->{'today'}->{'weather'}[0]->{'icon'}, 0, -1), $this->icon_sm);
$this->data['current']['conditions'] = (string)$parsed_json->{'today'}->{'weather'}[0]->{'description'};
- $this->data['current']['wind'] = translate('wind', 'openweathermap').' '.transunit('speed', (float)$parsed_json->{'today'}->{'wind'}->{'speed'});
- $this->data['current']['more'] = translate('humidity', 'openweathermap').' '.(float)$parsed_json->{'today'}->{'main'}->{'humidity'}.'%, '.(float)$parsed_json->{'today'}->{'main'}->{'pressure'}.' hPa';
+ $wind_speed = transunit('speed', (float)$parsed_json->{'today'}->{'wind'}->{'speed'});
+ $wind_dir = weather::getDirection((float)$parsed_json->{'today'}->{'wind'}->{'deg'});
+ $wind_desc = weather::getWindDescription ($wind_speed);
+ $this->data['current']['wind'] = $wind_desc.' '.translate('from', 'weather').' '.$wind_dir.' '.translate('at', 'weather').' '.$wind_speed;
+ $this->data['current']['more'] = translate('humidity', 'weather').' '.(float)$parsed_json->{'today'}->{'main'}->{'humidity'}.'%';
+ $this->data['current']['misc'] = translate('air pressure', 'weather').' '.(float)$parsed_json->{'today'}->{'main'}->{'pressure'}.' hPa';
// forecast
// openweathermap provides forecast infos for 5days in 3h slots (free account)
@@ -77,7 +89,7 @@ public function run()
if($i >= 0)
{
$init = false;
- $this->data['forecast'][$i]['temp'] = round($tempMax, 0).'°/'.round($tempMin, 0).'°';
+ $this->data['forecast'][$i]['temp'] = transunit('weathertemp', round($tempMax, 0)).'/'.transunit('weathertemp', round($tempMin, 0));
$add = $add." ID:".$i.$this->data['forecast'][$i]['temp'];
}
$nextday = ($fday+1) % 7;
@@ -103,8 +115,12 @@ public function run()
}
else
{
- $add = $parsed_json->{'response'}->{'error'}->{'description'};
- $this->error('Weather: openweathermap.org', 'Read request failed'.($add ? ': '.$add : '').'!');
+ if ($loadError != '')
+ $add = $loadError;
+ else
+ $add = $parsed_json->{'response'}->{'error'}->{'description'};
+
+ $this->error('Weather: openweathermap.org', 'Read request failed'.($add ? ' with message: '.$add : '!'));
}
}
diff --git a/lib/weather/service/weather.com.md b/lib/weather/service/weather.com.md
index add647110..69857ee22 100644
--- a/lib/weather/service/weather.com.md
+++ b/lib/weather/service/weather.com.md
@@ -1,6 +1,6 @@
-##Weather.com weather service for smartVISU
+# Weather.com weather service for smartVISU
-##Installation
+## Installation
1. register at weather.com to get an api key or use the public key 6532d6454b8aa370768e63d6ba5a832e
2. in SmartVISU config page
- choose weather.com,
@@ -8,9 +8,9 @@
- enter your api key
- enter your postal code followed by ":" and the country code, e.g. 70000:DE
-
-##Troubleshooting/Debug:
+## Troubleshooting/Debug:
1. check for files smartVISU/temp/weather_YOURSTATION_current.json andn weather_YOURSTATION_forecast.json.
- These are the api responses from weather.com. View them with a json viewer (i.e. addon to chrome)
+ These are the api responses from weather.com after you have called the service once.
+ View them with a json viewer (i.e. addon to chrome)
2. debug the service by calling YOURSERVER/smartVISU/lib/weather/service/weather.com.php?debug=1
-2. check for 'weather.com' entries in var/log/nginx/error.log (or /var/log/apache2/)
+3. check for 'weather.com' entries in var/log/nginx/error.log (or /var/log/apache2/)
diff --git a/lib/weather/service/weather.com.php b/lib/weather/service/weather.com.php
index dc9fdfcc2..d85f5a687 100644
--- a/lib/weather/service/weather.com.php
+++ b/lib/weather/service/weather.com.php
@@ -24,8 +24,10 @@ class weather_weather extends weather
*/
public function run()
{
- $units = 'm';
+ $units = trans('weather.com', 'units');
$data_node = 'metric';
+ if ($units == "e") $data_node = 'imperial';
+ if ($units == "h") $data_node = 'uk_hybrid';
// api call
$cache = new class_cache('weather_'.$this->location.'_current.json');
@@ -35,9 +37,13 @@ public function run()
}
else
{
+ $loadError_current = '';
$url = 'https://api.weather.com/v2/pws/observations/current?stationId='.$this->location.'&format=json&units='.$units.'&apiKey='.config_weather_key;
$content = file_get_contents($url);
- $cache->write($content);
+ if (substr($this->errorMessage, 0, 17) != 'file_get_contents')
+ $cache->write($content);
+ else
+ $loadError_current = substr(strrchr($this->errorMessage, ':'), 2);
}
$cache_forecast = new class_cache('weather_'.$this->location.'_forecast.json');
@@ -47,9 +53,13 @@ public function run()
}
else
{
- $url_forecast = 'https://api.weather.com/v3/wx/forecast/daily/5day?postalKey='.config_weather_postal.'&units=m&language='.config_lang.'&format=json&apiKey='.config_weather_key;
+ $loadError_forecast = '';
+ $url_forecast = 'https://api.weather.com/v3/wx/forecast/daily/5day?postalKey='.config_weather_postal.'&units='.$units.'&language='.trans('weather', 'lang').'&format=json&apiKey='.config_weather_key;
$content_forecast = file_get_contents($url_forecast);
- $cache_forecast->write($content_forecast);
+ if (substr($this->errorMessage, 0, 17) != 'file_get_contents')
+ $cache_forecast->write($content_forecast);
+ else
+ $loadError_forecast = substr(strrchr($this->errorMessage, ':'), 2);
}
// parse current
@@ -66,18 +76,22 @@ public function run()
$wind_speed = transunit('speed', (float)$parsed_json->{'observations'}[0]->{$data_node}->{'windSpeed'});
$wind_gust = transunit('speed', (float)$parsed_json->{'observations'}[0]->{$data_node}->{'windGust'});
- $wind_direction = $this->getDirection((float)$parsed_json->{'observations'}[0]->{'winddir'});
-
- $this->data['current']['wind'] = translate('wind', 'wunderground')." ".$from." ".$wind_direction.", ".translate('wind_speed', 'wunderground')." ".$wind_speed;
+ $wind_dir = weather::getDirection((float)$parsed_json->{'observations'}[0]->{'winddir'});
+ $wind_desc = weather::getWindDescription($wind_speed);
+ $this->data['current']['wind'] = $wind_desc." ".translate('from', 'weather'). ' '. $wind_dir." ".translate('at', 'weather')." ".$wind_speed;
if ($wind_gust > 0)
$this->data['current']['wind'] .= ", ".translate('wind_gust', 'wunderground')." ".$wind_gust;
- $this->data['current']['more'] = translate('humidity', 'wunderground')." ".(string)$parsed_json->{'observations'}[0]->{'humidity'}." %";
+ $this->data['current']['more'] = translate('humidity', 'weather')." ".(string)$parsed_json->{'observations'}[0]->{'humidity'}." %";
+ $this->data['current']['misc'] = translate('air pressure', 'weather')." ".(string)$parsed_json->{'observations'}[0]->{$data_node}->{'pressure'}." hPa";
}
else
{
- $add = $parsed_json->{'response'}->{'error'}->{'description'};
- $this->error('Weather: weather.com', 'Current read request failed'.($add ? ': '.$add : '').'!');
+ if ($loadError_current != '')
+ $add = $loadError_current;
+ else
+ $add = $parsed_json->{'response'}->{'error'}->{'description'};
+ $this->error('Weather: weather.com', 'Current read request failed'.($add ? ' with message: '.$add : '!'));
}
// parse forecast
@@ -100,23 +114,19 @@ public function run()
$this->data['forecast'][$i]['date'] = (string)$parsed_json_forecast->{'daypart'}[0]->{'daypartName'}[$i];
$this->data['forecast'][$i]['conditions'] = (string)$parsed_json_forecast->{'daypart'}[0]->{'wxPhraseLong'}[$i];
$this->data['forecast'][$i]['icon'] = $this->icon((int)$parsed_json_forecast->{'daypart'}[0]->{'iconCode'}[$i]);
- $this->data['forecast'][$i]['temp'] = (float)$parsed_json_forecast->{'daypart'}[0]->{'temperature'}[$i].'°';
+ $this->data['forecast'][$i]['temp'] = transunit('weathertemp',(float)$parsed_json_forecast->{'daypart'}[0]->{'temperature'}[$i]);
}
}
else
{
- $add = $parsed_json->{'response'}->{'error'}->{'description'};
- $this->error('Weather: weather.com', 'Forecast read request failed'.($add ? ': '.$add : '').'!');
+ if ($loadError_forecast != '')
+ $add = $loadError_forecast;
+ else
+ $add = $parsed_json->{'response'}->{'error'}->{'description'};
+ $this->error('Weather: weather.com', 'Forecast read request failed'.($add ? ' with message: '.$add : '!'));
}
-
}
- function getDirection($b)
- {
- $dirs = array('NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW', 'N');
- return $dirs[round(abs(($b - 11.25) % 360)/ 22.5)];
- }
-
/*
* Icon-Mapper
*/
diff --git a/lib/weather/service/wunderground.com.php b/lib/weather/service/wunderground.com.php
index f2c2e32cf..309ff0ccd 100644
--- a/lib/weather/service/wunderground.com.php
+++ b/lib/weather/service/wunderground.com.php
@@ -6,6 +6,7 @@
* @copyright 2012 - 2015
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
+ * @deprecated true
*/
diff --git a/lib/weather/service/yr.no.php b/lib/weather/service/yr.no.php
index 08f2c542b..be00ec3b8 100644
--- a/lib/weather/service/yr.no.php
+++ b/lib/weather/service/yr.no.php
@@ -7,6 +7,7 @@
* @license GPL [http://www.gnu.de]
* -----------------------------------------------------------------------------
* @hide weather_postal
+ * @deprecated true
*/
diff --git a/lib/weather/weather.php b/lib/weather/weather.php
index a214f56c8..87c63405d 100644
--- a/lib/weather/weather.php
+++ b/lib/weather/weather.php
@@ -27,9 +27,13 @@ class weather extends service
public function init($request)
{
parent::init($request);
-
- $this->location = $request['location'];
-
+
+ // enable debug display of weather data
+ if ($this->debug == "1" && !isset($request->location))
+ $this->location = config_weather_location;
+ else
+ $this->location = $request['location'];
+
// reduce real cache duration by 2 seconds to avoid getting old weather on calling repeat on widget
if (isset($request['cache_duration_minutes']))
$this->cache_duration_minutes = $request['cache_duration_minutes'] - (2/60);
@@ -54,7 +58,55 @@ public function prepare()
$this->data['forecast'][$id]['date'] = transdate('D', strtotime($ds['date']));
}
}
+
+ /**
+ * Translate wind directions
+ */
+ public function getDirection($angle)
+ {
+ $dirs = array('n', 'nne', 'ne', 'ene', 'e', 'ese', 'se', 'sse', 's', 'ssw', 'sw', 'wsw', 'w', 'wnw', 'nw', 'nnw', 'n');
+ return translate($dirs[round(($angle % 360)/ 22.5, 0)], 'weather');
+ }
+
+ /**
+ * Translate wind speed with unit (string). Unit must be km/h or mph !
+ * Source: https://www.windfinder.com/wind/windspeed.htm
+ */
+ public function getWindDescription($wind_speed)
+ {
+ $windspeed = (float)(substr($wind_speed, 0, strpos($wind_speed, ' ')));
+ if (substr($wind_speed,-3) =='mph')
+ $windspeed = $windspeed * 0.62;
+
+ $description = '';
+ if ($windspeed >= 0 and $windspeed < 1) {
+ $description = translate('calm', 'weather');
+ } elseif ($windspeed >= 1 and $windspeed < 6) {
+ $description = translate('light air', 'weather');
+ } elseif ($windspeed >= 6 and $windspeed < 12) {
+ $description = translate('light breeze', 'weather');
+ } elseif ($windspeed >= 12 and $windspeed < 20) {
+ $description = translate('gentle breeze', 'weather');
+ } elseif ($windspeed >= 20 and $windspeed < 29) {
+ $description = translate('moderate breeze', 'weather');
+ } elseif ($windspeed >= 29 and $windspeed < 39) {
+ $description = translate('fresh breeze', 'weather');
+ } elseif ($windspeed >= 39 and $windspeed < 50) {
+ $description = translate('strong breeze', 'weather');
+ } elseif ($windspeed >= 50 and $windspeed <62) {
+ $description = translate('near gale', 'weather');
+ } elseif ($windspeed >= 62 and $windspeed < 75) {
+ $description = translate('gale', 'weather');
+ } elseif ($windspeed >= 75 and $windspeed < 89) {
+ $description = translate('strong gale', 'weather');
+ } elseif ($windspeed >= 89 and $windspeed < 103) {
+ $description = translate('storm', 'weather');
+ } elseif ($windspeed >= 103 and $windspeed < 118) {
+ $description = translate('violent storm', 'weather');
+ } elseif ($windspeed >= 118) {
+ $description = translate('hurricane', 'weather');
+ }
+ return $description;
+ }
}
-
-?>
diff --git a/lib/widget_assistant/README.html b/lib/widget_assistant/README.html
index 0f6464688..d7429ced3 100644
--- a/lib/widget_assistant/README.html
+++ b/lib/widget_assistant/README.html
@@ -54,10 +54,11 @@
added support for all valid values of widget-types from twig-docu
+
some optical changes
+
added widget specific colours to autocomplete dict for colors (for example 'hidden','blank' for basic.print)
+
2021.01.18 - Version 1.1.0
changed handling for "render in new window" - if there is no Window a new one will be opened, else the opened window would be refreshed, also if there is a twig error
@@ -207,6 +214,7 @@
Known Issues
then everthing is working again. (STRG+F5 / CTRL + Shift + R)
Logic to create masteritem.json from shNG
+
Only needed for smarthomeNG <= v1.7.2. As of v1.8 the smartvisu plugin writes the file as default.
Using Scripts to change other elements. Leave the item empty and give the column an id name
diff --git a/pages/docu/quad/widget_quad.rtr.html b/pages/docu/quad/widget_quad.rtr.html
index 086e3a0b0..02c47775d 100755
--- a/pages/docu/quad/widget_quad.rtr.html
+++ b/pages/docu/quad/widget_quad.rtr.html
@@ -13,7 +13,7 @@
{% endblock %}
{% block example %}
-
Example
@@ -34,7 +33,7 @@
Example
- {{ quad.rtr('normal', 'Standard Order with plot and offset', 'istwert', 'soll', 'komfort', 'nacht', 'frostschutz', 'state') }}
+ {{ quad.rtr('normal1', 'Standard Order with plot and offset', 'istwert', 'soll', 'komfort', 'nacht', 'frostschutz', 'state') }}
+
+ Map the display items to the json field names by using the title, subtitle, content and level parameters.
+
+
+
+ json data:
+ {"alm_sn":"XXX","alert_id":"c01262d5-04fd-459f-8e7c-a7064af21fa9","headline":"Maeher benoetigt Hilfe.","date":"2016-07-27T10:03:15.377Z","message":"Ihr Indego hat sich festgefahren. Bitte stellen Sie den Maeher auf eine ebene Rasenflaeche und folgen Sie den Anweisungen im Display des Maehers.","read_status":"unread","flag":"warning"},{"alm_sn":"XXXX","alert_id":"5d5b5efc-7974-4f46-827b-50c3e42fb129","headline":"Maeher benoetigt Hilfe.","date":"2016-07-26T07:36:04.509Z","message":"Ihr Indego hat sich festgefahren. Bitte stellen Sie den Maeher auf eine ebene Rasenflaeche und folgen Sie den Anweisungen im Display des Maehers.","read_status":"unread","flag":"warning"}
+
-You may use this widget to collapse some html. To bind some divs to that widget you have to use the "data-bind" attribute in the div - tag.
-In this example the div is binded to the status.collapse and will be controled by it. The additional class="hide" will hide the div at startup.
+You may use this widget to hide or collapse some html. To bind divs to the widget, use the "data-bind" attribute in the div - tag.
+In this example the div is bound to the status.collapse and will be controled by it. The additional class="hide" will hide the div at startup.
+
+New in v3.1: a list of values can be used to make collapsing more flexible
+Select a value to show the effects.
+ {{ basic.select('s1', 'bath.multistate', 'mini', [0, 1, '2', 3, 4], '', [0, 1, 2, 3, 4] ) }}
+
+ {{ basic.symbol('', 'bath.multistate', '', ['secur_locked', 'audio_sound'], [0, 3]) }}
+ This section is collapsed if values match 1, 2 or 4
+
+
+ Now you've been choosing 1, 2 or 4. {{ basic.symbol('', 'bath.multistate', '', ['audio_play', 'audio_pause', 'audio_stop'], [1, 2, 4]) }}
+
+
{% endblock %}
diff --git a/pages/docu/status/widget_status.toast.html b/pages/docu/status/widget_status.toast.html
index 3a3967a59..12d5b41dc 100644
--- a/pages/docu/status/widget_status.toast.html
+++ b/pages/docu/status/widget_status.toast.html
@@ -10,7 +10,7 @@
{% extends "widget_status.html" %}
{% block example %}
-
- Schließen
-
-{% endmacro %}
-
-/**
- * Displays a colordisc-rgb-selector
- *
- * @param {id} unique id for this widget
- * @param {text=} popup-info of the colordisc (optional)
- * @param {percent} icon position from left side
- * @param {percent} icon position from top side
- * @param {item(bool,num)} a gad/item for on/off switching
- * @param {text=On} text for the 'on' state (optional, default 'On')
- * @param {text=Off} text for the 'off' state (optional, default 'Off')
- * @param {item(num)} a gad/item for the r - value (0-255)
- * @param {item(num)} a gad/item for the g - value (0-255)
- * @param {item(num)} a gad/item for the b - value (0-255)
- * @param {value=} the minimum value if the light is off (optional, for future use)
- * @param {value=255} the maximum value if the light is full on (optional, default 255)
- * @param {value=8} the granularity of the rings (optional, default 8)
- * @param {value=10} the number of colored segments (optional, default 10)
- * @param {type=mini} type: 'micro', 'mini', 'midi' (optional, default: mini)
- * @param {value=0} hide on mobile displays (optional value 1)
-*/
-{% macro colordisc (id, info, left, top, gad, txt_on, txt_off, gad_r, gad_g, gad_b, min, max, step, colors, type, hide) %}
- {% set uid = uid(page, id) %}
- {% import "basic.html" as basic %}
-
-
-