mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
1 Commits
feat/post-
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
5988fd5b81
|
25
bun.lock
25
bun.lock
@@ -89,15 +89,6 @@
|
|||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-source-caldav": {
|
|
||||||
"name": "@aris/source-caldav",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"ical.js": "^2.1.0",
|
|
||||||
"tsdav": "^2.1.7",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aris-source-google-calendar": {
|
"packages/aris-source-google-calendar": {
|
||||||
"name": "@aris/source-google-calendar",
|
"name": "@aris/source-google-calendar",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -144,8 +135,6 @@
|
|||||||
|
|
||||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
|
|
||||||
|
|
||||||
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
|
||||||
|
|
||||||
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
||||||
@@ -910,8 +899,6 @@
|
|||||||
|
|
||||||
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
|
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
|
||||||
|
|
||||||
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
|
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
@@ -1024,7 +1011,7 @@
|
|||||||
|
|
||||||
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
|
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
|
||||||
|
|
||||||
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
|
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
@@ -1402,8 +1389,6 @@
|
|||||||
|
|
||||||
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
|
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
|
||||||
|
|
||||||
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
|
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.0", "", {}, "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg=="],
|
"ignore": ["ignore@5.3.0", "", {}, "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg=="],
|
||||||
@@ -2178,8 +2163,6 @@
|
|||||||
|
|
||||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||||
|
|
||||||
"tsdav": ["tsdav@2.1.8", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-zvQvhZLzTaEmNNgJbBlUYT/JOq9Xpw/xkxCqs7IT2d2/7o7pss0iZOlZXuHJ5VcvSvTny42Vc6+6GyzZcrCJ1g=="],
|
|
||||||
|
|
||||||
"tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
|
"tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
|
||||||
|
|
||||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
@@ -2302,8 +2285,6 @@
|
|||||||
|
|
||||||
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
||||||
|
|
||||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
|
||||||
|
|
||||||
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
|
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
|
||||||
|
|
||||||
"xmlbuilder": ["xmlbuilder@14.0.0", "", {}, "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg=="],
|
"xmlbuilder": ["xmlbuilder@14.0.0", "", {}, "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg=="],
|
||||||
@@ -2632,8 +2613,6 @@
|
|||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"fbjs/cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
|
||||||
|
|
||||||
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
|
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
|
||||||
|
|
||||||
"figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
"figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||||
@@ -2960,8 +2939,6 @@
|
|||||||
|
|
||||||
"expo/@expo/config-plugins/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"expo/@expo/config-plugins/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"fbjs/cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
|
||||||
|
|
||||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
|
||||||
import type { FeedSource } from "./feed-source"
|
import type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
export interface SourceError {
|
export interface SourceError {
|
||||||
@@ -13,8 +12,6 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
|||||||
context: Context
|
context: Context
|
||||||
items: TItem[]
|
items: TItem[]
|
||||||
errors: SourceError[]
|
errors: SourceError[]
|
||||||
/** Item groups produced by post-processors */
|
|
||||||
groupedItems?: ItemGroup[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||||
@@ -69,7 +66,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||||
private cleanups: Array<() => void> = []
|
private cleanups: Array<() => void> = []
|
||||||
private started = false
|
private started = false
|
||||||
private postProcessors: FeedPostProcessor[] = []
|
|
||||||
|
|
||||||
private readonly cacheTtlMs: number
|
private readonly cacheTtlMs: number
|
||||||
private cachedResult: FeedResult<TItems> | null = null
|
private cachedResult: FeedResult<TItems> | null = null
|
||||||
@@ -112,23 +108,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a post-processor. Processors run in registration order
|
|
||||||
* after items are collected, on every update path.
|
|
||||||
*/
|
|
||||||
registerPostProcessor(processor: FeedPostProcessor): this {
|
|
||||||
this.postProcessors.push(processor)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unregisters a post-processor by reference.
|
|
||||||
*/
|
|
||||||
unregisterPostProcessor(processor: FeedPostProcessor): this {
|
|
||||||
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the feed by running all sources in dependency order.
|
* Refreshes the feed by running all sources in dependency order.
|
||||||
* Calls fetchContext() then fetchItems() on each source.
|
* Calls fetchContext() then fetchItems() on each source.
|
||||||
@@ -173,18 +152,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
|
|
||||||
this.context = context
|
this.context = context
|
||||||
|
|
||||||
const {
|
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
|
||||||
items: processedItems,
|
|
||||||
groupedItems,
|
|
||||||
errors: postProcessorErrors,
|
|
||||||
} = await this.applyPostProcessors(items as TItems[], context, errors)
|
|
||||||
|
|
||||||
const result: FeedResult<TItems> = {
|
|
||||||
context,
|
|
||||||
items: processedItems,
|
|
||||||
errors: postProcessorErrors,
|
|
||||||
...(groupedItems.length > 0 ? { groupedItems } : {}),
|
|
||||||
}
|
|
||||||
this.updateCache(result)
|
this.updateCache(result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -292,59 +260,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyPostProcessors(
|
|
||||||
items: TItems[],
|
|
||||||
context: Context,
|
|
||||||
errors: SourceError[],
|
|
||||||
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
|
|
||||||
let currentItems = items
|
|
||||||
const allGroupedItems: ItemGroup[] = []
|
|
||||||
const allErrors = [...errors]
|
|
||||||
|
|
||||||
for (const processor of this.postProcessors) {
|
|
||||||
const snapshot = currentItems
|
|
||||||
try {
|
|
||||||
const enhancement = await processor(currentItems, context)
|
|
||||||
|
|
||||||
if (enhancement.additionalItems?.length) {
|
|
||||||
// Post-processors operate on FeedItem[] without knowledge of TItems.
|
|
||||||
// Additional items are merged untyped — this is intentional. The
|
|
||||||
// processor contract is "FeedItem in, FeedItem out"; type narrowing
|
|
||||||
// is the caller's responsibility when consuming FeedResult.
|
|
||||||
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enhancement.suppress?.length) {
|
|
||||||
const suppressSet = new Set(enhancement.suppress)
|
|
||||||
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enhancement.groupedItems?.length) {
|
|
||||||
allGroupedItems.push(...enhancement.groupedItems)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const sourceId = processor.name || "anonymous"
|
|
||||||
allErrors.push({
|
|
||||||
sourceId,
|
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
|
||||||
})
|
|
||||||
currentItems = snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stale item IDs from groups and drop empty groups
|
|
||||||
const itemIds = new Set(currentItems.map((item) => item.id))
|
|
||||||
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
|
|
||||||
const ids = group.itemIds.filter((id) => itemIds.has(id))
|
|
||||||
if (ids.length > 0) {
|
|
||||||
acc.push({ ...group, itemIds: ids })
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureGraph(): SourceGraph {
|
private ensureGraph(): SourceGraph {
|
||||||
if (!this.graph) {
|
if (!this.graph) {
|
||||||
this.graph = buildGraph(Array.from(this.sources.values()))
|
this.graph = buildGraph(Array.from(this.sources.values()))
|
||||||
@@ -396,17 +311,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
items: processedItems,
|
|
||||||
groupedItems,
|
|
||||||
errors: postProcessorErrors,
|
|
||||||
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
|
|
||||||
|
|
||||||
const result: FeedResult<TItems> = {
|
const result: FeedResult<TItems> = {
|
||||||
context: this.context,
|
context: this.context,
|
||||||
items: processedItems,
|
items: items as TItems[],
|
||||||
errors: postProcessorErrors,
|
errors,
|
||||||
...(groupedItems.length > 0 ? { groupedItems } : {}),
|
|
||||||
}
|
}
|
||||||
this.updateCache(result)
|
this.updateCache(result)
|
||||||
|
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
|
|
||||||
|
|
||||||
import { FeedEngine } from "./feed-engine"
|
|
||||||
import { UnknownActionError } from "./index"
|
|
||||||
|
|
||||||
// No-op action methods for test sources
|
|
||||||
const noActions = {
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction(actionId: string): Promise<void> {
|
|
||||||
throw new UnknownActionError(actionId)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// FEED ITEMS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
type WeatherItem = FeedItem<"weather", { temp: number }>
|
|
||||||
type CalendarItem = FeedItem<"calendar", { title: string }>
|
|
||||||
|
|
||||||
function weatherItem(id: string, temp: number): WeatherItem {
|
|
||||||
return { id, type: "weather", timestamp: new Date(), data: { temp } }
|
|
||||||
}
|
|
||||||
|
|
||||||
function calendarItem(id: string, title: string): CalendarItem {
|
|
||||||
return { id, type: "calendar", timestamp: new Date(), data: { title } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// TEST SOURCES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function createWeatherSource(items: WeatherItem[]) {
|
|
||||||
return {
|
|
||||||
id: "aris.weather",
|
|
||||||
...noActions,
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems(): Promise<WeatherItem[]> {
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCalendarSource(items: CalendarItem[]) {
|
|
||||||
return {
|
|
||||||
id: "aris.calendar",
|
|
||||||
...noActions,
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems(): Promise<CalendarItem[]> {
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// REGISTRATION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("FeedPostProcessor", () => {
|
|
||||||
describe("registration", () => {
|
|
||||||
test("registerPostProcessor is chainable", () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
const processor: FeedPostProcessor = async () => ({})
|
|
||||||
const result = engine.registerPostProcessor(processor)
|
|
||||||
expect(result).toBe(engine)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("unregisterPostProcessor is chainable", () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
const processor: FeedPostProcessor = async () => ({})
|
|
||||||
const result = engine.unregisterPostProcessor(processor)
|
|
||||||
expect(result).toBe(engine)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("unregistered processor does not run", async () => {
|
|
||||||
const processor = mock(async () => ({}))
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(processor)
|
|
||||||
.unregisterPostProcessor(processor)
|
|
||||||
|
|
||||||
await engine.refresh()
|
|
||||||
expect(processor).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ADDITIONAL ITEMS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("additionalItems", () => {
|
|
||||||
test("injects additional items into the feed", async () => {
|
|
||||||
const extra = calendarItem("c1", "Meeting")
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(2)
|
|
||||||
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SUPPRESS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("suppress", () => {
|
|
||||||
test("removes suppressed items from the feed", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0].id).toBe("w2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("suppressing nonexistent ID is a no-op", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// GROUPED ITEMS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("groupedItems", () => {
|
|
||||||
test("accumulates grouped items on FeedResult", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("multiple processors accumulate groups", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
|
|
||||||
}))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.groupedItems).toEqual([
|
|
||||||
{ itemIds: ["c1"], summary: "Group A" },
|
|
||||||
{ itemIds: ["c2"], summary: "Group B" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("stale item IDs are removed from groups after suppression", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
|
|
||||||
}))
|
|
||||||
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("groups with all items suppressed are dropped", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
|
|
||||||
}))
|
|
||||||
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.groupedItems).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("groupedItems is omitted when no processors produce groups", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => ({}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.groupedItems).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PIPELINE ORDERING
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("pipeline ordering", () => {
|
|
||||||
test("each processor sees items as modified by the previous processor", async () => {
|
|
||||||
const seen: string[] = []
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
additionalItems: [calendarItem("c1", "Injected")],
|
|
||||||
}))
|
|
||||||
.registerPostProcessor(async (items) => {
|
|
||||||
seen.push(...items.map((i) => i.id))
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
|
|
||||||
await engine.refresh()
|
|
||||||
expect(seen).toEqual(["w1", "c1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("suppression in first processor affects second processor", async () => {
|
|
||||||
const seen: string[] = []
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
|
|
||||||
.registerPostProcessor(async (items) => {
|
|
||||||
seen.push(...items.map((i) => i.id))
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
|
|
||||||
await engine.refresh()
|
|
||||||
expect(seen).toEqual(["w2"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ERROR HANDLING
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("error handling", () => {
|
|
||||||
test("throwing processor is recorded in errors and pipeline continues", async () => {
|
|
||||||
const seen: string[] = []
|
|
||||||
|
|
||||||
async function failingProcessor(): Promise<never> {
|
|
||||||
throw new Error("processor failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(failingProcessor)
|
|
||||||
.registerPostProcessor(async (items) => {
|
|
||||||
seen.push(...items.map((i) => i.id))
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
|
|
||||||
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
|
|
||||||
expect(ppError).toBeDefined()
|
|
||||||
expect(ppError!.error.message).toBe("processor failed")
|
|
||||||
|
|
||||||
// Pipeline continued — observer still saw the original item
|
|
||||||
expect(seen).toEqual(["w1"])
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("anonymous throwing processor uses 'anonymous' as sourceId", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => {
|
|
||||||
throw new Error("anon failed")
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
const ppError = result.errors.find((e) => e.sourceId === "anonymous")
|
|
||||||
expect(ppError).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-Error throw is wrapped", async () => {
|
|
||||||
async function failingProcessor(): Promise<never> {
|
|
||||||
throw "string error"
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(failingProcessor)
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
|
|
||||||
expect(ppError).toBeDefined()
|
|
||||||
expect(ppError!.error).toBeInstanceOf(Error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// REACTIVE PATHS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("reactive updates", () => {
|
|
||||||
test("post-processors run during reactive context updates", async () => {
|
|
||||||
let callCount = 0
|
|
||||||
|
|
||||||
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
|
|
||||||
|
|
||||||
const source: FeedSource = {
|
|
||||||
id: "aris.reactive",
|
|
||||||
...noActions,
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
return [weatherItem("w1", 20)]
|
|
||||||
},
|
|
||||||
onContextUpdate(callback, _getContext) {
|
|
||||||
triggerUpdate = callback
|
|
||||||
return () => {
|
|
||||||
triggerUpdate = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
|
||||||
callCount++
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
|
|
||||||
engine.start()
|
|
||||||
|
|
||||||
// Wait for initial periodic refresh
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
const countAfterStart = callCount
|
|
||||||
|
|
||||||
// Trigger a reactive context update
|
|
||||||
triggerUpdate!({ foo: "bar" })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
|
||||||
|
|
||||||
engine.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("post-processors run during reactive item updates", async () => {
|
|
||||||
let callCount = 0
|
|
||||||
|
|
||||||
let triggerItemsUpdate: (() => void) | null = null
|
|
||||||
|
|
||||||
const source: FeedSource = {
|
|
||||||
id: "aris.reactive",
|
|
||||||
...noActions,
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
return [weatherItem("w1", 20)]
|
|
||||||
},
|
|
||||||
onItemsUpdate(callback, _getContext) {
|
|
||||||
triggerItemsUpdate = callback
|
|
||||||
return () => {
|
|
||||||
triggerItemsUpdate = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
|
||||||
callCount++
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
|
|
||||||
engine.start()
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
const countAfterStart = callCount
|
|
||||||
|
|
||||||
// Trigger a reactive items update
|
|
||||||
triggerItemsUpdate!()
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
|
||||||
|
|
||||||
engine.stop()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// NO PROCESSORS = NO CHANGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("no processors", () => {
|
|
||||||
test("engine without post-processors returns raw items unchanged", async () => {
|
|
||||||
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
|
|
||||||
const engine = new FeedEngine().register(createWeatherSource(items))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(2)
|
|
||||||
expect(result.items[0].id).toBe("w1")
|
|
||||||
expect(result.items[1].id).toBe("w2")
|
|
||||||
expect(result.groupedItems).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// COMBINED ENHANCEMENT
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("combined enhancement", () => {
|
|
||||||
test("single processor can use all enhancement fields at once", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
additionalItems: [calendarItem("c1", "Injected")],
|
|
||||||
suppress: ["w2"],
|
|
||||||
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
|
|
||||||
// w2 suppressed, c1 injected → w1 + c1
|
|
||||||
expect(result.items).toHaveLength(2)
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
|
|
||||||
|
|
||||||
// Groups on result
|
|
||||||
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { Context } from "./context"
|
|
||||||
import type { FeedItem } from "./feed"
|
|
||||||
|
|
||||||
export interface ItemGroup {
|
|
||||||
/** IDs of items to present together */
|
|
||||||
itemIds: string[]
|
|
||||||
/** Summary text for the group */
|
|
||||||
summary: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeedEnhancement {
|
|
||||||
/** New items to inject into the feed */
|
|
||||||
additionalItems?: FeedItem[]
|
|
||||||
/** Groups of items to present together with a summary */
|
|
||||||
groupedItems?: ItemGroup[]
|
|
||||||
/** Item IDs to remove from the feed */
|
|
||||||
suppress?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A function that transforms feed items and produces enhancement directives.
|
|
||||||
* Use named functions for meaningful error attribution.
|
|
||||||
*/
|
|
||||||
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement>
|
|
||||||
@@ -13,9 +13,6 @@ export { TimeRelevance } from "./feed"
|
|||||||
// Feed Source
|
// Feed Source
|
||||||
export type { FeedSource } from "./feed-source"
|
export type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
// Feed Post-Processor
|
|
||||||
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
|
||||||
|
|
||||||
// Feed Engine
|
// Feed Engine
|
||||||
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||||
export { FeedEngine } from "./feed-engine"
|
export { FeedEngine } from "./feed-engine"
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
# @aris/source-caldav
|
|
||||||
|
|
||||||
A FeedSource that fetches calendar events from any CalDAV server.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { CalDavSource } from "@aris/source-caldav"
|
|
||||||
|
|
||||||
// Basic auth (Nextcloud, Radicale, Baikal, iCloud, etc.)
|
|
||||||
const source = new CalDavSource({
|
|
||||||
serverUrl: "https://caldav.example.com",
|
|
||||||
authMethod: "basic",
|
|
||||||
username: "user",
|
|
||||||
password: "pass",
|
|
||||||
lookAheadDays: 7, // optional, default: 0 (today only)
|
|
||||||
timeZone: "America/New_York", // optional, default: UTC
|
|
||||||
})
|
|
||||||
|
|
||||||
// OAuth
|
|
||||||
const source = new CalDavSource({
|
|
||||||
serverUrl: "https://caldav.provider.com",
|
|
||||||
authMethod: "oauth",
|
|
||||||
accessToken: "...",
|
|
||||||
refreshToken: "...",
|
|
||||||
tokenUrl: "https://provider.com/oauth/token",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### iCloud
|
|
||||||
|
|
||||||
Use your Apple ID email as the username and an [app-specific password](https://support.apple.com/en-us/102654):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const source = new CalDavSource({
|
|
||||||
serverUrl: "https://caldav.icloud.com",
|
|
||||||
authMethod: "basic",
|
|
||||||
username: "you@icloud.com",
|
|
||||||
password: "<app-specific-password>",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Live test
|
|
||||||
|
|
||||||
`bun run test:live` connects to a real CalDAV server and prints all events to the console. It prompts for:
|
|
||||||
|
|
||||||
- **CalDAV server URL** — e.g. `https://caldav.icloud.com`
|
|
||||||
- **Username** — your account email
|
|
||||||
- **Password** — your password (or app-specific password for iCloud)
|
|
||||||
- **Look-ahead days** — how many days beyond today to fetch (default: 0)
|
|
||||||
|
|
||||||
The script runs both `fetchContext` and `fetchItems`, printing the calendar context (in-progress events, next event, today's count) followed by each event with its title, time, location, signals, and attendees.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:all-day-001@test
|
|
||||||
DTSTART;VALUE=DATE:20260115
|
|
||||||
DTEND;VALUE=DATE:20260116
|
|
||||||
SUMMARY:Company Holiday
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:cancelled-001@test
|
|
||||||
DTSTART:20260115T120000Z
|
|
||||||
DTEND:20260115T130000Z
|
|
||||||
SUMMARY:Cancelled Meeting
|
|
||||||
STATUS:CANCELLED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:minimal-001@test
|
|
||||||
DTSTART:20260115T180000Z
|
|
||||||
DTEND:20260115T190000Z
|
|
||||||
SUMMARY:Quick Chat
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:recurring-001@test
|
|
||||||
DTSTART:20260115T090000Z
|
|
||||||
DTEND:20260115T093000Z
|
|
||||||
SUMMARY:Weekly Sync
|
|
||||||
RRULE:FREQ=WEEKLY;COUNT=4
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:recurring-001@test
|
|
||||||
RECURRENCE-ID:20260122T090000Z
|
|
||||||
DTSTART:20260122T100000Z
|
|
||||||
DTEND:20260122T103000Z
|
|
||||||
SUMMARY:Weekly Sync (moved)
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Test//Test//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:single-event-001@test
|
|
||||||
DTSTART:20260115T140000Z
|
|
||||||
DTEND:20260115T150000Z
|
|
||||||
SUMMARY:Team Standup
|
|
||||||
LOCATION:Conference Room A
|
|
||||||
DESCRIPTION:Daily standup meeting
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
URL:https://example.com/meeting/123
|
|
||||||
ORGANIZER;CN=Alice Smith:mailto:alice@example.com
|
|
||||||
ATTENDEE;CN=Bob Jones;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@example.com
|
|
||||||
ATTENDEE;CN=Carol White;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:carol@example.com
|
|
||||||
BEGIN:VALARM
|
|
||||||
TRIGGER:-PT15M
|
|
||||||
ACTION:DISPLAY
|
|
||||||
DESCRIPTION:Reminder
|
|
||||||
END:VALARM
|
|
||||||
BEGIN:VALARM
|
|
||||||
TRIGGER:-PT5M
|
|
||||||
ACTION:AUDIO
|
|
||||||
END:VALARM
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aris/source-caldav",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test .",
|
|
||||||
"test:live": "bun run scripts/test-live.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aris/core": "workspace:*",
|
|
||||||
"ical.js": "^2.1.0",
|
|
||||||
"tsdav": "^2.1.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live test script for CalDavSource.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun run test-live.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CalDavSource } from "../src/index.ts"
|
|
||||||
|
|
||||||
const serverUrl = prompt("CalDAV server URL:")
|
|
||||||
const username = prompt("Username:")
|
|
||||||
const password = prompt("Password:")
|
|
||||||
const lookAheadRaw = prompt("Look-ahead days (default 0):")
|
|
||||||
|
|
||||||
if (!serverUrl || !username || !password) {
|
|
||||||
console.error("Server URL, username, and password are required.")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookAheadDays = Number(lookAheadRaw) || 0
|
|
||||||
|
|
||||||
const source = new CalDavSource({
|
|
||||||
serverUrl,
|
|
||||||
authMethod: "basic",
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
lookAheadDays,
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = { time: new Date() }
|
|
||||||
|
|
||||||
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
|
||||||
|
|
||||||
const contextResult = await source.fetchContext(context)
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
|
|
||||||
console.log("=== Context ===")
|
|
||||||
console.log(JSON.stringify(contextResult, null, 2))
|
|
||||||
|
|
||||||
console.log(`\n=== Feed Items (${items.length}) ===`)
|
|
||||||
for (const item of items) {
|
|
||||||
console.log(`\n--- ${item.data.title} ---`)
|
|
||||||
console.log(` ID: ${item.id}`)
|
|
||||||
console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`)
|
|
||||||
console.log(` Start: ${item.data.startDate.toISOString()}`)
|
|
||||||
console.log(` End: ${item.data.endDate.toISOString()}`)
|
|
||||||
console.log(` All-day: ${item.data.isAllDay}`)
|
|
||||||
console.log(` Location: ${item.data.location ?? "(none)"}`)
|
|
||||||
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
|
||||||
console.log(` Urgency: ${item.signals?.urgency}`)
|
|
||||||
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
|
||||||
if (item.data.attendees.length > 0) {
|
|
||||||
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
|
|
||||||
}
|
|
||||||
if (item.data.description) {
|
|
||||||
console.log(` Desc: ${item.data.description.slice(0, 100)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
console.log("(no events found in the time window)")
|
|
||||||
}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
import type { Context } from "@aris/core"
|
|
||||||
|
|
||||||
import { TimeRelevance, contextValue } from "@aris/core"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { readFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CalDavDAVCalendar,
|
|
||||||
CalDavDAVClient,
|
|
||||||
CalDavDAVObject,
|
|
||||||
CalDavEventData,
|
|
||||||
} from "./types.ts"
|
|
||||||
|
|
||||||
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
|
||||||
import { CalDavCalendarKey } from "./calendar-context.ts"
|
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
function createContext(time: Date): Context {
|
|
||||||
return { time }
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockDAVClient implements CalDavDAVClient {
|
|
||||||
credentials: Record<string, unknown> = {}
|
|
||||||
fetchCalendarsCallCount = 0
|
|
||||||
lastTimeRange: { start: string; end: string } | null = null
|
|
||||||
private calendars: CalDavDAVCalendar[]
|
|
||||||
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
calendars: CalDavDAVCalendar[],
|
|
||||||
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
|
|
||||||
) {
|
|
||||||
this.calendars = calendars
|
|
||||||
this.objectsByCalendarUrl = objectsByCalendarUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(): Promise<void> {}
|
|
||||||
|
|
||||||
async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
|
|
||||||
this.fetchCalendarsCallCount++
|
|
||||||
return this.calendars
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchCalendarObjects(params: {
|
|
||||||
calendar: CalDavDAVCalendar
|
|
||||||
timeRange: { start: string; end: string }
|
|
||||||
}): Promise<CalDavDAVObject[]> {
|
|
||||||
this.lastTimeRange = params.timeRange
|
|
||||||
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
|
|
||||||
return new CalDavSource({
|
|
||||||
serverUrl: "https://caldav.example.com",
|
|
||||||
authMethod: "basic",
|
|
||||||
username: "user",
|
|
||||||
password: "pass",
|
|
||||||
davClient: client,
|
|
||||||
lookAheadDays,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("CalDavSource", () => {
|
|
||||||
test("has correct id", () => {
|
|
||||||
const client = new MockDAVClient([], {})
|
|
||||||
const source = createSource(client)
|
|
||||||
expect(source.id).toBe("aris.caldav")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns empty array when no calendars exist", async () => {
|
|
||||||
const client = new MockDAVClient([], {})
|
|
||||||
const source = createSource(client)
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
expect(items).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns feed items from a single calendar", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
expect(items[0]!.type).toBe("caldav-event")
|
|
||||||
expect(items[0]!.id).toBe("caldav-event-single-event-001@test")
|
|
||||||
expect(items[0]!.data.title).toBe("Team Standup")
|
|
||||||
expect(items[0]!.data.location).toBe("Conference Room A")
|
|
||||||
expect(items[0]!.data.calendarName).toBe("Work")
|
|
||||||
expect(items[0]!.data.attendees).toHaveLength(2)
|
|
||||||
expect(items[0]!.data.alarms).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns feed items from multiple calendars", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
"/cal/personal": [
|
|
||||||
{
|
|
||||||
url: "/cal/personal/event2.ics",
|
|
||||||
data: loadFixture("all-day-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient(
|
|
||||||
[
|
|
||||||
{ url: "/cal/work", displayName: "Work" },
|
|
||||||
{ url: "/cal/personal", displayName: "Personal" },
|
|
||||||
],
|
|
||||||
objects,
|
|
||||||
)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
|
||||||
|
|
||||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
|
||||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
|
||||||
|
|
||||||
expect(standup).toBeDefined()
|
|
||||||
expect(standup!.data.calendarName).toBe("Work")
|
|
||||||
|
|
||||||
expect(holiday).toBeDefined()
|
|
||||||
expect(holiday!.data.calendarName).toBe("Personal")
|
|
||||||
expect(holiday!.data.isAllDay).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips objects with non-string data", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
|
||||||
{ url: "/cal/work/bad.ics", data: 12345 },
|
|
||||||
{ url: "/cal/work/empty.ics" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
expect(items[0]!.data.title).toBe("Team Standup")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses context time as feed item timestamp", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const now = new Date("2026-01-15T12:00:00Z")
|
|
||||||
const items = await source.fetchItems(createContext(now))
|
|
||||||
expect(items[0]!.timestamp).toEqual(now)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("assigns signals based on event proximity", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
|
||||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
// 2 hours before the event at 14:00
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
|
|
||||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
|
||||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
|
||||||
|
|
||||||
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours
|
|
||||||
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming)
|
|
||||||
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
|
|
||||||
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles calendar with non-string displayName", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/weird": [
|
|
||||||
{
|
|
||||||
url: "/cal/weird/event1.ics",
|
|
||||||
data: loadFixture("minimal-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient(
|
|
||||||
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
|
||||||
objects,
|
|
||||||
)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
expect(items[0]!.data.calendarName).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles recurring events with exceptions", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{
|
|
||||||
url: "/cal/work/recurring.ics",
|
|
||||||
data: loadFixture("recurring-event.ics"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
|
||||||
|
|
||||||
const base = items.find((i) => i.data.title === "Weekly Sync")
|
|
||||||
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
|
|
||||||
|
|
||||||
expect(base).toBeDefined()
|
|
||||||
expect(base!.data.recurrenceId).toBeNull()
|
|
||||||
|
|
||||||
expect(exception).toBeDefined()
|
|
||||||
expect(exception!.data.recurrenceId).not.toBeNull()
|
|
||||||
expect(exception!.id).toContain("-")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caches events within the same refresh cycle", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const context = createContext(new Date("2026-01-15T12:00:00Z"))
|
|
||||||
|
|
||||||
await source.fetchContext(context)
|
|
||||||
await source.fetchItems(context)
|
|
||||||
|
|
||||||
// Same context.time reference — fetchEvents should only hit the client once
|
|
||||||
expect(client.fetchCalendarsCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses timezone for time range when provided", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
|
|
||||||
// 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11)
|
|
||||||
const source = new CalDavSource({
|
|
||||||
serverUrl: "https://caldav.example.com",
|
|
||||||
authMethod: "basic",
|
|
||||||
username: "user",
|
|
||||||
password: "pass",
|
|
||||||
davClient: client,
|
|
||||||
timeZone: "Australia/Sydney",
|
|
||||||
})
|
|
||||||
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
|
|
||||||
|
|
||||||
// "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT)
|
|
||||||
expect(client.lastTimeRange).not.toBeNull()
|
|
||||||
expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z")
|
|
||||||
// End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window
|
|
||||||
expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaults to UTC midnight when no timezone provided", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
|
|
||||||
|
|
||||||
expect(client.lastTimeRange).not.toBeNull()
|
|
||||||
expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z")
|
|
||||||
expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("refetches events for a different context time", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
|
|
||||||
|
|
||||||
// Different context.time references — should fetch twice
|
|
||||||
expect(client.fetchCalendarsCallCount).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("CalDavSource.fetchContext", () => {
|
|
||||||
test("returns empty context when no calendars exist", async () => {
|
|
||||||
const client = new MockDAVClient([], {})
|
|
||||||
const source = createSource(client)
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
|
||||||
|
|
||||||
expect(calendar).toBeDefined()
|
|
||||||
expect(calendar!.inProgress).toEqual([])
|
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
|
||||||
expect(calendar!.hasTodayEvents).toBe(false)
|
|
||||||
expect(calendar!.todayEventCount).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("identifies in-progress events", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
// 14:30 is during the 14:00-15:00 event
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(1)
|
|
||||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("identifies next upcoming event", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
// 12:00 is before the 14:00 event
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
|
||||||
expect(calendar!.nextEvent).not.toBeNull()
|
|
||||||
expect(calendar!.nextEvent!.title).toBe("Team Standup")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("excludes all-day events from inProgress and nextEvent", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
|
||||||
expect(calendar!.todayEventCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("counts all events including all-day in todayEventCount", async () => {
|
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
|
||||||
"/cal/work": [
|
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
|
||||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
|
||||||
const source = createSource(client)
|
|
||||||
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
|
||||||
|
|
||||||
expect(calendar!.todayEventCount).toBe(2)
|
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("computeSignals", () => {
|
|
||||||
const now = new Date("2026-01-15T12:00:00Z")
|
|
||||||
|
|
||||||
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
|
|
||||||
return {
|
|
||||||
uid: "test-uid",
|
|
||||||
title: "Test",
|
|
||||||
startDate: new Date("2026-01-15T14:00:00Z"),
|
|
||||||
endDate: new Date("2026-01-15T15:00:00Z"),
|
|
||||||
isAllDay: false,
|
|
||||||
location: null,
|
|
||||||
description: null,
|
|
||||||
calendarName: null,
|
|
||||||
status: null,
|
|
||||||
url: null,
|
|
||||||
organizer: null,
|
|
||||||
attendees: [],
|
|
||||||
alarms: [],
|
|
||||||
recurrenceId: null,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("all-day events get urgency 0.3 and ambient relevance", () => {
|
|
||||||
const event = makeEvent({ isAllDay: true })
|
|
||||||
const signals = computeSignals(event, now)
|
|
||||||
expect(signals.urgency).toBe(0.3)
|
|
||||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T12:20:00Z"),
|
|
||||||
})
|
|
||||||
const signals = computeSignals(event, now)
|
|
||||||
expect(signals.urgency).toBe(0.9)
|
|
||||||
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events starting exactly at 30 minutes get urgency 0.9", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T12:30:00Z"),
|
|
||||||
})
|
|
||||||
expect(computeSignals(event, now).urgency).toBe(0.9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T13:00:00Z"),
|
|
||||||
})
|
|
||||||
const signals = computeSignals(event, now)
|
|
||||||
expect(signals.urgency).toBe(0.7)
|
|
||||||
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events later today get urgency 0.5", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T20:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(computeSignals(event, now).urgency).toBe(0.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("in-progress events get urgency 0.8 and imminent relevance", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T11:00:00Z"),
|
|
||||||
endDate: new Date("2026-01-15T13:00:00Z"),
|
|
||||||
})
|
|
||||||
const signals = computeSignals(event, now)
|
|
||||||
expect(signals.urgency).toBe(0.8)
|
|
||||||
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("fully past events get urgency 0.2 and ambient relevance", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T09:00:00Z"),
|
|
||||||
endDate: new Date("2026-01-15T10:00:00Z"),
|
|
||||||
})
|
|
||||||
const signals = computeSignals(event, now)
|
|
||||||
expect(signals.urgency).toBe(0.2)
|
|
||||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("events on future days get urgency 0.2", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-16T10:00:00Z"),
|
|
||||||
})
|
|
||||||
expect(computeSignals(event, now).urgency).toBe(0.2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("urgency boundaries are correct", () => {
|
|
||||||
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
|
|
||||||
const event31min = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T12:31:00Z"),
|
|
||||||
})
|
|
||||||
expect(computeSignals(event31min, now).urgency).toBe(0.7)
|
|
||||||
|
|
||||||
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
|
|
||||||
const event2h1m = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T14:01:00Z"),
|
|
||||||
})
|
|
||||||
expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cancelled events get urgency 0.1 regardless of timing", () => {
|
|
||||||
const event = makeEvent({
|
|
||||||
status: "cancelled",
|
|
||||||
startDate: new Date("2026-01-15T12:20:00Z"), // would be 0.9 if not cancelled
|
|
||||||
})
|
|
||||||
const signals = computeSignals(event, now)
|
|
||||||
expect(signals.urgency).toBe(0.1)
|
|
||||||
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses timezone for 'later today' boundary", () => {
|
|
||||||
// now = 2026-01-15T12:00:00Z = 2026-01-15T21:00:00 JST (UTC+9)
|
|
||||||
// event at 2026-01-15T15:30:00Z = 2026-01-16T00:30:00 JST — next day in JST
|
|
||||||
const event = makeEvent({
|
|
||||||
startDate: new Date("2026-01-15T15:30:00Z"),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Without timezone: UTC day ends at 2026-01-16T00:00:00Z, event is before that → "later today"
|
|
||||||
expect(computeSignals(event, now).urgency).toBe(0.5)
|
|
||||||
|
|
||||||
// With Asia/Tokyo: local day ends at 2026-01-15T15:00:00Z (midnight Jan 16 JST),
|
|
||||||
// event is after that → "future days"
|
|
||||||
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
|
||||||
|
|
||||||
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
|
||||||
import { DAVClient } from "tsdav"
|
|
||||||
|
|
||||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
|
||||||
import { CalDavEventStatus } from "./types.ts"
|
|
||||||
|
|
||||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
|
||||||
import { parseICalEvents } from "./ical-parser.ts"
|
|
||||||
|
|
||||||
// -- Source options --
|
|
||||||
|
|
||||||
interface CalDavSourceBaseOptions {
|
|
||||||
serverUrl: string
|
|
||||||
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
|
|
||||||
lookAheadDays?: number
|
|
||||||
/** IANA timezone for determining "today" (e.g. "America/New_York"). Default: UTC. */
|
|
||||||
timeZone?: string
|
|
||||||
/** Optional DAV client for testing. */
|
|
||||||
davClient?: CalDavDAVClient
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CalDavSourceBasicAuthOptions extends CalDavSourceBaseOptions {
|
|
||||||
authMethod: "basic"
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CalDavSourceOAuthOptions extends CalDavSourceBaseOptions {
|
|
||||||
authMethod: "oauth"
|
|
||||||
accessToken: string
|
|
||||||
refreshToken: string
|
|
||||||
tokenUrl: string
|
|
||||||
expiration?: number
|
|
||||||
clientId?: string
|
|
||||||
clientSecret?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CalDavSourceOptions = CalDavSourceBasicAuthOptions | CalDavSourceOAuthOptions
|
|
||||||
|
|
||||||
const DEFAULT_LOOK_AHEAD_DAYS = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A FeedSource that fetches calendar events from any CalDAV server.
|
|
||||||
*
|
|
||||||
* Supports Basic auth (username/password) and OAuth (access token + refresh token).
|
|
||||||
* The server URL is provided at construction time.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Basic auth (self-hosted servers)
|
|
||||||
* const source = new CalDavSource({
|
|
||||||
* serverUrl: "https://nextcloud.example.com/remote.php/dav",
|
|
||||||
* authMethod: "basic",
|
|
||||||
* username: "user",
|
|
||||||
* password: "pass",
|
|
||||||
* })
|
|
||||||
*
|
|
||||||
* // OAuth (cloud providers)
|
|
||||||
* const source = new CalDavSource({
|
|
||||||
* serverUrl: "https://caldav.provider.com",
|
|
||||||
* authMethod: "oauth",
|
|
||||||
* accessToken: "...",
|
|
||||||
* refreshToken: "...",
|
|
||||||
* tokenUrl: "https://provider.com/oauth/token",
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|
||||||
readonly id = "aris.caldav"
|
|
||||||
|
|
||||||
private options: CalDavSourceOptions | null
|
|
||||||
private readonly lookAheadDays: number
|
|
||||||
private readonly timeZone: string | undefined
|
|
||||||
private readonly injectedClient: CalDavDAVClient | null
|
|
||||||
private clientPromise: Promise<CalDavDAVClient> | null = null
|
|
||||||
private cachedEvents: { time: Date; events: CalDavEventData[] } | null = null
|
|
||||||
private pendingFetch: { time: Date; promise: Promise<CalDavEventData[]> } | null = null
|
|
||||||
|
|
||||||
constructor(options: CalDavSourceOptions) {
|
|
||||||
this.options = options
|
|
||||||
this.lookAheadDays = options.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
|
|
||||||
this.timeZone = options.timeZone
|
|
||||||
this.injectedClient = options.davClient ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async executeAction(actionId: string): Promise<void> {
|
|
||||||
throw new UnknownActionError(actionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
|
||||||
const events = await this.fetchEvents(context)
|
|
||||||
if (events.length === 0) {
|
|
||||||
return {
|
|
||||||
[CalDavCalendarKey]: {
|
|
||||||
inProgress: [],
|
|
||||||
nextEvent: null,
|
|
||||||
hasTodayEvents: false,
|
|
||||||
todayEventCount: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = context.time
|
|
||||||
const active = events.filter((e) => e.status !== CalDavEventStatus.Cancelled)
|
|
||||||
const inProgress = active.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
|
|
||||||
|
|
||||||
const upcoming = active
|
|
||||||
.filter((e) => !e.isAllDay && e.startDate > now)
|
|
||||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
|
||||||
|
|
||||||
const calendarContext: CalendarContext = {
|
|
||||||
inProgress,
|
|
||||||
nextEvent: upcoming[0] ?? null,
|
|
||||||
hasTodayEvents: events.length > 0,
|
|
||||||
todayEventCount: events.length,
|
|
||||||
}
|
|
||||||
|
|
||||||
return { [CalDavCalendarKey]: calendarContext }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
|
||||||
const now = context.time
|
|
||||||
const events = await this.fetchEvents(context)
|
|
||||||
return events.map((event) => createFeedItem(event, now, this.timeZone))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
|
|
||||||
if (this.cachedEvents && this.cachedEvents.time === context.time) {
|
|
||||||
return Promise.resolve(this.cachedEvents.events)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate concurrent fetches for the same context.time reference
|
|
||||||
if (this.pendingFetch && this.pendingFetch.time === context.time) {
|
|
||||||
return this.pendingFetch.promise
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = this.doFetchEvents(context).finally(() => {
|
|
||||||
if (this.pendingFetch?.promise === promise) {
|
|
||||||
this.pendingFetch = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.pendingFetch = { time: context.time, promise }
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doFetchEvents(context: Context): Promise<CalDavEventData[]> {
|
|
||||||
const client = await this.connectClient()
|
|
||||||
const calendars = await client.fetchCalendars()
|
|
||||||
|
|
||||||
const { start, end } = computeTimeRange(context.time, this.lookAheadDays, this.timeZone)
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
calendars.map(async (calendar) => {
|
|
||||||
const objects = await client.fetchCalendarObjects({
|
|
||||||
calendar,
|
|
||||||
timeRange: {
|
|
||||||
start: start.toISOString(),
|
|
||||||
end: end.toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// tsdav types displayName as string | Record<string, unknown> | undefined
|
|
||||||
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
|
|
||||||
return { objects, calendarName }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const allEvents: CalDavEventData[] = []
|
|
||||||
for (const result of results) {
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
console.warn("[aris.caldav] Failed to fetch calendar:", result.reason)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const { objects, calendarName } = result.value
|
|
||||||
for (const obj of objects) {
|
|
||||||
if (typeof obj.data !== "string") continue
|
|
||||||
|
|
||||||
const events = parseICalEvents(obj.data, calendarName)
|
|
||||||
for (const event of events) {
|
|
||||||
allEvents.push(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cachedEvents = { time: context.time, events: allEvents }
|
|
||||||
return allEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
private connectClient(): Promise<CalDavDAVClient> {
|
|
||||||
if (this.injectedClient) {
|
|
||||||
return Promise.resolve(this.injectedClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.clientPromise) {
|
|
||||||
this.clientPromise = this.createAndLoginClient().catch((err) => {
|
|
||||||
this.clientPromise = null
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.clientPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createAndLoginClient(): Promise<CalDavDAVClient> {
|
|
||||||
const opts = this.options
|
|
||||||
if (!opts) {
|
|
||||||
throw new Error("CalDavSource options have already been consumed")
|
|
||||||
}
|
|
||||||
|
|
||||||
let client: CalDavDAVClient
|
|
||||||
|
|
||||||
if (opts.authMethod === "basic") {
|
|
||||||
client = new DAVClient({
|
|
||||||
serverUrl: opts.serverUrl,
|
|
||||||
credentials: {
|
|
||||||
username: opts.username,
|
|
||||||
password: opts.password,
|
|
||||||
},
|
|
||||||
authMethod: "Basic",
|
|
||||||
defaultAccountType: "caldav",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
client = new DAVClient({
|
|
||||||
serverUrl: opts.serverUrl,
|
|
||||||
credentials: {
|
|
||||||
tokenUrl: opts.tokenUrl,
|
|
||||||
refreshToken: opts.refreshToken,
|
|
||||||
accessToken: opts.accessToken,
|
|
||||||
expiration: opts.expiration,
|
|
||||||
clientId: opts.clientId,
|
|
||||||
clientSecret: opts.clientSecret,
|
|
||||||
},
|
|
||||||
authMethod: "Oauth",
|
|
||||||
defaultAccountType: "caldav",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.login()
|
|
||||||
this.options = null
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeTimeRange(
|
|
||||||
now: Date,
|
|
||||||
lookAheadDays: number,
|
|
||||||
timeZone?: string,
|
|
||||||
): { start: Date; end: Date } {
|
|
||||||
const start = startOfDay(now, timeZone)
|
|
||||||
const end = new Date(start.getTime() + (1 + lookAheadDays) * 24 * 60 * 60 * 1000)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns midnight (start of day) as a UTC Date.
|
|
||||||
* When timeZone is provided, "midnight" is local midnight in that timezone
|
|
||||||
* converted to UTC. Otherwise, UTC midnight.
|
|
||||||
*/
|
|
||||||
function startOfDay(date: Date, timeZone?: string): Date {
|
|
||||||
if (!timeZone) {
|
|
||||||
const d = new Date(date)
|
|
||||||
d.setUTCHours(0, 0, 0, 0)
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the local year/month/day in the target timezone
|
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).formatToParts(date)
|
|
||||||
|
|
||||||
const year = Number(parts.find((p) => p.type === "year")!.value)
|
|
||||||
const month = Number(parts.find((p) => p.type === "month")!.value)
|
|
||||||
const day = Number(parts.find((p) => p.type === "day")!.value)
|
|
||||||
|
|
||||||
// Binary-search-free approach: construct a UTC date at the local date's noon,
|
|
||||||
// then use the timezone offset at that moment to find local midnight in UTC.
|
|
||||||
const noonUtc = Date.UTC(year, month - 1, day, 12, 0, 0)
|
|
||||||
const noonLocal = new Date(noonUtc).toLocaleString("sv-SE", { timeZone, hour12: false })
|
|
||||||
// sv-SE locale formats as "YYYY-MM-DD HH:MM:SS" which Date can parse
|
|
||||||
const noonLocalMs = new Date(noonLocal + "Z").getTime()
|
|
||||||
const offsetMs = noonLocalMs - noonUtc
|
|
||||||
|
|
||||||
return new Date(Date.UTC(year, month - 1, day) - offsetMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeSignals(
|
|
||||||
event: CalDavEventData,
|
|
||||||
now: Date,
|
|
||||||
timeZone?: string,
|
|
||||||
): FeedItemSignals {
|
|
||||||
if (event.status === CalDavEventStatus.Cancelled) {
|
|
||||||
return { urgency: 0.1, timeRelevance: TimeRelevance.Ambient }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.isAllDay) {
|
|
||||||
return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient }
|
|
||||||
}
|
|
||||||
|
|
||||||
const msUntilStart = event.startDate.getTime() - now.getTime()
|
|
||||||
|
|
||||||
// Event already started
|
|
||||||
if (msUntilStart < 0) {
|
|
||||||
const isInProgress = now.getTime() < event.endDate.getTime()
|
|
||||||
return isInProgress
|
|
||||||
? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent }
|
|
||||||
: { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting within 30 minutes
|
|
||||||
if (msUntilStart <= 30 * 60 * 1000) {
|
|
||||||
return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting within 2 hours
|
|
||||||
if (msUntilStart <= 2 * 60 * 60 * 1000) {
|
|
||||||
return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Later today (using local day boundary when timeZone is set)
|
|
||||||
const todayStart = startOfDay(now, timeZone)
|
|
||||||
const endOfDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000)
|
|
||||||
|
|
||||||
if (event.startDate.getTime() < endOfDay.getTime()) {
|
|
||||||
return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future days
|
|
||||||
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
|
||||||
return {
|
|
||||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
|
||||||
type: "caldav-event",
|
|
||||||
timestamp: now,
|
|
||||||
data: event,
|
|
||||||
signals: computeSignals(event, now, timeZone),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { ContextKey } from "@aris/core"
|
|
||||||
|
|
||||||
import { contextKey } from "@aris/core"
|
|
||||||
|
|
||||||
import type { CalDavEventData } from "./types.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendar context for downstream sources.
|
|
||||||
*
|
|
||||||
* Provides a snapshot of the user's upcoming CalDAV events so other sources
|
|
||||||
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
|
||||||
*/
|
|
||||||
export interface CalendarContext {
|
|
||||||
/** Events happening right now */
|
|
||||||
inProgress: CalDavEventData[]
|
|
||||||
/** Next upcoming event, if any */
|
|
||||||
nextEvent: CalDavEventData | null
|
|
||||||
/** Whether the user has any events today */
|
|
||||||
hasTodayEvents: boolean
|
|
||||||
/** Total number of events today */
|
|
||||||
todayEventCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { readFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
|
|
||||||
import { parseICalEvents } from "./ical-parser.ts"
|
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("parseICalEvents", () => {
|
|
||||||
test("parses a full event with all fields", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
const event = events[0]!
|
|
||||||
|
|
||||||
expect(event.uid).toBe("single-event-001@test")
|
|
||||||
expect(event.title).toBe("Team Standup")
|
|
||||||
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
|
|
||||||
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
|
|
||||||
expect(event.isAllDay).toBe(false)
|
|
||||||
expect(event.location).toBe("Conference Room A")
|
|
||||||
expect(event.description).toBe("Daily standup meeting")
|
|
||||||
expect(event.calendarName).toBe("Work")
|
|
||||||
expect(event.status).toBe("confirmed")
|
|
||||||
expect(event.url).toBe("https://example.com/meeting/123")
|
|
||||||
expect(event.organizer).toBe("Alice Smith")
|
|
||||||
expect(event.recurrenceId).toBeNull()
|
|
||||||
|
|
||||||
expect(event.attendees).toHaveLength(2)
|
|
||||||
expect(event.attendees[0]).toEqual({
|
|
||||||
name: "Bob Jones",
|
|
||||||
email: "bob@example.com",
|
|
||||||
role: "required",
|
|
||||||
status: "accepted",
|
|
||||||
})
|
|
||||||
expect(event.attendees[1]).toEqual({
|
|
||||||
name: "Carol White",
|
|
||||||
email: "carol@example.com",
|
|
||||||
role: "optional",
|
|
||||||
status: "tentative",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(event.alarms).toHaveLength(2)
|
|
||||||
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
|
|
||||||
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses an all-day event with optional fields as null", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
const event = events[0]!
|
|
||||||
|
|
||||||
expect(event.isAllDay).toBe(true)
|
|
||||||
expect(event.title).toBe("Company Holiday")
|
|
||||||
expect(event.calendarName).toBeNull()
|
|
||||||
expect(event.location).toBeNull()
|
|
||||||
expect(event.description).toBeNull()
|
|
||||||
expect(event.url).toBeNull()
|
|
||||||
expect(event.organizer).toBeNull()
|
|
||||||
expect(event.attendees).toEqual([])
|
|
||||||
expect(event.alarms).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses recurring event with exception", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
|
||||||
|
|
||||||
expect(events).toHaveLength(2)
|
|
||||||
expect(events[0]!.uid).toBe("recurring-001@test")
|
|
||||||
expect(events[1]!.uid).toBe("recurring-001@test")
|
|
||||||
|
|
||||||
const base = events.find((e) => e.title === "Weekly Sync")
|
|
||||||
expect(base).toBeDefined()
|
|
||||||
expect(base!.recurrenceId).toBeNull()
|
|
||||||
|
|
||||||
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
|
|
||||||
expect(exception).toBeDefined()
|
|
||||||
expect(exception!.recurrenceId).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses minimal event with defaults", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
|
|
||||||
|
|
||||||
expect(events).toHaveLength(1)
|
|
||||||
const event = events[0]!
|
|
||||||
|
|
||||||
expect(event.uid).toBe("minimal-001@test")
|
|
||||||
expect(event.title).toBe("Quick Chat")
|
|
||||||
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
|
|
||||||
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
|
|
||||||
expect(event.location).toBeNull()
|
|
||||||
expect(event.description).toBeNull()
|
|
||||||
expect(event.status).toBeNull()
|
|
||||||
expect(event.url).toBeNull()
|
|
||||||
expect(event.organizer).toBeNull()
|
|
||||||
expect(event.attendees).toEqual([])
|
|
||||||
expect(event.alarms).toEqual([])
|
|
||||||
expect(event.recurrenceId).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses cancelled status", () => {
|
|
||||||
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
|
|
||||||
expect(events[0]!.status).toBe("cancelled")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import ICAL from "ical.js"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AttendeeRole,
|
|
||||||
AttendeeStatus,
|
|
||||||
CalDavEventStatus,
|
|
||||||
type CalDavAlarm,
|
|
||||||
type CalDavAttendee,
|
|
||||||
type CalDavEventData,
|
|
||||||
} from "./types.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
|
||||||
* into CalDavEventData objects.
|
|
||||||
*
|
|
||||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
|
||||||
* @param calendarName - Display name of the calendar this event belongs to
|
|
||||||
*/
|
|
||||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
|
|
||||||
const jcal = ICAL.parse(icsData)
|
|
||||||
const comp = new ICAL.Component(jcal)
|
|
||||||
const vevents = comp.getAllSubcomponents("vevent")
|
|
||||||
|
|
||||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
|
||||||
parseVEvent(vevent, calendarName),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVEvent(
|
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
|
||||||
calendarName: string | null,
|
|
||||||
): CalDavEventData {
|
|
||||||
const event = new ICAL.Event(vevent)
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: event.uid ?? "",
|
|
||||||
title: event.summary ?? "",
|
|
||||||
startDate: event.startDate?.toJSDate() ?? new Date(0),
|
|
||||||
endDate: event.endDate?.toJSDate() ?? new Date(0),
|
|
||||||
isAllDay: event.startDate?.isDate ?? false,
|
|
||||||
location: event.location ?? null,
|
|
||||||
description: event.description ?? null,
|
|
||||||
calendarName,
|
|
||||||
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
|
||||||
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
|
||||||
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
|
||||||
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
|
||||||
alarms: parseAlarms(vevent),
|
|
||||||
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStatus(raw: string | null): CalDavEventStatus | null {
|
|
||||||
if (!raw) return null
|
|
||||||
switch (raw.toLowerCase()) {
|
|
||||||
case "confirmed":
|
|
||||||
return CalDavEventStatus.Confirmed
|
|
||||||
case "tentative":
|
|
||||||
return CalDavEventStatus.Tentative
|
|
||||||
case "cancelled":
|
|
||||||
return CalDavEventStatus.Cancelled
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOrganizer(
|
|
||||||
value: string | null,
|
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
|
||||||
): string | null {
|
|
||||||
if (!value) return null
|
|
||||||
|
|
||||||
// Try CN parameter first
|
|
||||||
const prop = vevent.getFirstProperty("organizer")
|
|
||||||
if (prop) {
|
|
||||||
const cn = prop.getParameter("cn") as string | undefined
|
|
||||||
if (cn) return cn
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to mailto: value
|
|
||||||
return value.replace(/^mailto:/i, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
|
|
||||||
if (properties.length === 0) return []
|
|
||||||
|
|
||||||
return properties.flatMap((prop) => {
|
|
||||||
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
|
|
||||||
const p = prop as InstanceType<typeof ICAL.Property>
|
|
||||||
const value = asStringOrNull(p.getFirstValue())
|
|
||||||
const cn = asStringOrNull(p.getParameter("cn"))
|
|
||||||
const role = asStringOrNull(p.getParameter("role"))
|
|
||||||
const partstat = asStringOrNull(p.getParameter("partstat"))
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: cn,
|
|
||||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
|
||||||
role: parseAttendeeRole(role),
|
|
||||||
status: parseAttendeeStatus(partstat),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
|
|
||||||
if (!raw) return null
|
|
||||||
switch (raw.toUpperCase()) {
|
|
||||||
case "CHAIR":
|
|
||||||
return AttendeeRole.Chair
|
|
||||||
case "REQ-PARTICIPANT":
|
|
||||||
return AttendeeRole.Required
|
|
||||||
case "OPT-PARTICIPANT":
|
|
||||||
return AttendeeRole.Optional
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
|
||||||
if (!raw) return null
|
|
||||||
switch (raw.toUpperCase()) {
|
|
||||||
case "ACCEPTED":
|
|
||||||
return AttendeeStatus.Accepted
|
|
||||||
case "DECLINED":
|
|
||||||
return AttendeeStatus.Declined
|
|
||||||
case "TENTATIVE":
|
|
||||||
return AttendeeStatus.Tentative
|
|
||||||
case "NEEDS-ACTION":
|
|
||||||
return AttendeeStatus.NeedsAction
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
|
|
||||||
const valarms = vevent.getAllSubcomponents("valarm")
|
|
||||||
if (!valarms || valarms.length === 0) return []
|
|
||||||
|
|
||||||
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
|
|
||||||
const trigger = valarm.getFirstPropertyValue("trigger")
|
|
||||||
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: trigger ? trigger.toString() : "",
|
|
||||||
action: action ?? "DISPLAY",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringOrNull(value: unknown): string | null {
|
|
||||||
return typeof value === "string" ? value : null
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
|
||||||
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
|
||||||
export { parseICalEvents } from "./ical-parser.ts"
|
|
||||||
export {
|
|
||||||
AttendeeRole,
|
|
||||||
AttendeeStatus,
|
|
||||||
CalDavEventStatus,
|
|
||||||
type CalDavAlarm,
|
|
||||||
type CalDavAttendee,
|
|
||||||
type CalDavDAVCalendar,
|
|
||||||
type CalDavDAVClient,
|
|
||||||
type CalDavDAVObject,
|
|
||||||
type CalDavEventData,
|
|
||||||
type CalDavFeedItem,
|
|
||||||
} from "./types.ts"
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
|
||||||
|
|
||||||
// -- Event status --
|
|
||||||
|
|
||||||
export const CalDavEventStatus = {
|
|
||||||
Confirmed: "confirmed",
|
|
||||||
Tentative: "tentative",
|
|
||||||
Cancelled: "cancelled",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
|
|
||||||
|
|
||||||
// -- Attendee types --
|
|
||||||
|
|
||||||
export const AttendeeRole = {
|
|
||||||
Chair: "chair",
|
|
||||||
Required: "required",
|
|
||||||
Optional: "optional",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type AttendeeRole = (typeof AttendeeRole)[keyof typeof AttendeeRole]
|
|
||||||
|
|
||||||
export const AttendeeStatus = {
|
|
||||||
Accepted: "accepted",
|
|
||||||
Declined: "declined",
|
|
||||||
Tentative: "tentative",
|
|
||||||
NeedsAction: "needs-action",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
|
||||||
|
|
||||||
export interface CalDavAttendee {
|
|
||||||
name: string | null
|
|
||||||
email: string | null
|
|
||||||
role: AttendeeRole | null
|
|
||||||
status: AttendeeStatus | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Alarm --
|
|
||||||
|
|
||||||
export interface CalDavAlarm {
|
|
||||||
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
|
||||||
trigger: string
|
|
||||||
/** e.g. "DISPLAY", "AUDIO" */
|
|
||||||
action: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Event data --
|
|
||||||
|
|
||||||
export interface CalDavEventData extends Record<string, unknown> {
|
|
||||||
uid: string
|
|
||||||
title: string
|
|
||||||
startDate: Date
|
|
||||||
endDate: Date
|
|
||||||
isAllDay: boolean
|
|
||||||
location: string | null
|
|
||||||
description: string | null
|
|
||||||
calendarName: string | null
|
|
||||||
status: CalDavEventStatus | null
|
|
||||||
url: string | null
|
|
||||||
organizer: string | null
|
|
||||||
attendees: CalDavAttendee[]
|
|
||||||
alarms: CalDavAlarm[]
|
|
||||||
recurrenceId: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Feed item --
|
|
||||||
|
|
||||||
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
|
|
||||||
|
|
||||||
// -- DAV client interface --
|
|
||||||
|
|
||||||
export interface CalDavDAVObject {
|
|
||||||
data?: unknown
|
|
||||||
etag?: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalDavDAVCalendar {
|
|
||||||
displayName?: string | Record<string, unknown>
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subset of tsdav's DAVClient used by CalDavSource. */
|
|
||||||
export interface CalDavDAVClient {
|
|
||||||
login(): Promise<void>
|
|
||||||
fetchCalendars(): Promise<CalDavDAVCalendar[]>
|
|
||||||
fetchCalendarObjects(params: {
|
|
||||||
calendar: CalDavDAVCalendar
|
|
||||||
timeRange: { start: string; end: string }
|
|
||||||
}): Promise<CalDavDAVObject[]>
|
|
||||||
credentials: Record<string, unknown>
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user