(gdb) break *0x972

Debugging, GNU± Linux and WebHosting and ... and ...

Add notification support to Owncloud Calendar with Selfloss (RSS aggregator)

In my different steps toward self-hosting, I switched from Google Calendar to Owncloud Calendar. However, one neat feature is missing in Owncloud, it's the ability to set task reminders. My schedule is not very busy, so I mainly put 'far-off' meetings and appointments ... and I forget to check the calendar again and miss the event!

One thing I check daily is my mail client, the other is my RSS feed aggregator, selfoss. And
selfoss
appears to be easily expendable, so the idea grew quickly in my mind, and today it's ready: I need to export the calendar in ICS format, parse it, and feed it to selfoss. I first thought about converting it to RSS, but I didn't want my events to be available online, so it was easier and quicker to jump directly from Owncloud to Selfoss.

Owncloud is open-source, so finding how to export the calendar was just a matter of code study, hopefully not done by myself this time:

define('OCROOT', '$PATH_TO_OWNCLOUD/owncloud/');

function owncloud_get_calendar($username, $cal_id) {
  //it's not necessary to load all apps
  $RUNTIME_NOAPPS = true;
  require_once(OCROOT . '/lib/base.php');
  require_once(OCROOT . '/apps/calendar/appinfo/app.php');
  
  //set userid
  OC_User::setUserId($username);
  
  OCP\User::checkLoggedIn();
  OCP\App::checkAppEnabled('calendar');
  
  $calendar = OC_Calendar_App::getCalendar($cal_id, true, true);
  if(!$calendar) {
    return;
  }
  
  return OC_Calendar_Export::export($cal_id, OC_Calendar_Export::CALENDAR);
}

The
cal_id
parameter is show in Owncloud calendar when you try to download the ICS file:


ICS is a well-define format, same luck, it wasn't hard to find a simple parser: ics-parser.

Last step, and not least one, I had to feed to parsed ICS to Selfoss, through a "spout", a source plugin able to a provide new items to Selfoss.

  • first, we tell selfoss what information we need to add a new calendar feed: the URL is mandatory, username and password are optional, "days in advance" tells for how many days in advance we want to fetch the events.

