summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimon Rettberg2025-07-14 16:50:29 +0200
committerSimon Rettberg2025-07-14 16:50:29 +0200
commit83c4bb160cea6e7fbadbe4c0876b754f0acf24c5 (patch)
tree96b8955da109cd15998e3a84699abb11627cb2c3
parent[locationinfo] Remove debug spam (diff)
downloadslx-admin-83c4bb160cea6e7fbadbe4c0876b754f0acf24c5.tar.gz
slx-admin-83c4bb160cea6e7fbadbe4c0876b754f0acf24c5.tar.xz
slx-admin-83c4bb160cea6e7fbadbe4c0876b754f0acf24c5.zip
[locationinfo] Add UPCOMING panel type
-rw-r--r--inc/dictionary.inc.php10
-rw-r--r--inc/render.inc.php18
-rw-r--r--inc/request.inc.php40
-rw-r--r--modules-available/dozmod/pages/runtimeconfig.inc.php15
-rwxr-xr-xmodules-available/js_weekcalendar/clientscript.js13
-rw-r--r--modules-available/locationinfo/api.inc.php5
-rw-r--r--modules-available/locationinfo/frontend/frontendscript.js290
-rw-r--r--modules-available/locationinfo/frontend/qrcode.min.js1
-rw-r--r--modules-available/locationinfo/frontend/upcoming.js248
-rw-r--r--modules-available/locationinfo/hooks/translation.inc.php18
-rw-r--r--modules-available/locationinfo/inc/infopanel.inc.php12
-rw-r--r--modules-available/locationinfo/inc/locationinfo.inc.php143
-rw-r--r--modules-available/locationinfo/install.inc.php17
-rw-r--r--modules-available/locationinfo/lang/de/panel-params.json26
-rw-r--r--modules-available/locationinfo/lang/de/template-tags.json12
-rw-r--r--modules-available/locationinfo/lang/en/panel-params.json26
-rw-r--r--modules-available/locationinfo/lang/en/template-tags.json10
-rw-r--r--modules-available/locationinfo/page.inc.php180
-rw-r--r--modules-available/locationinfo/templates/fragment-locationtree.html48
-rwxr-xr-xmodules-available/locationinfo/templates/frontend-default.html277
-rw-r--r--modules-available/locationinfo/templates/frontend-summary.html70
-rw-r--r--modules-available/locationinfo/templates/frontend-upcoming.html344
-rw-r--r--modules-available/locationinfo/templates/page-config-panel-upcoming.html51
-rw-r--r--modules-available/locationinfo/templates/page-panels.html11
24 files changed, 1458 insertions, 427 deletions
diff --git a/inc/dictionary.inc.php b/inc/dictionary.inc.php
index 3a2f9c2b..f95cb384 100644
--- a/inc/dictionary.inc.php
+++ b/inc/dictionary.inc.php
@@ -283,6 +283,16 @@ class Dictionary
return $img;
}
+ /**
+ * Check if the provided language code is supported.
+ * @param string $lang Language code to check
+ * @return bool True if the language code exists in the predefined language array, false otherwise
+ */
+ public static function hasLanguage(string $lang): bool
+ {
+ return in_array($lang, self::$languages);
+ }
+
}
Dictionary::init();
diff --git a/inc/render.inc.php b/inc/render.inc.php
index a636382e..d22379bb 100644
--- a/inc/render.inc.php
+++ b/inc/render.inc.php
@@ -311,4 +311,22 @@ class Render
return sprintf("#%02x%02x%02x", ($chans[0] + $b) / 3, ($chans[1] + $b) / 3, ($chans[2] + $b) / 3);
}
+ /**
+ * Mangle fields in the params array based on the checked fields.
+ * $checked is a list of field names that will be checked for true-ness
+ * in $params, and for each matching field name, a new key named "{$field}_checked"
+ * will be added to $params with value 'checked'.
+ *
+ * @param string[] &$params The array of parameters to be modified
+ * @param string[] $checked The array of fields to check for true-ness
+ */
+ public static function mangleFields(array &$params, array $checked): void
+ {
+ foreach ($checked as $field) {
+ if (($params[$field] ?? false)) {
+ $params[$field . '_checked'] = 'checked';
+ }
+ }
+ }
+
}
diff --git a/inc/request.inc.php b/inc/request.inc.php
index 3f1d96c6..0b0600da 100644
--- a/inc/request.inc.php
+++ b/inc/request.inc.php
@@ -99,4 +99,44 @@ class Request
return $array[$key];
}
+ /**
+ * Processes post parameters based on specified limits and assigns the processed values to the output array.
+ * $params is expected to have one or more keys specifying a type (int, string, ...), and each of them
+ * an array with field name as key, and then an array as value that can have optional
+ * keys 'default', 'min' and 'max', as well as 'enum' (array of allowed values).
+ *
+ * @param array $out Reference to an array where the processed parameter values will be stored.
+ * @param array $params An array containing the parameters to be processed, their types, and limits.
+ */
+ public static function processPostParameters(array &$out, array $params, ?string $source = 'POST'): void
+ {
+ if ($source === 'GET') {
+ $input =& $_GET;
+ } elseif ($source === 'POST') {
+ $input =& $_POST;
+ } elseif ($source === 'REQUEST') {
+ $input =& $_REQUEST;
+ } elseif ($source === null) {
+ $input = [];
+ } else {
+ ErrorHandler::traceError("Invalid parameter source: '$source'");
+ }
+ foreach ($params as $type => $list) {
+ foreach ($list as $field => $limits) {
+ $default = $limits['default'] ?? false;
+ $value = self::handle($input, $field, $default, $type);
+ if (isset($limits['min']) && $value < $limits['min']) {
+ $value = $limits['min'];
+ }
+ if (isset($limits['max']) && $value > $limits['max']) {
+ $value = $limits['max'];
+ }
+ if (isset($limits['enum']) && !in_array($value, $limits['enum'])) {
+ $value = array_shift($limits['enum']);
+ }
+ $out[$field] = $value;
+ }
+ }
+ }
+
}
diff --git a/modules-available/dozmod/pages/runtimeconfig.inc.php b/modules-available/dozmod/pages/runtimeconfig.inc.php
index f5790e82..d83a9564 100644
--- a/modules-available/dozmod/pages/runtimeconfig.inc.php
+++ b/modules-available/dozmod/pages/runtimeconfig.inc.php
@@ -35,19 +35,8 @@ class SubPage
'allowStudentDownload' => array('default' => false),
],
];
- foreach ($params as $type => $list) {
- foreach ($list as $field => $limits) {
- $default = $limits['default'] ?? false;
- $value = Request::post($field, $default, $type);
- if (isset($limits['min']) && $value < $limits['min']) {
- $value = $limits['min'];
- }
- if (isset($limits['max']) && $value > $limits['max']) {
- $value = $limits['max'];
- }
- $data[$field] = $value;
- }
- }
+ Request::processPostParameters($data, $params);
+
// ServerSide Copy
$data['serverSideCopy'] = Request::post('serverSideCopy', 'OFF', 'string');
if (!in_array($data['serverSideCopy'], ['OFF', 'ON', 'AUTO', 'USER'])) {
diff --git a/modules-available/js_weekcalendar/clientscript.js b/modules-available/js_weekcalendar/clientscript.js
index 45c97707..894e00c2 100755
--- a/modules-available/js_weekcalendar/clientscript.js
+++ b/modules-available/js_weekcalendar/clientscript.js
@@ -19,19 +19,6 @@
* If you're after a monthly calendar plugin, check out this one :
* http://arshaw.com/fullcalendar/
*/
-var startdate = 0;
-
-function SetUpDate(d) {
- startdate = d.getTime() - new Date().getTime();
-}
-
-/**
- *
- * @return {Date}
- */
-function MyDate() {
- return new Date(startdate + new Date().getTime());
-}
(function($) {
diff --git a/modules-available/locationinfo/api.inc.php b/modules-available/locationinfo/api.inc.php
index b066779b..7c301f86 100644
--- a/modules-available/locationinfo/api.inc.php
+++ b/modules-available/locationinfo/api.inc.php
@@ -16,7 +16,10 @@ function HandleParameters()
$uuid = Request::get('uuid', false, 'string');
$output = null;
if ($get === "timestamp") {
- $output = array('ts' => getLastChangeTs($uuid));
+ $output = [
+ 'ts' => getLastChangeTs($uuid),
+ 'now' => round(microtime(true) * 1000),
+ ];
} elseif ($get === "machines") {
$locationIds = LocationInfo::getLocationsOr404($uuid);
$output = array();
diff --git a/modules-available/locationinfo/frontend/frontendscript.js b/modules-available/locationinfo/frontend/frontendscript.js
index f39f2be8..0d336d14 100644
--- a/modules-available/locationinfo/frontend/frontendscript.js
+++ b/modules-available/locationinfo/frontend/frontendscript.js
@@ -1,3 +1,35 @@
+var slxAllIntervals = {};
+var slxAllTimeouts = {};
+
+(function() {
+ const originalSetTimeout = window.setTimeout;
+ window.setTimeout = function(callback, delay, ...args) {
+ const wrapCb = function() {
+ delete slxAllTimeouts[id];
+ callback(...args);
+ }
+ const id = originalSetTimeout(wrapCb, delay, ...args);
+ slxAllTimeouts[id] = true;
+ return id;
+ };
+ const originalClearTimeout = window.clearTimeout;
+ window.clearTimeout = function(id) {
+ delete slxAllTimeouts[id];
+ originalClearTimeout(id);
+ };
+ const originalSetInterval = window.setInterval;
+ window.setInterval = function(callback, delay, ...args) {
+ const id = originalSetInterval(callback, delay, ...args);
+ slxAllIntervals[id] = true;
+ return id;
+ };
+ const originalClearInterval = window.clearInterval;
+ window.clearInterval = function(id) {
+ delete slxAllIntervals[id];
+ originalClearInterval(id);
+ };
+})();
+
/**
* checks if a room is on a given date/time open
* @param date Date Object
@@ -37,6 +69,45 @@ function toInt(str) {
return NaN;
}
+var slxTimeOffset = 0;
+
+/**
+ * Calculate time offset of local clock to actual time, which is the
+ * one given by parameter. We then assume that the local clock is off
+ * by the calculated amount.
+ * The calculated difference will be applied when calling MyDate.
+ */
+function SetUpDate(d) {
+ var time;
+ var type = Object.prototype.toString.call(d);
+ if (type === '[object Date]') {
+ time = d;
+ } else {
+ if (type === '[object String]') {
+ var p = d.split('-');
+ if (p.length === 6) {
+ time = new Date(p[0], (p[1] - 1), p[2], p[3], p[4], p[5]);
+ }
+ }
+ if (!time || isNaN(time.getTime()) || time.getFullYear() < 2010) {
+ time = new Date(d);
+ }
+ if (isNaN(time.getTime()) || time.getFullYear() < 2010) {
+ time = new Date();
+ }
+ }
+ slxTimeOffset = time.getTime() - new Date().getTime();
+ console.log("time offset: ", slxTimeOffset);
+}
+
+/**
+ * Get corrected time, using the reference time supplied by the server.
+ * @return {Date}
+ */
+function MyDate() {
+ return new Date(slxTimeOffset + new Date().getTime());
+}
+
/**
* used for countdown
* computes the time difference between 2 Date objects
@@ -50,7 +121,7 @@ function GetTimeDiferenceAsString(a, b, globalConfig) {
return "";
}
var milliseconds = a.getTime() - b.getTime();
- var days = Math.floor((milliseconds / (1000 * 60 * 60 * 24)) % 31);
+ var days = Math.floor(milliseconds / (1000 * 60 * 60 * 24));
if (days !== 0) {
// don't show?
return "";
@@ -83,4 +154,219 @@ function GetTimeDiferenceAsString(a, b, globalConfig) {
seconds = "0" + seconds;
}
return hours + ":" + minutes + ":" + seconds;
-} \ No newline at end of file
+}
+
+const getWebApk = async function (fileName, manifestUrl = document.querySelector("link[rel=manifest]").href) {
+ Uint8Array.prototype.toBase64 ??= function () {
+ return btoa(Array.from(this, v => String.fromCharCode(v)).join(""));
+ }
+
+ const manifest = await (await fetch(manifestUrl)).json();
+ if (!manifest.start_url) {
+ alert('Manifest start_url missing');
+ return;
+ }
+ if (!manifest.icons) {
+ alert('Manifest icons missing');
+ return;
+ }
+ const startUrl = new URL(manifest.start_url, manifestUrl);
+ const imageData = new Uint8Array(await (await fetch(new URL(manifest.icons.find(v => v.type === "image/png").src, manifestUrl))).arrayBuffer()).toBase64();
+
+ const {downloadUrl} = await (
+ await fetch("https://webapk.googleapis.com/v1/webApks/?key=AIzaSyAoI6v-F31-3t9NunLYEiKcPIqgTJIUZBw", {
+ method: "POST",
+ body: JSON.stringify({
+ appKey: startUrl, manifestUrl, requesterApplicationPackage: "com.android.chrome",
+ manifest: {
+ name: manifest.name,
+ startUrl,
+ id: startUrl,
+ scopes: [new URL(".", startUrl)],
+ icons: [{imageData}],
+ displayMode: manifest.display
+ }
+ }),
+ headers: {
+ "content-type": "application/json",
+ },
+ })
+ ).json();
+
+ if (typeof downloadUrl !== 'string') {
+ alert('Error generating APK');
+ return;
+ }
+
+ location = downloadUrl;
+ return;
+};
+
+/**
+ * Checks whether the panel has been edited and reloads
+ * the entire page if so.
+ */
+function queryPanelChange(baseUrl, panelUuid, globalConfig) {
+ $.ajax({
+ url: baseUrl + "get=timestamp&uuid=" + panelUuid,
+ dataType: 'json',
+ cache: false,
+ timeout: 5000,
+ success: function (result) {
+ if (!result || !result.ts) {
+ console.log("Warning: get=timestamp didn't return json with ts field");
+ return;
+ }
+ if (globalConfig.ts && globalConfig.ts !== result.ts) {
+ // Change
+ window.location.reload(true);
+ }
+ if (result.now) {
+ SetUpDate(result.now);
+ }
+ globalConfig.ts = result.ts;
+ }
+ })
+}
+
+/**
+ * Display given error message and try reloading page once a minute
+ */
+function fatalError(message) {
+ for (var k in slxAllTimeouts) {
+ if (slxAllTimeouts.hasOwnProperty(k)) {
+ clearTimeout(slxAllTimeouts[k]);
+ }
+ }
+ for (var k in slxAllIntervals) {
+ if (slxAllIntervals.hasOwnProperty(k)) {
+ clearInterval(slxAllIntervals[k]);
+ }
+ }
+ $('body').empty().append($('<h1>').text(message));
+ window.setInterval(function () {
+ $.ajax('/').done(function () {
+ window.location.reload(true);
+ }).fail(function () {
+ $('body').append('...');
+ });
+ }, 60000);
+}
+
+/**
+ * returns parameter value from the url
+ * @param sParam
+ * @returns boolean|string for given parameter
+ */
+function getUrlParameter(sParam) {
+ var sPageURL = window.location.hash.substring(1),
+ sURLVariables = sPageURL.split('&'),
+ sParameterName,
+ i;
+
+ for (i = 0; i < sURLVariables.length; i++) {
+ sParameterName = sURLVariables[i].split('=', 2);
+
+ if (sParameterName[0] === sParam) {
+ if (sParameterName.length === 1) return true;
+ return decodeURIComponent(sParameterName[1]);
+ }
+ }
+ return false;
+}
+
+const PARAM_STRING = 1;
+const PARAM_INT = 2;
+const PARAM_BOOL = 3;
+
+/**
+ * Read given parameter from URL, replacing it in the config object if present.
+ * @param config object config object
+ * @param property string name of property in object, URL param of same name is being checked
+ * @param paramType int one of PARAM_STRING, PARAM_INT, PARAM_BOOL
+ * @param intScaleFactor int optional scale factor that will be applied if paramType == PARAM_INT
+ */
+function sanitizeConfigParam(config, property, paramType, defaultValue, min, max, intScaleFactor) {
+ var val = getUrlParameter(property);
+ if (val === true || val === false) {
+ // Not in URL, use given or fallback to default
+ if (typeof config[property] === 'undefined') {
+ config[property] = defaultValue;
+ }
+ } else {
+ // In URL
+ config[property] = val;
+ }
+ if (paramType === PARAM_STRING) {
+ config[property] += '';
+ } else if (paramType === PARAM_INT) {
+ config[property] = parseInt(config[property]);
+ if (isNaN(config[property]) || !isFinite(config[property]) || config[property] < min) {
+ config[property] = min;
+ } else if (config[property] > max) {
+ config[property] = max;
+ }
+ if (intScaleFactor) {
+ config[property] *= intScaleFactor;
+ }
+ } else if (paramType === PARAM_BOOL) {
+ if (typeof config[property] !== 'boolean') {
+ val = config[property].toLowerCase();
+ config[property] = val.length > 0 && val !== 'false' && val !== 'off' && val !== '0';
+ }
+ } else {
+ console.log('Invalid paramType: ' + paramType);
+ }
+}
+
+/**
+ * Cleans the input date value and returns a Date object.
+ *
+ * @param {string|number|Date} d The input date value to be cleaned.
+ * It can be a string in ISO8601, a numeric value, or a Date object.
+ * @returns {Date} A Date object representing the cleaned date value.
+ */
+function cleanDate(d) {
+ if (typeof d === 'string') {
+ // numeric -> handle as such
+ if (!isNaN(Number(d))) {
+ return cleanDate(parseInt(d, 10));
+ }
+
+ // this is a human-readable date, must be ISO8601
+ var utc = true;
+ if (d[d.length - 1] !== 'Z') {
+ // Work around old WebKit bug where an ISO time without any TZ information gets interpreted
+ // as UTC instead of local time, so force interpretation as UTC and then apply timezone manually
+ d += 'Z';
+ utc = false;
+ }
+ var o = new Date(d);
+ if (!utc) {
+ o.setTime(o.getTime() + (o.getTimezoneOffset() * 60 * 1000));
+ }
+ return o;
+ }
+
+ if (typeof d === 'number') {
+ // Assume unix timestamp (in ms)
+ return new Date(d);
+ }
+
+ return d;
+}
+
+/**
+ * Function for translation
+ * @param toTranslate key which we want to translate
+ * @returns r translated string
+ */
+const t = (function() {
+ var tCache = {};
+ return function(toTranslate) {
+ if (tCache[toTranslate])
+ return tCache[toTranslate];
+ var r = $('#i18n').find('[data-tag="' + toTranslate + '"]');
+ return tCache[toTranslate] = (r.length === 0 ? toTranslate : r.text());
+ }
+})(); \ No newline at end of file
diff --git a/modules-available/locationinfo/frontend/qrcode.min.js b/modules-available/locationinfo/frontend/qrcode.min.js
new file mode 100644
index 00000000..993e88f3
--- /dev/null
+++ b/modules-available/locationinfo/frontend/qrcode.min.js
@@ -0,0 +1 @@
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file
diff --git a/modules-available/locationinfo/frontend/upcoming.js b/modules-available/locationinfo/frontend/upcoming.js
new file mode 100644
index 00000000..af918de4
--- /dev/null
+++ b/modules-available/locationinfo/frontend/upcoming.js
@@ -0,0 +1,248 @@
+/** Next time we need to query the calendar */
+var nextCalendarUpdate = 0;
+/** Next time we need to redraw the list because the active item changed */
+var nextRender = 0;
+var calendars = {};
+var locations = {};
+var DATENOW, NOW;
+var globalTimer;
+
+$(document).ready(function () {
+ if (!globalConfig || globalConfig.constructor !== Object) {
+ fatalError("Embedded panel config is not valid JSON");
+ return;
+ }
+ initializePanel();
+});
+
+function initializePanel() {
+ if (!globalConfig.locations || globalConfig.locations.constructor !== Array) {
+ fatalError("Requested panel doesn't contain locations / not array");
+ return;
+ }
+
+ var locs = 0;
+ globalConfig.locations.forEach(function (x) {
+ // filter out if no numeric id, or id already present
+ if (typeof(x.id) !== 'number' || x.id <= 0 || locations[x.id])
+ return;
+ locs++;
+ locations[x.id] = x;
+ });
+
+ if (locs === 0) {
+ fatalError("List of location ids is empty");
+ return;
+ }
+
+ if (globalConfig.time) {
+ SetUpDate(globalConfig.time);
+ }
+ delete globalConfig.time;
+ delete globalConfig.locations;
+
+ sanitizeAndOverrideConfig(globalConfig);
+ if (!globalTimer) {
+ mainUpdateLoop();
+ if (globalConfig.qrcode) {
+ makeQrCode();
+ }
+ }
+}
+
+/**
+ * gets Additional Parameters from the URL, and from the
+ * downloaded json.
+ * also makes sure parameters are in a given range
+ */
+function sanitizeAndOverrideConfig(config) {
+ sanitizeConfigParam(config, 'calupdate', PARAM_INT, 30, 1, 60, 60 * 1000);
+ sanitizeConfigParam(config, 'qrcode', PARAM_BOOL, false);
+ sanitizeConfigParam(config, 'language', PARAM_STRING, 'en');
+}
+
+function updateNow() {
+ DATENOW = MyDate();
+ NOW = DATENOW.getTime();
+}
+
+/**
+ * queries the Calendar data
+ */
+function queryCalendars() {
+ console.log('Querying current calendar data');
+ if (!PANEL_UUID) return;
+ var url = API + "get=calendar&uuid=" + PANEL_UUID;
+ $.ajax({
+ url: url,
+ dataType: 'json',
+ cache: false,
+ timeout: 30000,
+ success: queryCalendarsCallback,
+ error: function () {
+ // Retry in 5 minutes (300 seconds)
+ updateNow();
+ nextCalendarUpdate = NOW + 300000;
+ }
+ });
+}
+
+var color;
+
+function queryCalendarsCallback(result) {
+ if (result && result.constructor === Array) {
+ var l = result.length;
+ var timeTable = {};
+ color = 'rgb(' + Math.round(Math.random() * 128 + 127) + ', ' + Math.round(Math.random() * 128 + 127) + ', ' + Math.round(Math.random() * 128 + 127) + ')';
+ updateNow();
+ for (var i = 0; i < l; i++) {
+ timeTable[result[i].id] = processSingleCalendar(result[i].calendar);
+ }
+ calendars = mergeCalendars(timeTable);
+ renderUnifiedEvents();
+ }
+}
+
+const SEVEN_DAYS = 7 * 86400 * 1000;
+
+/**
+ * Calendar data will be filtered and sorted.
+ * Expired events will be removed, remaining events sorted by start time, up to 7 days in advance.
+ *
+ * @param {Array} json - The new event data in JSON format to update the calendar.
+ */
+function processSingleCalendar(json) {
+ if (!json || json.constructor !== Array) {
+ console.log("Error: Calendar data was empty or malformed.");
+ return [];
+ }
+ if (json.length === 0) {
+ console.log("Notice: Calendar already empty from server");
+ }
+ json = json.filter(function (el) {
+ if (!el.title || !el.start || !el.end) return false;
+ el.s = new Date(el.start).getTime();
+ el.e = new Date(el.end).getTime();
+ return !(isNaN(el.s) || isNaN(el.e) || Math.abs(el.s - NOW) > SEVEN_DAYS || Math.abs(el.e) < NOW);
+ });
+ if (json.length === 0) {
+ console.log('Notice: Calendar has no current events');
+ }
+ json.sort(function (a, b) { return a.s - b.s; });
+ for (var i = json.length - 1; i > 0; i--) {
+ // if title, start and end are the same, "merge" two events by removing one of them
+ if (json[i].title === json[i-1].title && json[i].start === json[i-1].start && json[i].end === json[i-1].end) {
+ json.splice(i, 1);
+ }
+ }
+ return json;
+}
+
+/**
+ * Takes one or more calendars and merges the individual eventsfrom them, sorted ascending
+ * by start time. The inputcalendars are assumed to be sorted in ascending order already.
+ * If multiple calendars contain an event with matching start/end times and title, it is
+ * assumed to be the same event spread across multiple locations, so the event will be
+ * merged, and the location names concatenated.
+ * @param {Array} calendars - An array of calendars to be merged.
+ * @returns {Array} - Returns a new array containing the merged calendars.
+ */
+function mergeCalendars(calendars) {
+ var k, tt, merged = [];
+ var nextRefresh = null;
+ do {
+ var smallest = false;
+ for (k in calendars) {
+ if (!calendars.hasOwnProperty(k))
+ continue;
+ tt = calendars[k];
+ if (!tt || tt.length === 0 || tt[0].e < NOW)
+ continue;
+ if (smallest === false || tt[0].s < calendars[smallest][0].s) {
+ smallest = k;
+ }
+ }
+ if (smallest) {
+ var v = calendars[smallest].shift();
+ v.location = locations[smallest] ? locations[smallest].name : '???';
+ if (merged.length !== 0) {
+ var cur = merged.pop();
+ if (cur.s === v.s && cur.e === v.e && cur.title === v.title) {
+ v.location += ', ' + cur.location;
+ } else {
+ merged.push(cur);
+ }
+ }
+ merged.push(v);
+ }
+ } while (smallest);
+ return merged;
+}
+
+function pad(number, digits) {
+ var s = "00" + number;
+ return s.substr(s.length - digits);
+}
+
+function formatTime(dateObj) {
+ return pad(dateObj.getHours(), 2) + ":" + pad(dateObj.getMinutes(), 2);
+}
+
+function makeQrCode() {
+ var script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.src = 'modules/locationinfo/frontend/qrcode.min.js';
+
+ console.log("MakeQR");
+ var callback = function() {
+ console.log("Script cb");
+ var $code = $('<div id="qrcode">');
+ $('body').append($code);
+ setTimeout(function() {
+ new QRCode($code[0], {
+ text: window.location.href.replace(/([#&])qrcode=\d+/, '$1').replace(/[&#]+$/, ''),
+ width: $code.width() * 4,
+ height: $code.height() * 4,
+ correctLevel : QRCode.CorrectLevel.L
+ });
+ }, 1);
+ }
+
+ script.onload = function () {
+ this.onload = null;
+ this.onreadystatechange = null;
+ callback();
+ };
+ script.onreadystatechange = function () {
+ if (this.readyState === 'loaded' || this.readyState === 'complete') {
+ this.onload = null;
+ this.onreadystatechange = null;
+ callback();
+ }
+ };
+
+ document.getElementsByTagName('head')[0].appendChild(script);
+}
+
+//
+
+/**
+ * Main Update loop, this loop runs every 10 seconds
+ */
+function mainUpdateLoop() {
+ updateNow();
+ if (nextCalendarUpdate < NOW) {
+ nextCalendarUpdate = NOW + globalConfig.calupdate;
+ queryCalendars();
+ } else if (nextRender < NOW) {
+ renderUnifiedEvents();
+ } else {
+ queryPanelChange(API, PANEL_UUID, globalConfig);
+ }
+ var nx = Math.min(nextCalendarUpdate, nextRender) - NOW;
+ nx = Math.max(1000, nx);
+ nx = Math.min(15000, nx);
+ $('#time .date-today').text(t('long' + DATENOW.getDay()) + ', ' + DATENOW.toLocaleDateString(LANGUAGE));
+ $('#time .time-today').text(formatTime(DATENOW));
+ globalTimer = setTimeout(mainUpdateLoop, nx);
+}
diff --git a/modules-available/locationinfo/hooks/translation.inc.php b/modules-available/locationinfo/hooks/translation.inc.php
index e83dfd2d..0b58c0e9 100644
--- a/modules-available/locationinfo/hooks/translation.inc.php
+++ b/modules-available/locationinfo/hooks/translation.inc.php
@@ -21,4 +21,22 @@ if (Module::isAvailable('locationinfo')) {
return $return;
};
}
+ $HANDLER['subsections'][] = 'panel-params';
+ $HANDLER['grep_panel-params'] = function($module) {
+ $return = [];
+ foreach (['UPCOMING'] as $panelType) {
+ $params = LocationInfo::getPanelParameters($panelType);
+ $params['string']['panelname'] = [];
+ foreach ($params as $types) {
+ foreach ($types as $key => $data) {
+ $return[$key] = true;
+ $return[$key . '_helptext'] = true;
+ if (isset($data['section'])) {
+ $return['section_' . $data['section']] = true;
+ }
+ }
+ }
+ }
+ return $return;
+ };
}
diff --git a/modules-available/locationinfo/inc/infopanel.inc.php b/modules-available/locationinfo/inc/infopanel.inc.php
index 2557149d..2d8136c0 100644
--- a/modules-available/locationinfo/inc/infopanel.inc.php
+++ b/modules-available/locationinfo/inc/infopanel.inc.php
@@ -23,7 +23,7 @@ class InfoPanel
if ($panel['paneltype'] === 'URL') {
// Shortcut for URL redirect
- $config = json_decode($panel['panelconfig'], true) + $config;
+ $config = json_decode($panel['panelconfig'], true) + $config; // DO NOT REPLACE BY +=
return $panel['paneltype'];
}
@@ -37,7 +37,7 @@ class InfoPanel
$overrides = $json['overrides'];
}
unset($json['overrides']);
- $config = $json + $config;
+ $config = $json + $config; // DO NOT REPLACE BY +=
}
}
if (isset($config['showtitle']) && $config['showtitle']) {
@@ -53,7 +53,7 @@ class InfoPanel
}
}
}
- if ($panel['paneltype'] === 'DEFAULT') {
+ if ($panel['paneltype'] === 'DEFAULT' || $panel['paneltype'] === 'UPCOMING') {
foreach ($lids as $lid) {
$config['locations'][$lid] = array(
'id' => $lid,
@@ -64,7 +64,9 @@ class InfoPanel
$config['locations'][$lid]['config'] = $overrides[$lid];
}
}
- self::appendMachineData($config['locations'], $lids, true, $config['hostname']);
+ }
+ if ($panel['paneltype'] === 'DEFAULT') {
+ self::appendMachineData($config['locations'], $lids, true, $config['hostname'] ?? false);
}
self::appendOpeningTimes($config['locations'], $lids);
@@ -225,8 +227,6 @@ class InfoPanel
return array_map('intval', $allIds);
}
-// ########## <Openingtime> ##########
-
/**
* Format the openingtime in the frontend needed format.
* One key per week day, which contains an array of {
diff --git a/modules-available/locationinfo/inc/locationinfo.inc.php b/modules-available/locationinfo/inc/locationinfo.inc.php
index b41f3a33..831f5ad0 100644
--- a/modules-available/locationinfo/inc/locationinfo.inc.php
+++ b/modules-available/locationinfo/inc/locationinfo.inc.php
@@ -83,6 +83,73 @@ class LocationInfo
}
}
+ public static function getPanelParameters(string $type): array
+ {
+ $mods = [];
+ $lowType = strtolower($type);
+ foreach (glob('modules/locationinfo/templates/frontend-' . $lowType . '-*.html') as $file) {
+ if (!preg_match('/frontend-' . $lowType . '-([a-z0-9]+)\.html$/', $file, $matches))
+ continue;
+ $mods[] = $matches[1];
+ }
+ if (!empty($mods)) {
+ array_unshift($mods, '');
+ }
+ if ($type === 'UPCOMING') {
+ return [
+ 'string' => [
+ 'language' => [
+ 'section' => 'general',
+ 'default' => defined('LANG') ? LANG : 'en',
+ 'enum' => Dictionary::getLanguages(),
+ ],
+ 'heading' => ['section' => 'general'],
+ 'subheading' => ['section' => 'general'],
+ 'color_bg' => ['section' => 'styling', 'default' => 'rgb(52, 73, 154)'],
+ 'color_fg' => ['section' => 'styling', 'default' => '#fff'],
+ 'color_section' => ['section' => 'styling', 'default' => 'rgba(255,255,255, .2)'],
+ 'color_time' => ['section' => 'styling', 'default' => 'rgba(255,255,255, .5)'],
+ 'mod' => ['section' => 'general', 'enum' => $mods],
+ ],
+ 'bool' => [
+ 'qrcode' => ['section' => 'general'],
+ 'language-system' => ['section' => 'general'],
+ ]
+ ];
+ }
+ ErrorHandler::traceError("Invalid panel type: '$type'");
+ }
+
+ /**
+ * Takes a configuration for a specific panel type, and creates additional keys for all
+ * known boolean options that are set in the config, after the following pattern
+ * ```
+ * 'somekey' => true
+ * creates:
+ * 'somekey_checked' => 'checked'
+ * ```
+ * so that it can be used in templates more easily.
+ */
+ public static function makeCheckedProperties(string $paneltype, array &$config): void
+ {
+ $params = self::getPanelParameters($paneltype);
+ if (empty($params['bool']))
+ return;
+ Render::mangleFields($config, array_keys($params['bool']));
+ }
+
+ /**
+ * Read panel config from POST parameters for given panel type.
+ * @param string $type Type to process POST parameters for
+ * @return array mangled and sanitized config
+ */
+ public static function panelConfigFromPost(string $type): array
+ {
+ $conf = [];
+ Request::processPostParameters($conf, self::getPanelParameters($type));
+ return $conf;
+ }
+
/**
* Creates and returns a default config for room that didn't save a config yet.
*
@@ -135,7 +202,81 @@ class LocationInfo
'zoom-factor' => 100,
);
}
- return array();
+ if ($type === 'UPCOMING') {
+ $p = self::getPanelParameters($type);
+ $out = [];
+ foreach ($p as $sub) {
+ foreach ($sub as $field => $options) {
+ $out[$field] = $options['default'] ?? $options['min']
+ ?? (isset($options['enum']) ? array_shift($options['enum']) : '');
+ }
+ }
+ return $out;
+ }
+ ErrorHandler::traceError("Invalid panel type: '$type'");
+ }
+
+ public static function getEditTemplateData(string $type, array $current): array
+ {
+ $params = self::getPanelParameters($type);
+ $params['string'] = array_reverse($params['string'], true);
+ $params['string']['panelname'] = [];
+ $params['string'] = array_reverse($params['string'], true);
+ $sections = [];
+ foreach ($params as $varType => $options) {
+ foreach ($options as $key => $properties) {
+ if (isset($properties['enum']) && empty($properties['enum']))
+ continue; // Enum with no values - ignore
+ $section = ($properties['section'] ?? 'general');
+ if (!isset($sections[$section])) {
+ $sections[$section] = [];
+ }
+ $sections[$section][] = [
+ 'title' => Dictionary::translateFile('panel-params', $key, true),
+ 'helptext' => Dictionary::translateFile('panel-params', $key . '_helptext', true),
+ 'html' => self::makeEditField($varType, $key, $properties, $current[$key] ?? ''),
+ ];
+ }
+ }
+ $output = [];
+ foreach ($sections as $sectionName => $sectionData) {
+ $output[] = [
+ 'title' => Dictionary::translateFile('panel-params', 'section_' . $sectionName, true),
+ 'elements' => $sectionData,
+ ];
+ }
+ return $output;
+ }
+
+ private static function makeEditField(string $varType, string $key, array $var, $current): string
+ {
+ if (isset($var['enum'])) {
+ $return = '<select class="form-control" name="' . $key . '">';
+ foreach ($var['enum'] as $enumValue) {
+ $return .= '<option value="' . htmlspecialchars($enumValue) . '"'
+ . ($current == $enumValue ? ' selected' : '') . '>'
+ . htmlspecialchars($enumValue) . '</option>';
+ }
+ $return .= '</select>';
+ } elseif ($varType === 'int') {
+ $return = '<input class="form-control" type="number" name="' . $key . '" value="' . htmlspecialchars($current) . '"'
+ . ' placeholder="' . htmlspecialchars($var['default'] ?? '') . '"';
+ if (isset($var['min'])) {
+ $return .= ' min="' . $var['min'] . '"';
+ }
+ if (isset($var['max'])) {
+ $return .= ' max="' . $var['max'] . '"';
+ }
+ } elseif ($varType === 'bool') {
+ $return = '<div class="checkbox"><input type="checkbox" name="' . $key . '" value="1"'
+ . ($current ? ' checked' : '') . '><label></label></div>';
+ } elseif ($varType === 'string') {
+ $return = '<input class="form-control" type="text" name="' . $key . '" value="' . htmlspecialchars($current) . '"'
+ . ' placeholder="' . htmlspecialchars($var['default'] ?? '') . '">';
+ } else {
+ ErrorHandler::traceError("Invalid variable type: '$varType'");
+ }
+ return $return;
}
/**
diff --git a/modules-available/locationinfo/install.inc.php b/modules-available/locationinfo/install.inc.php
index 42bc8234..732ea6e9 100644
--- a/modules-available/locationinfo/install.inc.php
+++ b/modules-available/locationinfo/install.inc.php
@@ -26,8 +26,8 @@ $t2 = $res[] = tableCreate('locationinfo_coursebackend', '
$t3 = $res[] = tableCreate('locationinfo_panel', "
`paneluuid` char(36) CHARACTER SET ascii NOT NULL,
`panelname` varchar(30) NOT NULL,
- `locationids` varchar(100) CHARACTER SET ascii NOT NULL,
- `paneltype` enum('DEFAULT','SUMMARY', 'URL') NOT NULL,
+ `locationids` text CHARACTER SET ascii NOT NULL,
+ `paneltype` enum('DEFAULT', 'SUMMARY', 'URL', 'UPCOMING') NOT NULL,
`panelconfig` blob NOT NULL,
`lastchange` int(10) UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`paneluuid`),
@@ -80,10 +80,8 @@ if ($t1 === UPDATE_DONE) {
if ($t3 === UPDATE_NOOP) {
Database::exec("ALTER TABLE `locationinfo_panel` CHANGE `paneltype`
- `paneltype` ENUM('DEFAULT', 'SUMMARY', 'URL') CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL");
+ `paneltype` ENUM('DEFAULT', 'SUMMARY', 'URL', 'UPCOMING') NOT NULL");
// 2017-12-02 expand locationids column
- Database::exec("ALTER TABLE `locationinfo_panel` CHANGE `locationids`
- `locationids` varchar(100) CHARACTER SET ascii NOT NULL");
}
// 2017-07-26 Add servername key
@@ -105,6 +103,15 @@ if (tableGetConstraints('locationinfo_backendlog', 'serverid',
'locationinfo_coursebackend', 'serverid', 'ON UPDATE CASCADE ON DELETE CASCADE');
}
+// 2025-06-17: Make locationds text column
+if (stripos(tableColumnType('locationinfo_panel', 'locationids'), 'text') === false) {
+ if (Database::exec('ALTER TABLE `locationinfo_panel` MODIFY `locationids` text CHAR SET ascii NOT NULL')) {
+ $res[] = UPDATE_DONE;
+ } else {
+ $res[] = UPDATE_FAILED;
+ }
+}
+
// Create response for browser
if (in_array(UPDATE_RETRY, $res)) {
diff --git a/modules-available/locationinfo/lang/de/panel-params.json b/modules-available/locationinfo/lang/de/panel-params.json
new file mode 100644
index 00000000..a05a3e12
--- /dev/null
+++ b/modules-available/locationinfo/lang/de/panel-params.json
@@ -0,0 +1,26 @@
+{
+ "color_bg": "Hintergrund",
+ "color_bg_helptext": "Hintergrundfarbe des Panels",
+ "color_fg": "Vordergrundfarbe",
+ "color_fg_helptext": "Prim\u00e4re Textfarbe",
+ "color_section": "Sektion",
+ "color_section_helptext": "Textfarbe f\u00fcr \"Aktuell\"- und \"Anstehend\"-\u00dcberschrift",
+ "color_time": "Datum\/Zeit",
+ "color_time_helptext": "Textfarbe f\u00fcr die Datum-\/Zeitanzeige im Vollbild",
+ "heading": "\u00dcberschrift",
+ "heading_helptext": "Prim\u00e4re \u00dcberschrift",
+ "language": "Sprache",
+ "language-system": "Systemsprache bevorzugen",
+ "language-system_helptext": "Sofern unterst\u00fctzt, wird das Panel in der Sprache ausgeliefert, die der Browser des Users anfordert",
+ "language_helptext": "Anzeigesprache des Panels",
+ "mod": "Panelvariante",
+ "mod_helptext": "Alternatives Template f\u00fcr das Panel verwenden",
+ "panelname": "Interner Name",
+ "panelname_helptext": "Name des Panels, wie er in der Administrationsoberfl\u00e4che angezeigt wird",
+ "qrcode": "QRCode zeigen",
+ "qrcode_helptext": "QRCode in der unteren rechten Ecke anzeigen",
+ "section_general": "Allgemein",
+ "section_styling": "Design",
+ "subheading": "Zweite \u00dcberschrift",
+ "subheading_helptext": "Sekund\u00e4re \u00dcberschrift, die unter der prim\u00e4ren angezeigt wird"
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/de/template-tags.json b/modules-available/locationinfo/lang/de/template-tags.json
index 579b4315..f10a4ae2 100644
--- a/modules-available/locationinfo/lang/de/template-tags.json
+++ b/modules-available/locationinfo/lang/de/template-tags.json
@@ -24,18 +24,20 @@
"lang_createPanel": "Panel anlegen",
"lang_credentials": "Anmeldung",
"lang_currentDay": "Aktueller Tag",
+ "lang_currentEvents": "Aktuell",
"lang_daysToShow": "Tage",
"lang_daysToShowTooltip": "Legt die Anzahl an Tagen im Kalender fest, die angezeigt werden",
- "lang_defaultPanel": "Standard-Panel",
+ "lang_defaultPanel": "Raum\/Kalender",
"lang_deleteConfirmation": "Sind Sie sicher?",
"lang_display": "Anzeige",
"lang_displayName": "Name",
- "lang_displayNameTooltip": "Anzeigename f\u00fcr dieses Panel",
+ "lang_displayNameTooltip": "Name f\u00fcr dieses Panel (in Auflistung)",
"lang_ecoMode": "E-Ink Modus",
"lang_ecoTooltip": "Niedrigere Aktualisierungsrate, Countdown ohne Sekunden",
"lang_editDefaultPanelHints": "Hier k\u00f6nnen Sie ein Panel (z.B. digitales T\u00fcrschild) in Aussehen und Funktionsweise definieren. Um im Kalender \u00d6ffnungszeiten anzeigen zu k\u00f6nnen, m\u00fcssen Sie im Tab \"Raum-\/Ortsbezogene Einstellungen\" f\u00fcr den ausgew\u00e4hlten Raum entsprechend \u00d6ffnungszeiten eintragen. Damit im Kalender Veranstaltungen und andere Termine angezeigt werden k\u00f6nnen, muss ein funktionierendes Backend konfiguriert und den ausgew\u00e4hlten R\u00e4umen zugewiesen worden sein.",
"lang_editPanel": "Panel bearbeiten",
"lang_editSummaryPanelHints": "Hier k\u00f6nnen Sie ein \u00dcbersichts-Panel definieren. Das Panel zeigt eine \u00dcbersicht der in den R\u00e4umen enthalten PCs.",
+ "lang_editUpcomingPanelHints": "Eine Anzeigetafel, die die n\u00e4chsten Veranstaltungen in einem oder mehreren R\u00e4umen anzeigt. Die Veranstaltungen werden chronologisch aufsteigend untereinander angezeigt, mit Start- und Endzeit.",
"lang_editUrlPanelHints": "Hier k\u00f6nnen Sie konfigurieren, welche URL das Panel aufrufen soll. Dies erm\u00f6glicht Ihnen z.B. in Eingangsbereichen aktuelle Meldungen der Hochschule oder sonstige Webseiten anzuzeigen.",
"lang_entryName": "Name",
"lang_error": "Fehler",
@@ -80,6 +82,7 @@
"lang_modeTooltip": "Die Anzeigemodi, welche das Frontend unterst\u00fctzt",
"lang_monday": "Montag",
"lang_nameTooltip": "Legt den Namen des Servers fest",
+ "lang_noCurrentEvents": "Keine aktuellen Veranstaltungen",
"lang_noLocationsWarning": "Bitte w\u00e4hlen Sie mindestens einen Ort aus, der vom Panel angezeigt werden soll.",
"lang_noServer": "<Kein Server>",
"lang_openingTime": "\u00d6ffnungszeit",
@@ -134,15 +137,18 @@
"lang_splitloginTooltip": "Erlaube nur Gast-Login oder Gast+Nutzer-Login wenn aktiviert",
"lang_startDay": "Start Tag",
"lang_startDayTooltip": "Der Wochentag an dem der Kalender anf\u00e4ngt",
- "lang_summaryPanel": "\u00dcbersichts-Panel",
+ "lang_summaryPanel": "Gesamt\u00fcbersicht",
"lang_summaryUpdateIntervalTooltip": "Aktualisierungsintervall (Sekunden)",
"lang_sunday": "Sonntag",
"lang_switchTime": "Wechselintervall",
"lang_switchTimeTooltip": "[1-120] Legt die Zeit fest, die vergeht bis ein Wechsel erfolgt (in Sekunden)",
"lang_thuesday": "Dienstag",
"lang_thursday": "Donnerstag",
+ "lang_timeSpanSuffix": "Uhr",
"lang_to": "bis",
"lang_typeTooltip": "Legt fest um welchen Server-Typ es sich handelt",
+ "lang_upcomingEvents": "Anstehend",
+ "lang_upcomingPanel": "Anstehende Veranstaltungen",
"lang_updateRates": "Aktualisierungsintervall",
"lang_url": "URL",
"lang_urlListHelp": "Sie k\u00f6nnen hier Listen von URLs oder Hostnamen angeben, die dann als Whitelist bzw. Blacklist interpretiert werden. Je nach gew\u00e4hltem Browser hat entweder die Whitelist Vorrang, oder die \"genauere\" Regel. Wenn keine Regel zutrifft, wird der Zugriff erlaubt, es sei denn, es gibt den Eintrag \"*\" in der Blacklist. Je nach verwendetem Browser wird \"*\" als Wildcard an verschiedenen Stellen unterst\u00fctzt. Sie k\u00f6nnen Kommentare hinter oder zwischen den Zeilen mittels \"#\" einleiten.",
diff --git a/modules-available/locationinfo/lang/en/panel-params.json b/modules-available/locationinfo/lang/en/panel-params.json
new file mode 100644
index 00000000..0eff714d
--- /dev/null
+++ b/modules-available/locationinfo/lang/en/panel-params.json
@@ -0,0 +1,26 @@
+{
+ "color_bg": "Background",
+ "color_bg_helptext": "Background color of panel",
+ "color_fg": "Foreground",
+ "color_fg_helptext": "Primary text color",
+ "color_section": "Section",
+ "color_section_helptext": "Color for \"current\" and \"upcoming\" headline",
+ "color_time": "Date\/time",
+ "color_time_helptext": "Color for date\/time display in fullscreen display",
+ "heading": "Heading",
+ "heading_helptext": "Primary heading",
+ "language": "Language",
+ "language-system": "Prefer system language",
+ "language-system_helptext": "If supported, the panel will be displayed in the language the user's browser requested",
+ "language_helptext": "Language to display the panel in",
+ "mod": "Panel variant",
+ "mod_helptext": "Use alternate template for panel",
+ "panelname": "Internal name",
+ "panelname_helptext": "Name of panel as displayed in admin interface",
+ "qrcode": "QR code",
+ "qrcode_helptext": "Display QR code in lower right corner",
+ "section_general": "General",
+ "section_styling": "Styling",
+ "subheading": "Subheading",
+ "subheading_helptext": "Secondary heading, displayed below primary heading"
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/en/template-tags.json b/modules-available/locationinfo/lang/en/template-tags.json
index b2504356..7a25a512 100644
--- a/modules-available/locationinfo/lang/en/template-tags.json
+++ b/modules-available/locationinfo/lang/en/template-tags.json
@@ -24,18 +24,20 @@
"lang_createPanel": "Create panel",
"lang_credentials": "Login",
"lang_currentDay": "Current Day",
+ "lang_currentEvents": "Current",
"lang_daysToShow": "Days",
"lang_daysToShowTooltip": "Defines the amount of days to show in the calendar",
- "lang_defaultPanel": "Default panel",
+ "lang_defaultPanel": "Room\/Calendar",
"lang_deleteConfirmation": "Are you sure?",
"lang_display": "Display",
"lang_displayName": "Name",
- "lang_displayNameTooltip": "Display name for this panel",
+ "lang_displayNameTooltip": "Name for this panel (as shown in listing)",
"lang_ecoMode": "E-Ink mode",
"lang_ecoTooltip": "Lower update rate, countdown doesn't show seconds",
"lang_editDefaultPanelHints": "Here you can define panel properties for e.g. a digital door sign. To show opening times for a room you need to define corresponding times in the settings.\r\nIf you want to show calendar events you have to define a functioning backend first and link it to corresponding rooms.",
"lang_editPanel": "Edit panel",
"lang_editSummaryPanelHints": "Here you can define a summary panel which shows a overview of clients in your locations.",
+ "lang_editUpcomingPanelHints": "Panel that shows active and upcoming events in one or more locations. Events will be shown in ascending order.",
"lang_editUrlPanelHints": "Here you can define which URL is opened by the panel. This enables you to show news about your university or any other website.",
"lang_entryName": "Name",
"lang_error": "Error",
@@ -80,6 +82,7 @@
"lang_modeTooltip": "The display modes the frontend supports",
"lang_monday": "Monday",
"lang_nameTooltip": "Defines the name of the server",
+ "lang_noCurrentEvents": "No current events",
"lang_noLocationsWarning": "Please select at least one location this panel should display.",
"lang_noServer": "<no server>",
"lang_openingTime": "Opening time",
@@ -141,8 +144,11 @@
"lang_switchTimeTooltip": "[1-120] Sets the time between switching (in seconds)",
"lang_thuesday": "Thuesday",
"lang_thursday": "Thursday",
+ "lang_timeSpanSuffix": " ",
"lang_to": "to",
"lang_typeTooltip": "Defines on which type of server you want to connect to",
+ "lang_upcomingEvents": "Upcoming",
+ "lang_upcomingPanel": "Upcoming events",
"lang_updateRates": "Update rates",
"lang_url": "URL",
"lang_urlListHelp": "You can specify lists of URLs or hostnames here, which are then interpreted as a whitelist or blacklist. Depending on the selected browser, either the whitelist or the \"more precise\" rule takes precedence. If no rule applies, access will be allowed unless there is an entry \"*\" in the blacklist. Depending on the browser used, \"*\" is supported as a wildcard in different places. You can introduce comments at the end of or between lines using \"#\".",
diff --git a/modules-available/locationinfo/page.inc.php b/modules-available/locationinfo/page.inc.php
index fd9abb18..538f2514 100644
--- a/modules-available/locationinfo/page.inc.php
+++ b/modules-available/locationinfo/page.inc.php
@@ -203,13 +203,19 @@ class Page_LocationInfo extends Page
* and remove any ids that don't exist. The cleaned list will be returned.
* Will show error and redirect to main page if parameter is missing
*
- * @param bool $failIfEmpty Show error and redirect to main page if parameter is missing or list is empty
- * @return array list of locations from parameter
+ * @return int[] list of locations from parameter
*/
private function getLocationIdsFromRequest(): array
{
- $locationids = Request::post('locationids', Request::REQUIRED_EMPTY, 'string');
- $locationids = explode(',', $locationids);
+ $locationids = Request::post('locationids', Request::REQUIRED_EMPTY);
+ if (is_array($locationids)) {
+ // NOOP
+ } elseif (is_numeric($locationids)) {
+ $locationids = [(int)$locationids];
+ } elseif (is_string($locationids)) {
+ $locationids = explode(',', $locationids);
+ }
+ $locationids = array_map('intval', $locationids);
$all = Location::getAllLocationIds();
$locationids = array_filter($locationids, function ($item) use ($all) { return in_array($item, $all); });
if (empty($locationids)) {
@@ -233,13 +239,24 @@ class Page_LocationInfo extends Page
// Check panel type
$paneltype = Request::post('ptype', false, 'string');
- if ($paneltype === 'DEFAULT') {
+//Refactored Code
+ switch ($paneltype) {
+ case 'DEFAULT':
$params = $this->preparePanelConfigDefault();
- } elseif ($paneltype === 'URL') {
+ break;
+ case 'URL':
$params = $this->preparePanelConfigUrl();
- } elseif ($paneltype === 'SUMMARY') {
+ break;
+ case 'SUMMARY':
$params = $this->preparePanelConfigSummary();
- } else {
+ break;
+ case 'UPCOMING':
+ $params = [
+ 'config' => LocationInfo::panelConfigFromPost($paneltype),
+ 'locationids' => self::getLocationIdsFromRequest(),
+ ];
+ break;
+ default:
Message::addError('invalid-panel-type', $paneltype);
Util::redirect('?do=locationinfo', 400);
}
@@ -257,7 +274,7 @@ class Page_LocationInfo extends Page
WHERE paneluuid = :id";
}
$params['id'] = $paneluuid;
- $params['name'] = Request::post('name', '-', 'string');
+ $params['name'] = Request::post('panelname', '-', 'string');
$params['type'] = $paneltype;
$params['now'] = time();
$params['config'] = json_encode($params['config']);
@@ -542,7 +559,8 @@ class Page_LocationInfo extends Page
$locations = Location::getLocations(0, 0, false, true);
// Get hidden state of all locations
- $dbquery = Database::simpleQuery("SELECT li.locationid, li.serverid, li.serverlocationid, loc.openingtime, li.lastcalendarupdate, cb.servertype, cb.servername
+ $dbquery = Database::simpleQuery("SELECT li.locationid, li.serverid, li.serverlocationid,
+ li.lastcalendarupdate, loc.openingtime, cb.servertype, cb.servername
FROM `locationinfo_locationconfig` AS li
LEFT JOIN `locationinfo_coursebackend` AS cb USING (serverid)
LEFT JOIN `location` AS loc USING (locationid)");
@@ -903,28 +921,10 @@ class Page_LocationInfo extends Page
return;
}
$config = false;
- if ($id === 'new-default') {
- // Creating new panel
- $panel = array(
- 'panelname' => '',
- 'locationids' => '',
- 'paneltype' => 'DEFAULT',
- );
- $id = 'new';
- } elseif ($id === 'new-summary') {
- // Creating new panel
- $panel = array(
- 'panelname' => '',
- 'locationids' => '',
- 'paneltype' => 'SUMMARY',
- );
- $id = 'new';
- } elseif ($id === 'new-url') {
- // Creating new panel
- $panel = array(
- 'panelname' => '',
- 'paneltype' => 'URL',
- );
+ if (substr($id, 0, 4) === 'new-') {
+ $panel = [
+ 'paneltype' => substr($id, 4),
+ ];
$id = 'new';
} else {
// Get Config data from db
@@ -951,6 +951,7 @@ class Page_LocationInfo extends Page
} else {
$config += $def;
}
+ $config['panelname'] = $panel['panelname'];
$langs = Dictionary::getLanguages(true);
if (isset($config['language'])) {
@@ -965,7 +966,7 @@ class Page_LocationInfo extends Page
Render::addTemplate('page-config-panel-default', array(
'new' => $id === 'new',
'uuid' => $id,
- 'panelname' => $panel['panelname'],
+ 'panelname' => $panel['panelname'] ?? '',
'languages' => $langs,
'mode' => $config['mode'],
'vertical_checked' => $config['vertical'] ? 'checked' : '',
@@ -981,7 +982,7 @@ class Page_LocationInfo extends Page
'calupdate' => $config['calupdate'],
'roomupdate' => $config['roomupdate'],
'locations' => Location::getLocations(),
- 'locationids' => $panel['locationids'],
+ 'locationids' => $panel['locationids'] ?? '',
'overrides' => json_encode($config['overrides']),
'hostname_checked' => $config['hostname'] ? 'checked' : '',
));
@@ -1006,7 +1007,7 @@ class Page_LocationInfo extends Page
Render::addTemplate('page-config-panel-url', array(
'new' => $id === 'new',
'uuid' => $id,
- 'panelname' => $panel['panelname'],
+ 'panelname' => $panel['panelname'] ?? '',
'url' => $config['url'],
'zoom-factor' => $config['zoom-factor'],
'language' => $config['language'],
@@ -1022,16 +1023,27 @@ class Page_LocationInfo extends Page
'bookmarks' => $bookmarksArray,
'allow-tty_' . $config['allow-tty'] . '_checked' => 'checked',
));
- } else {
+ } elseif ($panel['paneltype'] === 'UPCOMING') {
+ $configData = LocationInfo::getEditTemplateData($panel['paneltype'], $config);
+ LocationInfo::makeCheckedProperties($panel['paneltype'], $config);
+ Render::addTemplate('page-config-panel-upcoming', array(
+ 'new' => $id === 'new',
+ 'uuid' => $id,
+ 'sections' => $configData,
+ 'locationtree' => Render::parse('fragment-locationtree', [
+ 'locations' => Location::getLocations(explode(',', $panel['locationids'] ?? '')),
+ ]),
+ ));
+ } elseif ($panel['paneltype'] === 'SUMMARY') {
Render::addTemplate('page-config-panel-summary', array(
'new' => $id === 'new',
'uuid' => $id,
- 'panelname' => $panel['panelname'],
+ 'panelname' => $panel['panelname'] ?? '',
'languages' => $langs,
'panelupdate' => $config['panelupdate'],
'roomplanner' => $config['roomplanner'],
'locations' => Location::getLocations(),
- 'locationids' => $panel['locationids'],
+ 'locationids' => $panel['locationids'] ?? '',
'eco_checked' => $config['eco'] ? 'checked' : '',
));
}
@@ -1044,6 +1056,7 @@ class Page_LocationInfo extends Page
http_response_code(400);
die('Missing parameter uuid');
}
+ $config = [];
$type = InfoPanel::getConfig($uuid, $config);
if ($type === null) {
http_response_code(404);
@@ -1054,7 +1067,7 @@ class Page_LocationInfo extends Page
Util::redirect($config['url']);
}
- $data = array();
+ $data = [];
preg_match('#^/(.*)/#', $_SERVER['PHP_SELF'], $script);
preg_match('#^/([^?]+)/#', $_SERVER['REQUEST_URI'], $request);
if ($script[1] !== $request[1]) {
@@ -1065,36 +1078,56 @@ class Page_LocationInfo extends Page
$data['api'] = 'api.php?do=locationinfo&';
}
- if ($type === 'DEFAULT') {
- $data += array(
- 'uuid' => $uuid,
- 'config' => json_encode($config),
- 'language' => $config['language'],
- );
+ $lang = $config['language'] ?? 'en';
+ $reqLang = Request::get('forcelang', false, 'string');
+ if ($reqLang !== false && Dictionary::hasLanguage($reqLang)) {
+ $lang = $reqLang;
+ } elseif ($config['language-system'] ?? false) {
+ $langs = preg_split('/[,\s]+/', $_SERVER['HTTP_ACCEPT_LANGUAGE']);
+ foreach ($langs as $check) {
+ $check = substr($check, 0, 2);
+ if (Dictionary::hasLanguage($check)) {
+ $lang = $check;
+ break;
+ }
+ }
+ }
+ unset($config['language']);
+ unset($config['language-system']);
+ if ($type === 'SUMMARY') {
+ $locations = LocationInfo::getLocationsOr404($uuid, false);
+ $config['tree'] = Location::getTree(...$locations);
+ }
+ $data += array(
+ 'uuid' => $uuid,
+ 'config' => json_encode($config),
+ 'language' => $lang,
+ ) + $config;
- die(Render::parse('frontend-default', $data, null, $config['language']));
+ if ($type === 'DEFAULT') {
+ die(Render::parse('frontend-default', $data, null, $lang));
}
if ($type === 'UPCOMING') {
- $data += array(
- 'uuid' => $uuid,
- 'config' => json_encode($config),
- 'language' => $config['language'],
- );
-
- die(Render::parse('frontend-kg2-upcoming', $data, null, $config['language']));
+ $mod = '';
+ if (!empty($config['mod']) && preg_match('#^[a-z0-9]+$#', $config['mod']) && file_exists('modules/locationinfo/templates/frontend-upcoming-' . $config['mod'] . '.html')) {
+ $mod = '-' . $config['mod'];
+ }
+ $bg = $this->parseCssColor($config['color_bg'] ?? '#000');
+ if ($bg !== null) {
+ $data['color_grad1'] = 'rgba(' . $bg[0] . ',' . $bg[1] . ',' . $bg[2] . ', 0)';
+ $data['color_grad2'] = 'rgba(' . $bg[0] . ',' . $bg[1] . ',' . $bg[2] . ', .1)';
+ $data['color_grad3'] = 'rgba(' . $bg[0] . ',' . $bg[1] . ',' . $bg[2] . ', .9)';
+ }
+ $fg = $this->parseCssColor($config['color_fg'] ?? '#fff');
+ if ($fg !== null) {
+ $data['color_half'] = 'rgba(' . $fg[0] . ',' . $fg[1] . ',' . $fg[2] . ', .5)';
+ }
+ die(Render::parse('frontend-upcoming' . $mod, $data, null, $lang));
}
if ($type === 'SUMMARY') {
- $locations = LocationInfo::getLocationsOr404($uuid, false);
- $config['tree'] = Location::getTree(...$locations);
- $data += array(
- 'uuid' => $uuid,
- 'config' => json_encode($config),
- 'language' => $config['language'],
- );
-
- die(Render::parse('frontend-summary', $data, null, $config['language']));
+ die(Render::parse('frontend-summary', $data, null, $lang));
}
http_response_code(500);
@@ -1138,4 +1171,27 @@ class Page_LocationInfo extends Page
Util::redirect('?do=locationinfo');
}
+ /**
+ * Parse a CSS color parameter into an array of color components.
+ * @param string $param The CSS color parameter to parse.
+ * @return ?array An array containing the color components [R, G, B, A] if valid, or null if parsing fails.
+ */
+ private function parseCssColor(string $param): ?array
+ {
+ $param = trim($param);
+ // #fff
+ if (preg_match('/^#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])$/', $param, $out))
+ return [hexdec($out[1]) * 17, hexdec($out[2]) * 17, hexdec($out[3]) * 17, 1];
+ // #ffffff
+ if (preg_match('/^#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$/', $param, $out))
+ return [hexdec($out[1]), hexdec($out[2]), hexdec($out[3]), 1];
+ // rgb(255,255,255)
+ if (preg_match('/^rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)$/i', $param, $out))
+ return [(int)($out[1]), (int)($out[2]), (int)($out[3]), 1];
+ // rgba(255,255,255,1)
+ if (preg_match('/^rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9.]{1,5})\s*\)$/i', $param, $out))
+ return [(int)$out[1], (int)$out[2], (int)$out[3], (float)$out[4]];
+ return null;
+ }
+
}
diff --git a/modules-available/locationinfo/templates/fragment-locationtree.html b/modules-available/locationinfo/templates/fragment-locationtree.html
new file mode 100644
index 00000000..96cd8e24
--- /dev/null
+++ b/modules-available/locationinfo/templates/fragment-locationtree.html
@@ -0,0 +1,48 @@
+<div class="col-md-6">
+ <div class="panel panel-default">
+ <div class="panel-heading">{{lang_locations}}</div>
+ <div class="panel-body">
+ <p>{{lang_locsHint}}</p>
+ <ul id="selected-locations" class="list-unstyled">
+ {{#locations}}
+ <li>
+ {{locationpad}}
+ <div class="checkbox checkbox-inline">
+ <input type="checkbox" name="locationids[]" value="{{locationid}}" {{checked}} id="locid-{{locationid}}">
+ <label for="locid-{{locationid}}">{{locationname}}</label>
+ </div>
+ </li>
+ {{/locations}}
+ </ul>
+ </div>
+ </div>
+</div>
+
+<div class="modal fade" id="no-locations-message" tabindex="-1" role="dialog">
+ <div class="modal-dialog"> <!--style="min-width:600px;width:70%"-->
+
+ <div class="modal-content">
+ <div class="modal-header">{{lang_error}}</div>
+ <div class="modal-body">
+ {{lang_noLocationsWarning}}
+ </div>
+ <div class="modal-footer">
+ <a class="btn btn-primary pull-right" data-dismiss="modal">{{lang_close}}</a>
+ <div class="clearfix"></div>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function() {
+ $('#config-form').submit(function(ev) {
+ if ($('#selected-locations input[type=checkbox]:checked').length > 0) {
+ return;
+ }
+ ev.preventDefault();
+ $('#no-locations-message').modal('show');
+ });
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/locationinfo/templates/frontend-default.html b/modules-available/locationinfo/templates/frontend-default.html
index 04f08590..5cd044d1 100755
--- a/modules-available/locationinfo/templates/frontend-default.html
+++ b/modules-available/locationinfo/templates/frontend-default.html
@@ -364,9 +364,9 @@ optional:
</style>
<script type='text/javascript' src='script/jquery.js'></script>
+ <script type='text/javascript' src='modules/locationinfo/frontend/frontendscript.js'></script>
<script type='text/javascript' src='modules/js_jqueryui/clientscript.js'></script>
<script type='text/javascript' src="modules/js_weekcalendar/clientscript.js"></script>
- <script type='text/javascript' src='modules/locationinfo/frontend/frontendscript.js'></script>
</head>
<body>
@@ -393,13 +393,16 @@ optional:
</div>
</body>
-<script type="text/javascript">
+<script>
+ const globalConfig = {{{config}}};
+</script>
+
+<script>
var rooms = {};
var lastRoomUpdate = 0;
var lastCalendarUpdate = 0;
var lastSwitchTime = 0;
var hasMode4 = false;
- var globalConfig = {};
var roomIds = [];
var panelUuid = '{{{uuid}}}';
const IMG_FORMAT_LIST = (function() {
@@ -410,34 +413,20 @@ optional:
})();
$(document).ready(function () {
- if (!SetUpDate) {
- fatalError("js_weekcalendar not loaded");
+ if (!globalConfig || globalConfig.constructor !== Object) {
+ fatalError("Embedded panel config is not valid JSON");
return;
}
- applyConfig({{{config}}});
+ initializePanel();
});
- /**
- * Display given error message and try reloading page once a minute
- */
- function fatalError(message) {
- $('body').empty().append($('<h1>').text(message));
- window.setInterval(function () {
- $.ajax('/').done(function () {
- window.location.reload(true);
- }).fail(function () {
- $('body').append('...');
- });
- }, 60000);
- }
-
- function applyConfig(result) {
- if (!result.locations || result.locations.constructor !== Array) {
+ function initializePanel() {
+ if (!globalConfig.locations || globalConfig.locations.constructor !== Array) {
fatalError("Requested panel doesn't contain locations / not array");
return;
}
- var fetchedRooms = result.locations.filter(function (x) {
+ var fetchedRooms = globalConfig.locations.filter(function (x) {
// filter out if no numeric id, or id already present, or already got 4 locations
if (typeof(x.id) !== 'number' || x.id <= 0 || roomIds.indexOf(x.id) !== -1 || roomIds.length >= 4)
return false;
@@ -451,23 +440,22 @@ optional:
}
var time = false;
- var p = result.time.split('-');
+ var p = globalConfig.time.split('-');
if (p.length === 6) {
time = new Date(p[0], (p[1] - 1), p[2], p[3], p[4], p[5]);
console.log(time);
}
if (time === false || isNaN(time.getTime()) || time.getFullYear() < 2010) {
- time = new Date(result.time);
+ time = new Date(globalConfig.time);
}
if (isNaN(time.getTime()) || time.getFullYear() < 2010) {
time = new Date();
}
SetUpDate(time);
- delete result.time;
- delete result.locations;
+ delete globalConfig.time;
+ delete globalConfig.locations;
- globalConfig = result;
- sanitizeGlobalConfig();
+ sanitizeConfig(globalConfig);
lastRoomUpdate = MyDate().getTime();
for (var i = 0; i < fetchedRooms.length; ++i) {
@@ -476,94 +464,18 @@ optional:
initRooms();
}
- const PARAM_STRING = 1;
- const PARAM_INT = 2;
- const PARAM_BOOL = 3;
-
- /**
- * Read given parameter from URL, replacing it in the config object if present.
- * @param config object config object
- * @param property string name of property in object, URL param of same name is being checked
- * @param paramType int one of PARAM_STRING, PARAM_INT, PARAM_BOOL
- * @param intScaleFactor int optional scale factor that will be applied if paramType == PARAM_INT
- */
- function setRoomConfigFromUrl(config, property, paramType, intScaleFactor) {
- var val = getUrlParameter(property);
- if (val === true || val === false)
- return;
- if (paramType === PARAM_STRING) {
- config[property] = val;
- } else if (paramType === PARAM_INT) {
- config[property] = parseInt(val);
- if (intScaleFactor) {
- config[property] *= intScaleFactor;
- }
- } else if (paramType === PARAM_BOOL) {
- val = val.toLowerCase();
- config[property] = val.length > 0 && val !== 'false' && val !== 'off' && val !== '0';
- } else {
- console.log('Invalid paramType: ' + paramType);
- }
- }
-
- /**
- * Put given numeric config property in range min..max (both inclusive),
- * if not in range, set to default.
- * @param config - object config object
- * @param property - string config property
- * @param min int - min allowed value (inclusive)
- * @param max int - max allowed value (inclusive)
- * @param defaultval - default value to use if out of range
- * @param scaleFactor int - optional scale factor to apply
- */
- function putInRange(config, property, min, max, defaultval, scaleFactor) {
- var v = config[property];
- if (!scaleFactor) {
- scaleFactor = 1;
- }
- if (v === null || !isFinite(v) || isNaN(v) || v < min * scaleFactor || v > max * scaleFactor) {
- config[property] = defaultval * scaleFactor;
- }
- }
-
- /**
- * gets Additional Parameters from the URL, and from the
- * downloaded json.
- * also makes sure parameters are in a given range
- */
- function sanitizeGlobalConfig() {
- sanitizeConfig(globalConfig);
- }
-
function sanitizeConfig(config) {
- if (config) {
- config.switchtime = config.switchtime * 1000;
- config.calupdate = config.calupdate * 60 * 1000;
- config.roomupdate = config.roomupdate * 1000;
- }
-
- setRoomConfigFromUrl(config, 'calupdate', PARAM_INT, 60 * 1000);
- setRoomConfigFromUrl(config, 'roomupdate', PARAM_INT, 1000);
- setRoomConfigFromUrl(config, 'startday', PARAM_INT);
- setRoomConfigFromUrl(config, 'daystoshow', PARAM_INT);
- setRoomConfigFromUrl(config, 'scaledaysauto', PARAM_BOOL);
- setRoomConfigFromUrl(config, 'vertical', PARAM_BOOL);
- setRoomConfigFromUrl(config, 'eco', PARAM_BOOL);
- setRoomConfigFromUrl(config, 'prettytime', PARAM_BOOL);
-
- setRoomConfigFromUrl(config, 'scale', PARAM_INT);
- setRoomConfigFromUrl(config, 'rotation', PARAM_INT);
- setRoomConfigFromUrl(config, 'switchtime', PARAM_INT, 1000);
-
- // parameter validation
- putInRange(config, 'switchtime', 5, 120, 6, 1000);
- putInRange(config, 'scale', 10, 90, 50);
- putInRange(config, 'startday', 0, 7, 0);
- putInRange(config, 'daystoshow', 1, 7, 7);
- putInRange(config, 'roomupdate', 15, 5 * 60, 60, 1000);
- putInRange(config, 'calupdate', 1, 60, 30, 60 * 1000);
- putInRange(config, 'mode', 1, 4, 1);
- putInRange(config, 'rotation', 0, 3, 0);
+ sanitizeConfigParam(config, 'calupdate', PARAM_INT, 30, 1, 60, 60 * 1000);
+ sanitizeConfigParam(config, 'roomupdate', PARAM_INT, 15, 10, 120, 1000);
+ sanitizeConfigParam(config, 'startday', PARAM_INT, 1, 0, 7);
+ sanitizeConfigParam(config, 'daystoshow', PARAM_INT, 5, 1, 7);
+ sanitizeConfigParam(config, 'scaledaysauto', PARAM_BOOL, true);
+ sanitizeConfigParam(config, 'vertical', PARAM_BOOL, false);
+ sanitizeConfigParam(config, 'eco', PARAM_BOOL, false);
+ sanitizeConfigParam(config, 'prettytime', PARAM_BOOL, true);
+ sanitizeConfigParam(config, 'scale', PARAM_INT, 50, 10, 90);
+ sanitizeConfigParam(config, 'rotation', PARAM_INT, 0, 0, 3);
+ sanitizeConfigParam(config, 'switchtime', PARAM_INT, 20, 5, 120, 1000);
}
var updateTimer = null;
@@ -682,7 +594,7 @@ optional:
lastRoomUpdate = now;
queryRooms();
} else {
- queryPanelChange();
+ queryPanelChange("{{{api}}}", panelUuid, globalConfig);
}
$('.calendar').weekCalendar("scrollToHour");
@@ -695,10 +607,10 @@ optional:
clearInterval(updateTimer);
updateTimer = null;
}
- // Delay by a minute, sometimes the calendar shows the previous day if we load too quickly.
+ // Delay by ten minutes, sometimes the calendar shows the previous day if we load too quickly.
setTimeout(function() {
location.reload(true);
- }, 60000);
+ }, 600 * 1000);
}
} else {
lastDate = today;
@@ -1048,27 +960,6 @@ optional:
}
}
- function cleanDate(d) {
- if (typeof d === 'string') {
- // if is numeric
- if (!isNaN(Number(d))) {
- return cleanDate(parseInt(d, 10));
- }
-
- // this is a human readable date
- if (d[d.length - 1] !== 'Z') d += 'Z';
- var o = new Date(d);
- o.setTime(o.getTime() + (o.getTimezoneOffset() * 60 * 1000));
- return o;
- }
-
- if (typeof d === 'number') {
- return new Date(d);
- }
-
- return d;
- }
-
/**
* scales calendar, called once on create and on window resize
* @param room Room Object
@@ -1513,30 +1404,6 @@ optional:
}
/**
- * Checks whether the panel has been edited and reloads
- * the entire page if so.
- */
- function queryPanelChange() {
- $.ajax({
- url: "{{{api}}}get=timestamp&uuid=" + panelUuid,
- dataType: 'json',
- cache: false,
- timeout: 5000,
- success: function (result) {
- if (!result || !result.ts) {
- console.log("Warning: get=timestamp didn't return json with ts field");
- return;
- }
- if (globalConfig.ts && globalConfig.ts !== result.ts) {
- // Change
- window.location.reload(true);
- }
- globalConfig.ts = result.ts;
- }
- })
- }
-
- /**
* Queries Pc states
*/
function queryRooms() {
@@ -1665,42 +1532,6 @@ optional:
}, 200);
});
-
- /**
- * returns parameter value from the url
- * @param sParam
- * @returns boolean|string for given parameter
- */
- function getUrlParameter(sParam) {
- var sPageURL = decodeURIComponent(window.location.search.substring(1)),
- sURLVariables = sPageURL.split('&'),
- sParameterName,
- i;
-
- for (i = 0; i < sURLVariables.length; i++) {
- sParameterName = sURLVariables[i].split('=', 2);
-
- if (sParameterName[0] === sParam) {
- if (sParameterName.length === 1) return true;
- return sParameterName[1];
- }
- }
- return false;
- }
-
- /**
- * Function for translation
- * @param toTranslate key which we want to translate
- * @returns r translated string
- */
- function t(toTranslate) {
- if (tCache[toTranslate])
- return tCache[toTranslate];
- var r = $('#i18n').find('[data-tag="' + toTranslate + '"]');
- return tCache[toTranslate] = (r.length === 0 ? toTranslate : r.text());
- }
- var tCache = {};
-
function resizeIfRequired(room) {
if (room.resizeCalendar) {
scaleCalendar(room);
@@ -1766,51 +1597,5 @@ optional:
}, interval);
}
- const getWebApk = async function (fileName, manifestUrl = document.querySelector("link[rel=manifest]").href) {
- Uint8Array.prototype.toBase64 ??= function () {
- return btoa(Array.from(this, v => String.fromCharCode(v)).join(""));
- }
-
- const manifest = await (await fetch(manifestUrl)).json();
- if (!manifest.start_url) {
- alert('Manifest start_url missing');
- return;
- }
- if (!manifest.icons) {
- alert('Manifest icons missing');
- return;
- }
- const startUrl = new URL(manifest.start_url, manifestUrl);
- const imageData = new Uint8Array(await (await fetch(new URL(manifest.icons.find(v => v.type === "image/png").src, manifestUrl))).arrayBuffer()).toBase64();
-
- const {downloadUrl} = await (
- await fetch("https://webapk.googleapis.com/v1/webApks/?key=AIzaSyAoI6v-F31-3t9NunLYEiKcPIqgTJIUZBw", {
- method: "POST",
- body: JSON.stringify({
- appKey: startUrl, manifestUrl, requesterApplicationPackage: "com.android.chrome",
- manifest: {
- name: manifest.name,
- startUrl,
- id: startUrl,
- scopes: [new URL(".", startUrl)],
- icons: [{imageData}],
- displayMode: manifest.display
- }
- }),
- headers: {
- "content-type": "application/json",
- },
- })
- ).json();
-
- if (typeof downloadUrl !== 'string') {
- alert('Error generating APK');
- return;
- }
-
- location = downloadUrl;
- return;
- };
-
</script>
</html>
diff --git a/modules-available/locationinfo/templates/frontend-summary.html b/modules-available/locationinfo/templates/frontend-summary.html
index d9851783..ababe08d 100644
--- a/modules-available/locationinfo/templates/frontend-summary.html
+++ b/modules-available/locationinfo/templates/frontend-summary.html
@@ -118,7 +118,6 @@
<script type='text/javascript'>
var rooms = {};
- var startdate;
var roomidsString = "";
var config = {{{config}}};
var lastPanelUpdate = 0;
@@ -148,14 +147,6 @@
setInterval(update, 10000);
}
- function SetUpDate(d) {
- startdate = d.getTime() - new Date().getTime();
- }
-
- function MyDate() {
- return new Date(startdate + new Date().getTime());
- }
-
function generateLayout(json) {
for (var i = 0; i < json.length; i++) {
console.log('Outermost for ' + json[i].locationid);
@@ -201,31 +192,10 @@
}
} else {
// Set Roomupdate Interval has NOT passed, check if panel was changed since last call and reload if true.
- queryPanelChange();
+ queryPanelChange("{{{api}}}", panelUuid, globalConfig);
}
}
- function cleanDate(d) {
- if (typeof d === 'string') {
- // if is numeric
- if (!isNaN(Number(d))) {
- return cleanDate(parseInt(d, 10));
- }
-
- // this is a human readable date
- if (d[d.length - 1] !== 'Z') d += 'Z';
- var o = new Date(d);
- o.setTime(o.getTime() + (o.getTimezoneOffset() * 60 * 1000));
- return o;
- }
-
- if (typeof d === 'number') {
- return new Date(d);
- }
-
- return d;
- }
-
function UpdateTimeTables(json) {
var l = json.length;
for (var i = 0; i < l; i++) {
@@ -717,44 +687,6 @@
}
});
}
-
- /**
- * Checks whether the panel has been edited and reloads
- * the entire page if so.
- */
- function queryPanelChange() {
- $.ajax({
- url: "{{{api}}}get=timestamp&uuid={{uuid}}",
- dataType: 'json',
- cache: false,
- timeout: 5000,
- success: function (result) {
- if (!result || !result.ts) {
- console.log("Warning: get=timestamp didn't return json with ts field");
- return;
- }
- if (config.ts && config.ts !== result.ts) {
- // Change
- window.location.reload(true);
- }
- config.ts = result.ts;
- }
- })
- }
-
- /**
- * Function for translation
- * @param toTranslate key which we want to translate
- * @returns r translated string
- */
- function t(toTranslate) {
- if (tCache[toTranslate])
- return tCache[toTranslate];
- var r = $('#i18n').find('[data-tag="' + toTranslate + '"]');
- return tCache[toTranslate] = (r.length === 0 ? toTranslate : r.text());
- }
- var tCache = {};
-
</script>
</head>
<body>
diff --git a/modules-available/locationinfo/templates/frontend-upcoming.html b/modules-available/locationinfo/templates/frontend-upcoming.html
new file mode 100644
index 00000000..b1878336
--- /dev/null
+++ b/modules-available/locationinfo/templates/frontend-upcoming.html
@@ -0,0 +1,344 @@
+<!DOCTYPE html>
+<!--
+
+parameter
+
+required:
+ uuid: [integer] panel id, see in admin panel
+
+optional:
+ calupdate: Update interval in minutes of the calendar data (pull from server)
+ qrcode: boolean - show qrcode to panel in bottom right corner
+
+-->
+<html lang="{{language}}">
+<head>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" charset="utf-8">
+ <title>{{heading}}</title>
+ <link rel="manifest" type="application/json" href="api/get=manifest&amp;uuid={{uuid}}">
+
+ <style type="text/css">
+
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ html, body {
+ line-height: 1.33;
+ }
+
+ body {
+ margin: 0;
+ padding: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: {{color_bg}};
+ color: {{color_fg}};
+ overflow: hidden;
+ position: fixed;
+ font-family: "Lucida Grande", Helvetica, Arial, Verdana, sans-serif;
+ }
+
+ #i18n {
+ display: none;
+ }
+
+ #qrcode {
+ background: #fff;
+ padding: 10px;
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ width: min(10vh, 10vw);
+ height: min(10vh, 10vw);
+ min-width: 128px;
+ min-height: 128px;
+ z-index: 20;
+ }
+
+ #qrcode canvas, #qrcode img {
+ width: 100%;
+ height: 100%;
+ }
+
+ #time {
+ font-size: min(7vh, 7vw);
+ color: {{color_time}};
+ display: inline-block;
+ float: right;
+ padding-right: min(1vh, 1vw);
+ position: relative;
+ z-index: 10;
+ }
+
+ #current {
+ padding: 0;
+ margin: 0;
+ border-bottom: min(.4vh, .4vw) solid {{color_section}};
+ position: relative;
+ }
+
+ @media all and not (display-mode: fullscreen) {
+ #time {
+ display: none;
+ }
+ }
+
+ #current .inner {
+ font-size: min(8vh, 8vw);
+ padding-top: min(5vh, 5vw);
+ padding-left: min(3vh, 3vw);
+ }
+
+ #heading {
+ font-size: min(8vh, 8vw);
+ font-weight: bold;
+ padding-left: min(3vh, 3vw);
+ }
+
+ #subheading {
+ font-size: min(8vh, 8vw);
+ padding-left: min(3vh, 3vw);
+ }
+
+ #upcoming {
+ padding: 0;
+ margin: 0;
+ position: relative;
+ }
+
+ #upcoming .inner {
+ margin-top: min(2vw, 2vh);
+ font-size: min(5vh, 5vw);
+ padding-top: min(4vw, 4vh);
+ padding-left: min(3.1vh, 3.1vw);
+ }
+ .section-header {
+ margin-top: max(-3vh, -3vw);
+ position: absolute;
+ display: inline-block;
+ font-size: min(9vh, 9vw);
+ color: {{color_section}};
+ }
+
+ .event {
+ position: relative;
+ hyphens: auto;
+ }
+
+ .from-to, .title-location {
+ margin: 0;
+ vertical-align: top;
+ display: inline-block;
+ }
+ .from-to {
+ margin: 0;
+ vertical-align: top;
+ margin-right: min(5vw, 5vh);
+ }
+ .location, .to {
+ margin: 0;
+ }
+ .to {
+ text-align: right;
+ }
+ .title-location {
+ position: absolute;
+ left: min(28vh, 28vw);
+ }
+ #current .location, #current .to {
+ margin-top: max(-2.2vh, -2.2vw);
+ font-size: min(1.6vh, 1.6vw);
+ }
+ #current .location {
+ margin-left: min(0.5vh, 0.5vw);
+ }
+ #current .to {
+ margin-right: min(0.5vh, 0.5vw);
+ }
+ #upcoming .location, #upcoming .to {
+ margin-top: max(-0.35vh, -0.35vw);
+ font-size: min(1.1vh, 1.1vw);
+ }
+ #upcoming .location {
+ margin-left: min(0.15vh, 0.15vw);
+ }
+ #upcoming .to {
+ margin-right: min(0.15vh, 0.15vw);
+ }
+ .title, .location {
+ text-wrap: nowrap;
+ overflow: hidden;
+ }
+ .event::after {
+ background: linear-gradient(90deg, {{color_grad1}} 0%, {{color_grad2}} 20%, {{color_grad3}} 70%, {{color_bg}} 90%);
+ width: min(10vh, 10vw);
+ height: 100%;
+ display: inline-block;
+ position: absolute;
+ right: 0;
+ content: "";
+ }
+ body::after {
+ background: linear-gradient(180deg, {{color_grad1}} 0%, {{color_grad2}} 20%, {{color_grad3}} 70%, {{color_bg}} 90%);
+ height: min(10vh, 10vw);
+ width: 100%;
+ display: inline-block;
+ position: absolute;
+ bottom: 0;
+ content: "";
+ }
+ #events-none {
+ font-size: min(5vh, 5vw);
+ text-align: center;
+ margin: auto;
+ }
+ .clearfix {
+ clear: both;
+ }
+ </style>
+
+ <script type='text/javascript' src='script/jquery.js'></script>
+ <script type='text/javascript' src='modules/locationinfo/frontend/frontendscript.js'></script>
+
+</head>
+<body>
+<div id="i18n">
+ <span data-tag="free">{{lang_free}}</span>
+ <span data-tag="for">{{lang_for}}</span>
+ <span data-tag="short0">{{lang_shortSun}}</span>
+ <span data-tag="short1">{{lang_shortMon}}</span>
+ <span data-tag="short2">{{lang_shortTue}}</span>
+ <span data-tag="short3">{{lang_shortWed}}</span>
+ <span data-tag="short4">{{lang_shortThu}}</span>
+ <span data-tag="short5">{{lang_shortFri}}</span>
+ <span data-tag="short6">{{lang_shortSat}}</span>
+ <span data-tag="long0">{{lang_longSun}}</span>
+ <span data-tag="long1">{{lang_longMon}}</span>
+ <span data-tag="long2">{{lang_longTue}}</span>
+ <span data-tag="long3">{{lang_longWed}}</span>
+ <span data-tag="long4">{{lang_longThu}}</span>
+ <span data-tag="long5">{{lang_longFri}}</span>
+ <span data-tag="long6">{{lang_longSat}}</span>
+ <span data-tag="to">{{lang_to}}</span>
+</div>
+
+<time id="time">
+ <span class="date-today"></span>
+ <span class="time-today"></span>
+</time>
+<div id="heading">{{heading}}</div>
+<div id="subheading">{{subheading}}</div>
+<div class="clearfix"></div>
+<div id="current">
+ <div class="section-header">{{lang_currentEvents}}</div>
+ <div class="inner"></div>
+</div>
+<div id="upcoming">
+ <div class="section-header">{{lang_upcomingEvents}}</div>
+ <div class="inner"></div>
+</div>
+<div id="events-none">
+ {{lang_noCurrentEvents}}
+</div>
+</body>
+
+<script>
+ const globalConfig = {{{config}}};
+ const API = '{{{api}}}';
+ const LANGUAGE = '{{{language}}}';
+ const PANEL_UUID = '{{{uuid}}}';
+ const SINGLE_LOCATION = globalConfig.locations.length === 1;
+</script>
+
+<script>
+ /**
+ * Render the calendar after we processed and merged
+ * all the schedules for the selected locations.
+ */
+ function renderUnifiedEvents() {
+ console.log('Render');
+ var $current = $('#current > .inner');
+ var $currentItems = $current.find('.event');
+ var curCount = 0;
+ var $upcoming = $('#upcoming > .inner');
+ var $upcomingItems = $upcoming.find('.event');
+ var upCount = 0;
+ var cutoff = NOW + 3600_000 * 14;
+ nextRender = 0xfffffffffff;
+ for (var i = 0; i < calendars.length; ++i) {
+ if (calendars[i].e < NOW)
+ continue;
+ if (calendars[i].s > cutoff)
+ break;
+ if (calendars[i].s <= NOW) {
+ addEntry($current, $currentItems, curCount++, calendars[i]);
+ nextRender = Math.min(nextRender, calendars[i].e); // Active - consider end time
+ } else {
+ addEntry($upcoming, $upcomingItems, upCount++, calendars[i]);
+ nextRender = Math.min(nextRender, calendars[i].s); // Upcoming - consider start time
+ }
+ }
+ while (curCount < $currentItems.length) {
+ console.log("Removing", curCount);
+ $currentItems.eq(curCount++).remove();
+ }
+ while (upCount < $upcomingItems.length) {
+ console.log("Removing", upCount);
+ $upcomingItems.eq(upCount++).remove();
+ }
+ console.log($currentItems.length, $upcomingItems.length);
+ if (curCount === 0) {
+ $('#current .section-header').hide();
+ } else {
+ $('#current .section-header').show();
+ }
+ if (upCount === 0) {
+ $('#upcoming .section-header').hide();
+ } else {
+ $('#upcoming .section-header').show();
+ }
+ if (curCount === 0 && upCount === 0) {
+ $('#events-none').show();
+ } else {
+ $('#events-none').hide();
+ }
+ console.log("Next render required in seconds:", Math.round((nextRender - NOW) / 1000));
+ }
+
+ /**
+ * Adds entry to the current or upcoming event list. Either replaces an existing
+ * item at the given index, or appends a new item.
+ *
+ * @param {jQuery} $root - The root element to append the new entry to.
+ * @param {jQuery} $list - The list of items where the entry will be added.
+ * @param {number} index - The index at which to add the entry in the list.
+ * @param {Object} entry - The entry data to be added, contains keys 's', 'title', 'e', 'location'.
+ */
+ function addEntry($root, $list, index, entry) {
+ var uid = entry.s + entry.title + entry.e;
+ var $el;
+ if ($list.length > index) {
+ $el = $list.eq(index);
+ if ($el.data('cmp') === uid)
+ return;
+ } else {
+ $el = $('<div class="event">');
+ $el.append($('<div class="from-to">').append($('<div class="from">')).append($('<div class="to">').append($('<span>').text(t('to') + ' ')).append('<span class="to-text">')));
+ $el.append($('<div class="title-location">').append($('<div class="title">')).append($('<div class="location">')));
+ $root.append($el);
+ }
+ $el.data('cmp', uid);
+ $el.find('.from').text(formatTime(new Date(entry.s)));
+ $el.find('.to-text').text(formatTime(new Date(entry.e)));
+ $el.find('.title').text(entry.title).attr('title', entry.title);
+ if (!SINGLE_LOCATION) {
+ $el.find('.location').text(entry.location);
+ }
+ }
+</script>
+
+<script src="modules/locationinfo/frontend/upcoming.js"></script>
+</html> \ No newline at end of file
diff --git a/modules-available/locationinfo/templates/page-config-panel-upcoming.html b/modules-available/locationinfo/templates/page-config-panel-upcoming.html
new file mode 100644
index 00000000..d99b1157
--- /dev/null
+++ b/modules-available/locationinfo/templates/page-config-panel-upcoming.html
@@ -0,0 +1,51 @@
+<h2>
+ {{#new}}{{lang_createPanel}}{{/new}}
+ {{^new}}{{lang_editPanel}}{{/new}}
+</h2>
+
+<p>{{lang_editUpcomingPanelHints}}</p>
+
+<form method="post" action="?do=locationinfo" id="config-form">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="writePanelConfig">
+ <input type="hidden" name="ptype" value="UPCOMING">
+ <input type="hidden" name="uuid" value="{{uuid}}">
+
+ <div class="row">
+
+ <div class="col-md-6">
+ {{#sections}}
+ <div class="panel panel-default">
+ <div class="panel-heading">{{title}}</div>
+ <div class="list-group">
+ {{#elements}}
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-5">
+ <label for="panel-title">{{title}}</label>
+ </div>
+ <div class="col-sm-7">
+ {{{html}}}
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{helptext}}
+ </div>
+ </div>
+ </div>
+ {{/elements}}
+ </div>
+ </div>
+ {{/sections}}
+ </div>
+
+ {{{locationtree}}}
+
+ </div>
+ <div class="text-right">
+ <a href="?do=locationinfo&amp;show=panels" class="btn btn-default">{{lang_cancel}}</a>
+ <button type="submit" class="btn btn-primary">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form> \ No newline at end of file
diff --git a/modules-available/locationinfo/templates/page-panels.html b/modules-available/locationinfo/templates/page-panels.html
index 5187335a..1f428cc6 100644
--- a/modules-available/locationinfo/templates/page-panels.html
+++ b/modules-available/locationinfo/templates/page-panels.html
@@ -60,15 +60,18 @@
</form>
<div class="buttonbar text-right">
- <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-default">
+ <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-DEFAULT">
<span class="glyphicon glyphicon-plus"></span>
{{lang_defaultPanel}}
- </a>
- <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-summary">
+ </a> <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-UPCOMING">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_upcomingPanel}}
+</a>
+ <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-SUMMARY">
<span class="glyphicon glyphicon-plus"></span>
{{lang_summaryPanel}}
</a>
- <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-url">
+ <a class="btn btn-success" href="?do=locationinfo&amp;show=edit-panel&amp;uuid=new-URL">
<span class="glyphicon glyphicon-plus"></span>
{{lang_urlPanel}}
</a>