diff --git a/.changeset/caldav-web.md b/.changeset/caldav-web.md new file mode 100644 index 00000000..b4f5a879 --- /dev/null +++ b/.changeset/caldav-web.md @@ -0,0 +1,5 @@ +--- +'@inkcre/ext-caldav': minor +--- + +新增 CalDAV Web 分发,提供目标 Peer 连接向导,以及日历、任务、联系人和集合的内容预览与阅读;从共享语义图组合 DAV 内容,并可打开独立电话、媒体及关联任务。 diff --git a/.github/preview/extensions.json b/.github/preview/extensions.json index 64a1379f..8ff23808 100644 --- a/.github/preview/extensions.json +++ b/.github/preview/extensions.json @@ -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", diff --git a/docs/30-unit-tdd/native-extension-runtime.md b/docs/30-unit-tdd/native-extension-runtime.md index d1018b0c..20aa12eb 100644 --- a/docs/30-unit-tdd/native-extension-runtime.md +++ b/docs/30-unit-tdd/native-extension-runtime.md @@ -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 diff --git a/docs/40-deployment/web-delivery.md b/docs/40-deployment/web-delivery.md index 86b8314f..17f46d22 100644 --- a/docs/40-deployment/web-delivery.md +++ b/docs/40-deployment/web-delivery.md @@ -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 diff --git a/extensions/caldav/docs/module-federation/images/web-all-day-mobile.png b/extensions/caldav/docs/module-federation/images/web-all-day-mobile.png new file mode 100644 index 00000000..9f72cee8 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-all-day-mobile.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-calendar.png b/extensions/caldav/docs/module-federation/images/web-calendar.png new file mode 100644 index 00000000..19212c53 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-calendar.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-collection.png b/extensions/caldav/docs/module-federation/images/web-collection.png new file mode 100644 index 00000000..93fada90 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-collection.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-contact-mobile.png b/extensions/caldav/docs/module-federation/images/web-contact-mobile.png new file mode 100644 index 00000000..c0d7ad45 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-contact-mobile.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-contact.png b/extensions/caldav/docs/module-federation/images/web-contact.png new file mode 100644 index 00000000..5c261535 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-contact.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-preview.png b/extensions/caldav/docs/module-federation/images/web-preview.png new file mode 100644 index 00000000..58e35ffe Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-preview.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-retained-task.png b/extensions/caldav/docs/module-federation/images/web-retained-task.png new file mode 100644 index 00000000..5775799e Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-retained-task.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-setup-mobile.png b/extensions/caldav/docs/module-federation/images/web-setup-mobile.png new file mode 100644 index 00000000..51c4acda Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-setup-mobile.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-setup.png b/extensions/caldav/docs/module-federation/images/web-setup.png new file mode 100644 index 00000000..521d98a9 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-setup.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-shared-contact-mobile.png b/extensions/caldav/docs/module-federation/images/web-shared-contact-mobile.png new file mode 100644 index 00000000..13b52b3e Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-shared-contact-mobile.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-shared-contact.png b/extensions/caldav/docs/module-federation/images/web-shared-contact.png new file mode 100644 index 00000000..77054d2c Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-shared-contact.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-shared-image.png b/extensions/caldav/docs/module-federation/images/web-shared-image.png new file mode 100644 index 00000000..3368993f Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-shared-image.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-shared-phone.png b/extensions/caldav/docs/module-federation/images/web-shared-phone.png new file mode 100644 index 00000000..1b944c74 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-shared-phone.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-shared-task.png b/extensions/caldav/docs/module-federation/images/web-shared-task.png new file mode 100644 index 00000000..2577d5e5 Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-shared-task.png differ diff --git a/extensions/caldav/docs/module-federation/images/web-task.png b/extensions/caldav/docs/module-federation/images/web-task.png new file mode 100644 index 00000000..dff6f2ad Binary files /dev/null and b/extensions/caldav/docs/module-federation/images/web-task.png differ diff --git a/extensions/caldav/docs/module-federation/index.md b/extensions/caldav/docs/module-federation/index.md new file mode 100644 index 00000000..22691d78 --- /dev/null +++ b/extensions/caldav/docs/module-federation/index.md @@ -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 与真机验收。 diff --git a/extensions/caldav/index.html b/extensions/caldav/index.html new file mode 100644 index 00000000..d7f3b001 --- /dev/null +++ b/extensions/caldav/index.html @@ -0,0 +1,12 @@ + + + + + + CalDAV Extension + + +
+ + + diff --git a/extensions/caldav/package.json b/extensions/caldav/package.json new file mode 100644 index 00000000..04fb4cd9 --- /dev/null +++ b/extensions/caldav/package.json @@ -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" + ] + } +} diff --git a/extensions/caldav/scripts/tests/content.test.ts b/extensions/caldav/scripts/tests/content.test.ts new file mode 100644 index 00000000..858de597 --- /dev/null +++ b/extensions/caldav/scripts/tests/content.test.ts @@ -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:\\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, '\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, '') +}) diff --git a/extensions/caldav/scripts/tests/fixtures/calendar.ics b/extensions/caldav/scripts/tests/fixtures/calendar.ics new file mode 100644 index 00000000..9d139f59 --- /dev/null +++ b/extensions/caldav/scripts/tests/fixtures/calendar.ics @@ -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 diff --git a/extensions/caldav/scripts/tests/fixtures/contact.vcf b/extensions/caldav/scripts/tests/fixtures/contact.vcf new file mode 100644 index 00000000..6eecef20 --- /dev/null +++ b/extensions/caldav/scripts/tests/fixtures/contact.vcf @@ -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 diff --git a/extensions/caldav/scripts/tests/fixtures/semantic-graph.json b/extensions/caldav/scripts/tests/fixtures/semantic-graph.json new file mode 100644 index 00000000..1d73b747 --- /dev/null +++ b/extensions/caldav/scripts/tests/fixtures/semantic-graph.json @@ -0,0 +1,168 @@ +[ + { + "kind": "contact", + "remainder": "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:+//IDN bitfire.at//DAVx5/4.5.14-ose ez-vcard/0.12.2 (ez-vcard/0.12.2\r\n )\r\nUID:inkcre-preview146-contact\r\nFN:InKCre Preview Photo Contact\r\nN:;InKCre Preview Photo Contact;;;\r\nREV:2026-10-20T13:14:58Z\r\nEND:VCARD\r\n", + "text": "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:+//IDN bitfire.at//DAVx5/4.5.14-ose ez-vcard/0.12.2 (ez-vcard/0.12.2\r\n )\r\nUID:inkcre-preview146-contact\r\nEMAIL:preview146@example.invalid\r\nFN:InKCre Preview Photo Contact\r\nN:;InKCre Preview Photo Contact;;;\r\nNOTE:PR 146 HTTPS acceptance test\r\nPHOTO;ENCODING=b;TYPE=jpeg:/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAACAAMDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgf/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCcgKKCP//Z\r\nREV:2026-10-20T13:14:58Z\r\nTEL;TYPE=cell:+8613800000000\r\nTEL;TYPE=work:+8613900000000\r\nEND:VCARD\r\n", + "properties": [ + { + "id": 1, + "link": { + "component": null, + "alarm": null, + "name": "EMAIL", + "params": {}, + "group": null, + "position": 0, + "representation": "text", + "media_type": null + }, + "value": "preview146@example.invalid", + "binary": false + }, + { + "id": 2, + "link": { + "component": null, + "alarm": null, + "name": "NOTE", + "params": {}, + "group": null, + "position": 0, + "representation": "text", + "media_type": null + }, + "value": "PR 146 HTTPS acceptance test", + "binary": false + }, + { + "id": 3, + "link": { + "component": null, + "alarm": null, + "name": "PHOTO", + "params": { + "ENCODING": ["b"], + "TYPE": ["jpeg"] + }, + "group": null, + "position": 0, + "representation": "binary", + "media_type": "image/jpeg" + }, + "value": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAACAAMDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgf/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCcgKKCP//Z", + "binary": true + }, + { + "id": 4, + "link": { + "component": null, + "alarm": null, + "name": "TEL", + "params": { + "TYPE": ["cell"] + }, + "group": null, + "position": 0, + "representation": "text", + "media_type": null + }, + "value": "+8613800000000", + "binary": false + }, + { + "id": 5, + "link": { + "component": null, + "alarm": null, + "name": "TEL", + "params": { + "TYPE": ["work"] + }, + "group": null, + "position": 1, + "representation": "text", + "media_type": null + }, + "value": "+8613900000000", + "binary": false + } + ] + }, + { + "kind": "calendar", + "remainder": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//InKCre//graph fixture//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Asia/Shanghai\r\nBEGIN:STANDARD\r\nDTSTART:20000101T000000\r\nRRULE:FREQ=YEARLY;BYMONTH=1\r\nTZNAME:CST\r\nTZOFFSETFROM:+0800\r\nTZOFFSETTO:+0800\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:shared-event\r\nDTSTART;TZID=Asia/Shanghai:20261020T090000\r\nDTSTAMP:20261021T025758Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:共享日程\r\nBEGIN:VALARM\r\nACTION:AUDIO\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:shared-event\r\nRECURRENCE-ID;TZID=Asia/Shanghai:20261021T090000\r\nDTSTART;TZID=Asia/Shanghai:20261021T100000\r\nDTSTAMP:20261021T025758Z\r\nSUMMARY:单次例外\r\nX-UNKNOWN;X-KEEP=yes:保留\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", + "text": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//InKCre//graph fixture//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Asia/Shanghai\r\nBEGIN:STANDARD\r\nDTSTART:20000101T000000\r\nRRULE:FREQ=YEARLY;BYMONTH=1\r\nTZNAME:CST\r\nTZOFFSETFROM:+0800\r\nTZOFFSETTO:+0800\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:shared-event\r\nDTSTART;TZID=Asia/Shanghai:20261020T090000\r\nATTACH;FMTTYPE=application/pdf:https://example.invalid/plan.pdf\r\nDESCRIPTION:共同正文\r\nDTSTAMP:20261021T025758Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:共享日程\r\nBEGIN:VALARM\r\nACTION:AUDIO\r\nATTACH;ENCODING=BASE64;FMTTYPE=audio/wav;VALUE=BINARY:aGVsbG8=\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:shared-event\r\nRECURRENCE-ID;TZID=Asia/Shanghai:20261021T090000\r\nDTSTART;TZID=Asia/Shanghai:20261021T100000\r\nDESCRIPTION:例外正文\r\nDTSTAMP:20261021T025758Z\r\nSUMMARY:单次例外\r\nX-UNKNOWN;X-KEEP=yes:保留\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", + "properties": [ + { + "id": 1, + "link": { + "component": ["VEVENT", "shared-event", ""], + "alarm": null, + "name": "DESCRIPTION", + "params": {}, + "group": null, + "position": 0, + "representation": "text", + "media_type": null + }, + "value": "共同正文", + "binary": false + }, + { + "id": 2, + "link": { + "component": ["VEVENT", "shared-event", ""], + "alarm": null, + "name": "ATTACH", + "params": { + "FMTTYPE": ["application/pdf"] + }, + "group": null, + "position": 0, + "representation": "uri", + "media_type": "application/pdf" + }, + "value": "https://example.invalid/plan.pdf", + "binary": false + }, + { + "id": 3, + "link": { + "component": ["VEVENT", "shared-event", ""], + "alarm": 0, + "name": "ATTACH", + "params": { + "VALUE": ["BINARY"], + "ENCODING": ["BASE64"], + "FMTTYPE": ["audio/wav"] + }, + "group": null, + "position": 0, + "representation": "binary", + "media_type": "audio/wav" + }, + "value": "aGVsbG8=", + "binary": true + }, + { + "id": 4, + "link": { + "component": [ + "VEVENT", + "shared-event", + "RECURRENCE-ID;TZID=Asia/Shanghai:20261021T090000" + ], + "alarm": null, + "name": "DESCRIPTION", + "params": {}, + "group": null, + "position": 0, + "representation": "text", + "media_type": null + }, + "value": "例外正文", + "binary": false + } + ] + } +] diff --git a/extensions/caldav/scripts/tests/fixtures/tasks.ics b/extensions/caldav/scripts/tests/fixtures/tasks.ics new file mode 100644 index 00000000..13c971ba --- /dev/null +++ b/extensions/caldav/scripts/tests/fixtures/tasks.ics @@ -0,0 +1,26 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//PYVOBJECT//NONSGML Version 0.9.9//EN +X-WR-CALNAME;VALUE=TEXT:InKCre 任务 +BEGIN:VTODO +CREATED:20261020T131432Z +DTSTAMP:20261020T131458Z +LAST-MODIFIED:20261020T131432Z +PRIORITY:9 +STATUS:NEEDS-ACTION +SUMMARY:InKCre Preview Parent +UID:1653452019178147571 +X-APPLE-SORT-ORDER:814194394 +END:VTODO +BEGIN:VTODO +CREATED:20261020T131432Z +DTSTAMP:20261020T131458Z +LAST-MODIFIED:20261020T131432Z +PRIORITY:9 +RELATED-TO;RELTYPE=PARENT:1653452019178147571 +RELATED-TO:1653452019178147571 +STATUS:NEEDS-ACTION +SUMMARY:InKCre Preview Child +UID:1870710616971510927 +END:VTODO +END:VCALENDAR diff --git a/extensions/caldav/scripts/tests/graph-content.test.ts b/extensions/caldav/scripts/tests/graph-content.test.ts new file mode 100644 index 00000000..54fafc13 --- /dev/null +++ b/extensions/caldav/scripts/tests/graph-content.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import ICAL from 'ical.js' +import { + composeGraph, + propertyLinkSchema, + resourceUid, + type GraphProperty, +} from '../../src/graph-content.ts' +import { parseResource } from '../../src/content.ts' + +const samples = JSON.parse( + readFileSync(new URL('./fixtures/semantic-graph.json', import.meta.url), 'utf8') +) +function properties(sample: (typeof samples)[number]): GraphProperty[] { + return sample.properties.map((field: any) => ({ + id: field.id, + link: propertyLinkSchema.parse(field.link), + value: field.binary ? new Uint8Array(Buffer.from(field.value, 'base64')) : field.value, + })) +} + +test('Python-authored contact graph composes the same phones, photo and unknown properties', () => { + const sample = samples.find((item: any) => item.kind === 'contact') + const text = composeGraph(sample.remainder, properties(sample)) + const actual = parseResource(JSON.stringify({ href: 'contact.vcf', text }), 'contact') + const expected = parseResource( + JSON.stringify({ href: 'contact.vcf', text: sample.text }), + 'contact' + ) + assert.notEqual(actual.kind, 'invalid') + if (actual.kind !== 'contact' || expected.kind !== 'contact') return + assert.equal(actual.title, expected.title) + assert.deepEqual(actual.fields, expected.fields) + assert.equal(actual.photo, expected.photo) + assert.equal(resourceUid(text), resourceUid(sample.text)) + const changed = properties(sample) + changed.find((field) => field.link.name === 'TEL')!.value = '+8613800000001' + assert.match(composeGraph(sample.remainder, changed), /\+8613800000001/) +}) + +test('component identity retains TZID exceptions and audio-alarm attachments', () => { + const sample = samples.find((item: any) => item.kind === 'calendar') + const text = composeGraph(sample.remainder, properties(sample)) + const actual = parseResource(JSON.stringify({ href: 'event.ics', text }), 'calendar') + const expected = parseResource( + JSON.stringify({ href: 'event.ics', text: sample.text }), + 'calendar' + ) + assert.notEqual(actual.kind, 'invalid') + if (actual.kind !== 'calendar' || expected.kind !== 'calendar') return + assert.deepEqual(actual.items, expected.items) + const root = new ICAL.Component(ICAL.parse(text)) + const attachment = root + .getFirstSubcomponent('vevent')! + .getFirstSubcomponent('valarm')! + .getFirstProperty('attach')! + .getFirstValue() + assert.ok(attachment instanceof ICAL.Binary) + assert.equal(attachment.decodeValue(), 'hello') + assert.match(text, /X-UNKNOWN;X-KEEP=yes:保留/) +}) + +test('duplicate authority and missing components fail instead of showing stale native values', () => { + const sample = samples.find((item: any) => item.kind === 'contact') + assert.throws( + () => composeGraph(sample.text, properties(sample)), + /both native and graph authority/ + ) + const props = properties(sample) + props[0]!.link.component = ['VEVENT', 'missing', ''] + assert.throws(() => composeGraph(sample.remainder, props), /missing or ambiguous/) +}) diff --git a/extensions/caldav/scripts/tests/setup-state.test.ts b/extensions/caldav/scripts/tests/setup-state.test.ts new file mode 100644 index 00000000..8a474c4f --- /dev/null +++ b/extensions/caldav/scripts/tests/setup-state.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { ExtensionRuntimeStatus } from '@inkcre/core' +import { + generatePassword, + configuredRegistry, + RegistryConfigurationError, + runtimeFailed, + runtimeReady, + savedPassword, + waitingForRuntime, +} from '../../src/setup-state.ts' + +const running: ExtensionRuntimeStatus = { + name: 'inkcre/caldav', + peer_id: '454cda4b-5576-58e4-9d26-7a7c93cd06b6', + distribution: 'python', + version: '0.1.0', + enabled: true, + config_schema: null, + supported_distributions: ['python'], + loaded_version: '0.1.0', + running: true, + restart_required: false, + operation: null, + error: null, +} + +test('a Python nullable Registry override uses shared configuration without guessing a process default', () => { + const shared = { + schema: 'extension.registry.config.v1', + value: { extension_registry_url: 'https://deployment.example' }, + } + assert.equal(configuredRegistry(null, shared), 'https://deployment.example') + assert.equal(configuredRegistry(undefined, shared), 'https://deployment.example') + assert.equal(configuredRegistry('https://peer.example', shared), 'https://peer.example') + assert.throws(() => configuredRegistry(null, null), RegistryConfigurationError) + assert.throws( + () => configuredRegistry(null, { ...shared, value: { extension_registry_url: null } }), + RegistryConfigurationError + ) + assert.throws(() => configuredRegistry(123, shared), RegistryConfigurationError) +}) + +test('readiness requires the selected version to be actually running', () => { + assert.equal(runtimeReady(running), true) + for (const change of [ + { enabled: false }, + { running: false }, + { loaded_version: '0.0.1' }, + { restart_required: true }, + { operation: 'restarting' }, + { operation: 'downloading' }, + { operation: 'failed' }, + { error: 'failed' }, + ]) + assert.equal(runtimeReady({ ...running, ...change }), false) + assert.equal(runtimeReady(null), false) +}) + +test('a completed failure is not presented as installation still in progress', () => { + assert.equal(waitingForRuntime({ ...running, operation: 'failed', error: 'failed' }), false) + assert.equal(waitingForRuntime({ ...running, operation: 'applying' }), true) + const failed = { ...running, operation: 'failed', restart_required: true } + assert.equal(waitingForRuntime(failed), false) + assert.equal(runtimeFailed(failed), true) + assert.equal(runtimeReady(failed), false) + assert.equal(waitingForRuntime({ ...running, version: null, operation: 'downloading' }), true) +}) + +test('password preparation matches Python character limits and preserves existing values', () => { + const extension = { name: 'inkcre/caldav', nickname: 'CalDAV', config: {} } + assert.equal(savedPassword(extension), null) + const existing = '日'.repeat(32) + assert.equal(savedPassword({ ...extension, config: { password: existing } }), existing) + for (const password of ['a'.repeat(31), 'a'.repeat(129), 123]) + assert.throws(() => savedPassword({ ...extension, config: { password } })) + const generated = generatePassword() + assert.match(generated, /^[0-9a-f]{64}$/) + assert.notEqual(generated, generatePassword()) +}) diff --git a/extensions/caldav/src/CalDAVSetup.vue b/extensions/caldav/src/CalDAVSetup.vue new file mode 100644 index 00000000..359bf6e2 --- /dev/null +++ b/extensions/caldav/src/CalDAVSetup.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/extensions/caldav/src/DAVContent.vue b/extensions/caldav/src/DAVContent.vue new file mode 100644 index 00000000..be2a2631 --- /dev/null +++ b/extensions/caldav/src/DAVContent.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/extensions/caldav/src/DAVPreview.vue b/extensions/caldav/src/DAVPreview.vue new file mode 100644 index 00000000..1cdc37ba --- /dev/null +++ b/extensions/caldav/src/DAVPreview.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/extensions/caldav/src/DevApp.vue b/extensions/caldav/src/DevApp.vue new file mode 100644 index 00000000..b8262670 --- /dev/null +++ b/extensions/caldav/src/DevApp.vue @@ -0,0 +1,7 @@ + + + diff --git a/extensions/caldav/src/content.ts b/extensions/caldav/src/content.ts new file mode 100644 index 00000000..17338a21 --- /dev/null +++ b/extensions/caldav/src/content.ts @@ -0,0 +1,229 @@ +import ICAL from 'ical.js' +import { z } from 'zod' + +const resourceSchema = z.object({ href: z.string(), text: z.string() }) +const collectionSchema = z.object({ path: z.string(), props: z.record(z.string(), z.string()) }) + +export interface CalendarDate { + value: string + zone: string + dateOnly: boolean +} + +export interface CalendarItem { + kind: 'vevent' | 'vtodo' + uid: string + title: string + description: string + location: string + status: string + start: CalendarDate | null + end: CalendarDate | null + due: CalendarDate | null + completed: CalendarDate | null + recurrenceId: CalendarDate | null + recurrence: string[] + excluded: string[] + added: string[] + duration: string + alarms: string[] + parents: string[] + priority: string + progress: string + url: string | null +} + +export interface ContactField { + value: string + label: string +} + +export type DAVContent = ( + | { kind: 'calendar'; href: string; text: string; items: CalendarItem[] } + | { + kind: 'contact' + href: string + text: string + title: string + fields: { name: string; values: ContactField[] }[] + photo: string | null + photoLink: string | null + } + | { + kind: 'collection' + title: string + path: string + tag: string + components: string + description: string + text: string + members: { id: number; title: string }[] + } + | { kind: 'invalid'; text: string } +) & { links?: { id: number; name: string; label: string; value: string }[] } + +export function safeHttpUrl(value: string): string | null { + try { + const url = new URL(value) + return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password + ? url.href + : null + } catch { + return null + } +} + +function valueText(value: unknown): string { + if (value == null) return '' + if (Array.isArray(value)) return value.map(valueText).filter(Boolean).join(' · ') + return String(value) +} + +function propertyText(component: ICAL.Component, name: string): string { + return valueText(component.getFirstPropertyValue(name)) +} + +function date(component: ICAL.Component, name: string): CalendarDate | null { + const property = component.getFirstProperty(name) + if (!property) return null + const value = property.getFirstValue() + if (!(value instanceof ICAL.Time)) throw new Error('Expected an iCalendar date.') + // Keep wall time and TZID together; unknown/custom zones must not silently become local time. + return { + value: value.toString().replace('T', ' ').replace(/Z$/, ''), + zone: value.isDate + ? '' + : valueText(property.getParameter('tzid')) || + (value.zone.tzid === 'UTC' ? 'UTC' : 'floating'), + dateOnly: value.isDate, + } +} + +function values(component: ICAL.Component, name: string): string[] { + return component.getAllProperties(name).flatMap((property) => property.getValues().map(valueText)) +} + +function calendarItem(component: ICAL.Component): CalendarItem { + return { + kind: component.name as CalendarItem['kind'], + uid: propertyText(component, 'uid'), + title: propertyText(component, 'summary'), + description: propertyText(component, 'description'), + location: propertyText(component, 'location'), + status: propertyText(component, 'status'), + start: date(component, 'dtstart'), + end: date(component, 'dtend'), + due: date(component, 'due'), + completed: date(component, 'completed'), + recurrenceId: date(component, 'recurrence-id'), + recurrence: values(component, 'rrule'), + excluded: component.getAllProperties('exdate').map((property) => property.toICALString()), + added: component.getAllProperties('rdate').map((property) => property.toICALString()), + duration: propertyText(component, 'duration'), + alarms: component.getAllSubcomponents('valarm').map((alarm) => + ['trigger', 'action', 'description'] + .map((name) => propertyText(alarm, name)) + .filter(Boolean) + .join(' · ') + ), + parents: [...new Set(values(component, 'related-to'))], + priority: propertyText(component, 'priority'), + progress: propertyText(component, 'percent-complete'), + url: safeHttpUrl(propertyText(component, 'url')), + } +} + +const rasterTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) + +function photo(component: ICAL.Component): { photo: string | null; photoLink: string | null } { + const property = component.getFirstProperty('photo') + if (!property) return { photo: null, photoLink: null } + const raw = property.getFirstValue() + const value = valueText(raw) + const embedded = /^data:(image\/[a-z]+);base64,([a-z0-9+/=\s]+)$/i.exec(value) + if (embedded && rasterTypes.has(embedded[1]!.toLowerCase())) { + return { + photo: `data:${embedded[1]!.toLowerCase()};base64,${embedded[2]!.replace(/\s/g, '')}`, + photoLink: null, + } + } + if ( + property.type === 'binary' || + /^(b|base64)$/i.test(valueText(property.getParameter('encoding'))) + ) { + const type = valueText(property.getParameter('type')).toLowerCase() + const mediaType = type === 'jpg' ? 'image/jpeg' : `image/${type}` + if (rasterTypes.has(mediaType) && /^[a-z0-9+/=\s]+$/i.test(value)) { + return { photo: `data:${mediaType};base64,${value.replace(/\s/g, '')}`, photoLink: null } + } + } + return { photo: null, photoLink: safeHttpUrl(value) } +} + +export function parseResource(raw: string, expected: 'calendar' | 'contact'): DAVContent { + let text = raw + try { + const resource = resourceSchema.parse(JSON.parse(raw)) + text = resource.text + const component = new ICAL.Component(ICAL.parse(text)) + if (expected === 'calendar') { + if (component.name !== 'vcalendar') throw new Error('Expected VCALENDAR.') + const items = component + .getAllSubcomponents() + .filter((child) => child.name === 'vevent' || child.name === 'vtodo') + .map(calendarItem) + if (!items.length) throw new Error('No calendar components.') + return { kind: 'calendar', href: resource.href, text, items } + } + if (component.name !== 'vcard') throw new Error('Expected VCARD.') + for (const property of component.getAllProperties()) { + if (property.name.includes('.')) property.jCal[0] = property.name.split('.').at(-1)! + } + const fields = ['tel', 'email', 'adr', 'org', 'title', 'bday', 'note', 'url'] + .map((name) => ({ + name, + values: component + .getAllProperties(name) + .map((property) => ({ + value: valueText(property.getFirstValue()), + label: valueText(property.getParameter('type')), + })) + .filter((field) => field.value), + })) + .filter((field) => field.values.length) + return { + kind: 'contact', + href: resource.href, + text, + title: propertyText(component, 'fn') || propertyText(component, 'n'), + fields, + ...photo(component), + } + } catch { + return { kind: 'invalid', text } + } +} + +export function parseCollection(raw: string): DAVContent { + try { + const { path, props } = collectionSchema.parse(JSON.parse(raw)) + return { + kind: 'collection', + title: props['D:displayname'] || path || 'inkcre', + path, + tag: props.tag ?? '', + components: props['C:supported-calendar-component-set'] ?? '', + members: [], + description: props['C:calendar-description'] || props['CR:addressbook-description'] || '', + text: raw, + } + } catch { + return { kind: 'invalid', text: raw } + } +} + +export function contentTitle(content: DAVContent): string { + if (content.kind === 'calendar') return content.items[0]?.title ?? '' + if (content.kind === 'contact' || content.kind === 'collection') return content.title + return '' +} diff --git a/extensions/caldav/src/graph-content.ts b/extensions/caldav/src/graph-content.ts new file mode 100644 index 00000000..aa04ec03 --- /dev/null +++ b/extensions/caldav/src/graph-content.ts @@ -0,0 +1,104 @@ +import ICAL from 'ical.js' +import { z } from 'zod' + +export const propertyPrefix = 'caldav:property:' +export const graphContentSchema = z.object({ href: z.string(), remainder: z.string() }).strict() +export const propertyLinkSchema = z + .object({ + component: z.tuple([z.string(), z.string(), z.string()]).nullable(), + alarm: z.number().int().nonnegative().nullable(), + name: z.string(), + params: z.record(z.string(), z.array(z.string())), + group: z.string().nullable(), + position: z.number().int().nonnegative(), + representation: z.enum(['text', 'uri', 'binary', 'data-uri', 'reference']), + media_type: z.string().nullable(), + }) + .strict() +export type PropertyLink = z.infer +export interface GraphProperty { + id: number + link: PropertyLink + value: string | Uint8Array +} + +export function resourceUid(text: string): string { + const root = new ICAL.Component(ICAL.parse(text)) + if (root.name === 'vcard') return String(root.getFirstPropertyValue('uid') ?? '') + const uids = new Set( + root + .getAllSubcomponents() + .filter((component) => ['vevent', 'vtodo', 'vjournal'].includes(component.name)) + .map((component) => String(component.getFirstPropertyValue('uid') ?? '')) + ) + if (uids.size !== 1) throw new Error('DAV calendar must have exactly one UID.') + return [...uids][0]! +} + +function targetComponent(root: ICAL.Component, link: PropertyLink): ICAL.Component { + if (link.component === null && root.name === 'vcard') return root + if (link.component === null) throw new Error('Missing DAV component selector.') + const [name, uid, recurrence] = link.component + const recurrenceKey = recurrence ? ICAL.Property.fromString(recurrence).toICALString() : '' + const targets = root + .getAllSubcomponents() + .filter( + (component) => + component.name === name.toLowerCase() && + component.getFirstPropertyValue('uid') === uid && + (component.getFirstProperty('recurrence-id')?.toICALString() ?? '') === recurrenceKey + ) + if (targets.length !== 1) + throw new Error('DAV property refers to a missing or ambiguous component.') + if (link.alarm === null) return targets[0]! + const alarm = targets[0]!.getAllSubcomponents('valarm')[link.alarm] + if (!alarm) throw new Error('DAV property refers to a missing alarm.') + return alarm +} + +function base64(bytes: Uint8Array): string { + let value = '' + for (const byte of bytes) value += String.fromCharCode(byte) + return btoa(value) +} + +export function composeGraph(remainder: string, properties: GraphProperty[]): string { + const root = new ICAL.Component(ICAL.parse(remainder)) + const targets = properties.map((field) => targetComponent(root, field.link)) + for (let index = 0; index < properties.length; index++) { + if (targets[index]!.hasProperty(properties[index]!.link.name.toLowerCase())) { + throw new Error('DAV property has both native and graph authority.') + } + } + const seen = new Set() + for (const field of [...properties].sort((a, b) => a.link.position - b.link.position)) { + const { link } = field + const key = JSON.stringify([link.component, link.alarm, link.name, link.position]) + if (seen.has(key)) throw new Error('Duplicate DAV property position.') + seen.add(key) + const target = targetComponent(root, link) + const property = new ICAL.Property(link.name.toLowerCase(), target) + target.addProperty(property) + for (const [name, values] of Object.entries(link.params)) { + if (name === 'VALUE') continue + property.setParameter(name.toLowerCase(), values.length === 1 ? values[0]! : values) + } + const valueType = link.params.VALUE?.[0] + if (valueType) property.resetType(valueType.toLowerCase()) + let value: string + if (link.representation === 'binary' || link.representation === 'data-uri') { + if (!(field.value instanceof Uint8Array)) throw new Error('DAV media requires bytes.') + value = base64(field.value) + if (link.representation === 'data-uri') { + value = `data:${link.media_type || 'application/octet-stream'};base64,${value}` + } + } else { + if (typeof field.value !== 'string') throw new Error('DAV text requires a string.') + value = field.value + } + property.setValue(value) + // vCard groups qualify wire names; the renderer reads the unqualified semantic property. + if (link.group) property.jCal[0] = `${link.group.toLowerCase()}.${link.name.toLowerCase()}` + } + return root.toString() +} diff --git a/extensions/caldav/src/index.ts b/extensions/caldav/src/index.ts new file mode 100644 index 00000000..80e8c762 --- /dev/null +++ b/extensions/caldav/src/index.ts @@ -0,0 +1,29 @@ +import { Resolver, type ExtensionModule } from '@inkcre/core' +import CalDAVSetup from './CalDAVSetup.vue' +import { + CalendarResolver, + CollectionResolver, + ContactResolver, + LegacyCalendarResolver, + LegacyCollectionResolver, + LegacyContactResolver, +} from './resolver' + +const resolvers = [ + CalendarResolver, + CollectionResolver, + ContactResolver, + LegacyCalendarResolver, + LegacyCollectionResolver, + LegacyContactResolver, +] +const extension: ExtensionModule = { + setup: { component: CalDAVSetup }, + async initialize() { + for (const resolver of resolvers) Resolver.register(resolver.type, resolver) + }, + async dispose() { + for (const resolver of resolvers) Resolver.unregister(resolver.type, resolver) + }, +} +export default extension diff --git a/extensions/caldav/src/locale.ts b/extensions/caldav/src/locale.ts new file mode 100644 index 00000000..5323c9c1 --- /dev/null +++ b/extensions/caldav/src/locale.ts @@ -0,0 +1,8 @@ +import { computed, inject } from 'vue' +import { INK_I18N_KEY } from '@inkcre/ui-web' + +export function useText() { + const i18n = inject(INK_I18N_KEY, undefined) + const zh = computed(() => i18n?.locale.value.startsWith('zh') ?? false) + return (en: string, cn: string) => (zh.value ? cn : en) +} diff --git a/extensions/caldav/src/main.ts b/extensions/caldav/src/main.ts new file mode 100644 index 00000000..4551a303 --- /dev/null +++ b/extensions/caldav/src/main.ts @@ -0,0 +1,10 @@ +import { bootstrapExtension } from '@inkcre/ext-dev-utils' +import '@inkcre/ui-web/styles' +import App from './DevApp.vue' +import extensionModule from './index' + +void bootstrapExtension({ + rootComponent: App, + extensionModule, + routes: [{ path: '/', component: () => import('./CalDAVSetup.vue') }], +}).catch(() => console.error('Unable to start the CalDAV extension playground.')) diff --git a/extensions/caldav/src/resolver.ts b/extensions/caldav/src/resolver.ts new file mode 100644 index 00000000..a13c928e --- /dev/null +++ b/extensions/caldav/src/resolver.ts @@ -0,0 +1,135 @@ +import { markRaw } from 'vue' +import { Block, Resolver, Storage, type ProjectionOptions } from '@inkcre/core' +import { contentTitle, parseCollection, parseResource, type DAVContent } from './content' +import { + composeGraph, + graphContentSchema, + propertyLinkSchema, + propertyPrefix, + resourceUid, + type GraphProperty, +} from './graph-content' +import DAVContentView from './DAVContent.vue' +import DAVPreview from './DAVPreview.vue' + +export class LegacyCalendarResolver extends Resolver { + static readonly type: string = 'extensions.caldav.calendar.v1' + static readonly previewRenderer = markRaw(DAVPreview) + static readonly solvedContentRenderer = markRaw(DAVContentView) + + protected async _getSolvedContent(options: ProjectionOptions): Promise { + return parseResource(await this.getRawContent(options), 'calendar') + } + + async getText(options: ProjectionOptions = {}): Promise { + const content = await this.getSolvedContent(options) + return content.kind === 'invalid' ? null : content.text + } +} + +export class LegacyContactResolver extends LegacyCalendarResolver { + static readonly type: string = 'extensions.caldav.contact.v1' + protected async _getSolvedContent(options: ProjectionOptions): Promise { + return parseResource(await this.getRawContent(options), 'contact') + } +} + +async function solveGraph( + resolver: Resolver, + kind: 'calendar' | 'contact', + options: ProjectionOptions +): Promise { + const canonical = graphContentSchema.parse(JSON.parse(await resolver.getRawContent(options))) + const relations = ( + await resolver.getRelations({ includeIn: false, refresh: options.refresh }) + ).filter((relation) => relation.content.startsWith(propertyPrefix)) + const blocks = new Map( + (await Block.getMany(relations.map((relation) => relation.to_))).map((block) => [ + block.id, + block, + ]) + ) + const properties: GraphProperty[] = [] + for (const relation of relations) { + const block = blocks.get(relation.to_) + if (!block) throw new Error('DAV property target is missing.') + const link = propertyLinkSchema.parse(JSON.parse(relation.content.slice(propertyPrefix.length))) + let value: string | Uint8Array + if (/^extensions\.caldav\.(calendar|contact)\.v[12]$/.test(block.resolver)) { + const root = JSON.parse(block.content) + value = resourceUid( + block.resolver.endsWith('.v1') ? root.text : graphContentSchema.parse(root).remainder + ) + } else { + if (block.storage === null && block.resolver !== 'core.text.v1') { + throw new Error('DAV inline property requires a text Block.') + } + if ( + block.storage !== null && + (await Storage.get(block.storage)).type !== 'postgresql_binary' + ) { + throw new Error('DAV embedded property requires database-owned bytes.') + } + value = await block.getHydratedContent(options) + } + properties.push({ id: block.id, link, value }) + } + const text = composeGraph(canonical.remainder, properties) + const content = parseResource(JSON.stringify({ href: canonical.href, text }), kind) + if (content.kind === 'invalid') throw new Error('The DAV graph cannot be rendered.') + content.links = properties.map(({ id, link, value }) => ({ + id, + name: link.name.toLowerCase(), + label: link.params.TYPE?.join(' · ') ?? '', + value: typeof value === 'string' ? value : '', + })) + return content +} + +export class CalendarResolver extends LegacyCalendarResolver { + static readonly type = 'extensions.caldav.calendar.v2' + protected async _getSolvedContent(options: ProjectionOptions): Promise { + return solveGraph(this, 'calendar', options) + } +} + +export class ContactResolver extends LegacyContactResolver { + static readonly type = 'extensions.caldav.contact.v2' + protected async _getSolvedContent(options: ProjectionOptions): Promise { + return solveGraph(this, 'contact', options) + } +} + +export class LegacyCollectionResolver extends Resolver { + static readonly type: string = 'extensions.caldav.collection.v1' + static readonly previewRenderer = markRaw(DAVPreview) + static readonly solvedContentRenderer = markRaw(DAVContentView) + + protected async _getSolvedContent(options: ProjectionOptions): Promise { + const content = parseCollection(await this.getRawContent(options)) + if (content.kind !== 'collection') return content + const relations = await this.getRelations({ includeIn: false, refresh: options.refresh }) + const blocks = await Block.getMany( + relations.filter((relation) => relation.content === 'member').map((relation) => relation.to_) + ) + content.members = blocks.map((block) => { + const raw = JSON.parse(block.content) + const kind = block.resolver.includes('.contact.') ? 'contact' : 'calendar' + const title = parseResource( + JSON.stringify({ href: raw.href, text: raw.remainder ?? raw.text }), + kind + ) + return { id: block.id, title: contentTitle(title) } + }) + return content + } + + async getText(options: ProjectionOptions = {}): Promise { + const content = parseCollection(await this.getRawContent(options)) + return content.kind === 'collection' ? content.title : null + } +} + +export class CollectionResolver extends LegacyCollectionResolver { + static readonly type = 'extensions.caldav.collection.v2' +} diff --git a/extensions/caldav/src/setup-state.ts b/extensions/caldav/src/setup-state.ts new file mode 100644 index 00000000..c5078e1e --- /dev/null +++ b/extensions/caldav/src/setup-state.ts @@ -0,0 +1,60 @@ +import type { ExtensionRuntimeStatus, InstalledExtension } from '@inkcre/core' + +export class RegistryConfigurationError extends Error {} + +export function configuredRegistry( + peerOverride: unknown, + deployment: { schema: string; value: unknown } | null +): string { + let value = peerOverride + if (value == null || value === '') { + if (deployment && deployment.schema !== 'extension.registry.config.v1') + throw new RegistryConfigurationError() + value = + deployment?.value && typeof deployment.value === 'object' + ? (deployment.value as Record).extension_registry_url + : null + } + if (typeof value !== 'string' || !value.trim()) throw new RegistryConfigurationError() + // A remote Python Peer's process-level default is not observable from the browser. + return value +} + +export function savedPassword(extension: InstalledExtension): string | null { + const value = extension.config.password + if (value == null) return null + if (typeof value !== 'string' || Array.from(value).length < 32 || Array.from(value).length > 128) + throw new Error('Invalid DAV password configuration.') + return value +} + +export function generatePassword(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') +} + +export function runtimeFailed(status: ExtensionRuntimeStatus | null): boolean { + return !!status && (!!status.error || status.operation === 'failed') +} + +export function waitingForRuntime(status: ExtensionRuntimeStatus | null): boolean { + return ( + !!status && + !runtimeFailed(status) && + (status.restart_required || + ['downloading', 'applying', 'restarting'].includes(status.operation ?? '')) + ) +} + +export function runtimeReady(status: ExtensionRuntimeStatus | null): boolean { + return ( + !!status && + status.enabled && + status.running && + !waitingForRuntime(status) && + !!status.version && + status.loaded_version === status.version && + !runtimeFailed(status) + ) +} diff --git a/extensions/caldav/src/setup.ts b/extensions/caldav/src/setup.ts new file mode 100644 index 00000000..a463e286 --- /dev/null +++ b/extensions/caldav/src/setup.ts @@ -0,0 +1,144 @@ +import corePackage from '../../../packages/core/package.json' +import { + ExtensionModel, + ExtensionRegistryOriginResolver, + DBAPIClient, + PeerManager, + PeerProtocolResponseSchema, + type ExtensionRuntimeStatus, + type Peer, +} from '@inkcre/core' +import { + listAdvertisedExtensionManagementPeers, + manageExtensionOnPeer, + readExtensionPeerStatus, + RegistryReleaseReader, + sortPublishedReleases, +} from '@inkcre/extension-runtime-client-web' + +import { + configuredRegistry, + generatePassword, + runtimeFailed, + runtimeReady, + savedPassword, + waitingForRuntime, +} from './setup-state' + +export const extensionName = 'inkcre/caldav' +export interface Candidate { + peer: Peer + status: ExtensionRuntimeStatus | null +} + +export async function readCandidates(): Promise<{ candidates: Candidate[]; unavailable: number }> { + const peers = await listAdvertisedExtensionManagementPeers() + const results = await Promise.allSettled( + peers.map(async (peer) => { + const current = await readExtensionPeerStatus(peer.id, extensionName) + return current.supported_distributions.includes('python') + ? { peer, status: current.extensions.find((row) => row.name === extensionName) ?? null } + : null + }) + ) + return { + candidates: results.flatMap((result) => + result.status === 'fulfilled' && result.value ? [result.value] : [] + ), + unavailable: results.filter((result) => result.status === 'rejected').length, + } +} + +export class ConnectionError extends Error { + constructor(readonly status: number) { + super('Unable to read DAV connection details.') + } +} + +export async function readConnection( + peerId: string +): Promise<{ server_url: string; username: string }> { + const response = PeerProtocolResponseSchema.parse( + await PeerManager.delegate('caldav.connection.v1', {}, peerId) + ) + if (response.status !== 200) throw new ConnectionError(response.status) + const body = response.body + if ( + !body || + typeof body !== 'object' || + !('server_url' in body) || + typeof body.server_url !== 'string' || + !('username' in body) || + body.username !== 'inkcre' + ) + throw new ConnectionError(502) + const url = new URL(body.server_url) + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) + throw new ConnectionError(502) + return { server_url: url.href, username: body.username } +} + +/** Each explicit action advances only this Peer; a restart requires a read before further writes. */ +export async function prepareConnection(peer: Peer): Promise { + const observed = await readExtensionPeerStatus(peer.id, extensionName) + if (!observed.supported_distributions.includes('python')) + throw new Error('Python is not supported.') + let status = observed.extensions.find((row) => row.name === extensionName) ?? null + if (runtimeFailed(status)) throw new Error('Recover the failed Peer operation before continuing.') + if (waitingForRuntime(status)) return status! + if (!status?.version) { + const override = peer.config.extension_registry_url + let deployment = null + if (override == null || override === '') { + const result = await new DBAPIClient<'configs'>('configs') + .from() + .select('schema,value') + .eq('key', 'extension.registry') + .maybeSingle() + if (result.error) throw new Error('Unable to read deployment Registry configuration.') + deployment = result.data + } + const configured = configuredRegistry(override, deployment) + const registryOrigin = await new ExtensionRegistryOriginResolver(() => configured).resolve() + const reader = new RegistryReleaseReader({ + registryOrigin, + hostSdk: { name: '@inkcre/core', version: corePackage.version }, + }) + const release = sortPublishedReleases( + (await reader.getExtension(extensionName, 'python')).releases ?? [] + )[0] + if (!release) throw new Error('No published Python release.') + status = await manageExtensionOnPeer(peer.id, { + action: 'select', + extension: extensionName, + distribution: 'python', + version: release.version, + }) + if (runtimeFailed(status) || waitingForRuntime(status)) return status + } + if (status.distribution !== 'python') throw new Error('The selected distribution is not Python.') + const extension = await ExtensionModel.get(extensionName) + if (!extension) throw new Error('CalDAV is not installed.') + if (!savedPassword(extension)) { + const password = generatePassword() + status = await manageExtensionOnPeer(peer.id, { + action: 'patch_config', + extension: extensionName, + patch: { password }, + }) + const saved = await ExtensionModel.get(extensionName) + if (!saved || savedPassword(saved) !== password) + throw new Error('Password save could not be confirmed.') + if (runtimeFailed(status) || waitingForRuntime(status)) return status + } + if (!runtimeReady(status)) { + status = await manageExtensionOnPeer(peer.id, { action: 'enable', extension: extensionName }) + } + return status +} diff --git a/extensions/caldav/tsconfig.json b/extensions/caldav/tsconfig.json new file mode 100644 index 00000000..dc91eb8d --- /dev/null +++ b/extensions/caldav/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "outDir": "./dist/types", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "experimentalDecorators": true, + "skipLibCheck": true, + "paths": { + "@inkcre/core": ["../../packages/core/src/index.ts"], + "@inkcre/core/*": ["../../packages/core/src/*"] + } + }, + "include": ["src/**/*", "src/**/*.vue"] +} diff --git a/extensions/caldav/vite.config.ts b/extensions/caldav/vite.config.ts new file mode 100644 index 00000000..9878d993 --- /dev/null +++ b/extensions/caldav/vite.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { federation } from '@module-federation/vite' +import mfShared from '../mf-shared' +import extensionPackage from './package.json' + +export default defineConfig({ + plugins: [ + vue(), + federation({ + name: 'extension.caldav', + filename: 'remoteEntry.js', + manifest: true, + exposes: { '.': fileURLToPath(new URL('./src/index.ts', import.meta.url)) }, + shared: mfShared(extensionPackage.inkcre.module_federation.host_sdk_version), + }), + ], + base: './', + build: { target: 'es2022', outDir: 'dist/client-web', sourcemap: true }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b446f57..448ac0bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,58 @@ importers: specifier: ^0.20.6 version: 0.20.27(@rspack/core@2.1.5)(@types/node@22.17.2)(eslint@9.39.5(jiti@2.7.0))(jiti@2.7.0)(lightningcss@1.33.0)(oxc-parser@0.131.0)(rolldown@1.2.0)(rollup@4.59.0)(sass-embedded@1.90.0)(sass@1.101.6)(yaml@2.9.0) + extensions/caldav: + dependencies: + '@inkcre/core': + specifier: workspace:* + version: link:../../packages/core + '@inkcre/extension-runtime-client-web': + specifier: https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.4.1/inkcre-extension-runtime-client-web-0.4.1.tgz + version: https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.4.1/inkcre-extension-runtime-client-web-0.4.1.tgz(@inkcre/core@packages+core)(@module-federation/runtime@0.22.1) + '@vueuse/core': + specifier: ^14.0.0 + version: 14.4.0(vue@3.5.40(typescript@5.9.3)) + ical.js: + specifier: 2.2.1 + version: 2.2.1 + pinia: + specifier: ^3.0.0 + version: 3.0.3(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)) + vue: + specifier: ^3.5.25 + version: 3.5.40(typescript@5.9.3) + vue-router: + specifier: ^4.5.0 + version: 4.5.1(vue@3.5.40(typescript@5.9.3)) + zod: + specifier: ^4.1.0 + version: 4.4.3 + devDependencies: + '@inkcre/ext-dev-utils': + specifier: workspace:* + version: link:../../packages/ext-dev-utils + '@inkcre/ui-web': + specifier: 2.3.0 + version: 2.3.0(unocss@66.7.5(vite@7.3.6(@types/node@22.17.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass-embedded@1.90.0)(sass@1.101.6)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3)) + '@module-federation/vite': + specifier: ^1.1.0 + version: 1.7.1(rollup@4.59.0) + '@vitejs/plugin-vue': + specifier: ^6.0.1 + version: 6.0.3(vite@7.3.6(@types/node@22.17.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass-embedded@1.90.0)(sass@1.101.6)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^7.0.6 + version: 7.3.6(@types/node@22.17.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass-embedded@1.90.0)(sass@1.101.6)(yaml@2.9.0) + vue-tsc: + specifier: 'catalog:' + version: 3.3.8(typescript@5.9.3) + extensions/github: dependencies: '@inkcre/core': @@ -4390,6 +4442,9 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + ical.js@2.2.1: + resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -10148,6 +10203,8 @@ snapshots: human-signals@8.0.1: {} + ical.js@2.2.1: {} + ieee754@1.2.1: {} ignore@5.3.2: diff --git a/scripts/check-package-contract.mjs b/scripts/check-package-contract.mjs index 37f4ea45..278d46fc 100644 --- a/scripts/check-package-contract.mjs +++ b/scripts/check-package-contract.mjs @@ -36,6 +36,7 @@ await Promise.all( 'apps/client-webext/.output/chrome-mv3/content-scripts/content.js', 'apps/client-webext/.output/chrome-mv3/content-scripts/content.css', 'extensions/github/dist/client-web/remoteEntry.js', + 'extensions/caldav/dist/client-web/remoteEntry.js', 'extensions/mail/dist/client-web/remoteEntry.js', 'extensions/memos/dist/client-web/remoteEntry.js', 'extensions/twitter/dist/client-web/remoteEntry.js', diff --git a/scripts/check-workspace-contract.mjs b/scripts/check-workspace-contract.mjs index 4add0ad1..6812f099 100644 --- a/scripts/check-workspace-contract.mjs +++ b/scripts/check-workspace-contract.mjs @@ -5,6 +5,12 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) const jsonOutput = process.argv.includes('--json') const workspaces = [ + { + path: 'extensions/caldav', + name: '@inkcre/ext-caldav', + requiredScripts: ['build', 'dev', 'type-check'], + builder: 'vite', + }, { path: 'packages/core', name: '@inkcre/core',