Skip to content

Commit

Permalink
Display age for recent / upcoming birthdays
Browse files Browse the repository at this point in the history
This commit introduces an age label in the description of generated
events. As this requires events to be generated for each year
individually, only recent birthdays and a few upcoming ones will receive
the age label: users can configure the interval in the settings.

Sadly, the calendar API draft cannot handle recurrence exceptions, even
when using ical directly. Technically, birthdays with age labels are
thus not recurrence exceptions, but separate events. The main recurring
event is amended with suitable EXDATE rules to prevent overlaps.

This commit also fixes a few stylistic issues and adds DTSTAMP
generation (which is mandated by the ical standard).
  • Loading branch information
rsjtdrjgfuzkfg committed Oct 24, 2020
1 parent 8fc277e commit f0c42a0
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 41 deletions.
12 changes: 11 additions & 1 deletion src/_locales/de/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@
}
},
"addressBookSelection": {
"message": "Adressbücher auswählen, für die Geburtstagskalender angezeigt werden sollen:",
"message": "Adressbücher, für die Geburtstagskalender angezeigt werden sollen:",
"description": "Label above the list of address books in the preferences"
},
"yearsToDisplayAgeForSetting": {
"message": "Zeitraum, in dem das Alter berechnet wird: $years$ Jahre",
"description": "Label for the setting controlling the number of years to generate age information for; the placeholder will be replaced with an editable field.",
"placeholders": {
"years": {
"content": "$1",
"example": "4"
}
}
}
}
10 changes: 10 additions & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,15 @@
"addressBookSelection": {
"message": "Select address books to display birthday calendars for:",
"description": "Label above the list of address books in the preferences"
},
"yearsToDisplayAgeForSetting": {
"message": "Calculate ages for birthdays within a period of $years$ years",
"description": "Label for the setting controlling the number of years to generate age information for; the placeholder will be replaced with an editable field.",
"placeholders": {
"years": {
"content": "$1",
"example": "4"
}
}
}
}
16 changes: 14 additions & 2 deletions src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,25 @@ const BC = {};
calendarInfo.color = "#ffff00";
await Mc.calendars.create(calendarInfo);
return true;
}
};

BC.removeCalendarForAddressBookId = async function(addressBookId) {
const url = BC.getCalendarURLForAddressBookId(addressBookId);
const calendarInfo = { type: BC.calendarType, url: url };
const existingCalendars = await Mc.calendars.query(calendarInfo);
await Promise.all(existingCalendars.map(c => Mc.calendars.remove(c.id)));
return existingCalendars.length > 0;
}
};


// Settings
BC.getGlobalSettings = async function() {
return await Msl.get({
yearsToDisplayAgeFor: 4
});
};

