TREK 日历订阅源(Calendar Feeds)完全指南:让旅行计划自动同步到你的日历应用
2026/9/15 7:17:07 网站建设 项目流程

TREK 日历订阅源(Calendar Feeds)完全指南:让旅行计划自动同步到你的日历应用

【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK

导读

TREK 是一个自托管的旅行行程规划工具(支持实时协作、交互式地图、PWA 离线模式等),其"日历订阅源(Calendar Feeds)"功能允许你把某一个行程或你名下的全部行程,以一条可订阅的实时 URL提供给 Google Calendar、Apple Calendar、Outlook 等日历应用,日历端会自动定期重新拉取,从而让日历始终跟随你在 TREK 中的编辑保持同步。读完本文,你将掌握单行程源与全行程源的区别、如何开启/轮换/撤销订阅、订阅 URL 的鉴权模型与安全边界,以及底层 ICS 生成与时区处理的具体实现机制。

与 ICS 一次性导出的区别:TREK 的"Download ICS"动作(详见 Day-Plans-and-Notes)会生成一个永不再变化的.ics快照文件;而日历订阅源则是一条日历应用会自行反复拉取的"活"URL。需要冻结副本用导出,需要跟随你的编辑持续更新则用订阅源。

从哪里找到日历订阅源

TREK 提供两类订阅源,分别从两个入口进入,两者共用同一个订阅对话框:

  • 单行程源(Per-trip feed):在行程规划器(Trip Planner)中,将鼠标悬停在 Day Plan 侧边栏工具栏的ICS按钮上,选择Subscribe to calendar。该菜单中的另一项Download ICS则是一次性导出。
  • 全部行程源(All-trips feed):在My Trips仪表盘(My-Trips-Dashboard)工具栏中,点击日历加号按钮(Subscribe to all trips)。

无论从哪个入口进入,最终都会打开同一个订阅对话框(前端实现位于 IcsSubscribeModal.tsx,通过endpoint参数区分/api/trips/{tripId}/feed/api/feed/user两个令牌端点)。打开对话框时只会读取当前状态,绝不会在你不知情的情况下悄悄生成链接。

单行程源与全部行程源的对比

