├── src ├── moment.js ├── CalendarSensor.js ├── index.js ├── CalendarActionHandler.js ├── CalendarScheduleHandler.js ├── CalendarActionBuilder.js ├── CalendarPoller.js └── CalendarAccessory.js ├── .gitignore ├── .travis.yml ├── .eslintrc.json ├── .eslintrc.tests.json ├── LICENSE ├── test ├── google.ics ├── CalendarSensor.spec.js ├── CalendarScheduleHandler.spec.js ├── CalendarActionHandler.spec.js ├── CalendarActionBuilderWithOffset.spec.js └── CalendarActionBuilder.spec.js ├── package.json └── README.md /src/moment.js: -------------------------------------------------------------------------------- 1 | 2 | const moment = require('relative-time-parser'); 3 | 4 | module.exports = moment; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | tests/config.json 4 | tests/accessories/ 5 | tests/persist/ 6 | coverage/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "9" 4 | 5 | script: 6 | - npm run lint 7 | - npm run coverage 8 | 9 | before_install: 10 | - pip install --user codecov 11 | after_success: 12 | - codecov 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "error", 14 | 2 15 | ], 16 | "linebreak-style": [ 17 | "error", 18 | "unix" 19 | ], 20 | "quotes": [ 21 | "error", 22 | "single" 23 | ], 24 | "semi": [ 25 | "error", 26 | "always" 27 | ] 28 | } 29 | } -------------------------------------------------------------------------------- /.eslintrc.tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "error", 14 | 2 15 | ], 16 | "linebreak-style": [ 17 | "error", 18 | "unix" 19 | ], 20 | "quotes": [ 21 | "error", 22 | "single" 23 | ], 24 | "semi": [ 25 | "error", 26 | "always" 27 | ], 28 | "no-console": [ 29 | "off" 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /src/CalendarSensor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class CalendarSensor { 4 | 5 | constructor(log, name, sensor, characteristic, onValue, offValue) { 6 | this.log = log; 7 | this.name = name; 8 | this.sensor = sensor; 9 | this._characteristic = characteristic; 10 | this._onValue = onValue; 11 | this._offValue = offValue; 12 | 13 | this.reset(); 14 | this.pushState(); 15 | } 16 | 17 | reset() { 18 | this._state = 0; 19 | } 20 | 21 | on() { 22 | this._state++; 23 | } 24 | 25 | off() { 26 | if (this._state > 0) { 27 | this._state--; 28 | } 29 | } 30 | 31 | pushState() { 32 | let value = this._offValue; 33 | if (this._state > 0) { 34 | value = this._onValue; 35 | } 36 | 37 | this.log(`Pushing calendar sensor '${this.name}' state ${this._state} - value ${value}`); 38 | this.sensor 39 | .getCharacteristic(this._characteristic) 40 | .updateValue(value); 41 | } 42 | } 43 | 44 | module.exports = CalendarSensor; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michael Fröhlich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/google.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | BEGIN:VTIMEZONE 7 | TZID:Europe/Brussels 8 | BEGIN:DAYLIGHT 9 | TZOFFSETFROM:+0100 10 | TZOFFSETTO:+0200 11 | TZNAME:CEST 12 | DTSTART:19700329T020000 13 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 14 | END:DAYLIGHT 15 | BEGIN:STANDARD 16 | TZOFFSETFROM:+0200 17 | TZOFFSETTO:+0100 18 | TZNAME:CET 19 | DTSTART:19701025T030000 20 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 21 | END:STANDARD 22 | END:VTIMEZONE 23 | BEGIN:VEVENT 24 | DTSTART;TZID=Europe/Brussels:20181212T123000 25 | DTEND;TZID=Europe/Brussels:20181212T153000 26 | DTSTAMP:20181229T114119Z 27 | UID:14a0hh8p6gb6qu5tivve19k0au@google.com 28 | RECURRENCE-ID;TZID=Europe/Brussels:20181212T083000 29 | CREATED:20181229T113846Z 30 | DESCRIPTION: 31 | LAST-MODIFIED:20181229T114026Z 32 | LOCATION: 33 | SEQUENCE:1 34 | STATUS:CONFIRMED 35 | SUMMARY:Test 36 | TRANSP:OPAQUE 37 | END:VEVENT 38 | BEGIN:VEVENT 39 | DTSTART;TZID=Europe/Brussels:20181201T083000 40 | DTEND;TZID=Europe/Brussels:20181201T113000 41 | RRULE:FREQ=DAILY;UNTIL=20181214T225959Z 42 | EXDATE;TZID=Europe/Brussels:20181205T083000 43 | DTSTAMP:20181229T114119Z 44 | UID:14a0hh8p6gb6qu5tivve19k0au@google.com 45 | CREATED:20181229T113846Z 46 | DESCRIPTION: 47 | LAST-MODIFIED:20181229T113947Z 48 | LOCATION: 49 | SEQUENCE:0 50 | STATUS:CONFIRMED 51 | SUMMARY:Test 52 | TRANSP:OPAQUE 53 | END:VEVENT 54 | END:VCALENDAR -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-calendar", 3 | "version": "0.4.0", 4 | "description": "A calendar plugin for homebridge (https://github.com/nfarina/homebridge), which allows flexible scheduling of triggers using any iCal calendar.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "release": "npm-github-release", 8 | "test": "./node_modules/.bin/mocha --reporter spec --recursive test/**/*.spec.js", 9 | "coverage": "./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha --reporter spec --recursive test/**/*.spec.js", 10 | "lint": "./node_modules/.bin/eslint src && ./node_modules/.bin/eslint -c .eslintrc.tests.json test" 11 | }, 12 | "keywords": [ 13 | "homebridge", 14 | "homebridge-plugin", 15 | "calendar", 16 | "ical", 17 | "homekit", 18 | "home-automation" 19 | ], 20 | "author": "Michael Fröhlich", 21 | "license": "MIT", 22 | "engines": { 23 | "node": ">=9.3.0", 24 | "homebridge": ">=0.4.36" 25 | }, 26 | "devDependencies": { 27 | "chai": "^4.1.2", 28 | "eslint": "^4.19.1", 29 | "ical-generator": "^1.4.2", 30 | "istanbul": "^0.4.5", 31 | "mocha": "^5.1.1", 32 | "node-ical": "^0.9.0", 33 | "npm-github-release": "^0.9.0", 34 | "sinon": "^4.5.0" 35 | }, 36 | "dependencies": { 37 | "clone": "^2.1.1", 38 | "ical-expander": "^2.0.0", 39 | "moment": "^2.22.1", 40 | "relative-time-parser": "^1.0.9", 41 | "request": "^2.85.0" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/grover/homebridge-calendar.git" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const version = require('../package.json').version; 4 | 5 | const CalendarAccessory = require('./CalendarAccessory'); 6 | 7 | 8 | const HOMEBRIDGE = { 9 | Accessory: null, 10 | Service: null, 11 | Characteristic: null, 12 | UUIDGen: null 13 | }; 14 | 15 | const platformName = 'homebridge-calendar'; 16 | const platformPrettyName = 'Calendar'; 17 | 18 | module.exports = (homebridge) => { 19 | HOMEBRIDGE.Accessory = homebridge.platformAccessory; 20 | HOMEBRIDGE.Service = homebridge.hap.Service; 21 | HOMEBRIDGE.Characteristic = homebridge.hap.Characteristic; 22 | HOMEBRIDGE.UUIDGen = homebridge.hap.uuid; 23 | 24 | homebridge.registerPlatform(platformName, platformPrettyName, CalendarPlatform, true); 25 | }; 26 | 27 | const CalendarPlatform = class { 28 | constructor(log, config, api) { 29 | this.log = log; 30 | this.log(`CalendarPlatform Plugin Loaded - version ${version}`); 31 | this.config = config; 32 | this.api = api; 33 | } 34 | 35 | accessories(callback) { 36 | let _accessories = []; 37 | const { calendars } = this.config; 38 | 39 | calendars.forEach(cal => { 40 | this.log(`Found calendar in config: "${cal.name}"`); 41 | if (cal.name === undefined || cal.name.length === 0) { 42 | throw new Error('Invalid configuration: Calendar name is invalid.'); 43 | } 44 | 45 | cal.version = version; 46 | cal.pollingInterval = cal.pollingInterval || 15; 47 | 48 | const accessory = new CalendarAccessory(this.api, this.log, cal); 49 | _accessories.push(accessory); 50 | }); 51 | 52 | callback(_accessories); 53 | } 54 | }; -------------------------------------------------------------------------------- /src/CalendarActionHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class CalendarActionHandler { 4 | constructor(log, calendarSensor, namedSensors) { 5 | this.log = log; 6 | 7 | this._sensor = calendarSensor; 8 | this._namedSensors = namedSensors; 9 | 10 | this._sensors = [calendarSensor, ...namedSensors]; 11 | 12 | this._actions = []; 13 | } 14 | 15 | actionsUpdated(actions, now) { 16 | this._actions = actions; 17 | this.execute(now); 18 | } 19 | 20 | execute(now) { 21 | 22 | this._resetSensors(); 23 | 24 | this._actions 25 | .filter(e => e.date.valueOf() <= now.valueOf()) 26 | .forEach(e => { 27 | this._handleAction(e); 28 | }); 29 | 30 | this._pushSensorState(); 31 | this._expireActions(now); 32 | } 33 | 34 | _resetSensors() { 35 | this._sensors.forEach(sensor => sensor.reset()); 36 | } 37 | 38 | _handleAction(e) { 39 | this._applyState(this._sensor, e.state); 40 | 41 | if (typeof e.summary === 'string') { 42 | for (const sensor of this._namedSensors) { 43 | if (e.summary.startsWith(sensor.name)) { 44 | this._applyState(sensor, e.state); 45 | } 46 | } 47 | } 48 | } 49 | 50 | _applyState(sensor, state) { 51 | this.log(`Setting ${sensor.name} to ${state}`); 52 | if (state) { 53 | sensor.on(); 54 | } 55 | else { 56 | sensor.off(); 57 | } 58 | } 59 | 60 | _pushSensorState() { 61 | this._sensors.forEach(sensor => sensor.pushState()); 62 | } 63 | 64 | _expireActions(now) { 65 | this._actions = this._actions.filter(e => e.expires > now); 66 | } 67 | } 68 | 69 | module.exports = CalendarActionHandler; 70 | -------------------------------------------------------------------------------- /src/CalendarScheduleHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events').EventEmitter; 4 | 5 | class CalendarScheduleHandler extends EventEmitter { 6 | 7 | constructor(log) { 8 | super(); 9 | 10 | this.log = log; 11 | this._calendarEntries = []; 12 | this._nextEventAt = undefined; 13 | this._timer = undefined; 14 | } 15 | 16 | scheduleUpdated(events, now) { 17 | this._calendarEntries = events 18 | .filter(value => value.valueOf() >= now.valueOf()) 19 | .filter((value, index) => { 20 | return index == 0 || events[index - 1].valueOf() != value.valueOf(); 21 | }); 22 | 23 | 24 | this._updateTimer(); 25 | } 26 | 27 | _updateTimer() { 28 | if (this._calendarEntries.length === 0) { 29 | this.log('No events to schedule.'); 30 | 31 | this._resetTimer(); 32 | this._nextEventAt = undefined; 33 | this._timer = undefined; 34 | return; 35 | } 36 | 37 | const nextEventAt = this._calendarEntries[0]; 38 | if (this._nextEventAt !== undefined && this._nextEventAt.valueOf() === nextEventAt.valueOf()) { 39 | return; 40 | } 41 | 42 | this._resetTimer(); 43 | 44 | let diffInMilliseconds = nextEventAt.valueOf() - Date.now(); 45 | const millisecondsPerDay = 24 * 60 * 60 * 1000; 46 | if (diffInMilliseconds > millisecondsPerDay) { 47 | diffInMilliseconds = millisecondsPerDay; 48 | } 49 | 50 | this.log(`Scheduling next action in ${diffInMilliseconds}ms`); 51 | 52 | this._timer = setTimeout(this._expire.bind(this), diffInMilliseconds); 53 | this._nextEventAt = nextEventAt; 54 | } 55 | 56 | _resetTimer() { 57 | if (this._timer !== undefined) { 58 | clearTimeout(this._timer); 59 | 60 | this._nextEventAt = undefined; 61 | this._timer = undefined; 62 | } 63 | } 64 | 65 | _expire() { 66 | const now = Date.now(); 67 | 68 | while (this._calendarEntries.length > 0 && this._calendarEntries[0].valueOf() <= now) { 69 | const nextEventAt = this._calendarEntries[0]; 70 | this._calendarEntries.splice(0, 1); 71 | 72 | this.emit('event', nextEventAt); 73 | } 74 | 75 | this._timer = undefined; 76 | this._nextEventAt = undefined; 77 | 78 | this._updateTimer(); 79 | } 80 | 81 | } 82 | 83 | module.exports = CalendarScheduleHandler; 84 | -------------------------------------------------------------------------------- /src/CalendarActionBuilder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('./moment'); 4 | 5 | class CalendarActionBuilder { 6 | 7 | constructor(offset) { 8 | if (offset === undefined) { 9 | this._startOffset = '-0s'; 10 | } else if (offset.startsWith('-') === false) { 11 | this._startOffset = `-${offset}`; 12 | } else { 13 | this._startOffset = offset; 14 | } 15 | 16 | if (moment().isRelativeTimeFormat(this._startOffset) === false) { 17 | throw new Error('Invalid relative time format.'); 18 | } 19 | } 20 | 21 | generateActions(cal, now) { 22 | let allEvents = [].concat( 23 | this._generateNonRecurringEvents(cal), 24 | this._generateRecurringEvents(cal, moment(now))); 25 | 26 | allEvents = this._sortEventsByDate(allEvents); 27 | allEvents = this._filterExpiredEvents(allEvents, now); 28 | 29 | return allEvents; 30 | } 31 | 32 | _generateNonRecurringEvents(cal) { 33 | 34 | const events = [].concat(cal.events.map(e => ({ 35 | date: moment(e.startDate.toJSDate()).relativeTime(this._startOffset).toDate(), 36 | expires: e.endDate.toJSDate(), 37 | state: true, 38 | summary: e.summary 39 | })), 40 | cal.events.map(e => ({ 41 | date: e.endDate.toJSDate(), 42 | expires: e.endDate.toJSDate(), 43 | state: false, 44 | summary: e.summary 45 | }))); 46 | 47 | return events; 48 | } 49 | 50 | _generateRecurringEvents(cal) { 51 | 52 | const events = [].concat(cal.occurrences.map(e => ({ 53 | date: moment(e.startDate.toJSDate()).relativeTime(this._startOffset).toDate(), 54 | expires: e.endDate.toJSDate(), 55 | state: true, 56 | summary: e.item.summary 57 | })), 58 | cal.occurrences.map(e => ({ 59 | date: e.endDate.toJSDate(), 60 | expires: e.endDate.toJSDate(), 61 | state: false, 62 | summary: e.item.summary 63 | }))); 64 | 65 | return events; 66 | } 67 | 68 | _sortEventsByDate(events) { 69 | // Sort in start-order 70 | return events.sort((a, b) => a.date.valueOf() - b.date.valueOf()); 71 | } 72 | 73 | _filterExpiredEvents(events, now) { 74 | // Keep only events that expire right now or in the future 75 | return events.filter(event => event.expires.valueOf() >= now); 76 | } 77 | } 78 | 79 | module.exports = CalendarActionBuilder; -------------------------------------------------------------------------------- /test/CalendarSensor.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('chai').assert; 4 | 5 | const CalendarSensor = require('./../src/CalendarSensor'); 6 | 7 | describe('CalendarSensor', () => { 8 | 9 | let value; 10 | beforeEach(() => { 11 | value = undefined; 12 | }); 13 | 14 | const characteristic = { 15 | updateValue: v => value = v 16 | }; 17 | const service = { 18 | getCharacteristic: () => characteristic 19 | }; 20 | 21 | 22 | it('Value should be undefined by default', () => { 23 | assert.isUndefined(value); 24 | }); 25 | 26 | it('Should initialize the sensor to off', () => { 27 | new CalendarSensor(console.log, 'Test', service, characteristic, 1, 0); 28 | assert.equal(value, 0); 29 | }); 30 | 31 | describe('Initialized calendar sensor', () => { 32 | 33 | let sensor; 34 | 35 | beforeEach(() => { 36 | sensor = new CalendarSensor(console.log, 'Test', service, characteristic, 1, 0); 37 | value = undefined; 38 | }); 39 | 40 | it('Turning on should not update the characteristic', () => { 41 | sensor.on(); 42 | assert.isUndefined(value); 43 | }); 44 | 45 | it('Turning on should increment the state', () => { 46 | sensor.on(); 47 | assert.equal(sensor._state, 1); 48 | }); 49 | 50 | it('Turning on twice should increment the state twice', () => { 51 | sensor.on(); 52 | sensor.on(); 53 | assert.equal(sensor._state, 2); 54 | }); 55 | 56 | it('Turning on and off should reset the state to zero', () => { 57 | sensor.on(); 58 | sensor.off(); 59 | assert.equal(sensor._state, 0); 60 | }); 61 | 62 | it('Turning on, on and off should keep the state at 1', () => { 63 | sensor.on(); 64 | sensor.on(); 65 | sensor.off(); 66 | assert.equal(sensor._state, 1); 67 | }); 68 | 69 | it('Should not underflow on too many offs', () => { 70 | sensor.on(); 71 | sensor.off(); 72 | sensor.off(); 73 | assert.equal(sensor._state, 0); 74 | }); 75 | 76 | it('Reset should set the state to zero', () => { 77 | sensor.on(); 78 | sensor.reset(); 79 | assert.equal(sensor._state, 0); 80 | }); 81 | 82 | it('pushState should update HomeKit to reflect the state value', () => { 83 | sensor.on(); 84 | sensor.pushState(); 85 | assert.equal(value, 1); 86 | }); 87 | 88 | it('pushState should update HomeKit to reflect the state value', () => { 89 | sensor.off(); 90 | sensor.pushState(); 91 | assert.equal(value, 0); 92 | }); 93 | }); 94 | }); -------------------------------------------------------------------------------- /src/CalendarPoller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events').EventEmitter; 4 | const IcalExpander = require('ical-expander'); 5 | const https = require('https'); 6 | 7 | class CalendarPoller extends EventEmitter { 8 | 9 | constructor(log, name, url, interval) { 10 | super(); 11 | 12 | this.log = log; 13 | this.name = name; 14 | 15 | this._url = url.replace('webcal://', 'https://'); 16 | this._interval = interval; 17 | this._isStarted = false; 18 | } 19 | 20 | start() { 21 | if (this._isStarted === false) { 22 | this.emit('started'); 23 | this._isStarted = true; 24 | this._loadCalendar(); 25 | } 26 | } 27 | 28 | stop() { 29 | if (this._isStarted === true) { 30 | this.emit('stopped'); 31 | this._isStarted = false; 32 | 33 | clearTimeout(this._refreshTimer); 34 | this._refreshTimer = undefined; 35 | } 36 | } 37 | 38 | _loadCalendar() { 39 | // TODO: Make use of HTTP cache control stuff 40 | this.log(`Updating calendar ${this.name}`); 41 | 42 | https.get(this._url, (resp) => { 43 | 44 | resp.setEncoding('utf8'); 45 | let data = ''; 46 | 47 | // A chunk of data has been recieved. 48 | resp.on('data', (chunk) => { 49 | data += chunk; 50 | }); 51 | 52 | // The whole response has been received. 53 | resp.on('end', () => { 54 | this._refreshCalendar(data); 55 | }); 56 | 57 | }).on('error', (err) => { 58 | 59 | if (err) { 60 | this.log(`Failed to load iCal calender: ${this.url} with error ${err}`); 61 | this.emit('error', err); 62 | } 63 | 64 | }); 65 | } 66 | 67 | _refreshCalendar(data) { 68 | 69 | const icalExpander = new IcalExpander({ 70 | ics: data, 71 | maxIterations: 1000 72 | }); 73 | 74 | const duration = 7; // days 75 | var now = new Date(); 76 | var next = new Date(now.getTime() + duration * 24 * 60 * 60 * 1000); 77 | 78 | const cal = icalExpander.between(now, next); 79 | 80 | if (cal) { 81 | this.emit('data', cal); 82 | } 83 | 84 | this._scheduleNextIteration(); 85 | } 86 | 87 | _scheduleNextIteration() { 88 | if (this._refreshTimer !== undefined || this._isStarted === false) { 89 | return; 90 | } 91 | 92 | this._refreshTimer = setTimeout(() => { 93 | this._refreshTimer = undefined; 94 | this._loadCalendar(); 95 | }, this._interval); 96 | } 97 | 98 | } 99 | 100 | module.exports = CalendarPoller; -------------------------------------------------------------------------------- /test/CalendarScheduleHandler.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('chai').assert; 4 | const sinon = require('sinon'); 5 | 6 | const CalendarScheduleHandler = require('./../src/CalendarScheduleHandler'); 7 | 8 | describe('CalendarScheduleHandler', () => { 9 | 10 | const now = new Date(2018, 0, 30, 9, 0, 0, 0); 11 | 12 | const log = console.log; 13 | let handler; 14 | 15 | let clock; 16 | 17 | beforeEach(() => { 18 | clock = sinon.useFakeTimers({ 19 | now: now 20 | }); 21 | 22 | handler = new CalendarScheduleHandler(log); 23 | }); 24 | 25 | afterEach(() => { 26 | clock.restore(); 27 | }); 28 | 29 | it('Should not have a schedule by default', () => { 30 | assert.deepEqual([], handler._calendarEntries); 31 | }); 32 | 33 | it('Should store given events', () => { 34 | const expectedEvents = [ 35 | new Date(2018, 0, 30, 10, 0, 0, 0), 36 | new Date(2018, 0, 30, 11, 0, 0, 0) 37 | ]; 38 | 39 | handler.scheduleUpdated(expectedEvents, now); 40 | assert.deepEqual(handler._calendarEntries, expectedEvents); 41 | }); 42 | 43 | it('Should only store unique events', () => { 44 | const expectedEvents = [ 45 | new Date(2018, 0, 30, 10, 0, 0, 0), 46 | new Date(2018, 0, 30, 11, 0, 0, 0) 47 | ]; 48 | const givenEvents = [ 49 | new Date(2018, 0, 30, 10, 0, 0, 0), 50 | new Date(2018, 0, 30, 10, 0, 0, 0), 51 | new Date(2018, 0, 30, 11, 0, 0, 0) 52 | ]; 53 | 54 | handler.scheduleUpdated(givenEvents, now); 55 | assert.deepEqual(handler._calendarEntries, expectedEvents); 56 | }); 57 | 58 | it('Should not store past events', () => { 59 | const expectedEvents = [ 60 | new Date(2018, 0, 30, 10, 0, 0, 0), 61 | new Date(2018, 0, 30, 11, 0, 0, 0) 62 | ]; 63 | 64 | const givenEvents = [ 65 | new Date(2018, 0, 30, 8, 0, 0, 0), 66 | new Date(2018, 0, 30, 10, 0, 0, 0), 67 | new Date(2018, 0, 30, 11, 0, 0, 0) 68 | ]; 69 | 70 | handler.scheduleUpdated(givenEvents, now); 71 | assert.deepEqual(handler._calendarEntries, expectedEvents); 72 | }); 73 | 74 | it('Should raise event with date when timer expires', () => { 75 | let raised; 76 | 77 | handler.once('event', date => { 78 | raised = date; 79 | }); 80 | 81 | const expectedEvents = [ 82 | new Date(2018, 0, 30, 10, 0, 0, 0), 83 | new Date(2018, 0, 30, 11, 0, 0, 0) 84 | ]; 85 | handler.scheduleUpdated(expectedEvents, now); 86 | 87 | clock.next(); 88 | assert.equal(raised.valueOf(), expectedEvents[0].valueOf()); 89 | }); 90 | 91 | it('Should raise event after updates with date when timer expires', () => { 92 | let raised; 93 | 94 | handler.once('event', date => { 95 | raised = date; 96 | }); 97 | 98 | const expectedEvents = [ 99 | new Date(2018, 0, 30, 10, 0, 0, 0), 100 | new Date(2018, 0, 30, 11, 0, 0, 0) 101 | ]; 102 | handler.scheduleUpdated(expectedEvents, now); 103 | handler.scheduleUpdated(expectedEvents, now); 104 | 105 | clock.next(); 106 | 107 | assert.equal(raised.valueOf(), expectedEvents[0].valueOf()); 108 | }); 109 | 110 | it('Should raise multiple events', () => { 111 | let raised = 0; 112 | 113 | handler.on('event', () => { 114 | raised++; 115 | }); 116 | 117 | const expectedEvents = [ 118 | new Date(2018, 0, 30, 10, 0, 0, 0), 119 | new Date(2018, 0, 30, 11, 0, 0, 0) 120 | ]; 121 | handler.scheduleUpdated(expectedEvents, now); 122 | 123 | clock.next(); 124 | clock.next(); 125 | 126 | assert.equal(raised, 2); 127 | }); 128 | 129 | it('Should only sleep for a day at most', () => { 130 | let raised = 0; 131 | 132 | handler.on('event', () => { 133 | raised++; 134 | }); 135 | 136 | const expectedEvents = [ 137 | new Date(2018, 1, 1, 10, 0, 0, 0) 138 | ]; 139 | handler.scheduleUpdated(expectedEvents, now); 140 | 141 | clock.next(); // 2018-0-31T09:00:00.000 142 | assert.equal(raised, 0); 143 | 144 | clock.next(); // 2018-1-1T09:00:00.000 145 | assert.equal(raised, 0); 146 | 147 | clock.next(); // 2018-1-1T10:00:00.000 148 | assert.equal(raised, 1); 149 | }); 150 | }); -------------------------------------------------------------------------------- /src/CalendarAccessory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CalendarActionBuilder = require('./CalendarActionBuilder'); 4 | const CalendarActionHandler = require('./CalendarActionHandler'); 5 | const CalendarScheduleHandler = require('./CalendarScheduleHandler'); 6 | const CalendarPoller = require('./CalendarPoller'); 7 | const CalendarSensor = require('./CalendarSensor'); 8 | 9 | let Accessory, Characteristic, Service; 10 | 11 | class CalendarAccessory { 12 | 13 | constructor(api, log, config) { 14 | Accessory = api.hap.Accessory; 15 | Characteristic = api.hap.Characteristic; 16 | Service = api.hap.Service; 17 | 18 | this.log = log; 19 | this.name = config.name; 20 | this.version = config.version; 21 | this.config = config; 22 | 23 | if (!this.config.sensors) { 24 | this.config.sensors = []; 25 | } 26 | 27 | this._services = this.createServices(); 28 | 29 | this._calendarPoller = new CalendarPoller(this.log, this.name, this.config.url, this.config.pollingInterval * 60000); 30 | this._calendarPoller 31 | .on('error', this._onPollingError.bind(this)) 32 | .on('data', this._onCalendar.bind(this)) 33 | .on('started', this._onPollingStarted.bind(this)) 34 | .on('stopped', this._onPollingStopped.bind(this)); 35 | 36 | this._actionBuilder = new CalendarActionBuilder(this.config.offset); 37 | 38 | this._scheduleHandler = new CalendarScheduleHandler(this.log); 39 | this._scheduleHandler 40 | .on('event', this._scheduledEvent.bind(this)); 41 | 42 | this._calendarPoller.start(); 43 | } 44 | 45 | getServices() { 46 | return this._services; 47 | } 48 | 49 | createServices() { 50 | const services = [ 51 | this.getAccessoryInformationService(), 52 | this.getBridgingStateService(), 53 | ...this.getSensors() 54 | ]; 55 | 56 | return services; 57 | } 58 | 59 | getAccessoryInformationService() { 60 | return new Service.AccessoryInformation() 61 | .setCharacteristic(Characteristic.Name, this.name) 62 | .setCharacteristic(Characteristic.Manufacturer, 'Michael Froehlich') 63 | .setCharacteristic(Characteristic.Model, 'Calendar Switch') 64 | .setCharacteristic(Characteristic.SerialNumber, '98') 65 | .setCharacteristic(Characteristic.FirmwareRevision, this.version) 66 | .setCharacteristic(Characteristic.HardwareRevision, this.version); 67 | } 68 | 69 | getBridgingStateService() { 70 | this._bridgingStateService = new Service.BridgingState() 71 | .setCharacteristic(Characteristic.Reachable, false) 72 | .setCharacteristic(Characteristic.LinkQuality, 4) 73 | .setCharacteristic(Characteristic.AccessoryIdentifier, this.name) 74 | .setCharacteristic(Characteristic.Category, Accessory.Categories.SWITCH); 75 | 76 | return this._bridgingStateService; 77 | } 78 | 79 | getSensors() { 80 | 81 | let subtype = 0; 82 | 83 | const sensors = []; 84 | 85 | const calendarSensor = this._createCalendarSensor(this.name, subtype++); 86 | sensors.push(calendarSensor.sensor); 87 | 88 | const namedSensors = []; 89 | for (const sw of this.config.sensors) { 90 | const namedSensor = this._createCalendarSensor(sw, subtype++); 91 | namedSensors.push(namedSensor); 92 | sensors.push(namedSensor.sensor); 93 | } 94 | 95 | this._actionHandler = new CalendarActionHandler(this.log, calendarSensor, namedSensors); 96 | 97 | return sensors; 98 | } 99 | 100 | _createCalendarSensor(name, subtype) { 101 | const sensor = new Service.ContactSensor(name, subtype); 102 | const characteristic = Characteristic.ContactSensorState; 103 | const on = Characteristic.ContactSensorState.CONTACT_NOT_DETECTED; 104 | const off = Characteristic.ContactSensorState.CONTACT_DETECTED; 105 | 106 | const result = new CalendarSensor(this.log, name, sensor, characteristic, on, off); 107 | return result; 108 | } 109 | 110 | identify(callback) { 111 | this.log(`Identify requested on ${this.name}`); 112 | callback(); 113 | } 114 | 115 | _setReachable(reachable) { 116 | this._bridgingStateService 117 | .getCharacteristic(Characteristic.Reachable) 118 | .updateValue(reachable); 119 | } 120 | 121 | _onPollingStarted() { 122 | this.log(`Polling calendar ${this.name} has started.`); 123 | } 124 | 125 | _onPollingStopped() { 126 | this.log(`Polling calendar ${this.name} has stopped.`); 127 | } 128 | 129 | _onPollingError(err) { 130 | this.log(`Polling calendar ${this.name} has raised error: ${err}`); 131 | } 132 | 133 | _onCalendar(data) { 134 | const now = new Date(); 135 | 136 | const actions = this._actionBuilder.generateActions(data, now); 137 | this._actionHandler.actionsUpdated(actions, now); 138 | 139 | const schedule = actions.map(action => action.date); 140 | this._scheduleHandler.scheduleUpdated(schedule, now); 141 | 142 | this._setReachable(true); 143 | } 144 | 145 | _scheduledEvent(now) { 146 | this._actionHandler.execute(now); 147 | } 148 | } 149 | 150 | module.exports = CalendarAccessory; 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-calendar 2 | 3 | A calendar plugin for [homebridge](https://github.com/nfarina/homebridge), which allows flexible scheduling of triggers using any iCal calendar. 4 | 5 | HomeKits own scheduling means are limited and in some instances not flexible enough for more advanced scheduling needs. This plugin integrates any iCal calendar (iCloud, Google Calendar, ...) into HomeKit and creates stateless switches for the events in the calendar using the scheduled event summary. 6 | 7 | ## Status 8 | 9 | [![HitCount](http://hits.dwyl.io/grover/homebridge-calendar.svg)](https://github.com/grover/homebridge-calendar) 10 | [![Build Status](https://travis-ci.org/grover/homebridge-calendar.png?branch=master)](https://travis-ci.org/grover/homebridge-calendar) 11 | [![codecov.io](https://img.shields.io/codecov/c/github/grover/homebridge-calendar/master.svg?style=flat-square)](http://codecov.io/github/grover/homebridge-calendar?branch=master) 12 | [![Dependency Status](https://img.shields.io/david/grover/homebridge-calendar.svg?style=flat-square)](https://david-dm.org/grover/homebridge-calendar) 13 | [![devDependency Status](https://img.shields.io/david/dev/grover/homebridge-calendar.svg?style=flat-square)](https://david-dm.org/grover/homebridge-calendar#info=devDependencies) 14 | [![Node version](https://img.shields.io/node/v/homebridge-calendar.svg?style=flat)](http://nodejs.org/download/) 15 | [![NPM Version](https://badge.fury.io/js/homebridge-calendar.svg?style=flat)](https://npmjs.org/package/homebridge-calendar) 16 | 17 | ## Supported calendars 18 | 19 | - iCloud Calendar 20 | - Google Calendar 21 | 22 | In theory any calendar solution that supports iCal ([RFC 5545](https://tools.ietf.org/html/rfc5545)) and sharing should work, however some vendors choose to deviate from the RFC in their implementation. If you use one that works, but isn't on the list - great please add it to this README.md. 23 | 24 | ## Installation 25 | 26 | After [Homebridge](https://github.com/nfarina/homebridge) has been installed: 27 | 28 | ```sudo npm install -g homebridge-calendar --unsafe-perm``` 29 | 30 | ## Configuration 31 | 32 | ```json 33 | { 34 | "bridge": { 35 | ... 36 | }, 37 | "platforms": [ 38 | { 39 | "platform": "Calendar", 40 | "calendars": [ 41 | { 42 | "name": "Cal 1", 43 | "url": "webcal://", 44 | "pollingInterval": 5, 45 | "offset": "-8h", 46 | "sensors": [ 47 | "Sensor 1", 48 | "Sensor 2" 49 | ] 50 | } 51 | ] 52 | } 53 | ] 54 | } 55 | ``` 56 | 57 | | Attributes | Usage | 58 | |------------|-------| 59 | | name | A unique name for the calendar. Will be used as the accessory name and default switch for any calendar events. | 60 | | url | The address of the calender. Can be a `webcal://`, a `http://` or an `https://` URL. | 61 | | pollingInterval | The polling interval the plugin uses to retrieve calendar updates in minutes. If not set, the plugin will update the calendar ones in 15 minutes. | 62 | | sensors | An array of event summaries to create special sensors for. | 63 | 64 | The above example creates the plugin with three contact sensors: 65 | 66 | - Cal 1 67 | - Sensor 1 68 | - Sensor 2 69 | 70 | `Cal 1` will be opened any time any event starts in the calendar. `Sensor 1` and `Sensor 2` will only open if the event name starts with `Sensor 1` or `Sensor 2` respectively. 71 | 72 | Calendar events may overlap, may be full day, recurring or single occurance events and can even span multiple days. 73 | 74 | ### Offset 75 | 76 | You might want to trigger the sensors earlier than the scheduled event. This can be done by applying an offset to the calendar. An offset specifies the time to subtract from the scheduled start of the event. The offset essentially moves the start date ahead by the specified amount of time. The end date of the events is unaffected. Essentially this extends the event duration by the offset. 77 | 78 | #### Offset syntax 79 | 80 | An offset is a combination of a number and a postfix that indicates the unit of time to move. The supported offset postfixes are the following: 81 | 82 | | Postfix | Example | Description | 83 | |---------|---------|-------------| 84 | | d | 2d | Make an event start earlier by two days. | 85 | | h | 8h | Make an event start earlier by eight hours. | 86 | | m | 15m | Make an event start earlier by fifteen minutes. | 87 | | s | 10s | Make an event start earlier by ten seconds. | 88 | 89 | An offset is always negative, e.g. it moves the start to an earlier time. You can specify the minus in front of the offset. Positive offsets (with a plus symbol in front) to move to a later time are not supported. 90 | 91 | ### Sharing an iCloud calender 92 | 93 | To give the plugin access to a calender it is advised to create a seperate (iCloud) calender and share it publically. Public sharing provides a read-only view on the calender and a URL that can be used by the plugin to access the calender. No one else can modify a publically shared calender in this way. 94 | 95 | [Here's good instructions](http://www.idownloadblog.com/2016/02/14/how-to-share-calendars-iphone-ipad-mac-iclod/) on how to do this. Refer to the public sharing section there. 96 | 97 | ## Contributing 98 | 99 | You can contribute to this homebridge plugin in following ways: 100 | 101 | - [Report issues](https://github.com/grover/homebridge-calendar/issues) and help verify fixes as they are checked in. 102 | - Review the [source code changes](https://github.com/grover/homebridge-calendar/pulls). 103 | - Contribute bug fixes. 104 | - Contribute changes to extend the capabilities 105 | 106 | Pull requests are accepted. 107 | 108 | ## Some asks for friendly gestures 109 | 110 | If you use this and like it - please leave a note by staring this package here or on GitHub. 111 | 112 | If you use it and have a 113 | problem, file an issue at [GitHub](https://github.com/grover/homebridge-calendar/issues) - I'll try 114 | to help. 115 | 116 | If you tried this, but don't like it: tell me about it in an issue too. I'll try my best 117 | to address these in my spare time. 118 | 119 | If you fork this, go ahead - I'll accept pull requests for enhancements. 120 | 121 | ## License 122 | 123 | MIT License 124 | 125 | Copyright (c) 2018 Michael Fröhlich 126 | 127 | Permission is hereby granted, free of charge, to any person obtaining a copy 128 | of this software and associated documentation files (the "Software"), to deal 129 | in the Software without restriction, including without limitation the rights 130 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 131 | copies of the Software, and to permit persons to whom the Software is 132 | furnished to do so, subject to the following conditions: 133 | 134 | The above copyright notice and this permission notice shall be included in all 135 | copies or substantial portions of the Software. 136 | 137 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 138 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 139 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 140 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 141 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 142 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 143 | SOFTWARE. 144 | -------------------------------------------------------------------------------- /test/CalendarActionHandler.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('chai').assert; 4 | const sinon = require('sinon'); 5 | 6 | const CalendarActionHandler = require('./../src/CalendarActionHandler'); 7 | 8 | describe('CalendarActionHandler', () => { 9 | 10 | const now = new Date(2018, 0, 30, 9, 0, 0, 0); 11 | 12 | const log = console.log; 13 | let calendarSensor; 14 | let testSensor; 15 | 16 | let handler; 17 | 18 | beforeEach(() => { 19 | calendarSensor = { 20 | name: 'Calendar', 21 | on: sinon.spy(), 22 | off: sinon.spy(), 23 | pushState: sinon.spy(), 24 | reset: sinon.spy() 25 | }; 26 | 27 | testSensor = { 28 | name: 'Test', 29 | on: sinon.spy(), 30 | off: sinon.spy(), 31 | pushState: sinon.spy(), 32 | reset: sinon.spy() 33 | }; 34 | 35 | handler = new CalendarActionHandler(log, calendarSensor, [testSensor]); 36 | }); 37 | 38 | afterEach(() => { 39 | }); 40 | 41 | it('Should store given actions', () => { 42 | const actions = [ 43 | { 44 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 45 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 46 | state: false, 47 | summary: 'foo' 48 | } 49 | ]; 50 | 51 | handler.actionsUpdated(actions, now); 52 | assert.deepEqual(handler._actions, actions); 53 | }); 54 | 55 | it('Should not fail without actions for the trigger date', () => { 56 | assert.doesNotThrow(() => handler.execute(now)); 57 | }); 58 | 59 | it('Should reset all sensors when executing', () => { 60 | handler.execute(now); 61 | 62 | assert.isOk(calendarSensor.reset.calledOnce); 63 | assert.isOk(testSensor.reset.calledOnce); 64 | }); 65 | 66 | it('Should push all sensors to HomeKit when executing', () => { 67 | handler.execute(now); 68 | 69 | assert.isOk(calendarSensor.pushState.calledOnce); 70 | assert.isOk(testSensor.pushState.calledOnce); 71 | }); 72 | 73 | it('Should remove expired actions, when the action is executed', () => { 74 | const actions = [ 75 | { 76 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 77 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 78 | state: false, 79 | summary: 'foo' 80 | } 81 | ]; 82 | 83 | handler.actionsUpdated(actions, now); 84 | handler.execute(actions[0].date); 85 | 86 | assert.isEmpty(handler._actions); 87 | }); 88 | 89 | it('Should enable the calendar sensor if action state is true', () => { 90 | const actions = [ 91 | { 92 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 93 | expires: new Date(2018, 0, 30, 11, 0, 0, 0), 94 | state: true, 95 | summary: 'foo' 96 | } 97 | ]; 98 | 99 | handler.actionsUpdated(actions, now); 100 | handler.execute(actions[0].date); 101 | 102 | assert.isOk(calendarSensor.on.calledOnce); 103 | }); 104 | 105 | it('Should disable the calendar sensor if action state is false', () => { 106 | const actions = [ 107 | { 108 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 109 | expires: new Date(2018, 0, 30, 11, 0, 0, 0), 110 | state: false, 111 | summary: 'foo' 112 | } 113 | ]; 114 | 115 | handler.actionsUpdated(actions, now); 116 | handler.execute(actions[0].date); 117 | 118 | assert.isOk(calendarSensor.off.calledOnce); 119 | }); 120 | 121 | 122 | it('Should enable the named sensor and calendar sensor if matching action state is true', () => { 123 | const actions = [ 124 | { 125 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 126 | expires: new Date(2018, 0, 30, 11, 0, 0, 0), 127 | state: true, 128 | summary: 'Test' 129 | } 130 | ]; 131 | 132 | handler.actionsUpdated(actions, now); 133 | handler.execute(actions[0].date); 134 | 135 | assert.isOk(calendarSensor.on.calledOnce); 136 | assert.isOk(testSensor.on.calledOnce); 137 | }); 138 | 139 | it('Should disable the named sensor and calendar sensor if matching action state is false', () => { 140 | const actions = [ 141 | { 142 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 143 | expires: new Date(2018, 0, 30, 11, 0, 0, 0), 144 | state: false, 145 | summary: 'Test' 146 | } 147 | ]; 148 | handler.actionsUpdated(actions, now); 149 | handler.execute(actions[0].date); 150 | 151 | assert.isOk(calendarSensor.off.calledOnce); 152 | assert.isOk(testSensor.off.calledOnce); 153 | }); 154 | 155 | it('Should process multiple actions, which are scheduled for the same time', () => { 156 | const actions = [ 157 | { 158 | date: new Date(2018, 0, 30, 9, 0, 0, 0), 159 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 160 | state: true, 161 | summary: 'Test' 162 | }, 163 | { 164 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 165 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 166 | state: false, 167 | summary: 'Test' 168 | }, 169 | { 170 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 171 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 172 | state: true, 173 | summary: 'foo' 174 | } 175 | ]; 176 | 177 | handler.actionsUpdated(actions, now); 178 | calendarSensor.on.reset(); 179 | testSensor.on.reset(); 180 | testSensor.off.reset(); 181 | 182 | handler.execute(actions[1].date); 183 | 184 | assert.isOk(calendarSensor.on.calledTwice); 185 | assert.isOk(testSensor.on.calledOnce); 186 | assert.isOk(testSensor.off.calledOnce); 187 | }); 188 | 189 | it('Should process multiple actions, which are scheduled for the same time independent of order', () => { 190 | const actions = [ 191 | { 192 | date: new Date(2018, 0, 30, 9, 0, 0, 0), 193 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 194 | state: true, 195 | summary: 'Test' 196 | }, 197 | { 198 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 199 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 200 | state: true, 201 | summary: 'foo' 202 | }, 203 | { 204 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 205 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 206 | state: false, 207 | summary: 'Test' 208 | } 209 | ]; 210 | 211 | handler.actionsUpdated(actions, now); 212 | calendarSensor.on.reset(); 213 | testSensor.on.reset(); 214 | testSensor.off.reset(); 215 | 216 | handler.execute(actions[1].date); 217 | 218 | assert.isOk(calendarSensor.on.calledTwice); 219 | assert.isOk(testSensor.on.calledOnce); 220 | assert.isOk(testSensor.off.calledOnce); 221 | }); 222 | 223 | it('Should not fail if summary is not given', () => { 224 | const actions = [ 225 | { 226 | date: new Date(2018, 0, 30, 9, 0, 0, 0), 227 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 228 | state: true, 229 | summary: undefined 230 | } 231 | ]; 232 | 233 | assert.doesNotThrow(() => { 234 | handler.actionsUpdated(actions, now); 235 | handler.execute(now); 236 | }); 237 | }); 238 | 239 | it('Should not fail if summary is empty string', () => { 240 | const actions = [ 241 | { 242 | date: new Date(2018, 0, 30, 9, 0, 0, 0), 243 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 244 | state: true, 245 | summary: '' 246 | } 247 | ]; 248 | 249 | assert.doesNotThrow(() => { 250 | handler.actionsUpdated(actions, now); 251 | handler.execute(now); 252 | }); 253 | }); 254 | }); -------------------------------------------------------------------------------- /test/CalendarActionBuilderWithOffset.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('chai').assert; 4 | const clone = require('clone'); 5 | const moment = require('./../src/moment'); 6 | 7 | const CalendarActionBuilder = require('./../src/CalendarActionBuilder'); 8 | 9 | const ical = require('ical-generator'); 10 | const IcalExpander = require('ical-expander'); 11 | 12 | function createCal(events) { 13 | 14 | const cal = ical({domain: 'github.com', name: 'test calendar'}); 15 | 16 | for (var property in events) { 17 | if (events.hasOwnProperty(property)) { 18 | 19 | var event = events[property]; 20 | 21 | var e = cal.createEvent({ 22 | start: event.start, 23 | end: event.end, 24 | summary: event.summary 25 | }); 26 | 27 | if (event.rrule) { 28 | e.repeating(event.rrule); 29 | } 30 | 31 | } 32 | } 33 | 34 | const ics = cal.toString(); 35 | 36 | const icalExpander = new IcalExpander({ 37 | ics, 38 | maxIterations: 100 39 | }); 40 | 41 | const calendar = icalExpander.all(); 42 | 43 | return calendar; 44 | 45 | } 46 | 47 | describe('CalendarActionBuilder with offset', () => { 48 | 49 | const oneEvent = { 50 | 'foo': { 51 | summary: 'Test', 52 | start: new Date(2018, 0, 30, 10, 0, 0, 0), 53 | end: new Date(2018, 0, 30, 10, 15, 0, 0), 54 | } 55 | }; 56 | 57 | const recurringEvent = { 58 | 'foo': { 59 | summary: 'Test', 60 | start: new Date(2018, 0, 30, 10, 0, 0, 0), 61 | end: new Date(2018, 0, 30, 10, 15, 0, 0), 62 | rrule: { 63 | freq: 'DAILY', 64 | until: new Date(2018, 1, 5, 10, 0) 65 | } 66 | } 67 | }; 68 | 69 | it('Move start of non-recurring event for 2d offset', () => { 70 | const expectedActions = [ 71 | { 72 | date: new Date(2018, 0, 28, 10, 0, 0, 0), 73 | expires: oneEvent.foo.end, 74 | state: true, 75 | summary: oneEvent.foo.summary 76 | }, { 77 | date: oneEvent.foo.end, 78 | expires: oneEvent.foo.end, 79 | state: false, 80 | summary: oneEvent.foo.summary 81 | } 82 | ]; 83 | 84 | const actionBuilder = new CalendarActionBuilder('-2d'); 85 | const actions = actionBuilder._generateNonRecurringEvents(createCal(oneEvent)); 86 | 87 | assert.deepEqual(actions, expectedActions); 88 | }); 89 | 90 | it('Move start of non-recurring event for 4h offset', () => { 91 | const expectedActions = [ 92 | { 93 | date: new Date(2018, 0, 30, 6, 0, 0, 0), 94 | expires: oneEvent.foo.end, 95 | state: true, 96 | summary: oneEvent.foo.summary 97 | }, { 98 | date: oneEvent.foo.end, 99 | expires: oneEvent.foo.end, 100 | state: false, 101 | summary: oneEvent.foo.summary 102 | } 103 | ]; 104 | 105 | const actionBuilder = new CalendarActionBuilder('-4h'); 106 | const actions = actionBuilder._generateNonRecurringEvents(createCal(oneEvent)); 107 | 108 | assert.deepEqual(actions, expectedActions); 109 | }); 110 | 111 | 112 | it('Move start of non-recurring event for 30m offset', () => { 113 | const expectedActions = [ 114 | { 115 | date: new Date(2018, 0, 30, 9, 30, 0, 0), 116 | expires: oneEvent.foo.end, 117 | state: true, 118 | summary: oneEvent.foo.summary 119 | }, { 120 | date: oneEvent.foo.end, 121 | expires: oneEvent.foo.end, 122 | state: false, 123 | summary: oneEvent.foo.summary 124 | } 125 | ]; 126 | 127 | const actionBuilder = new CalendarActionBuilder('-30m'); 128 | const actions = actionBuilder._generateNonRecurringEvents(createCal(oneEvent)); 129 | 130 | assert.deepEqual(actions, expectedActions); 131 | }); 132 | 133 | it('Move start of non-recurring event for 15s offset', () => { 134 | const expectedActions = [ 135 | { 136 | date: new Date(2018, 0, 30, 9, 59, 45, 0), 137 | expires: oneEvent.foo.end, 138 | state: true, 139 | summary: oneEvent.foo.summary 140 | }, { 141 | date: oneEvent.foo.end, 142 | expires: oneEvent.foo.end, 143 | state: false, 144 | summary: oneEvent.foo.summary 145 | } 146 | ]; 147 | 148 | const actionBuilder = new CalendarActionBuilder('-15s'); 149 | const actions = actionBuilder._generateNonRecurringEvents(createCal(oneEvent)); 150 | 151 | assert.deepEqual(actions, expectedActions); 152 | }); 153 | 154 | it('Moves all recurring events ahead by 2 days', () => { 155 | /** 156 | * Only test expansion of the recurring event - do not actually test 157 | * all recurrences as that's actually handled by node-ical. This only makes 158 | * sure that we're generating more than one pair of actions for them. 159 | */ 160 | const offset = 2 * 24 * 60 * 60 * 1000; 161 | 162 | const expectedActions = [ 163 | { 164 | date: new Date(recurringEvent.foo.start.valueOf() - offset), 165 | expires: recurringEvent.foo.end, 166 | state: true, 167 | summary: recurringEvent.foo.summary 168 | }, { 169 | date: recurringEvent.foo.end, 170 | expires: recurringEvent.foo.end, 171 | state: false, 172 | summary: recurringEvent.foo.summary 173 | } 174 | ]; 175 | 176 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 177 | for (let i = 1; i < 7; i++) { 178 | const start = clone(expectedActions[0]); 179 | const end = clone(expectedActions[1]); 180 | 181 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 182 | start.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 183 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 184 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 185 | expectedActions.push(start, end); 186 | } 187 | 188 | const actionBuilder = new CalendarActionBuilder('-2d'); 189 | const actions = actionBuilder._generateRecurringEvents(createCal(recurringEvent), moment('20180130')); 190 | 191 | assert.equal(actions.length, 14); 192 | assert.deepEqual(actionBuilder._sortEventsByDate(actions), actionBuilder._sortEventsByDate(expectedActions)); 193 | }); 194 | 195 | it('Moves all recurring events ahead by 2 hours', () => { 196 | /** 197 | * Only test expansion of the recurring event - do not actually test 198 | * all recurrences as that's actually handled by node-ical. This only makes 199 | * sure that we're generating more than one pair of actions for them. 200 | */ 201 | 202 | const offset = 2 * 60 * 60 * 1000; 203 | 204 | const expectedActions = [ 205 | { 206 | date: new Date(recurringEvent.foo.start.valueOf() - offset), 207 | expires: recurringEvent.foo.end, 208 | state: true, 209 | summary: recurringEvent.foo.summary 210 | }, { 211 | date: recurringEvent.foo.end, 212 | expires: recurringEvent.foo.end, 213 | state: false, 214 | summary: recurringEvent.foo.summary 215 | } 216 | ]; 217 | 218 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 219 | for (let i = 1; i < 7; i++) { 220 | const start = clone(expectedActions[0]); 221 | const end = clone(expectedActions[1]); 222 | 223 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 224 | start.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 225 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 226 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 227 | expectedActions.push(start, end); 228 | } 229 | 230 | const actionBuilder = new CalendarActionBuilder('-2h'); 231 | const actions = actionBuilder._generateRecurringEvents(createCal(recurringEvent), moment('20180130')); 232 | 233 | assert.equal(actions.length, 14); 234 | assert.deepEqual(actionBuilder._sortEventsByDate(actions), actionBuilder._sortEventsByDate(expectedActions)); 235 | }); 236 | 237 | it('Moves all recurring events ahead by 15 minutes', () => { 238 | /** 239 | * Only test expansion of the recurring event - do not actually test 240 | * all recurrences as that's actually handled by node-ical. This only makes 241 | * sure that we're generating more than one pair of actions for them. 242 | */ 243 | 244 | const offset = 15 * 60 * 1000; 245 | 246 | const expectedActions = [ 247 | { 248 | date: new Date(recurringEvent.foo.start.valueOf() - offset), 249 | expires: recurringEvent.foo.end, 250 | state: true, 251 | summary: recurringEvent.foo.summary 252 | }, { 253 | date: recurringEvent.foo.end, 254 | expires: recurringEvent.foo.end, 255 | state: false, 256 | summary: recurringEvent.foo.summary 257 | } 258 | ]; 259 | 260 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 261 | for (let i = 1; i < 7; i++) { 262 | const start = clone(expectedActions[0]); 263 | const end = clone(expectedActions[1]); 264 | 265 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 266 | start.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 267 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 268 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 269 | expectedActions.push(start, end); 270 | } 271 | 272 | const actionBuilder = new CalendarActionBuilder('15m'); 273 | const actions = actionBuilder._generateRecurringEvents(createCal(recurringEvent), moment('20180130')); 274 | 275 | assert.equal(actions.length, 14); 276 | assert.deepEqual(actionBuilder._sortEventsByDate(actions), actionBuilder._sortEventsByDate(expectedActions)); 277 | }); 278 | 279 | it('Moves all recurring events ahead by 30 seconds', () => { 280 | /** 281 | * Only test expansion of the recurring event - do not actually test 282 | * all recurrences as that's actually handled by node-ical. This only makes 283 | * sure that we're generating more than one pair of actions for them. 284 | */ 285 | const offset = 30 * 1000; 286 | 287 | const expectedActions = [ 288 | { 289 | date: new Date(recurringEvent.foo.start.valueOf() - offset), 290 | expires: recurringEvent.foo.end, 291 | state: true, 292 | summary: recurringEvent.foo.summary 293 | }, { 294 | date: recurringEvent.foo.end, 295 | expires: recurringEvent.foo.end, 296 | state: false, 297 | summary: recurringEvent.foo.summary 298 | } 299 | ]; 300 | 301 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 302 | for (let i = 1; i < 7; i++) { 303 | const start = clone(expectedActions[0]); 304 | const end = clone(expectedActions[1]); 305 | 306 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 307 | start.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 308 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 309 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 310 | expectedActions.push(start, end); 311 | } 312 | 313 | const actionBuilder = new CalendarActionBuilder('30s'); 314 | const actions = actionBuilder._generateRecurringEvents(createCal(recurringEvent), moment('20180130')); 315 | 316 | assert.equal(actions.length, 14); 317 | assert.deepEqual(actionBuilder._sortEventsByDate(actions), actionBuilder._sortEventsByDate(expectedActions)); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /test/CalendarActionBuilder.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('chai').assert; 4 | const clone = require('clone'); 5 | const moment = require('./../src/moment'); 6 | 7 | const CalendarActionBuilder = require('./../src/CalendarActionBuilder'); 8 | 9 | const ical = require('ical-generator'); 10 | const IcalExpander = require('ical-expander'); 11 | const fs = require('fs'); 12 | 13 | function createCal(events) { 14 | 15 | const cal = ical({domain: 'github.com', name: 'test calendar'}); 16 | 17 | for (var property in events) { 18 | if (events.hasOwnProperty(property)) { 19 | 20 | var event = events[property]; 21 | 22 | var e = cal.createEvent({ 23 | start: event.start, 24 | end: event.end, 25 | summary: event.summary 26 | }); 27 | 28 | if (event.rrule) { 29 | e.repeating(event.rrule); 30 | } 31 | 32 | } 33 | } 34 | 35 | const ics = cal.toString(); 36 | 37 | const icalExpander = new IcalExpander({ 38 | ics, 39 | maxIterations: 100 40 | }); 41 | 42 | const calendar = icalExpander.all(); 43 | 44 | return calendar; 45 | 46 | } 47 | 48 | describe('CalendarActionBuilder', () => { 49 | 50 | const builder = new CalendarActionBuilder(); 51 | 52 | const oneEvent = { 53 | 'foo': { 54 | summary: 'Test', 55 | start: new Date(2018, 0, 30, 10, 0, 0, 0), 56 | end: new Date(2018, 0, 30, 10, 15, 0, 0), 57 | } 58 | }; 59 | 60 | const twoEvents = { 61 | 'foo': { 62 | summary: 'Test', 63 | start: new Date(2018, 0, 30, 10, 0, 0, 0), 64 | end: new Date(2018, 0, 30, 10, 15, 0, 0), 65 | }, 66 | 'bar': { 67 | summary: 'Test2', 68 | start: new Date(2018, 0, 30, 11, 0, 0, 0), 69 | end: new Date(2018, 0, 30, 11, 15, 0, 0), 70 | } 71 | }; 72 | 73 | const recurringEvent = { 74 | 'foo': { 75 | summary: 'Test', 76 | start: new Date(2018, 0, 30, 10, 0, 0, 0), 77 | end: new Date(2018, 0, 30, 10, 15, 0, 0), 78 | rrule: { 79 | freq: 'DAILY', 80 | until: new Date(2018, 1, 5, 10, 0) 81 | } 82 | } 83 | }; 84 | 85 | const longRecurringEvent = { 86 | 'long': { 87 | summary: 'Long', 88 | start: new Date(2018, 2, 19, 7, 0, 0, 0), 89 | end: new Date(2018, 2, 26, 6, 0, 0, 0), 90 | rrule: { 91 | freq: 'WEEKLY', 92 | interval: 2, 93 | until: new Date(2018, 2, 26, 10, 0) 94 | } 95 | } 96 | }; 97 | 98 | it('Builds a start and end action for a single non-recurring event', () => { 99 | 100 | const expectedActions = [ 101 | { 102 | date: oneEvent.foo.start, 103 | expires: oneEvent.foo.end, 104 | state: true, 105 | summary: oneEvent.foo.summary 106 | }, { 107 | date: oneEvent.foo.end, 108 | expires: oneEvent.foo.end, 109 | state: false, 110 | summary: oneEvent.foo.summary 111 | } 112 | ]; 113 | 114 | const actions = builder._generateNonRecurringEvents(createCal(oneEvent)); 115 | assert.equal(actions.length, 2, 'Not enough actions created.'); 116 | assert.deepEqual(actions, expectedActions); 117 | }); 118 | 119 | 120 | it('Builds a start and end action for a multiple non-recurring event', () => { 121 | const expectedActions = [ 122 | { 123 | date: twoEvents.foo.start, 124 | expires: twoEvents.foo.end, 125 | state: true, 126 | summary: twoEvents.foo.summary 127 | }, { 128 | date: twoEvents.bar.start, 129 | expires: twoEvents.bar.end, 130 | state: true, 131 | summary: twoEvents.bar.summary 132 | }, { 133 | date: twoEvents.foo.end, 134 | expires: twoEvents.foo.end, 135 | state: false, 136 | summary: twoEvents.foo.summary 137 | }, { 138 | date: twoEvents.bar.end, 139 | expires: twoEvents.bar.end, 140 | state: false, 141 | summary: twoEvents.bar.summary 142 | } 143 | ]; 144 | 145 | const actions = builder._generateNonRecurringEvents(createCal(twoEvents)); 146 | assert.equal(actions.length, 4, 'Not enough actions created.'); 147 | assert.deepEqual(actions, expectedActions); 148 | }); 149 | 150 | 151 | it('Builds a start and end action for a single recurring event, occurring daily', () => { 152 | assert.isEmpty(builder._generateNonRecurringEvents(createCal(recurringEvent))); 153 | }); 154 | 155 | it('Builds a start and end action for a single recurring event, occurring daily', () => { 156 | /** 157 | * Only test expansion of the recurring event - do not actually test 158 | * all recurrences as that's actually handled by node-ical. This only makes 159 | * sure that we're generating more than one pair of actions for them. 160 | */ 161 | 162 | const expectedActions = [ 163 | { 164 | date: recurringEvent.foo.start, 165 | expires: recurringEvent.foo.end, 166 | state: true, 167 | summary: recurringEvent.foo.summary 168 | }, { 169 | date: recurringEvent.foo.end, 170 | expires: recurringEvent.foo.end, 171 | state: false, 172 | summary: recurringEvent.foo.summary 173 | } 174 | ]; 175 | 176 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 177 | for (let i = 1; i < 7; i++) { 178 | const start = clone(expectedActions[0]); 179 | const end = clone(expectedActions[1]); 180 | 181 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 182 | start.expires = new Date(start.expires.valueOf() + (i * millisecondsPerDay)); 183 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 184 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 185 | expectedActions.push(start, end); 186 | } 187 | 188 | const actions = builder._generateRecurringEvents(createCal(recurringEvent), moment('20180130')); 189 | assert.equal(actions.length, 14); 190 | assert.deepEqual(builder._sortEventsByDate(actions), builder._sortEventsByDate(expectedActions)); 191 | }); 192 | 193 | it('Builds long recurring event actions', () => { 194 | const actions = builder._generateRecurringEvents(createCal(longRecurringEvent), moment('20180501')); 195 | assert.equal(actions.length, 2); 196 | }); 197 | 198 | it('Should sort all events into an order', () => { 199 | const unsortedActions = [ 200 | { 201 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 202 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 203 | state: false, 204 | summary: 'Foo' 205 | }, { 206 | date: new Date(2018, 0, 29, 10, 0, 0, 0), 207 | expires: new Date(2018, 0, 29, 10, 0, 0, 0), 208 | state: true, 209 | summary: 'Foo2' 210 | } 211 | ]; 212 | 213 | const expectedResult = [ 214 | unsortedActions[1], 215 | unsortedActions[0] 216 | ]; 217 | 218 | const result = builder._sortEventsByDate(unsortedActions); 219 | assert.deepEqual(result, expectedResult); 220 | }); 221 | 222 | 223 | it('Should remove expired events', () => { 224 | const actions = [ 225 | { 226 | date: new Date(2018, 0, 30, 10, 0, 0, 0), 227 | expires: new Date(2018, 0, 30, 10, 0, 0, 0), 228 | state: false, 229 | summary: 'Foo' 230 | }, { 231 | date: new Date(2018, 0, 29, 10, 0, 0, 0), 232 | expires: new Date(2018, 0, 29, 10, 0, 0, 0), 233 | state: true, 234 | summary: 'Foo2' 235 | } 236 | ]; 237 | 238 | const expectedResult = [ 239 | actions[0] 240 | ]; 241 | 242 | const now = moment('20180130').valueOf(); 243 | 244 | const result = builder._filterExpiredEvents(actions, now); 245 | assert.deepEqual(result, expectedResult); 246 | }); 247 | 248 | it('Should generate actions for two events', () => { 249 | const expectedActions = [ 250 | { 251 | date: twoEvents.foo.start, 252 | expires: twoEvents.foo.end, 253 | state: true, 254 | summary: twoEvents.foo.summary 255 | }, { 256 | date: twoEvents.foo.end, 257 | expires: twoEvents.foo.end, 258 | state: false, 259 | summary: twoEvents.foo.summary 260 | }, 261 | { 262 | date: twoEvents.bar.start, 263 | expires: twoEvents.bar.end, 264 | state: true, 265 | summary: twoEvents.bar.summary 266 | }, { 267 | date: twoEvents.bar.end, 268 | expires: twoEvents.bar.end, 269 | state: false, 270 | summary: twoEvents.bar.summary 271 | } 272 | ]; 273 | 274 | const now = new Date(2018, 0, 30, 9, 0, 0, 0); 275 | const actions = builder.generateActions(createCal(twoEvents), now); 276 | 277 | assert.equal(actions.length, 4, 'Not enough actions created.'); 278 | assert.deepEqual(actions, expectedActions); 279 | }); 280 | 281 | 282 | it('Should generate actions for recurring event, occurring daily', () => { 283 | /** 284 | * Only test expansion of the recurring event - do not actually test 285 | * all recurrences as that's actually handled by node-ical. This only makes 286 | * sure that we're generating more than one pair of actions for them. 287 | */ 288 | 289 | const expectedActions = [ 290 | { 291 | date: recurringEvent.foo.start, 292 | expires: recurringEvent.foo.end, 293 | state: true, 294 | summary: recurringEvent.foo.summary 295 | }, { 296 | date: recurringEvent.foo.end, 297 | expires: recurringEvent.foo.end, 298 | state: false, 299 | summary: recurringEvent.foo.summary 300 | } 301 | ]; 302 | 303 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 304 | for (let i = 1; i < 7; i++) { 305 | const start = clone(expectedActions[0]); 306 | const end = clone(expectedActions[1]); 307 | 308 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 309 | start.expires = new Date(start.expires.valueOf() + (i * millisecondsPerDay)); 310 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 311 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 312 | expectedActions.push(start, end); 313 | } 314 | 315 | const now = new Date(2018, 0, 30, 9, 0, 0, 0); 316 | const actions = builder.generateActions(createCal(recurringEvent), now); 317 | 318 | assert.equal(actions.length, 14); 319 | assert.deepEqual(builder._sortEventsByDate(actions), builder._sortEventsByDate(expectedActions)); 320 | }); 321 | 322 | it('Should generate actions for recurring event, occurring daily, with exceptions', () => { 323 | 324 | const expectedActions = [ 325 | { 326 | date: new Date(Date.UTC(2018, 11, 1, 7, 30, 0, 0)), 327 | expires: new Date(Date.UTC(2018, 11, 1, 10, 30, 0, 0)), 328 | state: true, 329 | summary: 'Test' 330 | }, { 331 | date: new Date(Date.UTC(2018, 11, 1, 10, 30, 0, 0)), 332 | expires: new Date(Date.UTC(2018, 11, 1, 10, 30, 0, 0)), 333 | state: false, 334 | summary: 'Test' 335 | } 336 | ]; 337 | 338 | const millisecondsPerDay = 1000 * 60 * 60 * 24; 339 | 340 | for (let i = 1; i < 14; i++) { 341 | const start = clone(expectedActions[0]); 342 | const end = clone(expectedActions[1]); 343 | 344 | start.date = new Date(start.date.valueOf() + (i * millisecondsPerDay)); 345 | start.expires = new Date(start.expires.valueOf() + (i * millisecondsPerDay)); 346 | end.date = new Date(end.date.valueOf() + (i * millisecondsPerDay)); 347 | end.expires = new Date(end.expires.valueOf() + (i * millisecondsPerDay)); 348 | 349 | if (i == 4) { 350 | // exception on day 5 -> no events 351 | continue; 352 | 353 | } else if (i == 11) { 354 | // exception on day 11 -> move by 4 hours 355 | const delta = 4 * 60 * 60 * 1000; 356 | 357 | start.date = new Date(start.date.valueOf() + delta); 358 | start.expires = new Date(start.expires.valueOf() + delta); 359 | end.date = new Date(end.date.valueOf() + delta); 360 | end.expires = new Date(end.expires.valueOf() + delta); 361 | } 362 | 363 | expectedActions.push(start, end); 364 | } 365 | 366 | const ics = fs.readFileSync('./test/google.ics', 'utf-8'); 367 | const icalExpander = new IcalExpander({ 368 | ics, 369 | maxIterations: 1000 370 | }); 371 | 372 | const now = new Date(2018, 11, 1, 7, 0, 0, 0); 373 | const cal = icalExpander.all(); 374 | 375 | const actions = builder.generateActions(cal, now); 376 | 377 | assert.equal(actions.length, 26); 378 | assert.deepEqual(builder._sortEventsByDate(actions), builder._sortEventsByDate(expectedActions)); 379 | 380 | }); 381 | 382 | }); 383 | --------------------------------------------------------------------------------