zerocal - 一个用 Rust 编写、运行在 shuttle.rs 上的无服务器日历应用
每隔一段时间,我和朋友们会聚在一起吃顿晚饭。我很珍惜这些夜晚,但最糟糕的部分就是安排这些聚会!
我们在群里发一条消息。
等待回复。
定下日期。
有人发出日历邀请。
事情总算办成了。
这一切都不好玩,除了那顿晚饭。
作为一个通情达理的人,你可能会想:“为什么不直接用一个日程安排应用呢?”
我试过很多这类应用。没有一个好用。它们全都……太复杂了!
让我发个邀请,谁想来就来,不就行了吗。
- 我不想为你的日历/日程/随便什么应用注册账号。
- 我不想添加我的朋友。
- 我不想添加我朋友的朋友。
- 我不想添加我朋友的朋友的朋友。
- 你懂的:我只想发个邀请,然后不用再管你的任何回应。
书呆子内向工程师的解决方案
💡 我们绝对需要的是又一个日历应用,它可以创建活动并发送带有该活动链接的邀请!你肯定没想到吧?
哦对了,我不想用 Google 日历来创建活动,因为我不信任他们。
像任何一个通情达理的人一样,我希望有一种能从终端创建日历条目的方法。
上次我就是这么向朋友们推销这个想法的。他们的回答是:“不知道,听起来像是为一个不存在的问题寻找解决方案。”不过你知道人们常说的:永远不要问海星问路。
Show, don't tell(做出来,别光说)
那天晚上我回到家,做了一个网站,可以通过 GET 参数来创建日历条目。
它让你可以舒舒服服地在命令行里创建一个日历活动:
> curl https://zerocal.shuttleapp.rs?start=2022-11-04+20:00&duration=3h&title=Birthday&description=paaarty
BEGIN:VCALENDAR
VERSION:2.0
PRODID:ICALENDAR-RS
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTAMP:20221002T123149Z
CLASS:CONFIDENTIAL
DESCRIPTION:paaarty
DTEND:20221002T133149Z
DTSTART:20221002T123149Z
SUMMARY:Birthday
UID:c99dd4bb-5c35-4d61-9c46-7a471de0e7f4
END:VEVENT
END:VCALENDAR然后你可以把它保存成文件,用你的日历应用打开。
> curl https://zerocal.shuttleapp.rs?start=2022-11-04+20:00&duration=3h&title=Birthday&description=paaarty > birthday.ics
> open birthday.ics从某种意义上说,这是一个“无服务器日历应用”,哈哈。服务器上没有任何状态,它只是即时生成一个日历活动并返回。
我是怎么做的
你可能注意到 URL 里包含“shuttleapp.rs”。这是因为我用 shuttle.rs 来托管这个网站。
Shuttle 是一个面向 Rust 项目的托管服务,我一直想试试它。
为了用出色的 axum Web 框架初始化项目,我使用了:
cargo install cargo-shuttle
cargo shuttle init --axum --name zerocal zerocal然后我就得到了开始所需的一切:
use axum::{routing::get, Router};
use sync_wrapper::SyncWrapper;
async fn hello_world() -> &'static str {
"Hello, world!"
}
#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
let router = Router::new().route("/hello", get(hello_world));
let sync_wrapper = SyncWrapper::new(router);
Ok(sync_wrapper)
}让我们快速提交更改:
git add .gitignore Cargo.toml src/
git commit -m "Hello World"要部署代码,我需要注册一个 shuttle 账号。可以在 https://www.shuttle.rs/login 完成。
它会要求你授权其访问你的 Github 账号。
然后:
cargo shuttle login最后:
cargo shuttle deploy现在我们前往 zerocal.shuttleapp.rs:
Hello World!部署第一个版本花了不到 5 分钟。不错!我们的自定义日历应用已经万事俱备了。
编写应用
为了创建日历活动,我使用了 icalendar crate(感谢 hoodie 创建了这个好用的库!)。iCalendar 是一种用于创建日历活动的标准,大多数日历应用都支持它。
cargo add icalendar
cargo add chrono # 用于日期和时间解析我们来创建一个演示用的日历活动:
let event = Event::new()
.summary("test event")
.description("here I have something really important to do")
.starts(Utc::now())
.ends(Utc::now() + Duration::days(1))
.done();够简单吧。
如何返回一个文件!?
现在我们有了一个日历活动,需要把它返回给用户。但如何把它作为文件返回呢?
axum 中有一个关于如何动态返回文件的示例,在这里。
async fn calendar() -> impl IntoResponse {
let ical = Calendar::new()
.push(
// add an event
Event::new()
.summary("It works! 😀")
.description("Meeting with the Rust community")
.starts(Utc::now() + Duration::hours(1))
.ends(Utc::now() + Duration::hours(2))
.done(),
)
.done();
CalendarResponse(ical)
}这里有几点值得注意:
- 每个日历文件都是一组活动的集合,所以我们要把活动包在一个代表集合的
Calendar对象里。 impl IntoResponse是一个 trait,允许我们返回任何实现了它的类型。CalendarResponse是围绕Calendar的一个newtype 包装器,它实现了IntoResponse。
下面是 CalendarResponse 的实现:
/// Newtype wrapper around Calendar for `IntoResponse` impl
#[derive(Debug)]
pub struct CalendarResponse(pub Calendar);
impl IntoResponse for CalendarResponse {
fn into_response(self) -> Response {
let mut res = Response::new(boxed(Full::from(self.0.to_string())));
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/calendar"),
);
res
}
}我们只需创建一个新的 Response 对象,并把 Content-Type 头设置为 iCalendar 文件对应的 MIME 类型:text/calendar。然后返回这个响应。
添加日期解析
这部分有点 hacky,所以你可以随意略过。我们需要从查询字符串中解析日期和时长。我用了 dateparser,因为它支持非常非常多的日期格式。
async fn calendar(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let mut event = Event::new();
event.class(Class::Confidential);
if let Some(title) = params.get("title") {
event.summary(title);
} else {
event.summary(DEFAULT_EVENT_TITLE);
}
if let Some(description) = params.get("description") {
event.description(description);
} else {
event.description("Powered by zerocal.shuttleapp.rs");
}
if let Some(start) = params.get("start") {
let start = dateparser::parse(start).unwrap();
event.starts(start);
if let Some(duration) = params.get("duration") {
let duration = humantime::parse_duration(duration).unwrap();
let duration = chrono::Duration::from_std(duration).unwrap();
event.ends(start + duration);
}
}
if let Some(end) = params.get("end") {
let end = dateparser::parse(end).unwrap();
event.ends(end);
if let Some(duration) = params.get("duration") {
if params.get("start").is_none() {
let duration = humantime::parse_duration(duration).unwrap();
let duration = chrono::Duration::from_std(duration).unwrap();
event.starts(end - duration);
}
}
}
let ical = Calendar::new().push(event.done()).done();
CalendarResponse(ical)
}要是能支持更多日期格式就好了,比如 now 和 tomorrow,不过这个就留到以后再说吧。
我们来测试一下:
> cargo shuttle run # 这会启动一个本地开发服务器
> curl 127.0.0.1:8000?start=2022-11-04+20:00&duration=3h&title=Birthday&description=Party
*🤖 bleep bloop, calendar file created*很好,成功了!
在浏览器中打开它就会在日历中创建一个新活动:

