Emily F. Gorcenski

拥有自己的数据,第一部分:集成自托管的 Calendar 解决方案

#indieweb #calendar #baikal #python 05.04.2025

这是关于我收回和拥有自己的数据和技术的系列文章的第一部分。 在本文中,我将描述如何集成我自己的自托管日历解决方案。

介绍

我的日历简直是一场噩梦。 我经常出差,有些是为了工作,有些是为了娱乐,还有一些是因为我多年来一直在维持一段异地恋。 经常出差意味着你的亲人或同事总是很难知道你所在的时区或你何时在飞机上。 跨时区维持关系意味着需要不断进行比实际需要的更难的心理计算。 而且因为我没有助手,所以我对航班、火车、登机或前往机场的拦截器的重复录入感到沮丧等等。

作为一个经常旅行的人,从统计上讲,每当发生一些有新闻价值的事件时,我在飞机上的几率都比普通人高。 我希望我的妻子、朋友、同事知道我在哪个航班上,以及我在哪个城市。 我经历过一次恐怖袭击,差点躲过另外两次袭击和一次大规模枪击事件。 我想确保关心我的人可以轻松查看我的位置。

问题是,日历系统很糟糕。 所有的。 这些标准是两代计算机之前的遗留物,前端生态系统是一堆寻求租金的每月订阅移动应用程序,这些应用程序具有可疑的功能,并且大多数系统的用户体验都非常糟糕。 举个例子:如果我预订航班,我的电子邮件提供商会创建一个日历条目,但它经常错过转机或弄错时区,即使没有失败,它也不会让我成为组织者,这意味着我无法分享或修改它。 整个日历生态系统简直是一场噩梦。

可悲的是,在整个领域中,真正好的产品只有两种:Google Calendar 基本上已经占据了日记条目的市场,而 Facebook Events 如果没有附加到一家充斥着未提纯恶魔之血的公司和服务中,那将是一个令人钦佩的工具。 我正在尽可能地脱离大型科技公司,所以我需要某种解决方案。

所以我自己构建了一个。 可以这么说吧。 我打算将此作为我构建自己的技术以重新控制我的数据的长期系列文章的第一部分。

需求

我的核心需求:

附加要求:

之前的解决方案

现有日历共享解决方案的最大问题是,它们要求每个人都在一个通用平台上,例如更广泛的 Gmail 或 Outlook.com 生态系统,或者在同一环境中共享帐户,例如 Exchange 环境,以便拥有全部功能。 解决此问题的两种常见方法是:通过 HTTP 提供 iCal 数据,以“只读”模式发布日历,或者通过电子邮件将 iCal .ics 文件发送给收件人。

对于此日历系统的 beta 版本,我选择了前者:我会在我的网站上以公共但秘密且不可猜测的 URL 托管一个 .ics 文件,或者实际上是多个 URL 用于不同的用例。 然后我可以共享链接或使用我的工作帐户订阅它。 为了填充日历,我开始用 YAML 编写事件,并为每个想要分享的人生成一个 URL:

- name: World Aviation Festival
 begin: 2024-10-07
 end: 2024-10-10
 city: Amsterdam
 event:
  name: World Aviation Festival Conference Day
  type: CONFERENCE
  begin: 2024-10-08T08:30:00+02:00
  end: 2024-10-08T18:00:00+02:00
  location: |   RAI Exhibition and Convention Centre
   Halls 1 & 5 | Europaplein 24, Amsterdam   
  repeat:
   count: 3
   frequency: daily
 flights:
  - flight number: LH2310
   departure:
    airport: MUC
    time: 2024-10-07T20:05:00+02:00
   arrival:
    airport: AMS
    time: 2024-10-07T21:40:00+02:00
  - flight number: LH2305
   departure:
    airport: AMS
    time: 2024-10-10T15:40:00+02:00
   arrival:
    airport: MUC
    time: 2024-10-10T17:05:00+02:00
  - flight number: LH1952
   departure:
    airport: MUC
    time: 2024-10-10T18:00:00+02:00
   arrival:
    airport: BER
    time: 2024-10-10T19:05:00+02:00
 hotel:
  - name: Sheraton Amsterdam Airport Hotel And Conference Center
   address: Schiphol Boulevard 101, Schiphol, 1, Netherlands 1118
 share:
  - Christine
  - Work
  - Em

我会获取这个 YAML 文件,并编写一个小脚本,将其重新序列化为我 CI/CD 管道中的 ICS 文件。

这在一段时间内有效,但变得难以控制。 手写 YAML 对于原型设计来说还可以,但从规模上看,我经常会犯错误,而且对于本应是一项轻松的工作来说,这需要花费大量精力。 我需要一个新的解决方案。

