UNPKG

iobroker.birthdays

Version:

Use an ical file to import your contacts birthdays

803 lines (694 loc) 31.2 kB
'use strict'; const utils = require('@iobroker/adapter-core'); const fs = require('node:fs'); const moment = require('moment'); const axios = require('axios').default; const https = require('node:https'); const ICAL = require('ical.js'); const adapterName = require('./package.json').name.split('.').pop(); class Birthdays extends utils.Adapter { constructor(options) { super({ ...options, name: adapterName, useFormatDate: true, }); this.today = moment({ hour: 0, minute: 0 }); this.birthdays = []; this.birthdaysSignificant = []; this.on('ready', this.onReady.bind(this)); this.on('unload', this.onUnload.bind(this)); } async onReady() { const helpFilePath = '/UPLOAD_FILES_HERE.txt'; const fileExists = await this.fileExistsAsync(this.namespace, helpFilePath); if (!fileExists) { await this.writeFileAsync(this.namespace, helpFilePath, 'Place your *.ics files in this directory'); } // Create month channels for (let m = 1; m <= 12; m++) { const mm = moment({ month: m - 1 }); await this.setObjectNotExistsAsync(this.getMonthPath(m), { type: 'channel', common: { name: { en: this.getMonthTranslation(mm, 'en'), de: this.getMonthTranslation(mm, 'de'), ru: this.getMonthTranslation(mm, 'ru'), pt: this.getMonthTranslation(mm, 'pt'), nl: this.getMonthTranslation(mm, 'nl'), fr: this.getMonthTranslation(mm, 'fr'), it: this.getMonthTranslation(mm, 'it'), es: this.getMonthTranslation(mm, 'es'), pl: this.getMonthTranslation(mm, 'pl'), uk: this.getMonthTranslation(mm, 'uk'), 'zh-cn': this.getMonthTranslation(mm, 'zh-cn'), }, }, native: {}, }); await this.setObjectNotExistsAsync(`${this.getMonthPath(m)}.count`, { type: 'state', common: { name: { en: 'Number of birthdays', de: 'Anzahl der Geburtstage', ru: 'Количество дней рождения', pt: 'Número de aniversários', nl: 'Nummer van verjaardagen', fr: `Nombre d' anniversaires`, it: 'Numero di compleanni', es: 'Número de cumpleaños', pl: 'Liczba urodzin', uk: 'Кількість днів', 'zh-cn': '出生日数', }, type: 'number', role: 'value', read: true, write: false, }, native: {}, }); await this.setObjectNotExistsAsync(`${this.getMonthPath(m)}.json`, { type: 'state', common: { name: { en: 'Birthdays JSON', de: 'Geburtstage JSON', ru: 'Дни рождения JSON', pt: 'JSON de aniversários', nl: 'Verjaardagen JSON', fr: 'Anniversaires JSON', it: 'Compleanni JSON', es: 'Cumpleaños JSON', pl: 'Urodziny JSON', uk: 'День народження JSON', 'zh-cn': '生日 JSON', }, type: 'string', role: 'json', read: true, write: false, }, native: {}, }); } Promise.all([this.addBySettings(), this.addByCalendarUrl(), this.addByCalendarFile(), this.addByCardDav()]) .then(data => { this.log.debug(`[onReady] everything collected: ${JSON.stringify(data)}`); const addedBirthdaysSum = data.reduce((pv, cv) => pv + cv, 0); if (addedBirthdaysSum === 0) { this.log.error( `No birthdays found in any configured source - please check configuration and retry`, ); } return this.fillStates(); }) .then(() => { this.log.debug(`[onReady] Everything done`); }) .catch(err => { this.log.error(`[onReady] Error: ${JSON.stringify(err)}`); }) .finally(() => { this.log.debug(`[onReady] Finally shutting down`); this.stop(); }); } async addBySettings() { return new Promise(resolve => { const birthdays = this.config.birthdays; let addedBirthdays = 0; if (birthdays && Array.isArray(birthdays)) { for (const b in birthdays) { const birthday = birthdays[b]; if (birthday.name) { const configBirthday = moment({ year: birthday.year, month: birthday.month - 1, day: birthday.day, }); if (configBirthday.isValid() && configBirthday.year() <= this.today.year()) { this.log.debug(`[settings] found birthday: ${birthday.name} (${birthday.year})`); if (this.addBirthday(birthday.name, configBirthday)) { addedBirthdays++; } } else { this.log.warn(`[settings] invalid birthday date: ${birthday.name}`); } } } } this.log.debug(`[settings] done`); resolve(addedBirthdays); }); } async addByCalendarFile() { return new Promise(resolve => { resolve(0); }); } async addByCalendarUrl() { return new Promise(resolve => { let iCalUrl = this.config.icalUrl; if (iCalUrl) { this.log.debug(`[ical] url/path: ${iCalUrl}`); if (iCalUrl.startsWith('webcal')) { iCalUrl = iCalUrl.replace('webcal://', 'http://'); } if (iCalUrl.startsWith('http')) { this.log.debug('[ical] addByCalendarUrl - looks like an http url, performing get request'); const httpsAgentOptions = {}; if (this.config.icalUrlIgnoreCertErrors) { this.log.debug( '[ical] addByCalendarUrl - performing https requests with rejectUnauthorized = false', ); httpsAgentOptions.rejectUnauthorized = false; } axios({ method: 'get', url: iCalUrl, timeout: 30000, httpsAgent: new https.Agent(httpsAgentOptions), auth: { username: this.config.icalUser, password: this.config.icalPassword }, }) .then(async response => { this.log.debug(`[ical] http(s) request finished with status: ${response.status}`); let addedBirthdays = 0; if (response.data) { this.log.silly(`[ical] addByCalendarUrl - received file contents: ${response.data}`); addedBirthdays = await this.addByIcalData(response.data); } resolve(addedBirthdays); }) .catch(error => { this.log.warn(`[ical] ${error}`); this.log.debug(`[ical] done with error`); resolve(0); }); } else { try { this.log.debug('[ical] addByCalendarUrl - try to load local file'); // local file if (fs.existsSync(iCalUrl)) { const data = fs.readFileSync(iCalUrl).toString(); this.log.silly(`[ical] addByCalendarUrl - loaded file contents: ${data}`); this.addByIcalData(data).then(addedBirthdays => { resolve(addedBirthdays); }); } else { this.log.error(`[ical] local file "${iCalUrl}" doesn't exists`); resolve(0); } } catch (err) { this.log.error(`[ical] error when loading local file "${iCalUrl}": ${err}`); resolve(0); } } } else { this.log.debug(`[ical] done - url not configured - skipped`); resolve(0); } }); } async addByIcalData(dataStr) { return new Promise(resolve => { let addedBirthdays = 0; try { // Parse ical const icalData = ICAL.parse(dataStr); const comp = new ICAL.Component(icalData); const vevents = comp.getAllSubcomponents('vevent'); this.log.debug(`[ical] found ${vevents.length} events`); for (const vevent of vevents) { const event = new ICAL.Event(vevent); if (event.summary !== undefined && !isNaN(event.description) && event.startDate) { const name = event.summary; const birthYear = parseInt(event.description); this.log.debug( `[ical] processing event: ${JSON.stringify(event)} - ${event.isRecurring() ? Object.keys(event.getRecurrenceTypes()) : 'not recurring!'}`, ); if (name && birthYear && !isNaN(birthYear)) { const startDate = event.startDate.toJSDate(); const calendarBirthday = moment({ year: birthYear, month: startDate.getMonth(), day: startDate.getDate(), }); if (calendarBirthday.isValid() && calendarBirthday.year() <= this.today.year()) { this.log.debug(`[ical] found birthday: ${name} (${birthYear})`); if (!event.isRecurring()) { this.log.info( `[ical] birthday event of ${name} is not defined as recurring - skipped: ${JSON.stringify(event)}`, ); } else if (!Object.keys(event.getRecurrenceTypes()).includes('YEARLY')) { this.log.info( `[ical] birthday event of ${name} is not recurring yearly - skipped: ${JSON.stringify(event)}`, ); } else if (this.addBirthday(name, calendarBirthday)) { // everything okay, add it addedBirthdays++; } } else { this.log.info(`[ical] birthday year of ${name} is invalid - skipped`); } } else if (name) { this.log.info(`[ical] missing birth year in event description: ${name} - skipped`); } } } this.log.debug(`[ical] processed all events`); } catch (err) { this.log.error(`[ical] unable to parse ical data (invalid file format?): ${err}`); } resolve(addedBirthdays); }); } async addByCardDav() { return new Promise(resolve => { const carddavUrl = this.config.carddavUrl; if (carddavUrl) { this.log.debug(`[carddav] url: ${carddavUrl}`); const httpsAgentOptions = {}; if (this.config.carddavIgnoreCertErrors) { this.log.debug( '[carddav] addByCardDav - performing https requests with rejectUnauthorized = false', ); httpsAgentOptions.rejectUnauthorized = false; } axios({ method: 'get', url: carddavUrl, timeout: 30000, httpsAgent: new https.Agent(httpsAgentOptions), auth: { username: this.config.carddavUser, password: this.config.carddavPassword, }, }) .then(response => { this.log.debug(`[carddav] http(s) request finished with status: ${response.status}`); let addedBirthdays = 0; if (response.data) { // Parse vcards const vcards = ICAL.parse(response.data); this.log.debug(`[carddav] found ${vcards.length} contacts`); for (const vcard of vcards) { this.log.debug(`[carddav] processing vcard: ${JSON.stringify(vcard)}`); const comp = new ICAL.Component(vcard); const name = comp.getFirstPropertyValue('fn'); const bday = comp.getFirstPropertyValue('bday'); if (name && bday) { const carddavBirthday = moment(bday, 'YYYY-MM-DD'); if (carddavBirthday.isValid() && carddavBirthday.year() <= this.today.year()) { this.log.debug(`[carddav] found birthday: ${name} (${carddavBirthday.year()})`); if (this.addBirthday(name, carddavBirthday)) { addedBirthdays++; } } else { this.log.warn(`[carddav] invalid birthdate: ${name}`); } } else if (name) { this.log.debug(`[carddav] missing birthdate in event: ${name}`); } } } this.log.debug(`[carddav] done`); resolve(addedBirthdays); }) .catch(error => { this.log.warn(`[carddav] ${error}`); this.log.debug(`[carddav] done with error`); resolve(0); }); } else { this.log.debug(`[carddav] done - url not configured - skipped`); resolve(0); } }); } /** * @param {string} name Name * @param {moment.Moment} birthday Birthday date * @returns {boolean} Added */ addBirthday(name, birthday) { const birthdaysSameName = this.birthdays.find(b => b.name === name); if (birthdaysSameName) { this.log.warn( `[addBirthday] birthday with name "${name}" has already been added (${birthdaysSameName.dateFormat}) - skipping`, ); return false; } const nextBirthday = birthday.clone(); nextBirthday.add(this.today.year() - birthday.year(), 'y'); // If birthday was already this year, add one year to the nextBirthday if (this.today.isAfter(nextBirthday) && !this.today.isSame(nextBirthday)) { nextBirthday.add(1, 'y'); } const nextAge = nextBirthday.diff(birthday, 'years'); this.birthdays.push({ name: name, birthYear: birthday.year(), dateFormat: this.formatDate(nextBirthday.toDate()), age: nextAge, currentAgeText: this.getCurrentAgeAsText(birthday), daysLeft: nextBirthday.diff(this.today, 'days'), _birthday: birthday, _nextBirthday: nextBirthday, }); const nextSignificantBirthday = nextBirthday.clone(); const nextSignficantAge = Math.ceil(nextAge / 10) * 10; if (nextSignficantAge > nextAge) { nextSignificantBirthday.add(nextSignficantAge - nextAge, 'y'); } this.birthdaysSignificant.push({ name: name, birthYear: birthday.year(), dateFormat: this.formatDate(nextSignificantBirthday.toDate()), age: nextSignificantBirthday.diff(birthday, 'years'), daysLeft: nextSignificantBirthday.diff(this.today, 'days'), _birthday: birthday, _nextBirthday: nextSignificantBirthday, }); return true; } async fillStates() { // Sort by daysLeft this.birthdays.sort((a, b) => (a.daysLeft > b.daysLeft ? 1 : -1)); this.birthdaysSignificant.sort((a, b) => (a.daysLeft > b.daysLeft ? 1 : -1)); this.log.debug(`[fillStates] birthdays: ${JSON.stringify(this.birthdays)}`); await this.setState('summary.json', { val: JSON.stringify(this.birthdays, null, 2), ack: true }); await this.setState('summary.count', { val: this.birthdays.length, ack: true }); this.log.debug(`[fillStates] birthdays significant: ${JSON.stringify(this.birthdaysSignificant)}`); await this.setState('summary.jsonSignificant', { val: JSON.stringify(this.birthdaysSignificant, null, 2), ack: true, }); const keepBirthdays = []; const allBirthdays = (await this.getChannelsOfAsync('month')) .map(obj => { return this.removeNamespace(obj._id); }) .filter(id => new RegExp('month.[0-9]{2}..+', 'g').test(id)); for (const birthdayObj of this.birthdays) { const cleanName = this.cleanNamespace(birthdayObj.name); const monthPath = `${this.getMonthPath(birthdayObj._birthday.month() + 1)}.${cleanName}`; keepBirthdays.push(monthPath); if (!allBirthdays.includes(monthPath)) { this.log.debug(`birthday added: ${monthPath}`); } await this.fillPathWithBirthday(monthPath, birthdayObj); } // Delete non existent birthdays for (const birthdayId of allBirthdays) { if (!keepBirthdays.includes(birthdayId)) { await this.delObjectAsync(birthdayId, { recursive: true }); this.log.debug(`[fillStates] birthday deleted: ${birthdayId}`); } } // next birthdays if (this.birthdays.length > 0) { const nextBirthdayDaysLeft = this.birthdays[0].daysLeft; await this.fillAfter('next', this.birthdays, nextBirthdayDaysLeft); const nextAfterBirthdaysList = this.birthdays.filter(birthday => birthday.daysLeft > nextBirthdayDaysLeft); if (nextAfterBirthdaysList.length > 0) { const nextAfterBirthdaysLeft = nextAfterBirthdaysList[0].daysLeft; await this.fillAfter('nextAfter', this.birthdays, nextAfterBirthdaysLeft); } } // next significant birthdays if (this.birthdaysSignificant.length > 0) { const nextBirthdaySignificantDaysLeft = this.birthdaysSignificant[0].daysLeft; await this.fillAfter('nextSignificant', this.birthdaysSignificant, nextBirthdaySignificantDaysLeft); } // fill month json for (let m = 1; m <= 12; m++) { // get all birthdays with same month const monthlyBirthdays = this.birthdays.filter(birthday => birthday._birthday.month() + 1 === m); await this.setState(`${this.getMonthPath(m)}.json`, { val: JSON.stringify(monthlyBirthdays, null, 2), ack: true, }); await this.setStateChangedAsync(`${this.getMonthPath(m)}.count`, { val: monthlyBirthdays.length, ack: true, }); } } async fillAfter(path, birthdays, daysLeft) { this.log.debug(`[fillAfter] filling ${path} with ${daysLeft} days left`); const nextBirthdays = birthdays.filter(birthday => birthday.daysLeft == daysLeft); // get all birthdays with same days left const nextBirthdaysText = nextBirthdays.map(birthday => { return this.config.nextTextTemplate.replace('%n', birthday.name).replace('%a', birthday.age).trim(); }); await this.setState(`${path}.json`, { val: JSON.stringify(nextBirthdays, null, 2), ack: true }); await this.setStateChangedAsync(`${path}.daysLeft`, { val: daysLeft, ack: true }); await this.setStateChangedAsync(`${path}.text`, { val: nextBirthdaysText.join(this.config.nextSeparator), ack: true, }); const birthdayDate = moment().set({ hour: 0, minute: 0, second: 0 }).add(daysLeft, 'days'); await this.setStateChangedAsync(`${path}.date`, { val: birthdayDate.valueOf(), ack: true }); await this.setStateChangedAsync(`${path}.dateFormat`, { val: this.formatDate(birthdayDate.toDate()), ack: true, }); } async fillPathWithBirthday(path, birthdayObj) { this.log.debug(`[fillPathWithBirthday] path: "${path}", birthday: ${JSON.stringify(birthdayObj)}`); const birthday = birthdayObj._birthday; await this.setObjectNotExistsAsync(path, { type: 'channel', common: { name: birthdayObj.name, }, native: {}, }); await this.setObjectNotExistsAsync(`${path}.name`, { type: 'state', common: { name: { en: 'Name', de: 'Name', ru: 'Имя', pt: 'Nome', nl: 'Naam', fr: 'Nom', it: 'Nome', es: 'Nombre', pl: 'Nazwa', uk: "Ім'я", 'zh-cn': '姓名', }, type: 'string', role: 'text', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.name`, { val: birthdayObj.name, ack: true }); await this.setObjectNotExistsAsync(`${path}.age`, { type: 'state', common: { name: { en: 'Age', de: 'Alter', ru: 'Возраст', pt: 'Era', nl: 'Leeftijd', fr: 'Âge', it: 'Età', es: 'La edad', pl: 'Wiek', uk: 'Вік', 'zh-cn': '年龄', }, type: 'number', role: 'value', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.age`, { val: birthdayObj.age, ack: true }); await this.setObjectNotExistsAsync(`${path}.currentAge`, { type: 'state', common: { name: { en: 'Current age as text', de: 'Aktuelles Alter als Text', ru: 'Текущий возраст как текст', pt: 'Idade atual como texto', nl: 'Current leeftijd als tekst', fr: 'Âge actuel du texte', it: 'Età attuale come testo', es: 'Edad actual como texto', pl: 'Aktualny wiek jako tekst', uk: 'Поточний вік як текст', 'zh-cn': '目前的案文', }, type: 'string', role: 'text', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.currentAge`, { val: birthdayObj.currentAgeText, ack: true }); await this.setObjectNotExistsAsync(`${path}.day`, { type: 'state', common: { name: { en: 'Day of month', de: 'Monatstag', ru: 'День месяца', pt: 'Dia do mês', nl: 'Dag van de maand', fr: 'Jour du mois', it: 'Giorno del mese', es: 'Dia del mes', pl: 'Dzień miesiąca', uk: 'День місяця', 'zh-cn': '每月的第几天', }, type: 'number', role: 'value', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.day`, { val: birthday.date(), ack: true }); await this.setObjectNotExistsAsync(`${path}.year`, { type: 'state', common: { name: { en: 'Birth year', de: 'Geburtsjahr', ru: 'Год рождения', pt: 'Ano de Nascimento', nl: 'Geboortejaar', fr: 'Année de naissance', it: 'Anno di nascita', es: 'Año de nacimiento', pl: 'Rok urodzenia', uk: 'Рік народження', 'zh-cn': '出生年', }, type: 'number', role: 'value', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.year`, { val: birthdayObj.birthYear, ack: true }); await this.setObjectNotExistsAsync(`${path}.daysLeft`, { type: 'state', common: { name: { en: 'Days left', de: 'Tage übrig', ru: 'Осталось дней', pt: 'Dias restantes', nl: 'Dagen over', fr: 'Jours restants', it: 'Giorni rimasti', es: 'Días restantes', pl: 'Pozostałe dni', uk: 'Днів зліва', 'zh-cn': '剩余天数', }, type: 'number', role: 'value', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.daysLeft`, { val: birthdayObj.daysLeft, ack: true }); await this.setObjectNotExistsAsync(`${path}.nextWeekday`, { type: 'state', common: { name: { en: 'Weekday', de: 'Woche', ru: 'День недели', pt: 'Dia de semana', nl: 'Weekdag', fr: 'Jour de semaine', it: 'Rassegna', es: 'Día de semana', pl: 'Weekend', uk: 'День народження', 'zh-cn': '工作日', }, type: 'number', role: 'value', read: true, write: false, }, native: {}, }); await this.setStateChangedAsync(`${path}.nextWeekday`, { val: birthday.weekday(), ack: true }); } getMonthPath(m) { return `month.${String(m).padStart(2, '0')}`; } getMonthTranslation(moment, locale) { const momentCopy = moment.clone(); momentCopy.locale(locale); return momentCopy.format('MMMM'); } getCurrentAgeAsText(birthday) { const clonedBirthday = birthday.clone(); const years = this.today.diff(clonedBirthday, 'year'); clonedBirthday.add(years, 'years'); const months = this.today.diff(clonedBirthday, 'months'); clonedBirthday.add(months, 'months'); const days = this.today.diff(clonedBirthday, 'days'); return String(this.config.currentAgeTemplate).replace('%y', years).replace('%m', months).replace('%d', days); } cleanNamespace(id) { return id .trim() .replace(/\s/g, '_') // Replace whitespaces with underscores .replace(/[^\p{Ll}\p{Lu}\p{Nd}]+/gu, '_') // Replace not allowed chars with underscore .replace(/[_]+$/g, '') // Remove underscores end .replace(/^[_]+/g, '') // Remove underscores beginning .replace(/_+/g, '_') // Replace multiple underscores with one .toLowerCase() .replace(/_([a-z])/g, (m, w) => { return w.toUpperCase(); }); } removeNamespace(id) { const re = new RegExp(`${this.namespace}*\\.`, 'g'); return id.replace(re, ''); } /** * @param {() => void} callback */ onUnload(callback) { try { this.log.debug('cleaned everything up...'); callback(); } catch { callback(); } } } // @ts-expect-error parent is a valid property on module if (module.parent) { // Export the constructor in compact mode /** * @param {Partial<utils.AdapterOptions>} [options] */ module.exports = options => new Birthdays(options); } else { // otherwise start the instance directly new Birthdays(); }