维度单行程源(Per-trip feed)全部行程源(All-trips feed)
覆盖范围一个行程你拥有作为成员参与的全部行程
日历名称行程标题*{your username} – All Trips*
排除项已归档(archived)的行程,以及结束时间超过 90 天的行程
订阅 URL/api/feed/trip/{token}.ics/api/feed/user/{token}.ics
令牌存放位置行程记录(trips.feed_token用户账户(users.feed_token

全部行程源会把每个符合条件的行程合并进同一份日历,按开始日期排序,并对时区定义(VTIMEZONE)进行去重,保证每个事件仍能解析到正确的本地时间。

在服务端,全部行程源的查询逻辑位于 feeds.service.ts:它通过一次 SQL 同时命中"自己拥有的行程"和"以成员身份加入的行程"(WHERE user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?)),并过滤掉is_archived = 1以及end_date < 90 天前的行程,最后按start_date ASC排序。e2e 测试 feeds.e2e.test.ts 明确验证了"已归档行程与 90 天前结束的行程被排除""作为成员共享的行程会被包含"这两个行为。

开启日历订阅

  1. 打开Subscribe to calendar(或Subscribe to all trips)对话框。如前所述,打开它只读取当前状态,不会静默创建链接。
  2. 点击Enable calendar subscription。TREK 会生成一个随机令牌并显示订阅 URL。从源码看,令牌由 Node 的crypto.randomUUID()生成(见 feeds.service.ts),写入trips.feed_tokenusers.feed_token字段;生成接口是幂等的——如果该行程/用户已有令牌,再次 POST 会直接返回原 URL,而不是生成新令牌(e2e 测试验证了这一点,见 feeds.e2e.test.ts)。
  3. 将 URL 交给你的日历应用,有三种方式(前端实现见 SubscribeLinks.tsx):
    • Add to Google Calendar—— 打开 Google 的"按 URL 添加"页面,订阅地址已预填。其深层链接格式为https://www.google.com/calendar/render?cid={URL 编码的 webcal:// 地址}
    • Add to Apple Calendar / Outlook—— 一个webcal://链接,由操作系统转交给默认日历应用。
    • Or copy a link manually—— 展开后可复制原始https://…URL(用于日历的"From URL"输入框)或webcal://变体。

APP_URL 对订阅 URL 的影响

订阅 URL 的生成规则在 feeds.controller.ts 的resolveFeedBase中实现:当设置了APP_URL环境变量时,优先使用它作为 URL 的基址;未设置时(例如普通docker run部署的默认情形),回退到当前请求的Host头与协议来拼接。因此,在反向代理后面部署时,务必设置APP_URL,让订阅链接指向你的日历应用实际能够访问到的地址——详见 Environment-Variables 与 Reverse-Proxy。

此外,前端在拿到服务端返回的 URL 后还会做一次absolutize兜底(见 IcsSubscribeModal.tsx):如果服务端因未配置APP_URL而返回了以/开头的相对路径,则拼接当前页面的 origin,保证webcal://交接与 Google 深层链接始终拿到绝对地址。webcal://变体则通过把https://前缀替换为webcal://得到(同文件 IcsSubscribeModal.tsx)。

令牌即凭证:谁能读取订阅源

订阅 URL 中的随机令牌本身就是访问凭证。订阅源端点不需要任何登录:任何拿到链接的人都可以读取整个行程——包括每个事件、笔记、地址和确认信息——无需注册账户。订阅对话框对此有明确提示:Creates a secret link anyone with it can read without logging in. You can turn it off anytime.("创建一个秘密链接,任何持有它的人都无需登录即可读取。你可以随时关闭它。")

请像对待密码一样对待这个 URL,不要把它贴在共享文档或公开的 issue 里。

从后端实现看,两个公开端点(/api/feed/trip/:token.ics/api/feed/user/:token.ics)位于 feeds.controller.ts,控制器上没有挂任何认证守卫;服务端通过SELECT … WHERE feed_token = ?反查令牌对应的行程/用户,查不到就返回 404{ error: 'Feed not found' }。这种"令牌即密码"的设计意味着:启用订阅本质上等于把该行程的内容共享给任何持有链接的人,这与公享链接(Public Share Links)的取舍一致(见 Public-Share-Links)。

轮换与撤销

订阅启用后,对话框订阅按钮下方会出现两个操作按钮(见 IcsSubscribeModal.tsx):

  • Regenerate(轮换)—— 签发一个新令牌(PUT 请求)。旧 URL 立即失效,因此所有仍订阅旧链接的日历都会"断源",需要重新添加。
  • Turn off(关闭)—— 彻底清空令牌(DELETE 请求)。原 URL 返回 404,在重新启用之前不存在任何订阅源。

链接泄露时使用 Regenerate;不再需要公开订阅源时使用 Turn off。服务端实现分别对应rotateTripToken/disableTripToken/rotateUserToken/disableUserToken(见 feeds.service.ts),轮换总是无条件签发新令牌(旧令牌不再被任何查询命中,自然失效),关闭则把令牌置为NULL。e2e 测试完整覆盖了这三个生命周期动作(见 feeds.e2e.test.ts):轮换后旧令牌 404、新令牌 200;关闭后令牌被清空、URL 404、GET 重新返回feed_url: null

订阅源中会出现哪些内容

订阅源携带的事件与 ICS 导出完全一致(生成逻辑集中在 tripService.ts 的exportICS,订阅源与下载共用这一实现):

  • 行程本身—— 一个跨越行程开始与结束日期的全天事件,描述为行程描述。注意 DTEND 遵循 RFC 5545 的"排他"语义,取结束日期的后一天(见 tripService.ts)。
  • 定时日程(timed day assignments)—— 每个带时间的场所生成一个事件:场所名称为标题,地址作为 LOCATION,笔记放入 DESCRIPTION。时间锚定在场所自己的时区上(通过场所经纬度resolveTimeZone解析出 IANA 时区,并以DTSTART;TZID=…形式输出)。
  • 每日汇总事件—— 对于含有无时间场所或笔记的每一天,生成一个全天事件,标题为日期标题(无标题时用Day N),在描述中列出这些场所与笔记。
  • 预订(Reservations)—— 酒店、餐厅与交通。航班及其他交通的开始/结束时间取自出发与到达端点(reservation_endpoints),各自使用自己的时区;无法确定日期的预订会被跳过(见 tripService.ts 的buildReservationTimeLines)。

刷新提示与缓存头

订阅源响应会携带禁止客户端缓存的缓存头,以及每小时一次的刷新提示:

  • Cache-Control: no-cache, no-store
  • Content-Type: text/calendar; charset=utf-8
  • X-PUBLISHED-TTL: PT1H(以及日历正文内的REFRESH-INTERVAL;VALUE=DURATION:PT1H

这些头部在 feeds.controller.ts 设置;REFRESH-INTERVAL/X-PUBLISHED-TTL这两个属性则由 feeds.service.ts 在exportICS输出的METHOD:PUBLISH之后注入——只注入到订阅源路径,不影响一次性下载路径。e2e 测试验证了响应确实包含这两个刷新提示(见 feeds.e2e.test.ts)。

需要说明的是:大多数日历应用只把刷新周期当作建议——尤其是 Google Calendar 会按自己的节奏刷新,通常远慢于每小时——所以一次编辑可能要过一段时间才会在日历端显现,这是日历应用侧的行为,并非 TREK 可以完全控制。

权限模型

没有专门的权限开关来控制订阅源。你可以管理某行程的令牌,前提是你拥有该行程或它是其中成员;其他人调用令牌接口只会得到Trip not found(404)。全部行程源则始终限定在你自己的账户范围内。这些令牌接口(/api/trips/:tripId/feed/token/api/feed/user/token的 GET/POST/PUT/DELETE)都挂在JwtAuthGuard之下,服务端通过user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?)校验访问权(见 feeds.controller.ts)。e2e 测试同样验证了:未登录访问令牌接口返回 401、无权用户生成令牌返回 404(见 feeds.e2e.test.ts)。

时区处理与合并的底层细节

由于订阅源要跨多时区、多行程地合并事件,TREK 在 ICS 生成上做了几层关键处理,值得展开:

  1. 显式时区而非浮点时间:定时事件必须携带明确的 IANA 时区;裸的YYYYMMDDTHHMMSS是 RFC 5545 的"浮动时间"(floating time),会被客户端按订阅者自己的时区渲染,这正是项目 issue #1453 修复的核心问题。实现上,dtLine会在时区合法且值符合格式时输出DTSTART;TZID={zone}:…(见 tripService.ts)。
  2. 时区合法性校验:存储或插件提供的时区是自由字符串,未必是真实 IANA 时区,Intl.DateTimeFormat遇到未知时区会抛RangeError导致整个导出崩溃。因此isValidTimeZoneIntl.DateTimeFormat探测一次,并带一个上限 1000 条目的缓存,非法时区退化为浮动本地时间(见 tripService.ts)。
  3. VTIMEZONE 去重合并:全部行程源把每个行程的 VTIMEZONE 块按 TZID 去重后统一放在合并日历的头部、所有 VEVENT 之前,这样各行程事件引用的 TZID 在extractVEvents剥离后仍能解析(见 feeds.service.ts)。提取 VEVENT/VTIMEZONE 采用结构化行扫描而非正则,因为用户内容(被转义进 SUMMARY/DESCRIPTION 行)可能合法地包含字面量END:VEVENT,非贪婪正则会把截断误判为事件终止(见 feeds.service.ts)。
  4. RFC 5545 行折叠:超过 75 字节的内容行必须用 CRLF + 单个前导空格折叠;foldICS在 UTF-8字节边界上折叠,绝不切断多字节字符,因此带重音、CJK、emoji 的标题/笔记都能保持完整(见 tripService.ts)。
  5. 日历名称转义:合并日历的X-WR-CALNAME:{username} – All Trips及所有文本字段都经过esc转义(反斜杠、分号、逗号、换行),保证 ICS 结构完整性(见 feeds.service.ts)。

最佳实践小结

  • 需要"跟随编辑持续更新"的日历同步,使用订阅源;需要"不可变的冻结快照",使用 Download ICS 导出。
  • 在反向代理之后部署时,设置APP_URL,确保订阅 URL 是外部可达的绝对地址。
  • 把订阅 URL 当密码保管;怀疑泄露时立刻Regenerate,不再需要公开访问时Turn off
  • 日历端(尤其 Google Calendar)刷新频率由客户端决定,编辑后请耐心等待,必要时可在日历应用中手动触发刷新。

延伸阅读

  • Day-Plans-and-Notes —— ICS 一次性导出与每日计划/笔记
  • Trip-Planner-Overview —— 行程规划器与 Day Plan 侧边栏
  • Reservations-and-Bookings —— 预订(酒店/餐厅/交通)在日历中的呈现
  • Public-Share-Links —— 与订阅源类似的"令牌即访问"共享模型
  • My-Trips-Dashboard —— 全部行程订阅入口
  • Environment-Variables ——APP_URL等部署相关环境变量
  • Reverse-Proxy —— 反向代理部署下的外部 URL 配置

源码参考:feeds.controller.ts(公开端点与令牌管理 API)、feeds.service.ts(令牌生命周期与 ICS 合并)、tripService.ts(exportICS事件生成)、IcsSubscribeModal.tsx 与 SubscribeLinks.tsx(订阅对话框前端)、feeds.e2e.test.ts(端到端行为验证)。

【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询