架构

对于我的新解决方案,我知道我需要摆脱我的静态解决方案,并且需要运行一些托管的东西。 即使这会花费我更多,但我已经接受了摆脱大型科技公司最终将需要我为各种需求托管自己的解决方案。 所以我决定跳入 CalDAV 的世界。

CalDAV 是 WebDAV 分布式创作规范的扩展,具有与日历应用程序相关的特定功能。 WebDAV 是 90 年代出现的一个想法,当时 Web 开发仍然非常同步,并且 Web 开发感觉更像软件开发。 然而,它是运行自托管日历系统的少数可用解决方案之一。

旁白:这是一个需要颠覆的领域。 看看 Wikipedia 上的此 CalDAV 和 CardDAV 实现列表。 那里一片惨淡。 难怪在 Calendly 和 Doodle 等第三方工具伪装下,数据聚合器如此受欢迎。 景观一片糟糕。 总之。

有了 CalDAV 服务器,我可以使用我选择的前端应用程序从多个设备连接到它。 这将允许我从我的笔记本电脑、手机或任何设备查看和管理事件。 但是很少有 CalDAV 服务器允许轻松地对日历进行无需身份验证的订阅。 所以我需要有一个脚本定期轮询服务器,提取事件,并通过我的网站将它们发布为 iCal 文件。

此外,我想连接到各种其他数据源,其中一些我控制,另一些则不然。 这些包括我的航班跟踪器(自托管)、我的电子邮件(付费托管)和我的语言学校(外部)。 我将构建的流程如下所示:

为了实现这一点,我设计了一个如下所示的架构:

Calendar system architecture

设置 Baïkal

我选择的工具是 Baïkal,一个轻量级的、可自托管的 CalDAV(和 CardDAV)服务器,用于管理日历和联系人。 使用 Docker Compose 设置服务很容易:

services:
 baikal:
  image: ckulka/baikal:0.9.5
  restart: always
  ports:
   - "XXXX:80"
  volumes:
   - /mnt/baikal/data:/var/www/baikal/config
   - /mnt/baikal/data:/var/www/baikal/Specific
volumes:
 config:
 data:

您可以配置 Baïkal 以使用 MySQL,但它也可以与 SQLLite 配合使用,这简化了其管理。 设置端口并修改本地卷(如果需要),然后使用简单的 docker compose up -d 启动它。

为了使其可用于 Web,我正在运行一个带有基本配置的 nginx 反向代理:

