Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/caldav-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@inkcre/ext-caldav': minor
---

新增 CalDAV Web 分发,提供目标 Peer 连接向导,以及日历、任务、联系人和集合的内容预览与阅读;从共享语义图组合 DAV 内容,并可打开独立电话、媒体及关联任务。
5 changes: 5 additions & 0 deletions .github/preview/extensions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"schema_version": 1,
"distributions": [
{
"kind": "module_federation",
"producer": "../../extensions/caldav/package.json",
"artifact": "../../extensions/caldav/dist/client-web"
},
{
"kind": "module_federation",
"producer": "../../extensions/twitter/package.json",
Expand Down
20 changes: 19 additions & 1 deletion docs/30-unit-tdd/native-extension-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,25 @@ Host resolves its own native format. A missing release requires an explicit repl
Removing a participation requires it to be disabled and does not remove the plugin's shared identity,
configuration, authorization state, Sources or info-base content. Another Peer's selection is unchanged.

## Documentation and Memos Connection
## Documentation and Connection Setup

`inkcre/caldav` 的 Web setup contribution 按选定 Python Peer 的实际运行状态准备连接。
没有选择时才从该 Peer 自己的 Registry 选择发行版;安装返回重启状态后停止后续写入,
要求刷新观察原目标。已有密码保持不变,缺失时才使用浏览器密码学随机数生成专用密码。
首次发现发行版只接受目标 Peer override 或共享部署 Registry。Python override 可以是
`null`,不能按 Web Peer 的完整配置 schema 校验;两处均未配置时要求明确设置目标地址,
因为浏览器无法读取远程进程的环境默认,不能用自己的产品默认替代。下载期间阻止再次
安装;`failed` 优先于待重启标志,恢复失败的 Peer 后才能继续。
完成条件包括 `running`、与 selection 一致的 `loaded_version`,以及
`caldav.connection.v1` 返回合法服务地址。启用意图不能替代运行事实。未知写入结果锁定
原目标直到刷新成功,不重放请求;关闭后的回调不能更新向导状态。

CalDAV 的三个 resolver 注册 `extensions.caldav.collection.v1`、
`extensions.caldav.calendar.v1` 和 `extensions.caldav.contact.v1`。ical.js 解析原生
iCalendar/vCard 为只读内存投影;calendar 同时承载 VEVENT 与 VTODO。内容视图不展开
重复规则、不隐式转换源时区,也不把展示字段写回另一份模型。集合成员只从 outgoing
`member` Relations 读取。嵌入式照片仅渲染常见栅格类型,远程照片保留显式链接。
解析失败保留原文;dispose 只注销当前模块自己注册的 resolver 类。

Extension cards discover documentation independently of executable preflight or enablement.
They resolve the existing Registry origin and read documentation for the exact installed name and
Expand Down
2 changes: 1 addition & 1 deletion docs/40-deployment/web-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ open, same-repository pull requests targeting `main`; fork pull requests are ine
exact candidate head, installs the candidate's frozen pnpm and PDM environments, builds its SPA and
selected Module Federation snapshots, and uses `inkcre-ext preview build` with the explicit
`.github/preview/extensions.json` inventory to add a same-origin static Registry facade. The
current inventory includes the Twitter and Mail Module Federation distributions. Before
current inventory includes the CalDAV, GitHub, Twitter and Mail Module Federation distributions. Before
deployment it revalidates that the PR is still open and its identity and head SHA have not changed.

The Toolkit places read-only native Extension snapshots and preview Releases beside the exact-head
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
100 changes: 100 additions & 0 deletions extensions/caldav/docs/module-federation/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# CalDAV Web 扩展

在当前浏览器安装并启用 `inkcre/caldav` 的 Module Federation 分发,然后在扩展卡片中
点击「设置」打开连接向导。Web 分发提供连接准备和内容阅读;持续接收手机同步的能力由运行 Python
分发的 Peer 提供。两种分发共享同一份扩展配置与信息。

## 准备手机连接

向导列出能够管理 Python 扩展的在线 Peer。选中提供服务的 Peer,点击「准备连接」。
如果该 Peer 尚未选择 CalDAV,向导从它自己的 Registry 选择可用发行版;已有版本保持不变。
首次安装需要该 Peer 的 Registry 地址或共享部署 Registry 配置。若地址仅保存在 Python
进程环境中,Web 无法读取它;按向导提示在「Peer → 编辑配置」中明确填写一次。
首次安装可能重启 Peer,看到安装或重启提示后点击「刷新状态」,待操作结束再继续准备连接。
打开向导、切换 Peer 和刷新只读取状态,不会生成密码或启用服务。
下载、安装和重启期间不能重复准备连接;如果安装失败,请先在扩展卡片中检查并恢复
对应 Peer,再刷新向导。失败时的待重启标志不代表 Peer 会自动恢复。

已有专用密码会继续使用;尚未配置时才生成随机密码并保存。连接准备完成要求所选 Peer
实际运行了选定版本,并成功返回连接地址。保存成功但响应丢失时,向导要求先刷新确认结果,
不会自动重复写入或换一个 Peer 重试。

在 DAVx⁵ 中选择「使用 URL 和用户名登录」,填写向导显示的完整服务地址、`inkcre`
用户名和 DAV 专用密码。密码默认遮蔽,可以主动显示或复制。随后在 CalDAV 页选择日历和
任务,在 CardDAV 页选择联系人;任务同步还需要安装 Tasks.org 并授予 DAVx⁵ 任务权限。
手机必须能够访问服务地址;向导读到地址不代表已验证手机网络。

![连接向导:选择提供服务的 Peer,复制地址与遮蔽的专用密码](images/web-setup.png)

窄屏可上下滚动查看同一向导:[390px 窄屏截图](images/web-setup-mobile.png)。

如果服务已运行但无法提供连接地址,在「Peer → 编辑配置」设置该 Peer 的公共 HTTP
基址,再刷新向导。手机端后续操作见
[DAVx⁵、Tasks.org 和联系人教程](https://github.com/InKCre/impl-py/blob/main/extensions/caldav/docs/global/android.md)。

所有 DAV 客户端共享密码和集合。准备连接不会轮换已有密码;如需撤销或轮换,使用所选
Python 分发的配置表单修改 `password`,并更新仍需同步的客户端。

## 阅读同步内容

信息库的预览用于辨认对象,打开内容后可以阅读完整事件、任务、联系人和集合成员。
图中的集合和联系人卡片会显示名称、类型与摘要,成员关系仍来自同一信息库。

![信息图中的集合和联系人预览](images/web-preview.png)

日历资源中的主事件与每个重复例外分别展示;没有把重复系列展开成未来日程表。
日期类型保持全天语义,全天结束日期标明不包含该日。时间显示保存的墙上时间与原始
TZID/UTC,未指定时区的时间会明确标注,不按浏览器时区隐式换算。
排除日期与补充日期保留原生属性中的时区和日期类型参数。

![主事件与手机离线修改产生的重复例外](images/web-calendar.png)

任务显示完成状态、到期、提醒与关联任务 UID;解析到同集合的父任务后,可以从「关联内容」直接打开父任务。联系人显示多个电话、邮箱、地址、备注
和嵌入式照片;外部照片地址需要主动打开,不在阅读时自动访问第三方。「DAV 内容」由当前根和关联内容组合,包含未单独展示的扩展字段;无法解析的记录会显示错误。

「关联内容」提供电话、邮箱、备注、媒体和关联任务的入口。点击照片可用通用图片视图打开独立图片 Block;这些内容可以在信息库其他位置引用,修改后会进入下一次手机同步。旧格式记录仍可阅读,运行 Python 0.2.0 后会原地升级并保留对象 ID。

打开集合后,点击成员名称进入对应内容。以下任务集合与父子关系来自 Tasks.org 的真实同步。

![任务集合与成员入口](images/web-collection.png)

![子任务的状态、优先级与关联任务 UID](images/web-task.png)

![联系人照片、两个电话号码与备注](images/web-contact.png)

窄屏也可以阅读联系人和全天事件。全天结束日期 `2026-10-21` 表示此事件只覆盖
`2026-10-20`,没有延长到第二天。

| 联系人 | 全天事件 |
| -------------------------------------------- | ---------------------------------------------- |
| ![窄屏联系人](images/web-contact-mobile.png) | ![窄屏全天事件](images/web-all-day-mobile.png) |

## 打开和复用关联内容

联系人下方的「关联内容」可以打开电话、备注和照片等独立 Block。以下号码和绿色头像来自另一 Peer 对共享内容的修改;刷新联系人后即可看到变化,DAVx⁵ 同步后手机也显示相同结果。

![联系人显示共享内容及独立入口](images/web-shared-contact.png)

点击「照片」进入宿主的通用图片视图;点击「电话」进入通用文本视图。它们拥有自己的 Block ID,可以被信息库其他对象引用。手机随后更换或重新编码照片时,联系人会关联新图片,已有引用仍指向原图片。

| 独立图片 | 独立电话 |
| -------------------------------------------- | -------------------------------------------------------- |
| ![通用图片视图](images/web-shared-image.png) | ![通用文本视图显示电话号码](images/web-shared-phone.png) |

[390px 联系人关联内容视图](images/web-shared-contact-mobile.png)保留相同入口。任务中的「关联对象」可打开对应父任务;原始 RELATED-TO 参数和 UID 仍保存在图中。

![子任务可直接打开父任务](images/web-shared-task.png)

手机或 DAV 客户端删除对象时,只撤销它的 DAV 发布;信息库中的原对象和已有引用继续存在。下图是 Tasks.org 已删除并完成同步的测试任务,它仍可通过原 Block 地址读取,正文入口也仍然有效。这不是擦除信息库数据的操作。

![已撤销 DAV 发布的任务仍能通过原引用阅读](images/web-retained-task.png)

这些视图只读取共享 Blocks 与成员 Relations,不写回简化模型。手机同步更新后,使用宿主
的信息库刷新操作取得新内容。停用 Web 分发会卸载对应渲染能力,不会删除 DAV 内容或
停止其他 Peer 上运行的 Python 分发。

本文截图使用 Web PR #142 与 Python PR #146 的 preview 和专用测试数据。浏览器通过本机
限定 preview 域名的 CONNECT 转发访问服务,HTTPS 证书校验保持启用;这一网络条件不属于
插件部署要求。手机同步沿用 Python 教程中记录的 USB 转发条件。

开发排查脚本保留在扩展的 `scripts/tests/`,可按需运行 `pnpm --filter @inkcre/ext-caldav verify`。这些脚本不纳入默认测试或 CI 门槛,不能替代上述 preview 与真机验收。
12 changes: 12 additions & 0 deletions extensions/caldav/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CalDAV Extension</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
48 changes: 48 additions & 0 deletions extensions/caldav/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@inkcre/ext-caldav",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"verify": "node --experimental-strip-types --test scripts/tests/*.test.ts"
},
"dependencies": {
"@inkcre/core": "workspace:*",
"@inkcre/extension-runtime-client-web": "https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.4.1/inkcre-extension-runtime-client-web-0.4.1.tgz",
"ical.js": "2.2.1"
},
"devDependencies": {
"@inkcre/ext-dev-utils": "workspace:*",
"@inkcre/ui-web": "2.3.0",
"@module-federation/vite": "^1.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "catalog:",
"vite": "^7.0.6",
"vue-tsc": "catalog:"
},
"peerDependencies": {
"@vueuse/core": "^14.0.0",
"pinia": "^3.0.0",
"vue": "^3.5.25",
"vue-router": "^4.5.0",
"zod": "^4.1.0"
},
"inkcre": {
"name": "inkcre/caldav",
"nickname": "CalDAV",
"module_federation": {
"host_sdk": "@inkcre/core",
"host_sdk_version": ">=0.4.0 <0.5.0"
}
},
"intent": {
"skills": [
"@inkcre/ui-web"
]
}
}
106 changes: 106 additions & 0 deletions extensions/caldav/scripts/tests/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { parseCollection, parseResource, safeHttpUrl } from '../../src/content.ts'

const resource = (text: string) => JSON.stringify({ href: 'test.ics', text })
const fixture = (name: string) =>
readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')

test('the phone calendar retains the master, offline exception and all-day date boundaries', () => {
const result = parseResource(resource(fixture('calendar.ics')), 'calendar')
assert.equal(result.kind, 'calendar')
if (result.kind !== 'calendar') return
assert.equal(result.items.length, 3)
const master = result.items.find((item) => item.title === 'InKCre Preview Event')!
assert.ok(master.recurrence.some((rule) => rule.includes('COUNT=3')))
assert.ok(master.alarms.some((alarm) => alarm.includes('-PT15M')))
const exception = result.items.find((item) => item.title === 'InKCre Preview Offline Edit')!
assert.ok(exception.recurrenceId)
assert.equal(exception.uid, master.uid)
const allDay = result.items.find((item) => item.title === 'InKCre Preview All Day')!
assert.deepEqual(allDay.start, { value: '2026-10-20', zone: '', dateOnly: true })
assert.deepEqual(allDay.end, { value: '2026-10-21', zone: '', dateOnly: true })
})

test('Tasks.org parent references and completion state remain readable', () => {
const result = parseResource(resource(fixture('tasks.ics')), 'calendar')
assert.equal(result.kind, 'calendar')
if (result.kind !== 'calendar') return
const parent = result.items.find((item) => item.title === 'InKCre Preview Parent')!
const child = result.items.find((item) => item.title === 'InKCre Preview Child')!
assert.equal(child.kind, 'vtodo')
assert.deepEqual(child.parents, [parent.uid])
assert.equal(child.status, 'NEEDS-ACTION')
})

test('a real Android vCard retains its two phones and embedded raster photo', () => {
const result = parseResource(resource(fixture('contact.vcf')), 'contact')
assert.equal(result.kind, 'contact')
if (result.kind !== 'contact') return
assert.equal(result.title, 'InKCre Preview Photo Contact')
assert.equal(result.fields.find((field) => field.name === 'tel')?.values.length, 2)
assert.match(result.photo ?? '', /^data:image\/jpeg;base64,/)
assert.equal(result.photoLink, null)
})

test('floating and unknown TZID wall times are not converted; folded text and recurrence dates survive', () => {
const text = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
'UID:folded',
'SUMMARY:Folded\\, title',
' continuation',
'DTSTART;TZID=Custom/Zone:20261020T103000',
'DTEND:20261020T113000',
'EXDATE;VALUE=DATE:20261021',
'RDATE;TZID=America/New_York:20261022T103000',
'DESCRIPTION:<script>alert(1)</script>\\nSecond line',
'X-EXTRA:preserved',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
const result = parseResource(resource(text), 'calendar')
assert.equal(result.kind, 'calendar')
if (result.kind !== 'calendar') return
const item = result.items[0]!
assert.equal(item.title, 'Folded, titlecontinuation')
assert.deepEqual(item.start, {
value: '2026-10-20 10:30:00',
zone: 'Custom/Zone',
dateOnly: false,
})
assert.equal(item.end?.zone, 'floating')
assert.deepEqual(item.excluded, ['EXDATE;VALUE=DATE:20261021'])
assert.deepEqual(item.added, ['RDATE;TZID=America/New_York:20261022T103000'])
assert.equal(item.description, '<script>alert(1)</script>\nSecond line')
assert.equal(result.text, text)
})

test('unsafe photos and links cannot become active content; remote photos remain explicit links', () => {
assert.equal(safeHttpUrl('javascript:alert(1)'), null)
assert.equal(safeHttpUrl('https://user:secret@example.com/'), null)
for (const [photo, embedded, link] of [
['data:image/svg+xml;base64,PHN2Zz4=', null, null],
['https://example.com/photo.png', null, 'https://example.com/photo.png'],
['data:image/png;base64,aGVsbG8=', 'data:image/png;base64,aGVsbG8=', null],
]) {
const result = parseResource(
resource(`BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Test\r\nPHOTO:${photo}\r\nEND:VCARD`),
'contact'
)
assert.equal(result.kind, 'contact')
if (result.kind !== 'contact') continue
assert.equal(result.photo, embedded)
assert.equal(result.photoLink, link)
}
})

test('malformed records preserve original content and the principal is not mislabeled as a calendar', () => {
const invalid = parseResource(resource('not a calendar'), 'calendar')
assert.deepEqual(invalid, { kind: 'invalid', text: 'not a calendar' })
const principal = parseCollection(JSON.stringify({ path: 'inkcre', props: {} }))
assert.equal(principal.kind, 'collection')
if (principal.kind === 'collection') assert.equal(principal.tag, '')
})
50 changes: 50 additions & 0 deletions extensions/caldav/scripts/tests/fixtures/calendar.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//PYVOBJECT//NONSGML Version 0.9.9//EN
X-WR-CALNAME;VALUE=TEXT:InKCre 日历
BEGIN:VEVENT
UID:7a13ee9f-0173-4e77-9ee5-fa8fbc6995a7
DTSTART;VALUE=DATE:20261020
DTEND;VALUE=DATE:20261021
CATEGORIES:Come From :ws.xsoh.etar
DTSTAMP:20261020T132049Z
STATUS:CONFIRMED
SUMMARY:InKCre Preview All Day
TRANSP:TRANSPARENT
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:InKCre Preview All Day
TRIGGER:-PT10M
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:inkcre-preview146-event
DTSTART:20261021T020000Z
DTEND:20261021T030000Z
DESCRIPTION:PR 146 HTTPS acceptance test
DTSTAMP:20261020T130929Z
RRULE:FREQ=DAILY;COUNT=3
SUMMARY:InKCre Preview Event
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:InKCre Preview Event
TRIGGER:-PT15M
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:inkcre-preview146-event
RECURRENCE-ID:20261021T020000Z
DTSTART:20261021T020000Z
DTEND:20261021T030000Z
DESCRIPTION:PR 146 HTTPS acceptance test
DTSTAMP:20261020T130929Z
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:InKCre Preview Offline Edit
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:InKCre Preview Offline Edit
TRIGGER:-PT15M
END:VALARM
END:VEVENT
END:VCALENDAR
14 changes: 14 additions & 0 deletions extensions/caldav/scripts/tests/fixtures/contact.vcf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
BEGIN:VCARD
VERSION:3.0
PRODID:+//IDN bitfire.at//DAVx5/4.5.14-ose ez-vcard/0.12.2 (ez-vcard/0.12.2
)
UID:inkcre-preview146-contact
EMAIL:preview146@example.invalid
FN:InKCre Preview Photo Contact
N:;InKCre Preview Photo Contact;;;
NOTE:PR 146 HTTPS acceptance test
PHOTO;ENCODING=b;TYPE=jpeg:/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAACAAMDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgf/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCcgKKCP//Z
REV:2026-10-20T13:14:58Z
TEL;TYPE=cell:+8613800000000
TEL;TYPE=work:+8613900000000
END:VCARD
Loading
Loading