而对于那些不用终端创建日历活动的奇怪的人,我们也给网站加个表单吧。
添加表单
<form>
<table>
<tr>
<td>
<label for="title">Event Title</label>
</td>
<td>
<input type="text" id="title" name="title" value="Birthday" />
</td>
</tr>
<tr>
<td>
<label for="desc">Description</label>
</td>
<td>
<input type="text" id="desc" name="desc" value="Party" />
</td>
</tr>
<tr>
<td><label for="start">Start</label></td>
<td>
<input type="datetime-local" id="start" name="start" />
</td>
</tr>
<tr>
<td><label for="end">End</label></td>
<td>
<input type="datetime-local" id="end" name="end" />
</td>
</tr>
</table>
</form>我对 calendar 函数稍作修改,让它在查询字符串为空时返回表单:
async fn calendar(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
// if query is empty, show form
if params.is_empty() {
return Response::builder()
.status(200)
.body(boxed(Full::from(include_str!("../static/index.html"))))
.unwrap();
}
// ...
}经过一番调整,我们就得到了一个散发着 Web 1.0 光辉的漂亮小表单:

就这样!我们现在有了一个能创建日历活动的小 Web 应用。嗯,差不多吧。我们还需要部署它。
部署
cargo shuttle deploy对,就这么多了。就是这么简单。感谢 shuttle.rs 的小伙伴们让这一切成为可能。
这个日历应用现在已经可以在 zerocal.shuttleapp.rs 上使用了。
现在我终于可以给朋友们发一个下次酒吧巡游的日历活动链接了。他们肯定会很感激的。是吗是吗
用 100 行 Rust 从零到日历
天哪,重新写点朴素的 HTML 感觉真好。
构建小应用永远不会过时。
欢迎在 GitHub 上查看源代码,帮我把它做得更好!🙏
以下是一些想法:
- ✅ 添加地点支持(例如
location=Berlin或location=https://zoom.us/test)。感谢 sigaloid。 - 支持更多人类可读的日期格式(例如
now、tomorrow)。 - 支持重复性活动。
- 支持时区。
- 添加 Google 日历短链接(
https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20221003T224500Z%2F20221003T224500Z&details=&location=&text=)。 - 添加从命令行创建日历活动的示例 bash 命令。
- 缩短 URL(例如
zerocal.shuttleapp.rs/2022-11-04T20:00/3h/Birthday/Party)?
欢迎查看issue 跟踪器并随时提交 PR!
随机一篇博客