server {
  server_name MYDOMAIN;
  location / {
    proxy_pass http://localhost:XXXX;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
  location /.well-known/caldav {
    return 301 https://MYDOMAIN/dav.php;
  }
  listen 80;
  listen [::]:80;
}

当然,我使用 Let's Encrypt 来安全地提供此服务,但为了简单起见,我省略了此步骤。 如果你想做同样的事情,请用你的子域名/域名替换 MYDOMAIN。

需要注意的是:你会注意到执行到 dav.php 的 301 重定向的 location 指令。 如果你想将此日历添加到你的 iPhone 或 Mac 日历应用程序,则需要此 /.well-known/caldav 重定向。 最终在 MacOS 或 iOS 上设置你的日历时,你将需要使用手动设置,而不是自动设置(而不是高级设置——我不确定为什么手动设置有效时我无法让高级设置工作)。

我设置了 DNS 并使用 certbot 为我的域生成一个 Let's Encrypt 证书,它会自动更新 nginx 配置文件。

一旦启动并运行,我就可以在浏览器中导航到我的域以设置一个管理员帐户。 从那里,我为自己配置了一个用户并创建了一个日历。 理论上,如果我选择,我可以创建多个日历,例如,如果我想为旅行或其他事情设置一个特殊的日历。 但我发现没有必要,因为我的目标是最多一次数据输入。 要获取我的日历的 URL,我必须导航到我的用户页面,单击“Calendars”按钮,然后在小信息图标下找到它。

Baikal admin page showing calendar icon

我将它连接到我的 iOS 和 MacOS 默认日历应用程序,一切都进行得很顺利。

设置事件分类

我将在这里稍微绕道,进行另一次抱怨。 iCalendar 规范包括一个可选的 CATEGORIES 属性,用于 EVENT 组件。 此属性的意图似乎是为用户提供对事件(例如约会、会议等)进行分类的能力。 这将是日历前端中一个非常有用的功能; 例如,我可以轻松地在一个繁忙的星期中搜索并找到一个医生预约。 但是,大多数前端和日历应用程序根本没有以任何方式实现此功能。 MacOS Calendar 没有。 iOS Calendar 没有。 Google Calendar 没有。 我使用的每个工具都完全忽略了这个原本有用的字段。

我想使用这个字段。

但是自由文本分类存在问题:它很糟糕。 很难保持其一致性。 很难使其在上下文中具有意义,同时又明确,更不用说普遍理解了。 所以我需要对此做些什么。

既然无论如何我都需要编写一些 Python 脚本来提取日历事件,那么我可以尝试在数据模型中编码这些事件类型是有道理的。 所以我为此编写了一个小数据模型,使用 Python 枚举,此处摘录了一部分,请原谅随机的德语:

from enum import Enum
class TerminType(Enum):
  MEETUP = 1
  CONFERENCE = 2
  CLASS = 3
  TRAINING = 4
  APPOINTMENT = 10 # values 10 or higher are set private for my work calendar
  MEETING = 11
  EXAM = 12
  HEARING = 13
  INTERVIEW = 14
  def __str__(self):
    return self.name
  
class CultureType(Enum):
  MOVIE = 1
  CONCERT = 2
  SPORTS = 3
  MUSEUM = 4
  ENTERTAINMENT = 5
  def __str__(self):
    return self.name
class SocialType(Enum):
  ...
class AwayType(Enum):
  ...
class TransportType(Enum):
  ...
all_event_names = set(TerminType._member_names_) \
          .union(set(CultureType._member_names_)) \
          .union(set(SocialType._member_names_)) \
          .union(set(AwayType._member_names_)) \
          .union(set(TransportType._member_names_))

没有真正的原因像这样分解事物,除非它有助于在概念上组织事件类型。 此外,我确实实现了一些隐藏的业务逻辑:两位数枚举值默认情况下对我的工作日历是私有的。

构建此分类将有助于我实施一个 ad hoc 解决方案来解决之前描述的问题:它将帮助我使事件更容易搜索或一目了然地可见,以便前端允许您对事件进行颜色编码。

编译和共享日历

我已经说过几次我想做“最多一次”的数据输入。 这意味着有很多事件我根本不想输入数据,例如我的在线语言学校的预定课程(该课程托管了我的课程的 ICS 文件)或从我的电子邮件中提取的事件。 但是要自动化获取这些数据,我需要轮询这些端点,因为它们在添加新事件或删除旧事件时并没有真正发布事件。 这意味着我需要编写一个小 Python 脚本并将其连接到 cron 作业。

Python 脚本需要几个组件:

IMAP 部分非常好,这为该系统提供了类似 Google Calendar 的功能。 如果有人通过电子邮件向我发送日历邀请,此脚本会获取它并自动将其添加到我的日历中。

这是一段很多的代码,大多数都是 ad hoc 的,我不会在这里分享所有代码,但是编写起来并不难。 我将分享的是 cron 作业的入口点脚本:

from enum import Enum
from ics import Calendar, Event
import event_types as Categories
import airtrail
import baikal
import imap
def is_work_public(event : Event) -> bool:
  def get_value(type : Enum, category):
    try:
      return type[category].value < 10
    except:
      return False
    
  if not event.categories:
    return False
  
  return all((get_value(Categories.TerminType, c) |
        get_value(Categories.AwayType, c) |
        get_value(Categories.TransportType, c))
        for c in event.categories)
if __name__ == "__main__":
  family = Calendar()
  work = Calendar()
  # these add events to baikal directly
  airtrail.fetch_airtrail_events()
  imap.fetch_email_events()
  # I left out my language school fetcher because it's not active at the moment
  events = baikal.fetch_remote_events()
  for event in events:
    family.events.add(event)
    if "work.email@example.com" not in event.serialize():
      if is_work_public(event):
        event.classification = "PUBLIC"
      else:
        event.classification = "PRIVATE"
      work.events.add(event)
  try:
    with open("/www/calendar/emilygorcenski.ics", "wt") as ics_file:
      ics_file.write(family.serialize())
    with open("/www/calendar/emilygorcenski_work.ics", "wt") as ics_file:
      ics_file.write(work.serialize())
  except:
    pass

以及与 Baïkal 交互的脚本:

import os
import re
import requests
import xml.etree.ElementTree as ET
from dotenv import load_dotenv
from ics import Calendar, Event
from requests.auth import HTTPDigestAuth
from event_types import all_event_names
load_dotenv()
# Baikal server information
USERNAME = os.environ["BAIKAL_USERNAME"]
PASSWORD = os.environ["BAIKAL_PASSWORD"]
BASE_URL = os.environ["BAIKAL_URL"]
HEADERS = {
  "Content-Type": "application/xml; charset=utf-8",
  "Depth": "infinity"
}
PROPFIND_BODY = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop>
    <d:displayname/>
    <c:calendar-data/>
  </d:prop>
</d:propfind>
"""
def categorize(event : Event) -> Event:
  # ignores any user-input values that we don't care about, and focuses on what we do
  # this is to convert the description field in an event into categories fields
  # this allows manual categorization by editing the event description
  if not event.description:
    return event
  category_match = re.search(r'\b(CATEGORIES:)(\S+)\b', event.description)
  if category_match:
    label = category_match.group(1) # this should always be "CATEGORIES:""
    cat_list = category_match.group(2)
    categories = set(cat_list.split(","))
    event.categories = categories.intersection(all_event_names)
    event.description = event.description \
                 .replace(label + cat_list, "") \
                 .replace(" ", " ") \
                 .strip()
  return event
def fetch_remote_events() -> list[Event]:
  response = requests.request("PROPFIND",
                BASE_URL,
                headers=HEADERS,
                data=PROPFIND_BODY,
                auth=HTTPDigestAuth(USERNAME, PASSWORD))
  if response.ok:
    root = ET.fromstring(response.content)
    propstats    = [r.find('{DAV:}propstat')
              for r in root.findall('{DAV:}response')]
    calendar_data  = [p
              .find('{DAV:}prop')
              .find('{urn:ietf:params:xml:ns:caldav}calendar-data')
              for p in filter(lambda x: x is not None, propstats)]
    events     = [categorize(event)
              for data in filter(lambda x: x is not None, calendar_data)
              for event in Calendar(data.text).events]
    return events
  return []
def add_event(filename : str, event_ics : str):
  header = {
    "Content-Type": "text/calendar; charset=utf-8"
  }
  event_ics = event_ics.replace("METHOD:REQUEST\r\n", "")
  r = requests.put(f"{BASE_URL}{filename}",
           data=event_ics,
           headers=header,
           auth=HTTPDigestAuth(USERNAME, PASSWORD))
  return r.status_code

请注意,我是如何确保某些类型的事件(例如医生预约)被标记为私有,并序列化到我的工作日历中的一个单独文件中。

然后,我在 nginx 中设置一个重定向,以便通过一个不可查找的 URL 提供这些文件,该 URL 是从一个随机的、经过哈希处理和加盐的字符串生成的。

我每 15 分钟通过 cron 作业运行它。

在我的工作日历中共享我的事件

这项练习的全部意义不仅在于_我_可以看到事件,还在于我日历中的任何事件都会阻止我的工作日历,并且同事可以看到,以便他们知道我是否在航班上或在另一个城市旅行。 为此,我需要将这些事件复制到我的工作日历中。

这有点讽刺,因为整个练习开始于我试图_减少_我对 Google Calendar 的依赖。 但是,公平地说,Google Calendar 是我的工作场所的选择,而不是_我_在工作之外所依赖的东西。 我不热衷于将数据交给 Google,但至少如果我选择,我可以轻松地离开它们。

为了实现这一点,我正在使用 Google Script Engine 和此开源脚本的修改版本。 老实说,我很纠结于此 JavaScript 代码的组织方式,但它以最小的难度完成了工作。 我确实修改了它以从日历 CATEGORIES 属性中读取信息并对我的日历进行颜色编码。 结果意味着一目了然地解析我的日历真的很容易——显然我只共享了一小段非敏感信息。

Color coded blocks on a calendar showing a conference in green, a meeting in blue, and a flight in lavender

我在 30 分钟的时间间隔内运行此 Google Script。

结论

在过去的 6 个月左右的时间里,我一直在修改这个系统,并在此期间进行了一些小的调整和补充。 我不得不说,它运行得非常好。 我做的最新更新是通过 API 集成 Airtrail。 现在,当我预订航班时,我会将数据输入到我的航班跟踪器中,并在 15 分钟内将其添加到我的日历中,并在一个小时内自动复制到我的工作日历中。 这是一个巨大的生活质量改进,它为我节省了大量时间来管理我复杂的旅行要求的后勤工作。

该系统的总成本非常低。 我认为如果你愿意,你可以轻松地从家里的 NAS 设置并运行它,但我选择将我的数据安全地保存在瑞士,所以我每月订阅大约 100 美元的服务器时间来运行我的网站和我所有的集成。 这有点过头了——我肯定可以优化这些成本,并且会随着时间的推移这样做,但是在一个托管提供商的 VM 实例上的 Docker 主机上设置所有东西的便捷性值得额外的钱。 而且我每个月轻松节省 100 美元的时间,仅仅是为了更容易地管理我的日程安排。

它不是一个完美的解决方案,但如果它比我尝试过的任何其他解决方案都好,那就太糟糕了。

如果你尝试过类似的东西,请告诉我!