BC.setGlobalSettings = async function(settings) {
await Msl.set(settings);
};
}
9 changes: 8 additions & 1 deletion src/options.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
html, body {
* {
font-size: 100%;
}
body {
display: block;
}
hr {
border: 0;
border-top: 1px solid gray;
}
label {
display: block;
padding: 5px;
Expand Down
35 changes: 31 additions & 4 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async function refreshAbList() {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = await BC.hasCalendarForAddressBookId(ab.id);
checkbox.addEventListener('click', () => (async () =>{
checkbox.addEventListener("click", () => (async () =>{
if (checkbox.checked) {
await BC.createCalendarForAddressBook(ab);
} else {
Expand All @@ -26,9 +26,36 @@ async function refreshAbList() {
}

addEventListener('load', () => (async () => {
const label = document.createElement("p");
label.textContent = Mi.getMessage("addressBookSelection");
document.body.appendChild(label);
const separator = '$this string is unlikely to occur in any locale file@';
let settings = await BC.getGlobalSettings();

const ageYearsLabel = document.createElement("label");
const ageYearsText = Mi.getMessage("yearsToDisplayAgeForSetting",
[separator]).split(separator);
ageYearsLabel.appendChild(document.createTextNode(ageYearsText[0]));
const ageYearsSpinner = document.createElement("input");
ageYearsSpinner.type = "number";
ageYearsSpinner.min = 0;
ageYearsSpinner.max = 100;
ageYearsSpinner.value = settings.yearsToDisplayAgeFor;
ageYearsSpinner.addEventListener("change", () => (async () => {
const newValue = parseInt(ageYearsSpinner.value);
if (!(newValue >= 0) || settings.yearsToDisplayAgeFor === newValue) {
return;
}
settings.yearsToDisplayAgeFor = newValue;
await BC.setGlobalSettings(settings);
await Mc.calendars.synchronize();
})().catch(console.error));
ageYearsLabel.appendChild(ageYearsSpinner);
ageYearsLabel.appendChild(document.createTextNode(ageYearsText[1]));
document.body.appendChild(ageYearsLabel);

document.body.appendChild(document.createElement("hr"));

const abListLabel = document.createElement("p");
abListLabel.textContent = Mi.getMessage("addressBookSelection");
document.body.appendChild(abListLabel);

abList = document.createElement("div");
document.body.appendChild(abList);
Expand Down
120 changes: 87 additions & 33 deletions src/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,50 +23,104 @@ Mc.provider.onSync.addListener(async (cal) => {
}
}

// We cannot use the more simple event representation of the Calendar API,
// as it does not support repetitions, exceptions or whole-day events yet.
// We also can't use recurrence exceptions, and simulate them with RDATEs
// and separate events (as the calendar API draft does not permit access to
// recurrence exceptions in any way). [October 2020]
const icalStrip = s => s.replace(/\n\\,;/g, "");
const zeroPad = (s, d) => s.toString().padStart(d || 2, "0");
const icalDate = d => zeroPad(d.getFullYear(), 4) + zeroPad(d.getMonth() + 1)
+ zeroPad(d.getDate());
const icalUTZTime = d => zeroPad(d.getUTCFullYear(), 4)
+ zeroPad(d.getUTCMonth() + 1) + zeroPad(d.getUTCDate()) + "T"
+ zeroPad(d.getUTCHours()) + zeroPad(d.getUTCMinutes())
+ zeroPad(d.getUTCSeconds()) + "Z";

const now = new Date();
const dtStamp = "DTSTAMP:" + icalUTZTime(now) + "\n";

const settings = await BC.getGlobalSettings();
const ageStartYear = now.getFullYear()
- Math.ceil(settings.yearsToDisplayAgeFor / 2);
const ageEndYear = ageStartYear + settings.yearsToDisplayAgeFor;

await Mc.calendars.clear(cal.cacheId);
for (let contact of ab.contacts) {
const bDay = contact.properties.BirthDay;
const bMonth = contact.properties.BirthMonth;
const bDay = parseInt(contact.properties.BirthDay);
const bMonth = parseInt(contact.properties.BirthMonth);
if (!bDay || !bMonth) {
continue; // Skip contacts without birthday
}

// TODO: use the simple calendar format instead of ical generation, once it
// supports repetition and whole-day-events.
const icalStrip = s => s.replace(/\n\\,;/g, "");
const zeroPad = (s, d) => s.toString().padStart(d || 2, "0");
const icalDate = d => "DATE:" + zeroPad(d.getFullYear(), 4)
+ zeroPad(d.getMonth() + 1) + zeroPad(d.getDate());

let ical = "BEGIN:VCALENDAR\nBEGIN:VEVENT\n";
ical += "UID:" + icalStrip(contact.id) + "\n";

const name = contact.properties.DisplayName || contact.id;
const bYear = contact.properties.BirthYear;
ical += "SUMMARY:" + icalStrip(name + (bYear ? " (" + bYear + ")" : ""))
+ "\n";
const bYear = parseInt(contact.properties.BirthYear);

// If we do not know a birth year, we will assume 1972, as that is the first
// leap year of the unix epoch (we need a leap year in order to correctly
// process birthdays on 29th of February).
let firstInstance = new Date(bYear || 1972, bMonth - 1, bDay);
if (bYear < 100) { // unlikely, but just in case...
firstInstance.setFullYear(bYear);
let years; // array with all years that get an event for this birthday
if (bYear) {
years = [bYear];
for (let year = Math.max(ageStartYear, bYear + 1); year < ageEndYear;
++year) {
years.push(year);
}
} else {
// If we do not know a birth year, we will assume 1972, as that is the
// first leap year of the unix epoch (we need a leap year in order to
// correctly process birthdays on 29th of February).
years = [1972];
}
ical += "DTSTART;VALUE=" + icalDate(firstInstance) + "\n";
firstInstance.setDate(firstInstance.getDate() + 1);
ical += "DTEND;VALUE=" + icalDate(firstInstance) + "\n";
ical += "TRANSP:TRANSPARENT\n";
ical += "RRULE:FREQ=YEARLY\n";

ical += "END:VEVENT\nEND:VCALENDAR\n";
for (let year of years) {
let instanceDate = new Date(year, bMonth - 1, bDay);
if (instanceDate < 100) { // unlikely, but just in case...
instanceDate.setFullYear(year);
}

let ical = "BEGIN:VCALENDAR\nVersion:2.0\n";
ical += "BEGIN:VEVENT\n";
ical += "UID:" + icalStrip(contact.id) + "-" + year + "\n" + dtStamp;

await Mc.items.create(cal.cacheId, {
type: "event",
formats: {
use: "ical",
ical: ical
ical += "SUMMARY:" + icalStrip(name);
if (year > bYear) {
// This is an exception to the regular event, containing the exact
// age for this particular year
ical += (bYear ? " (" + (year - bYear) + ")" : "") + "\n";
// If we had recurrence exception support, we'd also add
// "RECURRENCE-ID;VALUE=DATE:" + icalDate(instanceDate) + "\n"
} else {
// This is the main event with the recurrence rule. Contains the birth
// year iff it is set and we do not display ages anywhere
if (!settings.yearsToDisplayAgeFor && bYear) {
ical += " (" + bYear + ")";
}
ical += "\nRRULE:FREQ=YEARLY\n";
// As we don't have real recurrence exceptions, we need to explicitly
// exclude all dates with 'exceptions' in the main event:
if (years.length > 1) {
for (let exYear of years) {
if (exYear === year) {
continue;
}
ical += "EXDATE;VALUE=DATE:" + icalDate(new Date(exYear, bMonth - 1,
bDay)) + "\n";
}
}
}
});

ical += "DTSTART;VALUE=DATE:" + icalDate(instanceDate) + "\n";
instanceDate.setDate(instanceDate.getDate() + 1);
ical += "DTEND;VALUE=DATE:" + icalDate(instanceDate) + "\n";
ical += "TRANSP:TRANSPARENT\n";

ical += "END:VEVENT\n";
ical += "END:VCALENDAR\n";
await Mc.items.create(cal.cacheId, {
type: "event",
formats: {
use: "ical",
ical: ical
}
});
}
}
});

0 comments on commit f0c42a0

Please sign in to comment.