public $params = array(
        "url" => array(
            "title"      => "URL",
            "type"       => "text",
            "default"    => "",
             "required"   => true,
            "validation" => array("notempty")
       ),
        "username" => array(
            "title"      => "Username",
            "type"       => "text",
            "default"    => "",
            "required"   => false,
            "validation" => ""
       ),
        "password" => array(
            "title"      => "Password",
            "type"       => "password",
            "default"    => "",
            "required"   => false,
            "validation" => ""
       ),
        "days" => array(
            "title"      => "Days in advance",
            "type"       => "text",
            "default"    => "-1",
            "required"   => false,
            "validation" => "int"
       )
   );

  • then we load the calendar, either from Owncloud or directly from its URL. If URL is "owncloud_", we load from owncloud, otherwise, we fetch the URL. Spouts implement the iterator interface, so here we prepare iterator, "rewind" it, and it's ready.

    public function load($params) {
      $link = $params['url'];
      if (strpos($link, "owncloud_") === 0) { // owncloud_<cal_id>
        $calendar_lines = owncloud_get_calendar($params['username'],
                                                substr($link, 1+strpos($link, "_")));

        $ical = new ICal(null, explode("\n", $calendar_lines));
      } else {
        if (!empty($params['password']) && !empty($params['username'])) {
          $auth = $params['username'].":".$params['password']."@";
          $link = str_replace("://", "://$auth", $link);
        }

        $ical = new ICal($link);
      }
      
      $this->items = $ical->events();
      
      $this->days = $params['days'];
      $this->rewind();

      $this->params = $params;
    }

  • next this it to select the which events we want to return to Selfoss, in the
    next
    other of the iterator. We count the distance in days, and only print it if it's below the threshold. Obviously, we also discard the past events.

    public function next() {
      if ($this->items == false) {
        return false;
      }
      while (1) {
        $this->position++;

        $event = $this->current_event();
        if (!$event) {
          return false;
        }
        
        $event_date = strtotime($event["DTSTART"]);
        $daydiff = floor(($event_date - time()) / 60 / 60 / 24); // in days
          
        if (isset($event["RRULE"])) { // repeating event
          // explained at the step

        } else { // normal event
          if ($event_date < time()) { // not in the past
            continue;
          }
        }
        if ($this->days !== -1 && $daydiff > $this->days) { // not more than $days of distance
          continue;
        }
        $this->items[$this->position]["DDIST"] = $daydiff;

        return $this->current();
      }
    }

  • one kind of events were missing after the first try, it's the repeating events: "every Saturdays, starting the 18th of october". So far I only implemented weekly events, I'll add other kinds whenever I'll need them!

       if (isset($event["RRULE"])) { // repeating event
          if ($event["RRULE"]["FREQ"] === "WEEKLY") {
            if ($event["RRULE"]["INTERVAL"] !== "1") {
              //ignore for now
              continue;
            }
            $DAYS_OF_WEEK = array("SU" => 0, "MO" => 1, "TU" => 2, "WE" => 3, "TH" => 4, "FR" => 5, "SA" => 6);
            $daydiff = $DAYS_OF_WEEK[$event["RRULE"]["BYDAY"]] - date("w");
            if ($daydiff < 0) $daydiff += 7;
            
          } else {
            // ignore for now
            continue;
          }
     }

  • the last step was providing Selfoss with the events/items:

    public function getTitle() {
      if ($this->items == false || !$this->valid()) {
        return false;
      }
      
      $event = $this->current_event();
      if (isset($event["RRULE"])) {
        $dispdate = repeating_time($event["RRULE"]) . " " . end_time($event["DTSTART"]);
      } else {
        $dispdate = start_time($event["DTSTART"]);
      }
      $dispdate .= " -> " . end_time($event["DTEND"]);
      
      $text = stripslashes(htmlentities($event["SUMMARY"]));

      return "$dispdate | $text";
    }

    public function getContent() {
        if ($this->items == false || !$this->valid()) {
          return false;
        }
        
        $event = $this->current_event();
            
        $text = stripslashes(htmlentities($event["SUMMARY"]));
        
        $description = "";
        if (isset($event["DESCRIPTION"])) {
          $description = $event["DESCRIPTION"];
        }
        if (isset($event["LOCATION"])) {
          $description .= "\nLocation: ".htmlentities($event["LOCATION"]);
        }

        if ($event["DDIST"] === 0) {
	  $description .= "\nAujourd'hui";
	} else {
          $description .= "\nDans ".$event["DDIST"]. " jour";
          
          if ($disttime !== 1) {
            $description .= "s";
          }
        }

        $description = str_replace("<br>", "\n", htmlentities($description));
        
        return $description;
    }

    public function getId() {
      if ($this->items == false || !$this->valid()) {
        return false;
      }
       
      $id = $this->current_event()["UID"];
      $id .= date("Y-m-d"); // refresh the event every day

      if (strlen($id) > 255) {
        $id = md5($id);
      }
      return $id;
    }

You can see in
getId
that I concatenate the date of the day to the event ID. That means that every day, Selfoss will "think" that the event is new, and mark it as unread ... and there we are !


Actually, there is one more step I had to implement: my Selfoss feeds are public, I don't mind sharing what I read, but I don't want my calendars to be publicly available. So I hacked Selfoss to "hide" some items if the session is not authenticated. Easiest way: hide tags starting with a "@". Nothing very complicated, we just remove the unwanted tags from the lists !

- return $this->backend->get($options);
+ $items = $this->backend->get($options);
+
+ if(!\F3::get('auth')->showHiddenTags()) {
+ foreach($items as $idx => $item) {
+ if (strpos($item['tags'], "@") !== false) {
+ unset($items[$idx]);
+ }
+ }
+ $items = array_values($items);
+ }
+
+ return $items;

(and the same for sources, tags and items)

And there we are, daily notification of Owncloud calendar events, directly in my feed reader :-)