// vim: set et sw=2 ts=2: // // This file is part of Moonfire NVR, a security camera network video recorder. // Copyright (C) 2018 The Moonfire NVR Authors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // In addition, as a special exception, the copyright holders give // permission to link the code of portions of this program with the // OpenSSL library under certain conditions as described in each // individual source file, and distribute linked combinations including // the two. // // You must obey the GNU General Public License in all respects for all // of the code used other than OpenSSL. If you modify file(s) with this // exception, you may extend this exception to your version of the // file(s), but you are not obligated to do so. If you do not wish to do // so, delete this exception statement from your version. If you delete // this exception statement from all source files in the program, then // also delete it here. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . import $ from 'jquery'; import DatePickerView from './DatePickerView'; import CalendarTSRange from '../models/CalendarTSRange'; import TimeStamp90kFormatter from '../support/TimeStamp90kFormatter'; import Time90kParser from '../support/Time90kParser'; /** * Find the earliest and latest dates from an array of StreamView * objects. * * Each camera view has a "days" property, whose keys identify days with * recordings. All such dates are collected and then scanned to find earliest * and latest dates. * * "days" for camera views that are not enabled are ignored. * * @param {[Iterable]} streamViews Camera views to look into * @return {[Set, String, String]} Array with set of all dates, and * earliest and latest dates */ function minMaxDates(streamViews) { /* * Produce a set with all dates, across all enabled cameras, that * have at least one recording available (allDates). */ const allDates = new Set( [].concat( ...streamViews .filter((v) => v.enabled) .map((v) => Array.from(v.stream.days.keys())) ) ); return [ allDates, ...Array.from(allDates.values()).reduce((acc, dateStr) => { acc[0] = !acc[0] || dateStr < acc[0] ? dateStr : acc[0]; acc[1] = !acc[1] || dateStr > acc[1] ? dateStr : acc[1]; return acc; }, []), ]; } /** * Class to represent a calendar view. * * The view consists of: * - Two date pickers (from and to) * - A time input box with each date picker (from time, to time) * - A set of radio buttons to select between same day or not * */ export default class CalendarView { /** * Construct the view with UI elements IDs specified. * * @param {String} options.fromPickerId Id for from datepicker * @param {String} options.toPickerId Id for to datepicker * @param {String} options.isSameDayId Id for same day radio button * @param {String} options.isOtherDayId Id for other day radio button * @param {String} options.fromPickerTimeId Id for from time field * @param {String} options.toPickerTimeId Id for to time field * @param {[type]} options.timeZone Timezone */ constructor({ fromPickerId = 'start-date', toPickerId = 'end-date', isSameDayId = 'end-date-same', isOtherDayId = 'end-date-other', fromPickerTimeId = 'start-time', toPickerTimeId = 'end-time', timeZone = null, } = {}) { // Lookup all by id, check and remember [ this.fromPickerView_, this.toPickerView_, this.sameDayElement_, this.otherDayElement_, this.startTimeElement_, this.endTimeElement_, ] = [ fromPickerId, toPickerId, isSameDayId, isOtherDayId, fromPickerTimeId, toPickerTimeId, ].map((id) => { const el = $(`#${id}`); if (el.length == 0) { console.log('Warning: Calendar element with id "' + id + '" not found'); } return el; }); this.fromPickerView_ = new DatePickerView(this.fromPickerView_); this.toPickerView_ = new DatePickerView(this.toPickerView_); this.timeFormatter_ = new TimeStamp90kFormatter(timeZone); this.timeParser_ = new Time90kParser(timeZone); this.selectedRange_ = new CalendarTSRange(timeZone); this.sameDay_ = true; // Start in single day view this.sameDayElement_.prop('checked', this.sameDay_); this.otherDayElement_.prop('checked', !this.sameDay_); this.availableDates_ = null; this.minDateStr_ = null; this.maxDateStr_ = null; this.singleDateStr_ = null; this.streamViews_ = null; this.rangeChangedHandler_ = null; } /** * Change timezone. * * @param {String} tz New timezone */ set tz(tz) { this.timeParser_.tz = tz; } /** * (Re)configure the datepickers and other calendar range inputs to reflect * available dates. */ configureDatePickers_() { const dateSet = this.availableDates_; const minDateStr = this.minDateStr_; const maxDateStr = this.maxDateStr_; const fromPickerView = this.fromPickerView_; const toPickerView = this.toPickerView_; const beforeShowDay = function(date) { const dateStr = date.toISOString().substr(0, 10); return [dateSet.has(dateStr), '', '']; }; if (this.sameDay_) { fromPickerView.option({ dateFormat: DatePickerView.datepicker.ISO_8601, minDate: minDateStr, maxDate: maxDateStr, onSelect: (dateStr /* , picker */) => this.updateRangeDates_(dateStr, dateStr), beforeShowDay: beforeShowDay, disabled: false, }); toPickerView.destroy(); toPickerView.activate(); // Default state, but alive } else { fromPickerView.option({ dateFormat: DatePickerView.datepicker.ISO_8601, minDate: minDateStr, onSelect: (dateStr /* , picker */) => { toPickerView.minDate = this.fromDateISO; this.updateRangeDates_(dateStr, this.toDateISO); }, beforeShowDay: beforeShowDay, disabled: false, }); toPickerView.option({ dateFormat: DatePickerView.datepicker.ISO_8601, minDate: fromPickerView.dateISO, maxDate: maxDateStr, onSelect: (dateStr /* , picker */) => { fromPickerView.maxDate = this.toDateISO; this.updateRangeDates_(this.fromDateISO, dateStr); }, beforeShowDay: beforeShowDay, disabled: false, }); toPickerView.date = fromPickerView.date; fromPickerView.maxDate = toPickerView.date; } } /** * Call an installed handler (if any) to inform that range has changed. */ informRangeChange_() { if (this.rangeChangedHandler_ !== null) { this.rangeChangedHandler_(this.selectedRange_); } } /** * Handle a change in the time input of either from or to. * * The change requires updating the selected range and then informing * any listeners that the range has changed (so they can update data). * * @param {event} event DOM event that triggered us * @param {Boolean} isEnd True if this is for end time */ pickerTimeChanged_(event, isEnd) { const pickerElement = event.currentTarget; const newTimeStr = pickerElement.value; const selectedRange = this.selectedRange_; const parsedTS = isEnd ? selectedRange.setEndTime(newTimeStr) : selectedRange.setStartTime(newTimeStr); if (parsedTS === null) { console.warn('bad time change'); $(pickerElement).addClass('ui-state-error'); return; } $(pickerElement).removeClass('ui-state-error'); console.log( (isEnd ? 'End' : 'Start') + ' time changed to: ' + parsedTS + ' (' + this.timeFormatter_.formatTimeStamp90k(parsedTS) + ')' ); this.informRangeChange_(); } /** * Handle a change in the calendar's same/other day settings. * * The change means the selected range changes. * * @param {Boolean} isSameDay True if the same day radio button was activated */ pickerSameDayChanged_(isSameDay) { // Prevent change if not real change (can happen on initial setup) if (this.sameDay_ != isSameDay) { /** * This is called when the status of the same/other day radio buttons * changes. We need to determine a new selected range and activiate it. * Doing so will then also inform the change listener. */ const endDate = isSameDay ? this.selectedRange.start.dateStr : this.selectedRange.end.dateStr; this.updateRangeDates_(this.selectedRange.start.dateStr, endDate); this.sameDay_ = isSameDay; // Switch between single day and multi-day this.configureDatePickers_(); } } /** * Reflect a change in start and end date in the calendar view. * * The selected range is update, the view is reconfigured as necessary and * any listeners are informed. * * @param {String} startDateStr New starting date * @param {String} endDateStr New ending date */ updateRangeDates_(startDateStr, endDateStr) { const newRange = this.selectedRange_; const originalStart = Object.assign({}, newRange.start); const originalEnd = Object.assign({}, newRange.end); newRange.setStartDate(startDateStr); newRange.setEndDate(endDateStr); const isSameRange = (a, b) => { return ( a.dateStr == b.dateStr && a.timeStr == b.timeStr && a.ts90k == b.ts90k ); }; // Do nothing if effectively no change if ( !isSameRange(newRange.start, originalStart) || !isSameRange(newRange.end, originalEnd) ) { console.log('New range: ' + startDateStr + ' - ' + endDateStr); this.informRangeChange_(); } } /** * Install event handlers for same/other day radio buttons and the * time input boxes as both need to result in an update of the calendar * view. */ wireControls_() { // If same day status changed, update the view this.sameDayElement_.change(() => this.pickerSameDayChanged_(true)); this.otherDayElement_.change(() => this.pickerSameDayChanged_(false)); // Handle changing of a time input (either from or to) const handler = (e, isEnd) => { console.log('Time changed for: ' + (isEnd ? 'end' : 'start')); this.pickerTimeChanged_(e, isEnd); }; this.startTimeElement_.change((e) => handler(e, false)); this.endTimeElement_.change((e) => handler(e, true)); } /** * (Re)Initialize the calendar based on a collection of camera views. * * This is necessary as the camera views ultimately define the limits on * the date pickers. * * @param {Iterable} streamViews Collection of camera views */ initializeWith(streamViews) { this.streamViews_ = streamViews; [this.availableDates_, this.minDateStr_, this.maxDateStr_] = minMaxDates( streamViews ); this.configureDatePickers_(); // Initialize the selected range to the from picker's date // if we do not have a selected range yet if (!this.selectedRange.hasStart()) { const date = this.fromDateISO; this.updateRangeDates_(date, date); this.wireControls_(); } } /** * Set a handler to be called when the calendar selection range changes. * * The handler will be called with one argument, an object of type * CalendarTSRange reflecting the current calendar range. It will be called * whenever that range changes. * * @param {Function} handler Function that will be called */ set onRangeChange(handler) { this.rangeChangedHandler_ = handler; } /** * Get the "to" selected date as Date object. * * @return {Date} Date value of the "to"date picker */ get toDate() { return this.toPickerView_.date; } /** * Get the "from" selected date as Date object. * * @return {Date} Date value of the "from"date picker */ get fromDate() { return this.fromPickerView_.date; } /** * Get the "to" selected date as the date component of an ISO-8601 * formatted string. * * @return {String} Date value (YYYY-MM-D) of the "to" date picker */ get toDateISO() { return this.toPickerView_.dateISO; } /** * Get the "from" selected date as the date component of an ISO-8601 * formatted string. * * @return {String} Date value (YYYY-MM-D) of the "from" date picker */ get fromDateISO() { return this.fromPickerView_.dateISO; } /** * Get the currently selected range in the calendar view. * * @return {CalendarTSRange} Range object */ get selectedRange() { return this.selectedRange_; } }