Skip to content

Calendar

Recap

很久以前用谷歌日历,记得当时还有短信提醒,后来就主推Android客户端提醒了,Google Now上也会有对应卡片提醒。 标准就是CalDAV,这个是从WebDAV扩展而来,具体见 Wikipedia CalDAV。 比如Firefox就提供了两个Calendar, 一个是大众版,基本就是大版本和ESR发布,一个是详细版,各种beta日程都有。

本文主要参考了 4 open source alternatives for Google Calendar

Note

之前我一直存在一个误解,就是一个ics地址/文件只能是一个事件,其实是可以存多个事件的。 当然有些其他标准/客户端只支持一个Event,比如vdir格式。

Roadmap

  1. 替换掉客户端,包括Android,Web等。
  2. 从Goole Calendar导出或者同步数据。
  3. 自建CalDAV server。
之前的方案
  1. own my data
  2. replace desktop/mobile/web client
  3. replace servers(e2e encryption)

第二步和第一步做了调整,因为首先我们日常使用的客户端,不管是GUI,CLI,TUI。 另外第三步如果做到了self-hosting,然后本身还有ssl之类的加密那e2e意义就不大了。

Progress

Clients

  • 移动客户端 simplemobiletools 系列里的calendar, 或者 Etar都行,我选择git commit数高一倍的Etar。 这一步就完成了显示(本地存储)日历,并不包含跟CalDAV Server同步功能,Etar #196 Etar安装后可以选择要显示的日历,可能唯一比谷歌日历缺少的是农历支持。 当然还可以使用系统自带的Calendar,某些国产手机可以使用Stock Calendar
  • PC端建议使用thunderbird,配合 Ligntning 或者 exchangecalendar 服用。
  • Web端可以使用 AgenDAV,但是我看见PHP写的,然后我对Web依赖目前并不高,就本能避让了。

Export/Sync

Google Calendar的数据导出途径

  1. 日历 ics 格式地址下载 7
  2. Google CalDAV API
  3. Google takeout

为了达到命令行控的癖好,我采用了 vdirsyncer ,其中使用的vdir格式跟maildir类似,其flat file理念深得我的喜爱。 以下为我的配置文件 ~/.vdirsyncer/config

[general]
status_path = "~/.vdirsyncer/status/"

[pair my_calendar]
a = "my_calendar_local"
b = "my_calendar_google"
collections = ["from a", "from b"]
;conflict_resolution = ["command", "vimdiff"]
conflict_resolution = "b wins"

[storage my_calendar_local]
type = "filesystem"
path = "~/.calendar/"
fileext = ".ics"

[storage my_calendar_google]
type = "google_calendar"
token_file = "~/.google-token-calendar"
client_id = "xxx.apps.googleusercontent.com"
client_secret = "xxx"

; caldav example
[storage my_calendar_work]
type = "caldav"
url = "http://example.com/users/username@example.com/calendar"
auth = "basic"
username = "xxx"
password = "xxx"
start_date = "datetime.now() - timedelta(days=365)"
end_date = "datetime.now() + timedelta(days=7)"
read_only = true

Warning

如果也配置了同步联系人,请不要使用相同的token_file,因为OAuth授权范围不同。 1 如果有多个配置文件(vdirsyncer -c 指定配置文件),status_path需要设置为不同路径。

示例中的client_id和client_secret自己可以去Google API & Service Dashboard创建5。执行vdirsyncer discover,会打开浏览器完成授权。然后执行vdirsyncer sync就可以同步数据了。 6

日期格式错误

如果提示类似00001231T000000Z时间格式错误

sed -i s/CREATED:00001231T000000Z/CREATED:19701231T000000Z/ ~/.calendar/xxx@gmail.com/*

如果手机上需要同步,可以依赖本身的Google日历同步,或者安装DAVdroid,或者etesync 3

Servers

Warnings

这部分还未完成。

CLI

最后发现khal非常简单4,以下为配置 ~/.config/khal/config

[calendars]
[[calendars]]
;readonly = true
path = ~/.calendar/*
type = discover
color = dark green

[[private]]
path = ~/.local/share/khal/calendars/private
type = calendar

[locale]
timeformat = %H:%M
dateformat = %Y-%m-%d
longdateformat = %Y-%m-%d
datetimeformat = %Y-%m-%d %H:%M
longdatetimeformat = %Y-%m-%d %H:%M

[default]
timedelta = 14d
highlight_event_days = True

Taskwarrior

配合taskwarrior使用,可以使用hook来更新日历。

#!/usr/bin/env python3
import json
import os.path
import subprocess

# https://gist.github.com/andir/dc29d470f5df6206628d4d090094c869
# use https://icalendar.readthedocs.io/en/latest/usage.html#more-documentation
from icalendar import Calendar, Event

TASK_BASE_DIR = os.path.expanduser('~/.calendar/xxx@group.calendar.google.com')

def get_tasks():
    output = subprocess.check_output(['task', 'export'])
    for task in json.loads(output.decode('utf-8')):
        if task['status'] in ['completed', 'deleted']:
            continue
        if 'scheduled' in task:
            yield task


def write_ics():

    for task in get_tasks():
        ics_path = os.path.join(TASK_BASE_DIR, '{}.ics'.format(task['uuid']))
        if os.path.exists(ics_path):
            calendar = Calendar.from_ical(open(ics_path, 'rb').read())
            # TODO assert calendar.subcomponents count == 1
            event = calendar.subcomponents[0]
            calendar.subcomponents = []
        else:
            calendar = Calendar()
            event = Event()
        event['summary'] = task['description']
        event['dtstart'] = task['scheduled']
        event['uid'] = task['uuid']
        event['created'] = task['entry']
        event['description'] = '\n'.join(annotation['description'] for annotation in task.get('annotations', []))
        calendar.add_component(event)
        with open(ics_path, 'wb') as fp:
            fp.write(calendar.to_ical())

if __name__ == '__main__':
    write_ics()

唯一需要注意的是,icalendar新添加的事件属性较少,同步到Google Calendar后会增加许多其他属性,如果taskwarrior修改了某个任务后,为了避免冲突,只需要修改已有日历任务的相关属性,无须新建Event,尽管uid不变。


  1. 本来应该还包括联系人部分,但是这部分因为敏感性目前还在试验中。 

  2. radicale 

  3. etesync, journal-manager, etesync-dav。 

  4. 这家还有todo的工具 todoman,不过我还是继续用taskwarrior吧。 

  5. vdirsync 

  6. 如果服务器为exchange可以使用 davmail 来提供CalDAV endpoint。 

  7. 在日历的Setting and Share里Integrate calendar里可以看到。 

Comments