mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
2 Commits
feat/tuple
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
b68331904f
|
|||
|
bbefd01fe0
|
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
name: gpg-commit-signing
|
|
||||||
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
|
|
||||||
---
|
|
||||||
|
|
||||||
# GPG Commit Signing
|
|
||||||
|
|
||||||
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
|
|
||||||
```
|
|
||||||
|
|
||||||
If not set, skip signing — commit without `-S`.
|
|
||||||
|
|
||||||
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -S -m "message"
|
|
||||||
```
|
|
||||||
|
|
||||||
If this succeeds, no further steps are needed.
|
|
||||||
|
|
||||||
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
|
|
||||||
chmod +x /tmp/gpg-sign.sh
|
|
||||||
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
|
|
||||||
rm /tmp/gpg-sign.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
|
|
||||||
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
|
|
||||||
- Do not leave wrapper scripts on disk after committing.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<Partial<Context> | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aris/source-location"
|
import { LocationSource } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<Partial<Context> | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -89,8 +89,8 @@
|
|||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-source-caldav": {
|
"packages/aris-source-apple-calendar": {
|
||||||
"name": "@aris/source-caldav",
|
"name": "@aris/source-apple-calendar",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
"@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-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
|
||||||
|
|
||||||
"@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"],
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { ContextProvider } from "./context-provider"
|
import type { ContextProvider } from "./context-provider"
|
||||||
|
|
||||||
import { contextKey } from "./context"
|
|
||||||
|
|
||||||
interface ContextUpdatable {
|
interface ContextUpdatable {
|
||||||
pushContextUpdate(entries: readonly ContextEntry[]): void
|
pushContextUpdate(update: Partial<Context>): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderError {
|
export interface ProviderError {
|
||||||
@@ -56,7 +54,7 @@ export class ContextBridge {
|
|||||||
this.providers.set(provider.key, provider as ContextProvider)
|
this.providers.set(provider.key, provider as ContextProvider)
|
||||||
|
|
||||||
const cleanup = provider.onUpdate((value) => {
|
const cleanup = provider.onUpdate((value) => {
|
||||||
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
|
this.controller.pushContextUpdate({ [provider.key]: value })
|
||||||
})
|
})
|
||||||
this.cleanups.push(cleanup)
|
this.cleanups.push(cleanup)
|
||||||
|
|
||||||
@@ -69,7 +67,7 @@ export class ContextBridge {
|
|||||||
* Returns errors from providers that failed to fetch.
|
* Returns errors from providers that failed to fetch.
|
||||||
*/
|
*/
|
||||||
async refresh(): Promise<RefreshResult> {
|
async refresh(): Promise<RefreshResult> {
|
||||||
const collected: ContextEntry[] = []
|
const updates: Partial<Context> = {}
|
||||||
const errors: ProviderError[] = []
|
const errors: ProviderError[] = []
|
||||||
|
|
||||||
const entries = Array.from(this.providers.entries())
|
const entries = Array.from(this.providers.entries())
|
||||||
@@ -80,7 +78,7 @@ export class ContextBridge {
|
|||||||
entries.forEach(([key], i) => {
|
entries.forEach(([key], i) => {
|
||||||
const result = results[i]
|
const result = results[i]
|
||||||
if (result?.status === "fulfilled") {
|
if (result?.status === "fulfilled") {
|
||||||
collected.push([contextKey(key), result.value])
|
updates[key] = result.value
|
||||||
} else if (result?.status === "rejected") {
|
} else if (result?.status === "rejected") {
|
||||||
errors.push({
|
errors.push({
|
||||||
key,
|
key,
|
||||||
@@ -89,7 +87,7 @@ export class ContextBridge {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.controller.pushContextUpdate(collected)
|
this.controller.pushContextUpdate(updates)
|
||||||
|
|
||||||
return { errors }
|
return { errors }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { ContextKey } from "./context"
|
|
||||||
|
|
||||||
import { Context, contextKey } from "./context"
|
|
||||||
|
|
||||||
interface Weather {
|
|
||||||
temperature: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NextEvent {
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
|
|
||||||
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
|
||||||
|
|
||||||
describe("Context", () => {
|
|
||||||
describe("get", () => {
|
|
||||||
test("returns undefined for missing key", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
expect(ctx.get(WeatherKey)).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns value for exact key match", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
const weather: Weather = { temperature: 20 }
|
|
||||||
ctx.set([[WeatherKey, weather]])
|
|
||||||
|
|
||||||
expect(ctx.get(WeatherKey)).toEqual(weather)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("distinguishes keys with different parts", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[WeatherKey, { temperature: 20 }],
|
|
||||||
[NextEventKey, { title: "Standup" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
|
|
||||||
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("last write wins for same key", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[WeatherKey, { temperature: 20 }]])
|
|
||||||
ctx.set([[WeatherKey, { temperature: 25 }]])
|
|
||||||
|
|
||||||
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("find", () => {
|
|
||||||
test("returns empty array when no keys match", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
expect(ctx.find(WeatherKey)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns exact match as single result", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[NextEventKey, { title: "Standup" }]])
|
|
||||||
|
|
||||||
const results = ctx.find(NextEventKey)
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
expect(results[0]!.value).toEqual({ title: "Standup" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefix match returns multiple instances", () => {
|
|
||||||
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
|
||||||
account: "work",
|
|
||||||
})
|
|
||||||
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
|
||||||
account: "personal",
|
|
||||||
})
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[workKey, { title: "Sprint Planning" }],
|
|
||||||
[personalKey, { title: "Dentist" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
|
||||||
const results = ctx.find(prefix)
|
|
||||||
|
|
||||||
expect(results).toHaveLength(2)
|
|
||||||
const titles = results.map((r) => r.value.title).sort()
|
|
||||||
expect(titles).toEqual(["Dentist", "Sprint Planning"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefix match includes exact match and longer keys", () => {
|
|
||||||
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
|
||||||
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
|
||||||
account: "work",
|
|
||||||
})
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[baseKey, { title: "Base" }],
|
|
||||||
[instanceKey, { title: "Instance" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(baseKey)
|
|
||||||
expect(results).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not match keys that share a string prefix but differ at segment boundary", () => {
|
|
||||||
const keyA = contextKey<string>("aris.calendar", "next")
|
|
||||||
const keyB = contextKey<string>("aris.calendar", "nextEvent")
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[keyA, "a"],
|
|
||||||
[keyB, "b"],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(keyA)
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
expect(results[0]!.value).toBe("a")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("object key parts with different property order match", () => {
|
|
||||||
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
|
|
||||||
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
|
|
||||||
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[key1, "value"]])
|
|
||||||
|
|
||||||
// Exact match via get should work regardless of property order
|
|
||||||
expect(ctx.get(key2)).toBe("value")
|
|
||||||
|
|
||||||
// find with the reordered key as prefix should also match
|
|
||||||
const prefix = contextKey<string>("source", "ctx")
|
|
||||||
const results = ctx.find(prefix)
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("single-segment prefix matches all keys starting with that segment", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[contextKey("aris.weather", "current"), { temperature: 20 }],
|
|
||||||
[contextKey("aris.weather", "forecast"), { high: 25 }],
|
|
||||||
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(contextKey("aris.weather"))
|
|
||||||
expect(results).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not match shorter keys", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([[contextKey("aris.weather"), "short"]])
|
|
||||||
|
|
||||||
const results = ctx.find(contextKey("aris.weather", "current"))
|
|
||||||
expect(results).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("numeric key parts match correctly", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[contextKey("source", 1, "data"), "one"],
|
|
||||||
[contextKey("source", 2, "data"), "two"],
|
|
||||||
])
|
|
||||||
|
|
||||||
const results = ctx.find(contextKey("source", 1))
|
|
||||||
expect(results).toHaveLength(1)
|
|
||||||
expect(results[0]!.value).toBe("one")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("size", () => {
|
|
||||||
test("returns 0 for empty context", () => {
|
|
||||||
expect(new Context().size).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reflects number of entries", () => {
|
|
||||||
const ctx = new Context()
|
|
||||||
ctx.set([
|
|
||||||
[WeatherKey, { temperature: 20 }],
|
|
||||||
[NextEventKey, { title: "Standup" }],
|
|
||||||
])
|
|
||||||
expect(ctx.size).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,131 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Tuple-keyed context system inspired by React Query's query keys.
|
* Branded type for type-safe context keys.
|
||||||
*
|
*
|
||||||
* Context keys are arrays that form a hierarchy. Sources write to specific
|
* Each package defines its own keys with associated value types:
|
||||||
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
|
* ```ts
|
||||||
* and consumers can query by exact match or prefix match to get all values
|
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||||
* of a given type across source instances.
|
* ```
|
||||||
*/
|
*/
|
||||||
|
export type ContextKey<T> = string & { __contextValue?: T }
|
||||||
// -- Key types --
|
|
||||||
|
|
||||||
/** A single segment of a context key: string, number, or a record of primitives. */
|
|
||||||
export type ContextKeyPart = string | number | Record<string, unknown>
|
|
||||||
|
|
||||||
/** A context key is a readonly tuple of parts, branded with the value type. */
|
|
||||||
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
|
|
||||||
|
|
||||||
/** Creates a typed context key. */
|
|
||||||
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
|
|
||||||
return parts as ContextKey<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Serialization --
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic serialization of a context key for use as a Map key.
|
* Creates a typed context key.
|
||||||
* Object parts have their keys sorted for stable comparison.
|
|
||||||
*/
|
|
||||||
export function serializeKey(key: readonly ContextKeyPart[]): string {
|
|
||||||
return JSON.stringify(key, (_key, value) => {
|
|
||||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
const sorted: Record<string, unknown> = {}
|
|
||||||
for (const k of Object.keys(value).sort()) {
|
|
||||||
sorted[k] = value[k]
|
|
||||||
}
|
|
||||||
return sorted
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Key matching --
|
|
||||||
|
|
||||||
/** Returns true if `key` starts with all parts of `prefix`. */
|
|
||||||
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
|
|
||||||
if (key.length < prefix.length) return false
|
|
||||||
|
|
||||||
for (let i = 0; i < prefix.length; i++) {
|
|
||||||
if (!partsEqual(key[i]!, prefix[i]!)) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
|
|
||||||
function partsEqual(a: unknown, b: unknown): boolean {
|
|
||||||
if (a === b) return true
|
|
||||||
if (typeof a !== typeof b) return false
|
|
||||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
||||||
const aKeys = Object.keys(a)
|
|
||||||
const bKeys = Object.keys(b)
|
|
||||||
if (aKeys.length !== bKeys.length) return false
|
|
||||||
return aKeys.every((key) =>
|
|
||||||
partsEqual(
|
|
||||||
(a as Record<string, unknown>)[key],
|
|
||||||
(b as Record<string, unknown>)[key],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Context store --
|
|
||||||
|
|
||||||
/** A single context entry: a key-value pair. */
|
|
||||||
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutable context store with tuple keys.
|
|
||||||
*
|
*
|
||||||
* Supports exact-match lookups and prefix-match queries.
|
* @example
|
||||||
* Sources write context in topological order during refresh.
|
* ```ts
|
||||||
|
* interface Location { lat: number; lng: number; accuracy: number }
|
||||||
|
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export class Context {
|
export function contextKey<T>(key: string): ContextKey<T> {
|
||||||
|
return key as ContextKey<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe accessor for context values.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const location = contextValue(context, LocationKey)
|
||||||
|
* if (location) {
|
||||||
|
* console.log(location.lat, location.lng)
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
|
||||||
|
return context[key] as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbitrary key-value bag representing the current state.
|
||||||
|
* Always includes `time`. Other keys are added by context providers.
|
||||||
|
*/
|
||||||
|
export interface Context {
|
||||||
time: Date
|
time: Date
|
||||||
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
|
[key: string]: unknown
|
||||||
|
|
||||||
constructor(time: Date = new Date()) {
|
|
||||||
this.time = time
|
|
||||||
this.store = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Merges entries into this context. */
|
|
||||||
set(entries: readonly ContextEntry[]): void {
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
this.store.set(serializeKey(key), { key, value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exact-match lookup. Returns the value for the given key, or undefined. */
|
|
||||||
get<T>(key: ContextKey<T>): T | undefined {
|
|
||||||
const entry = this.store.get(serializeKey(key))
|
|
||||||
return entry?.value as T | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefix-match query. Returns all entries whose key starts with the given prefix.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Get all "nextEvent" values across calendar source instances
|
|
||||||
* const events = context.find(contextKey("nextEvent"))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
|
|
||||||
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
|
|
||||||
|
|
||||||
for (const entry of this.store.values()) {
|
|
||||||
if (keyStartsWith(entry.key, prefix)) {
|
|
||||||
results.push({ key: entry.key, value: entry.value as T })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the number of entries (excluding time). */
|
|
||||||
get size(): number {
|
|
||||||
return this.store.size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { FeedItem } from "./feed"
|
|||||||
* readonly type = "weather"
|
* readonly type = "weather"
|
||||||
*
|
*
|
||||||
* async query(context: Context): Promise<WeatherItem[]> {
|
* async query(context: Context): Promise<WeatherItem[]> {
|
||||||
* const location = context.get(LocationKey)
|
* const location = contextValue(context, LocationKey)
|
||||||
* if (!location) return []
|
* if (!location) return []
|
||||||
* const data = await fetchWeather(location)
|
* const data = await fetchWeather(location)
|
||||||
* return [{
|
* return [{
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { DataSource } from "./data-source"
|
import type { DataSource } from "./data-source"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { ReconcileResult } from "./reconciler"
|
import type { ReconcileResult } from "./reconciler"
|
||||||
|
|
||||||
import { Context } from "./context"
|
|
||||||
import { Reconciler } from "./reconciler"
|
import { Reconciler } from "./reconciler"
|
||||||
|
|
||||||
export interface FeedControllerConfig {
|
export interface FeedControllerConfig {
|
||||||
@@ -41,7 +40,7 @@ const DEFAULT_DEBOUNCE_MS = 100
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Context update triggers debounced reconcile
|
* // Context update triggers debounced reconcile
|
||||||
* controller.pushContextUpdate([[LocationKey, location]])
|
* controller.pushContextUpdate({ [LocationKey]: location })
|
||||||
*
|
*
|
||||||
* // Direct reconcile (no debounce)
|
* // Direct reconcile (no debounce)
|
||||||
* const result = await controller.reconcile()
|
* const result = await controller.reconcile()
|
||||||
@@ -60,7 +59,7 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
private stopped = false
|
private stopped = false
|
||||||
|
|
||||||
constructor(config?: FeedControllerConfig) {
|
constructor(config?: FeedControllerConfig) {
|
||||||
this.context = config?.initialContext ?? new Context()
|
this.context = config?.initialContext ?? { time: new Date() }
|
||||||
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||||
this.timeout = config?.timeout
|
this.timeout = config?.timeout
|
||||||
}
|
}
|
||||||
@@ -95,10 +94,9 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merges entries into context and schedules a debounced reconcile. */
|
/** Merges update into context and schedules a debounced reconcile. */
|
||||||
pushContextUpdate(entries: readonly ContextEntry[]): void {
|
pushContextUpdate(update: Partial<Context>): void {
|
||||||
this.context.time = new Date()
|
this.context = { ...this.context, ...update, time: new Date() }
|
||||||
this.context.set(entries)
|
|
||||||
this.scheduleReconcile()
|
this.scheduleReconcile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { FeedEngine } from "./feed-engine"
|
import { FeedEngine } from "./feed-engine"
|
||||||
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
function createLocationSource(): SimulatedLocationSource {
|
||||||
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
let callback: ((update: Partial<Context>) => void) | null = null
|
||||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return [[LocationKey, currentLocation]]
|
return { [LocationKey]: currentLocation }
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.([[LocationKey, location]])
|
callback?.({ [LocationKey]: location })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,15 +85,15 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return [[WeatherKey, weather]]
|
return { [WeatherKey]: weather }
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
order.push("location")
|
||||||
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = ctx.get(LocationKey)
|
const loc = contextValue(ctx, LocationKey)
|
||||||
expect(loc).toBeDefined()
|
expect(loc).toBeDefined()
|
||||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context } = await engine.refresh()
|
const { context } = await engine.refresh()
|
||||||
|
|
||||||
expect(context.get(LocationKey)).toEqual({
|
expect(contextValue(context, LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(context.get(WeatherKey)).toEqual({
|
expect(contextValue(context, WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -361,7 +361,7 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context, items } = await engine.refresh()
|
const { context, items } = await engine.refresh()
|
||||||
|
|
||||||
expect(context.get(WeatherKey)).toBeUndefined()
|
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ describe("FeedEngine", () => {
|
|||||||
await engine.refresh()
|
await engine.refresh()
|
||||||
|
|
||||||
const context = engine.currentContext()
|
const context = engine.currentContext()
|
||||||
expect(context.get(LocationKey)).toEqual({
|
expect(contextValue(context, LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
@@ -734,7 +734,7 @@ describe("FeedEngine", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("reactive item update refreshes cache", async () => {
|
test("reactive item update refreshes cache", async () => {
|
||||||
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
|
let itemUpdateCallback: (() => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "reactive-items",
|
id: "reactive-items",
|
||||||
@@ -765,7 +765,7 @@ describe("FeedEngine", () => {
|
|||||||
engine.start()
|
engine.start()
|
||||||
|
|
||||||
// Trigger item update
|
// Trigger item update
|
||||||
itemUpdateCallback!([])
|
itemUpdateCallback!()
|
||||||
|
|
||||||
// Wait for async refresh
|
// Wait for async refresh
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
@@ -885,12 +885,12 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
fetchCount++
|
fetchCount++
|
||||||
const loc = ctx.get(LocationKey)
|
const loc = contextValue(ctx, LocationKey)
|
||||||
if (!loc) return null
|
if (!loc) return null
|
||||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||||
},
|
},
|
||||||
async fetchItems(ctx) {
|
async fetchItems(ctx) {
|
||||||
const weather = ctx.get(WeatherKey)
|
const weather = contextValue(ctx, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { ContextEntry } 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"
|
||||||
|
|
||||||
import { Context } from "./context"
|
|
||||||
|
|
||||||
export interface SourceError {
|
export interface SourceError {
|
||||||
sourceId: string
|
sourceId: string
|
||||||
error: Error
|
error: Error
|
||||||
@@ -15,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
|
||||||
@@ -67,11 +62,10 @@ interface SourceGraph {
|
|||||||
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private graph: SourceGraph | null = null
|
private graph: SourceGraph | null = null
|
||||||
private context: Context = new Context()
|
private context: Context = { time: new Date() }
|
||||||
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
|
||||||
@@ -114,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.
|
||||||
@@ -140,14 +117,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const errors: SourceError[] = []
|
const errors: SourceError[] = []
|
||||||
|
|
||||||
// Reset context with fresh time
|
// Reset context with fresh time
|
||||||
const context = new Context()
|
let context: Context = { time: new Date() }
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
try {
|
try {
|
||||||
const entries = await source.fetchContext(context)
|
const update = await source.fetchContext(context)
|
||||||
if (entries) {
|
if (update) {
|
||||||
context.set(entries)
|
context = { ...context, ...update }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -175,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
|
||||||
@@ -215,8 +181,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
if (source.onContextUpdate) {
|
if (source.onContextUpdate) {
|
||||||
const cleanup = source.onContextUpdate(
|
const cleanup = source.onContextUpdate(
|
||||||
(entries) => {
|
(update) => {
|
||||||
this.handleContextUpdate(source.id, entries)
|
this.handleContextUpdate(source.id, update)
|
||||||
},
|
},
|
||||||
() => this.context,
|
() => this.context,
|
||||||
)
|
)
|
||||||
@@ -294,72 +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]
|
|
||||||
const boostScores = new Map<string, number>()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enhancement.boost) {
|
|
||||||
for (const [id, score] of Object.entries(enhancement.boost)) {
|
|
||||||
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const sourceId = processor.name || "anonymous"
|
|
||||||
allErrors.push({
|
|
||||||
sourceId,
|
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
|
||||||
})
|
|
||||||
currentItems = snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
|
|
||||||
// Stable sort within each tier preserves original relative order.
|
|
||||||
if (boostScores.size > 0) {
|
|
||||||
currentItems = applyBoostOrder(currentItems, boostScores)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()))
|
||||||
@@ -367,9 +267,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this.graph
|
return this.graph
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
|
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
|
||||||
this.context.time = new Date()
|
this.context = { ...this.context, ...update, time: new Date() }
|
||||||
this.context.set(entries)
|
|
||||||
|
|
||||||
// Re-run dependents and notify
|
// Re-run dependents and notify
|
||||||
this.refreshDependents(sourceId)
|
this.refreshDependents(sourceId)
|
||||||
@@ -384,9 +283,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const source = graph.sources.get(id)
|
const source = graph.sources.get(id)
|
||||||
if (source) {
|
if (source) {
|
||||||
try {
|
try {
|
||||||
const entries = await source.fetchContext(this.context)
|
const update = await source.fetchContext(this.context)
|
||||||
if (entries) {
|
if (update) {
|
||||||
this.context.set(entries)
|
this.context = { ...this.context, ...update }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Errors during reactive updates are logged but don't stop propagation
|
// Errors during reactive updates are logged but don't stop propagation
|
||||||
@@ -412,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)
|
||||||
|
|
||||||
@@ -503,47 +395,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
|
||||||
return Math.min(max, Math.max(min, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
|
|
||||||
const positive: T[] = []
|
|
||||||
const neutral: T[] = []
|
|
||||||
const negative: T[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const raw = boostScores.get(item.id)
|
|
||||||
if (raw === undefined || raw === 0) {
|
|
||||||
neutral.push(item)
|
|
||||||
} else {
|
|
||||||
const clamped = clamp(raw, -1, 1)
|
|
||||||
if (clamped > 0) {
|
|
||||||
positive.push(item)
|
|
||||||
} else if (clamped < 0) {
|
|
||||||
negative.push(item)
|
|
||||||
} else {
|
|
||||||
neutral.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort positive descending by boost, negative descending (least negative first, most negative last)
|
|
||||||
positive.sort((a, b) => {
|
|
||||||
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
|
||||||
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
|
||||||
return bScore - aScore
|
|
||||||
})
|
|
||||||
|
|
||||||
negative.sort((a, b) => {
|
|
||||||
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
|
||||||
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
|
||||||
return bScore - aScore
|
|
||||||
})
|
|
||||||
|
|
||||||
return [...positive, ...neutral, ...negative]
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGraph(sources: FeedSource[]): SourceGraph {
|
function buildGraph(sources: FeedSource[]): SourceGraph {
|
||||||
const byId = new Map<string, FeedSource>()
|
const byId = new Map<string, FeedSource>()
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
|
|||||||
@@ -1,602 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ActionDefinition,
|
|
||||||
ContextEntry,
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// BOOST
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("boost", () => {
|
|
||||||
test("positive boost moves item before non-boosted items", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w2: 0.8 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("negative boost moves item after non-boosted items", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: -0.5 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("multiple boosted items are sorted by boost descending", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
boost: { w3: 0.3, w1: 0.9 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 (0.9) first, w3 (0.3) second, w2 (no boost) last
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("multiple processors accumulate boost scores", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.3 } }))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.4 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 accumulated boost = 0.7, moves before w2
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accumulated boost is clamped to [-1, 1]", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } }))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 0.8 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("out-of-range boost values are clamped", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { w1: 5.0 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// Clamped to 1, still boosted to front
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosting a suppressed item is a no-op", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
suppress: ["w1"],
|
|
||||||
boost: { w1: 1.0 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0].id).toBe("w2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boosting a nonexistent item ID is a no-op", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
|
||||||
.registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } }))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0].id).toBe("w1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("items with equal boost retain original relative order", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
boost: { w1: 0.5, w3: 0.5 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w1 and w3 have equal boost — original order preserved: w1 before w3
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("negative boosts preserve relative order among demoted items", async () => {
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(
|
|
||||||
createWeatherSource([
|
|
||||||
weatherItem("w1", 20),
|
|
||||||
weatherItem("w2", 25),
|
|
||||||
weatherItem("w3", 30),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
boost: { w1: -0.3, w2: -0.3 },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// w3 (neutral) first, then w1 and w2 (equal negative) in original order
|
|
||||||
expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("boost works alongside additionalItems and groupedItems", async () => {
|
|
||||||
const extra = calendarItem("c1", "Meeting")
|
|
||||||
|
|
||||||
const engine = new FeedEngine()
|
|
||||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
|
||||||
.registerPostProcessor(async () => ({
|
|
||||||
additionalItems: [extra],
|
|
||||||
boost: { c1: 1.0 },
|
|
||||||
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await engine.refresh()
|
|
||||||
// c1 boosted to front
|
|
||||||
expect(result.items[0].id).toBe("c1")
|
|
||||||
expect(result.items).toHaveLength(3)
|
|
||||||
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// 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: ((entries: readonly ContextEntry[]) => 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!([])
|
|
||||||
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: ((items: FeedItem[]) => 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!([weatherItem("w1", 25)])
|
|
||||||
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,26 +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[]
|
|
||||||
/** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */
|
|
||||||
boost?: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
function createLocationSource(): SimulatedLocationSource {
|
||||||
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
let callback: ((update: Partial<Context>) => void) | null = null
|
||||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return [[LocationKey, currentLocation]]
|
return { [LocationKey]: currentLocation }
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.([[LocationKey, location]])
|
callback?.({ [LocationKey]: location })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,15 +84,15 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return [[WeatherKey, weather]]
|
return { [WeatherKey]: weather }
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = context.get(WeatherKey)
|
const weather = contextValue(context, WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
||||||
const context = new Context()
|
let context: Context = { time: new Date() }
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
const entries = await source.fetchContext(context)
|
const update = await source.fetchContext(context)
|
||||||
if (entries) {
|
if (update) {
|
||||||
context.set(entries)
|
context = { ...context, ...update }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ describe("FeedSource", () => {
|
|||||||
|
|
||||||
test("source without context returns null from fetchContext", async () => {
|
test("source without context returns null from fetchContext", async () => {
|
||||||
const source = createAlertSource()
|
const source = createAlertSource()
|
||||||
const result = await source.fetchContext(new Context())
|
const result = await source.fetchContext({ time: new Date() })
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -369,7 +369,7 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
order.push("location")
|
||||||
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,9 +379,9 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = ctx.get(LocationKey)
|
const loc = contextValue(ctx, LocationKey)
|
||||||
expect(loc).toBeDefined()
|
expect(loc).toBeDefined()
|
||||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +400,11 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context } = await refreshGraph(graph)
|
const { context } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(context.get(LocationKey)).toEqual({
|
expect(contextValue(context, LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(context.get(WeatherKey)).toEqual({
|
expect(contextValue(context, WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -447,10 +447,12 @@ describe("FeedSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("source without location context returns empty items", async () => {
|
test("source without location context returns empty items", async () => {
|
||||||
|
// Location source exists but hasn't been updated
|
||||||
const location: FeedSource = {
|
const location: FeedSource = {
|
||||||
id: "location",
|
id: "location",
|
||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
|
// Simulate no location available
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -460,7 +462,7 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context, items } = await refreshGraph(graph)
|
const { context, items } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(context.get(WeatherKey)).toBeUndefined()
|
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -474,7 +476,7 @@ describe("FeedSource", () => {
|
|||||||
() => {
|
() => {
|
||||||
updateCount++
|
updateCount++
|
||||||
},
|
},
|
||||||
() => new Context(),
|
() => ({ time: new Date() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
location.simulateUpdate({ lat: 1, lng: 1 })
|
location.simulateUpdate({ lat: 1, lng: 1 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context, ContextEntry } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Maps to: source/contextUpdated (notification, source → host)
|
* Maps to: source/contextUpdated (notification, source → host)
|
||||||
*/
|
*/
|
||||||
onContextUpdate?(
|
onContextUpdate?(
|
||||||
callback: (entries: readonly ContextEntry[]) => void,
|
callback: (update: Partial<Context>) => void,
|
||||||
getContext: () => Context,
|
getContext: () => Context,
|
||||||
): () => void
|
): () => void
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Return null if this source cannot provide context.
|
* Return null if this source cannot provide context.
|
||||||
* Maps to: source/fetchContext
|
* Maps to: source/fetchContext
|
||||||
*/
|
*/
|
||||||
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
|
fetchContext(context: Context): Promise<Partial<Context> | null>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to reactive feed item updates.
|
* Subscribe to reactive feed item updates.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Context
|
// Context
|
||||||
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
|
export type { Context, ContextKey } from "./context"
|
||||||
export { Context, contextKey, serializeKey } from "./context"
|
export { contextKey, contextValue } from "./context"
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
export type { ActionDefinition } from "./action"
|
export type { ActionDefinition } from "./action"
|
||||||
@@ -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,6 +1,5 @@
|
|||||||
import type { ContextKey } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { Context, contextKey } from "@aris/core"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||||
@@ -16,25 +15,14 @@ const mockCredentials = {
|
|||||||
serviceId: "mock",
|
serviceId: "mock",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
accuracy: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
|
||||||
|
|
||||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||||
fetch: async () => response,
|
fetch: async () => response,
|
||||||
})
|
})
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
||||||
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
time: new Date("2026-01-17T00:00:00Z"),
|
||||||
if (location) {
|
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||||
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
})
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WeatherKitDataSource", () => {
|
describe("WeatherKitDataSource", () => {
|
||||||
test("returns empty array when location is missing", async () => {
|
test("returns empty array when location is missing", async () => {
|
||||||
@@ -51,7 +39,7 @@ describe("WeatherKitDataSource", () => {
|
|||||||
credentials: mockCredentials,
|
credentials: mockCredentials,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(dataSource.type).toBe(WeatherFeedItemType.Current)
|
expect(dataSource.type).toBe(WeatherFeedItemType.current)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("throws error if neither client nor credentials provided", () => {
|
test("throws error if neither client nor credentials provided", () => {
|
||||||
@@ -142,9 +130,9 @@ describe("query() with mocked client", () => {
|
|||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
expect(items.length).toBeGreaterThan(0)
|
expect(items.length).toBeGreaterThan(0)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies hourly and daily limits", async () => {
|
test("applies hourly and daily limits", async () => {
|
||||||
@@ -157,8 +145,8 @@ describe("query() with mocked client", () => {
|
|||||||
|
|
||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(3)
|
expect(hourlyItems.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(2)
|
||||||
@@ -188,8 +176,8 @@ describe("query() with mocked client", () => {
|
|||||||
units: Units.imperial,
|
units: Units.imperial,
|
||||||
})
|
})
|
||||||
|
|
||||||
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current)
|
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current)
|
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
|
|
||||||
expect(metricCurrent).toBeDefined()
|
expect(metricCurrent).toBeDefined()
|
||||||
expect(imperialCurrent).toBeDefined()
|
expect(imperialCurrent).toBeDefined()
|
||||||
@@ -215,7 +203,7 @@ describe("query() with mocked client", () => {
|
|||||||
expect(item.signals!.timeRelevance).toBeDefined()
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
|
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
|
||||||
|
|
||||||
import { TimeRelevance, contextKey } from "@aris/core"
|
import { TimeRelevance } from "@aris/core"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
@@ -40,18 +40,11 @@ export interface WeatherKitQueryConfig {
|
|||||||
units?: Units
|
units?: Units
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
|
||||||
|
|
||||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||||
|
|
||||||
readonly type = WeatherFeedItemType.Current
|
readonly type = WeatherFeedItemType.current
|
||||||
private readonly client: WeatherKitClient
|
private readonly client: WeatherKitClient
|
||||||
private readonly hourlyLimit: number
|
private readonly hourlyLimit: number
|
||||||
private readonly dailyLimit: number
|
private readonly dailyLimit: number
|
||||||
@@ -66,8 +59,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
}
|
}
|
||||||
|
|
||||||
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||||
const location = context.get(LocationKey)
|
if (!context.location) {
|
||||||
if (!location) {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +67,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
const timestamp = context.time
|
const timestamp = context.time
|
||||||
|
|
||||||
const response = await this.client.fetch({
|
const response = await this.client.fetch({
|
||||||
lat: location.lat,
|
lat: context.location.lat,
|
||||||
lng: location.lng,
|
lng: context.location.lng,
|
||||||
})
|
})
|
||||||
|
|
||||||
const items: WeatherFeedItem[] = []
|
const items: WeatherFeedItem[] = []
|
||||||
@@ -236,7 +228,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.current,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -270,7 +262,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.hourly,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -304,7 +296,7 @@ function createDailyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.daily,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -331,7 +323,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.alert,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
export const WeatherFeedItemType = {
|
export const WeatherFeedItemType = {
|
||||||
Current: "weather-current",
|
current: "weather-current",
|
||||||
Hourly: "weather-hourly",
|
hourly: "weather-hourly",
|
||||||
Daily: "weather-daily",
|
daily: "weather-daily",
|
||||||
Alert: "weather-alert",
|
alert: "weather-alert",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentWeatherFeedItem extends FeedItem<
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Current,
|
typeof WeatherFeedItemType.current,
|
||||||
CurrentWeatherData
|
CurrentWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Hourly,
|
typeof WeatherFeedItemType.hourly,
|
||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Daily,
|
typeof WeatherFeedItemType.daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherAlertFeedItem extends FeedItem<
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Alert,
|
typeof WeatherFeedItemType.alert,
|
||||||
WeatherAlertData
|
WeatherAlertData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@aris/source-caldav",
|
"name": "@aris/source-apple-calendar",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test .",
|
"test": "bun test ."
|
||||||
"test:live": "bun run scripts/test-live.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aris/core": "workspace:*",
|
"@aris/core": "workspace:*",
|
||||||
@@ -2,23 +2,23 @@ import type { ContextKey } from "@aris/core"
|
|||||||
|
|
||||||
import { contextKey } from "@aris/core"
|
import { contextKey } from "@aris/core"
|
||||||
|
|
||||||
import type { CalDavEventData } from "./types.ts"
|
import type { CalendarEventData } from "./types.ts"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar context for downstream sources.
|
* Calendar context for downstream sources.
|
||||||
*
|
*
|
||||||
* Provides a snapshot of the user's upcoming CalDAV events so other sources
|
* Provides a snapshot of the user's upcoming events so other sources
|
||||||
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
||||||
*/
|
*/
|
||||||
export interface CalendarContext {
|
export interface CalendarContext {
|
||||||
/** Events happening right now */
|
/** Events happening right now */
|
||||||
inProgress: CalDavEventData[]
|
inProgress: CalendarEventData[]
|
||||||
/** Next upcoming event, if any */
|
/** Next upcoming event, if any */
|
||||||
nextEvent: CalDavEventData | null
|
nextEvent: CalendarEventData | null
|
||||||
/** Whether the user has any events today */
|
/** Whether the user has any events today */
|
||||||
hasTodayEvents: boolean
|
hasTodayEvents: boolean
|
||||||
/** Total number of events today */
|
/** Total number of events today */
|
||||||
todayEventCount: number
|
todayEventCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar")
|
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")
|
||||||
@@ -1,45 +1,60 @@
|
|||||||
import type { ContextEntry } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance } from "@aris/core"
|
import { TimeRelevance, contextValue } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CalDavDAVCalendar,
|
CalendarCredentialProvider,
|
||||||
CalDavDAVClient,
|
CalendarCredentials,
|
||||||
CalDavDAVObject,
|
CalendarDAVCalendar,
|
||||||
CalDavEventData,
|
CalendarDAVClient,
|
||||||
|
CalendarDAVObject,
|
||||||
|
CalendarEventData,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
import { CalendarKey } from "./calendar-context.ts"
|
||||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
import { CalendarSource, computeSignals } from "./calendar-source.ts"
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
function loadFixture(name: string): string {
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time: Date): Context {
|
function createContext(time: Date): Context {
|
||||||
return new Context(time)
|
return { time }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract the CalendarContext value from fetchContext entries. */
|
const mockCredentials: CalendarCredentials = {
|
||||||
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined {
|
accessToken: "mock-access-token",
|
||||||
if (!entries) return undefined
|
refreshToken: "mock-refresh-token",
|
||||||
const entry = entries.find(([key]) => key === CalDavCalendarKey)
|
expiresAt: Date.now() + 3600000,
|
||||||
return entry?.[1] as CalendarContext | undefined
|
tokenUrl: "https://appleid.apple.com/auth/token",
|
||||||
|
clientId: "com.example.aris",
|
||||||
|
clientSecret: "mock-secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockDAVClient implements CalDavDAVClient {
|
class NullCredentialProvider implements CalendarCredentialProvider {
|
||||||
|
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockCredentialProvider implements CalendarCredentialProvider {
|
||||||
|
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
|
||||||
|
return mockCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockDAVClient implements CalendarDAVClient {
|
||||||
credentials: Record<string, unknown> = {}
|
credentials: Record<string, unknown> = {}
|
||||||
fetchCalendarsCallCount = 0
|
fetchCalendarsCallCount = 0
|
||||||
lastTimeRange: { start: string; end: string } | null = null
|
private calendars: CalendarDAVCalendar[]
|
||||||
private calendars: CalDavDAVCalendar[]
|
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
|
||||||
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
calendars: CalDavDAVCalendar[],
|
calendars: CalendarDAVCalendar[],
|
||||||
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
|
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
|
||||||
) {
|
) {
|
||||||
this.calendars = calendars
|
this.calendars = calendars
|
||||||
this.objectsByCalendarUrl = objectsByCalendarUrl
|
this.objectsByCalendarUrl = objectsByCalendarUrl
|
||||||
@@ -47,57 +62,54 @@ class MockDAVClient implements CalDavDAVClient {
|
|||||||
|
|
||||||
async login(): Promise<void> {}
|
async login(): Promise<void> {}
|
||||||
|
|
||||||
async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
|
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
|
||||||
this.fetchCalendarsCallCount++
|
this.fetchCalendarsCallCount++
|
||||||
return this.calendars
|
return this.calendars
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchCalendarObjects(params: {
|
async fetchCalendarObjects(params: {
|
||||||
calendar: CalDavDAVCalendar
|
calendar: CalendarDAVCalendar
|
||||||
timeRange: { start: string; end: string }
|
timeRange: { start: string; end: string }
|
||||||
}): Promise<CalDavDAVObject[]> {
|
}): Promise<CalendarDAVObject[]> {
|
||||||
this.lastTimeRange = params.timeRange
|
|
||||||
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
|
describe("CalendarSource", () => {
|
||||||
return new CalDavSource({
|
|
||||||
serverUrl: "https://caldav.example.com",
|
|
||||||
authMethod: "basic",
|
|
||||||
username: "user",
|
|
||||||
password: "pass",
|
|
||||||
davClient: client,
|
|
||||||
lookAheadDays,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("CalDavSource", () => {
|
|
||||||
test("has correct id", () => {
|
test("has correct id", () => {
|
||||||
const client = new MockDAVClient([], {})
|
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||||
const source = createSource(client)
|
expect(source.id).toBe("aris.apple-calendar")
|
||||||
expect(source.id).toBe("aris.caldav")
|
})
|
||||||
|
|
||||||
|
test("returns empty array when credentials are null", async () => {
|
||||||
|
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
expect(items).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns empty array when no calendars exist", async () => {
|
test("returns empty array when no calendars exist", async () => {
|
||||||
const client = new MockDAVClient([], {})
|
const client = new MockDAVClient([], {})
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
expect(items).toEqual([])
|
expect(items).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns feed items from a single calendar", async () => {
|
test("returns feed items from a single calendar", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
expect(items).toHaveLength(1)
|
expect(items).toHaveLength(1)
|
||||||
expect(items[0]!.type).toBe("caldav-event")
|
expect(items[0]!.type).toBe("calendar-event")
|
||||||
expect(items[0]!.id).toBe("caldav-event-single-event-001@test")
|
expect(items[0]!.id).toBe("calendar-event-single-event-001@test")
|
||||||
expect(items[0]!.data.title).toBe("Team Standup")
|
expect(items[0]!.data.title).toBe("Team Standup")
|
||||||
expect(items[0]!.data.location).toBe("Conference Room A")
|
expect(items[0]!.data.location).toBe("Conference Room A")
|
||||||
expect(items[0]!.data.calendarName).toBe("Work")
|
expect(items[0]!.data.calendarName).toBe("Work")
|
||||||
@@ -106,7 +118,7 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("returns feed items from multiple calendars", async () => {
|
test("returns feed items from multiple calendars", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
"/cal/personal": [
|
"/cal/personal": [
|
||||||
{
|
{
|
||||||
@@ -122,7 +134,9 @@ describe("CalDavSource", () => {
|
|||||||
],
|
],
|
||||||
objects,
|
objects,
|
||||||
)
|
)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
@@ -140,7 +154,7 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("skips objects with non-string data", async () => {
|
test("skips objects with non-string data", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [
|
"/cal/work": [
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||||
{ url: "/cal/work/bad.ics", data: 12345 },
|
{ url: "/cal/work/bad.ics", data: 12345 },
|
||||||
@@ -148,7 +162,9 @@ describe("CalDavSource", () => {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
expect(items).toHaveLength(1)
|
expect(items).toHaveLength(1)
|
||||||
@@ -156,11 +172,13 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("uses context time as feed item timestamp", async () => {
|
test("uses context time as feed item timestamp", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const now = new Date("2026-01-15T12:00:00Z")
|
const now = new Date("2026-01-15T12:00:00Z")
|
||||||
const items = await source.fetchItems(createContext(now))
|
const items = await source.fetchItems(createContext(now))
|
||||||
@@ -168,14 +186,16 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("assigns signals based on event proximity", async () => {
|
test("assigns signals based on event proximity", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [
|
"/cal/work": [
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
// 2 hours before the event at 14:00
|
// 2 hours before the event at 14:00
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
@@ -190,7 +210,7 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("handles calendar with non-string displayName", async () => {
|
test("handles calendar with non-string displayName", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/weird": [
|
"/cal/weird": [
|
||||||
{
|
{
|
||||||
url: "/cal/weird/event1.ics",
|
url: "/cal/weird/event1.ics",
|
||||||
@@ -202,14 +222,16 @@ describe("CalDavSource", () => {
|
|||||||
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
|
||||||
objects,
|
objects,
|
||||||
)
|
)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
expect(items[0]!.data.calendarName).toBeNull()
|
expect(items[0]!.data.calendarName).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles recurring events with exceptions", async () => {
|
test("handles recurring events with exceptions", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [
|
"/cal/work": [
|
||||||
{
|
{
|
||||||
url: "/cal/work/recurring.ics",
|
url: "/cal/work/recurring.ics",
|
||||||
@@ -218,7 +240,9 @@ describe("CalDavSource", () => {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||||
|
|
||||||
@@ -236,11 +260,13 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("caches events within the same refresh cycle", async () => {
|
test("caches events within the same refresh cycle", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const context = createContext(new Date("2026-01-15T12:00:00Z"))
|
const context = createContext(new Date("2026-01-15T12:00:00Z"))
|
||||||
|
|
||||||
@@ -251,51 +277,14 @@ describe("CalDavSource", () => {
|
|||||||
expect(client.fetchCalendarsCallCount).toBe(1)
|
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 () => {
|
test("refetches events for a different context time", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
|
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
|
||||||
@@ -305,12 +294,11 @@ describe("CalDavSource", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("CalDavSource.fetchContext", () => {
|
describe("CalendarSource.fetchContext", () => {
|
||||||
test("returns empty context when no calendars exist", async () => {
|
test("returns empty context when credentials are null", async () => {
|
||||||
const client = new MockDAVClient([], {})
|
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||||
const source = createSource(client)
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||||
const calendar = extractCalendar(entries)
|
|
||||||
|
|
||||||
expect(calendar).toBeDefined()
|
expect(calendar).toBeDefined()
|
||||||
expect(calendar!.inProgress).toEqual([])
|
expect(calendar!.inProgress).toEqual([])
|
||||||
@@ -320,30 +308,34 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("identifies in-progress events", async () => {
|
test("identifies in-progress events", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
// 14:30 is during the 14:00-15:00 event
|
// 14:30 is during the 14:00-15:00 event
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(1)
|
expect(calendar!.inProgress).toHaveLength(1)
|
||||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("identifies next upcoming event", async () => {
|
test("identifies next upcoming event", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
// 12:00 is before the 14:00 event
|
// 12:00 is before the 14:00 event
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
expect(calendar!.nextEvent).not.toBeNull()
|
expect(calendar!.nextEvent).not.toBeNull()
|
||||||
@@ -351,14 +343,16 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("excludes all-day events from inProgress and nextEvent", async () => {
|
test("excludes all-day events from inProgress and nextEvent", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
expect(calendar!.nextEvent).toBeNull()
|
||||||
@@ -367,17 +361,19 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("counts all events including all-day in todayEventCount", async () => {
|
test("counts all events including all-day in todayEventCount", async () => {
|
||||||
const objects: Record<string, CalDavDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [
|
"/cal/work": [
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||||
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||||
|
davClient: client,
|
||||||
|
})
|
||||||
|
|
||||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = extractCalendar(entries)
|
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||||
|
|
||||||
expect(calendar!.todayEventCount).toBe(2)
|
expect(calendar!.todayEventCount).toBe(2)
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
expect(calendar!.hasTodayEvents).toBe(true)
|
||||||
@@ -387,7 +383,7 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
describe("computeSignals", () => {
|
describe("computeSignals", () => {
|
||||||
const now = new Date("2026-01-15T12:00:00Z")
|
const now = new Date("2026-01-15T12:00:00Z")
|
||||||
|
|
||||||
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
|
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
||||||
return {
|
return {
|
||||||
uid: "test-uid",
|
uid: "test-uid",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
@@ -486,29 +482,4 @@ describe("computeSignals", () => {
|
|||||||
})
|
})
|
||||||
expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
|
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
252
packages/aris-source-apple-calendar/src/calendar-source.ts
Normal file
252
packages/aris-source-apple-calendar/src/calendar-source.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
|
import { DAVClient } from "tsdav"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CalendarCredentialProvider,
|
||||||
|
CalendarCredentials,
|
||||||
|
CalendarDAVClient,
|
||||||
|
CalendarEventData,
|
||||||
|
CalendarFeedItem,
|
||||||
|
} from "./types.ts"
|
||||||
|
|
||||||
|
export interface CalendarSourceOptions {
|
||||||
|
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
|
||||||
|
lookAheadDays?: number
|
||||||
|
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
|
||||||
|
davClient?: CalendarDAVClient
|
||||||
|
}
|
||||||
|
|
||||||
|
import { CalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
|
import { parseICalEvents } from "./ical-parser.ts"
|
||||||
|
|
||||||
|
const ICLOUD_CALDAV_URL = "https://caldav.icloud.com"
|
||||||
|
const DEFAULT_LOOK_AHEAD_DAYS = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FeedSource that fetches Apple Calendar events via CalDAV.
|
||||||
|
*
|
||||||
|
* Credentials are provided by an injected CalendarCredentialProvider.
|
||||||
|
* The server is responsible for managing OAuth tokens and storage.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const source = new CalendarSource(credentialProvider, "user-123")
|
||||||
|
* const engine = new FeedEngine()
|
||||||
|
* engine.register(source)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class CalendarSource implements FeedSource<CalendarFeedItem> {
|
||||||
|
readonly id = "aris.apple-calendar"
|
||||||
|
|
||||||
|
private readonly credentialProvider: CalendarCredentialProvider
|
||||||
|
private readonly userId: string
|
||||||
|
private readonly lookAheadDays: number
|
||||||
|
private readonly injectedClient: CalendarDAVClient | null
|
||||||
|
private davClient: CalendarDAVClient | null = null
|
||||||
|
private lastAccessToken: string | null = null
|
||||||
|
private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
credentialProvider: CalendarCredentialProvider,
|
||||||
|
userId: string,
|
||||||
|
options?: CalendarSourceOptions,
|
||||||
|
) {
|
||||||
|
this.credentialProvider = credentialProvider
|
||||||
|
this.userId = userId
|
||||||
|
this.lookAheadDays = options?.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
|
||||||
|
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 {
|
||||||
|
[CalendarKey]: {
|
||||||
|
inProgress: [],
|
||||||
|
nextEvent: null,
|
||||||
|
hasTodayEvents: false,
|
||||||
|
todayEventCount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = context.time
|
||||||
|
const inProgress = events.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
|
||||||
|
|
||||||
|
const upcoming = events
|
||||||
|
.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 { [CalendarKey]: calendarContext }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||||
|
const now = context.time
|
||||||
|
const events = await this.fetchEvents(context)
|
||||||
|
return events.map((event) => createFeedItem(event, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
|
||||||
|
if (this.cachedEvents && this.cachedEvents.time === context.time) {
|
||||||
|
return this.cachedEvents.events
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await this.credentialProvider.fetchCredentials(this.userId)
|
||||||
|
if (!credentials) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await this.connectClient(credentials)
|
||||||
|
const calendars = await client.fetchCalendars()
|
||||||
|
|
||||||
|
const { start, end } = computeTimeRange(context.time, this.lookAheadDays)
|
||||||
|
|
||||||
|
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
|
||||||
|
// because the XML parser can return an object for some responses
|
||||||
|
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
|
||||||
|
return { objects, calendarName }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const allEvents: CalendarEventData[] = []
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status !== "fulfilled") 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a ready-to-use DAVClient. Creates and logs in a new client
|
||||||
|
* on first call; reuses the existing one on subsequent calls, updating
|
||||||
|
* credentials if the access token has changed.
|
||||||
|
*/
|
||||||
|
private async connectClient(credentials: CalendarCredentials): Promise<CalendarDAVClient> {
|
||||||
|
if (this.injectedClient) {
|
||||||
|
return this.injectedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
const davCredentials = {
|
||||||
|
tokenUrl: credentials.tokenUrl,
|
||||||
|
refreshToken: credentials.refreshToken,
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
expiration: credentials.expiresAt,
|
||||||
|
clientId: credentials.clientId,
|
||||||
|
clientSecret: credentials.clientSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.davClient) {
|
||||||
|
this.davClient = new DAVClient({
|
||||||
|
serverUrl: ICLOUD_CALDAV_URL,
|
||||||
|
credentials: davCredentials,
|
||||||
|
authMethod: "Oauth",
|
||||||
|
defaultAccountType: "caldav",
|
||||||
|
})
|
||||||
|
await this.davClient.login()
|
||||||
|
this.lastAccessToken = credentials.accessToken
|
||||||
|
return this.davClient
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.accessToken !== this.lastAccessToken) {
|
||||||
|
this.davClient.credentials = davCredentials
|
||||||
|
this.lastAccessToken = credentials.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.davClient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: Date } {
|
||||||
|
const start = new Date(now)
|
||||||
|
start.setUTCHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setUTCDate(end.getUTCDate() + 1 + lookAheadDays)
|
||||||
|
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSignals(event: CalendarEventData, now: Date): FeedItemSignals {
|
||||||
|
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
|
||||||
|
const startOfDay = new Date(now)
|
||||||
|
startOfDay.setUTCHours(0, 0, 0, 0)
|
||||||
|
const endOfDay = new Date(startOfDay)
|
||||||
|
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
|
||||||
|
|
||||||
|
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: CalendarEventData, now: Date): CalendarFeedItem {
|
||||||
|
return {
|
||||||
|
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
|
type: "calendar-event",
|
||||||
|
timestamp: now,
|
||||||
|
data: event,
|
||||||
|
signals: computeSignals(event, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,20 @@ import ICAL from "ical.js"
|
|||||||
import {
|
import {
|
||||||
AttendeeRole,
|
AttendeeRole,
|
||||||
AttendeeStatus,
|
AttendeeStatus,
|
||||||
CalDavEventStatus,
|
CalendarEventStatus,
|
||||||
type CalDavAlarm,
|
type CalendarAlarm,
|
||||||
type CalDavAttendee,
|
type CalendarAttendee,
|
||||||
type CalDavEventData,
|
type CalendarEventData,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||||
* into CalDavEventData objects.
|
* into CalendarEventData objects.
|
||||||
*
|
*
|
||||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||||
* @param calendarName - Display name of the calendar this event belongs to
|
* @param calendarName - Display name of the calendar this event belongs to
|
||||||
*/
|
*/
|
||||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
|
export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] {
|
||||||
const jcal = ICAL.parse(icsData)
|
const jcal = ICAL.parse(icsData)
|
||||||
const comp = new ICAL.Component(jcal)
|
const comp = new ICAL.Component(jcal)
|
||||||
const vevents = comp.getAllSubcomponents("vevent")
|
const vevents = comp.getAllSubcomponents("vevent")
|
||||||
@@ -29,7 +29,7 @@ export function parseICalEvents(icsData: string, calendarName: string | null): C
|
|||||||
function parseVEvent(
|
function parseVEvent(
|
||||||
vevent: InstanceType<typeof ICAL.Component>,
|
vevent: InstanceType<typeof ICAL.Component>,
|
||||||
calendarName: string | null,
|
calendarName: string | null,
|
||||||
): CalDavEventData {
|
): CalendarEventData {
|
||||||
const event = new ICAL.Event(vevent)
|
const event = new ICAL.Event(vevent)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -50,15 +50,15 @@ function parseVEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStatus(raw: string | null): CalDavEventStatus | null {
|
function parseStatus(raw: string | null): CalendarEventStatus | null {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
switch (raw.toLowerCase()) {
|
switch (raw.toLowerCase()) {
|
||||||
case "confirmed":
|
case "confirmed":
|
||||||
return CalDavEventStatus.Confirmed
|
return CalendarEventStatus.Confirmed
|
||||||
case "tentative":
|
case "tentative":
|
||||||
return CalDavEventStatus.Tentative
|
return CalendarEventStatus.Tentative
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return CalDavEventStatus.Cancelled
|
return CalendarEventStatus.Cancelled
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -81,25 +81,22 @@ function parseOrganizer(
|
|||||||
return value.replace(/^mailto:/i, "")
|
return value.replace(/^mailto:/i, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
|
function parseAttendees(properties: unknown[]): CalendarAttendee[] {
|
||||||
if (properties.length === 0) return []
|
if (properties.length === 0) return []
|
||||||
|
|
||||||
return properties.flatMap((prop) => {
|
return properties.map((prop) => {
|
||||||
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
|
|
||||||
const p = prop as InstanceType<typeof ICAL.Property>
|
const p = prop as InstanceType<typeof ICAL.Property>
|
||||||
const value = asStringOrNull(p.getFirstValue())
|
const value = asStringOrNull(p.getFirstValue())
|
||||||
const cn = asStringOrNull(p.getParameter("cn"))
|
const cn = asStringOrNull(p.getParameter("cn"))
|
||||||
const role = asStringOrNull(p.getParameter("role"))
|
const role = asStringOrNull(p.getParameter("role"))
|
||||||
const partstat = asStringOrNull(p.getParameter("partstat"))
|
const partstat = asStringOrNull(p.getParameter("partstat"))
|
||||||
|
|
||||||
return [
|
return {
|
||||||
{
|
name: cn,
|
||||||
name: cn,
|
email: value ? value.replace(/^mailto:/i, "") : null,
|
||||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
role: parseAttendeeRole(role),
|
||||||
role: parseAttendeeRole(role),
|
status: parseAttendeeStatus(partstat),
|
||||||
status: parseAttendeeStatus(partstat),
|
}
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +130,7 @@ function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
|
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
|
||||||
const valarms = vevent.getAllSubcomponents("valarm")
|
const valarms = vevent.getAllSubcomponents("valarm")
|
||||||
if (!valarms || valarms.length === 0) return []
|
if (!valarms || valarms.length === 0) return []
|
||||||
|
|
||||||
16
packages/aris-source-apple-calendar/src/index.ts
Normal file
16
packages/aris-source-apple-calendar/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
|
export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
|
||||||
|
export {
|
||||||
|
CalendarEventStatus,
|
||||||
|
AttendeeRole,
|
||||||
|
AttendeeStatus,
|
||||||
|
type CalendarCredentials,
|
||||||
|
type CalendarCredentialProvider,
|
||||||
|
type CalendarDAVClient,
|
||||||
|
type CalendarDAVCalendar,
|
||||||
|
type CalendarDAVObject,
|
||||||
|
type CalendarAttendee,
|
||||||
|
type CalendarAlarm,
|
||||||
|
type CalendarEventData,
|
||||||
|
type CalendarFeedItem,
|
||||||
|
} from "./types.ts"
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
import type { FeedItem } from "@aris/core"
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
// -- Event status --
|
// -- Credential provider --
|
||||||
|
|
||||||
export const CalDavEventStatus = {
|
export interface CalendarCredentials {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
/** Unix timestamp in milliseconds when the access token expires */
|
||||||
|
expiresAt: number
|
||||||
|
tokenUrl: string
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarCredentialProvider {
|
||||||
|
fetchCredentials(userId: string): Promise<CalendarCredentials | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Feed item types --
|
||||||
|
|
||||||
|
export const CalendarEventStatus = {
|
||||||
Confirmed: "confirmed",
|
Confirmed: "confirmed",
|
||||||
Tentative: "tentative",
|
Tentative: "tentative",
|
||||||
Cancelled: "cancelled",
|
Cancelled: "cancelled",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
|
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
|
||||||
|
|
||||||
// -- Attendee types --
|
|
||||||
|
|
||||||
export const AttendeeRole = {
|
export const AttendeeRole = {
|
||||||
Chair: "chair",
|
Chair: "chair",
|
||||||
@@ -29,25 +43,21 @@ export const AttendeeStatus = {
|
|||||||
|
|
||||||
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
||||||
|
|
||||||
export interface CalDavAttendee {
|
export interface CalendarAttendee {
|
||||||
name: string | null
|
name: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
role: AttendeeRole | null
|
role: AttendeeRole | null
|
||||||
status: AttendeeStatus | null
|
status: AttendeeStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Alarm --
|
export interface CalendarAlarm {
|
||||||
|
|
||||||
export interface CalDavAlarm {
|
|
||||||
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
||||||
trigger: string
|
trigger: string
|
||||||
/** e.g. "DISPLAY", "AUDIO" */
|
/** e.g. "DISPLAY", "AUDIO" */
|
||||||
action: string
|
action: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Event data --
|
export interface CalendarEventData extends Record<string, unknown> {
|
||||||
|
|
||||||
export interface CalDavEventData extends Record<string, unknown> {
|
|
||||||
uid: string
|
uid: string
|
||||||
title: string
|
title: string
|
||||||
startDate: Date
|
startDate: Date
|
||||||
@@ -56,46 +66,36 @@ export interface CalDavEventData extends Record<string, unknown> {
|
|||||||
location: string | null
|
location: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
calendarName: string | null
|
calendarName: string | null
|
||||||
status: CalDavEventStatus | null
|
status: CalendarEventStatus | null
|
||||||
url: string | null
|
url: string | null
|
||||||
organizer: string | null
|
organizer: string | null
|
||||||
attendees: CalDavAttendee[]
|
attendees: CalendarAttendee[]
|
||||||
alarms: CalDavAlarm[]
|
alarms: CalendarAlarm[]
|
||||||
recurrenceId: string | null
|
recurrenceId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Feed item type --
|
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
|
||||||
|
|
||||||
export const CalDavFeedItemType = {
|
|
||||||
Event: "caldav-event",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
|
|
||||||
|
|
||||||
// -- Feed item --
|
|
||||||
|
|
||||||
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData>
|
|
||||||
|
|
||||||
// -- DAV client interface --
|
// -- DAV client interface --
|
||||||
|
|
||||||
export interface CalDavDAVObject {
|
export interface CalendarDAVObject {
|
||||||
data?: unknown
|
data?: unknown
|
||||||
etag?: string
|
etag?: string
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalDavDAVCalendar {
|
export interface CalendarDAVCalendar {
|
||||||
displayName?: string | Record<string, unknown>
|
displayName?: string | Record<string, unknown>
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subset of tsdav's DAVClient used by CalDavSource. */
|
/** Subset of DAVClient used by CalendarSource. */
|
||||||
export interface CalDavDAVClient {
|
export interface CalendarDAVClient {
|
||||||
login(): Promise<void>
|
login(): Promise<void>
|
||||||
fetchCalendars(): Promise<CalDavDAVCalendar[]>
|
fetchCalendars(): Promise<CalendarDAVCalendar[]>
|
||||||
fetchCalendarObjects(params: {
|
fetchCalendarObjects(params: {
|
||||||
calendar: CalDavDAVCalendar
|
calendar: CalendarDAVCalendar
|
||||||
timeRange: { start: string; end: string }
|
timeRange: { start: string; end: string }
|
||||||
}): Promise<CalDavDAVObject[]>
|
}): Promise<CalendarDAVObject[]>
|
||||||
credentials: Record<string, unknown>
|
credentials: Record<string, unknown>
|
||||||
}
|
}
|
||||||
@@ -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,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live test script for CalDavSource.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun run test-live.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Context } from "@aris/core"
|
|
||||||
|
|
||||||
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 = new Context()
|
|
||||||
|
|
||||||
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,351 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
|
||||||
import { DAVClient } from "tsdav"
|
|
||||||
|
|
||||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
|
||||||
|
|
||||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
|
||||||
import { parseICalEvents } from "./ical-parser.ts"
|
|
||||||
import { CalDavEventStatus, CalDavFeedItemType } from "./types.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<readonly ContextEntry[] | 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: CalDavFeedItemType.Event,
|
|
||||||
timestamp: now,
|
|
||||||
data: event,
|
|
||||||
signals: computeSignals(event, now, timeZone),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +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,
|
|
||||||
CalDavFeedItemType,
|
|
||||||
type CalDavAlarm,
|
|
||||||
type CalDavAttendee,
|
|
||||||
type CalDavDAVCalendar,
|
|
||||||
type CalDavDAVClient,
|
|
||||||
type CalDavDAVObject,
|
|
||||||
type CalDavEventData,
|
|
||||||
type CalDavFeedItem,
|
|
||||||
} from "./types.ts"
|
|
||||||
@@ -10,4 +10,4 @@ export interface NextEvent {
|
|||||||
location: string | null
|
location: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { CalendarEventData } from "./types"
|
import type { CalendarEventData } from "./types"
|
||||||
|
|
||||||
export const CalendarFeedItemType = {
|
export const CalendarFeedItemType = {
|
||||||
Event: "calendar-event",
|
event: "calendar-event",
|
||||||
AllDay: "calendar-all-day",
|
allDay: "calendar-all-day",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
|
||||||
|
|
||||||
export interface CalendarEventFeedItem extends FeedItem<
|
export interface CalendarEventFeedItem extends FeedItem<
|
||||||
typeof CalendarFeedItemType.Event,
|
typeof CalendarFeedItemType.event,
|
||||||
CalendarEventData
|
CalendarEventData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export interface CalendarAllDayFeedItem extends FeedItem<
|
export interface CalendarAllDayFeedItem extends FeedItem<
|
||||||
typeof CalendarFeedItemType.AllDay,
|
typeof CalendarFeedItemType.allDay,
|
||||||
CalendarEventData
|
CalendarEventData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Context, TimeRelevance } from "@aris/core"
|
import { TimeRelevance, contextValue, type Context } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||||
|
|
||||||
import fixture from "../fixtures/events.json"
|
import fixture from "../fixtures/events.json"
|
||||||
import { NextEventKey, type NextEvent } from "./calendar-context"
|
import { NextEventKey } from "./calendar-context"
|
||||||
import { CalendarFeedItemType } from "./feed-items"
|
import { CalendarFeedItemType } from "./feed-items"
|
||||||
import { GoogleCalendarSource } from "./google-calendar-source"
|
import { GoogleCalendarSource } from "./google-calendar-source"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time?: Date): Context {
|
function createContext(time?: Date): Context {
|
||||||
return new Context(time ?? NOW)
|
return { time: time ?? NOW }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("GoogleCalendarSource", () => {
|
describe("GoogleCalendarSource", () => {
|
||||||
@@ -69,7 +69,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.Event)
|
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
|
||||||
expect(timedItems.length).toBe(4)
|
expect(timedItems.length).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.AllDay)
|
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
|
||||||
expect(allDayItems.length).toBe(1)
|
expect(allDayItems.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -229,16 +229,15 @@ describe("GoogleCalendarSource", () => {
|
|||||||
|
|
||||||
test("returns next upcoming timed event (not ongoing)", async () => {
|
test("returns next upcoming timed event (not ongoing)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const entries = await source.fetchContext(createContext())
|
const result = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(entries).toHaveLength(1)
|
const nextEvent = contextValue(result! as Context, NextEventKey)
|
||||||
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
expect(nextEvent).toBeDefined()
|
||||||
expect(key).toEqual(NextEventKey)
|
|
||||||
// evt-soon starts at 10:10, which is the nearest future timed event
|
// evt-soon starts at 10:10, which is the nearest future timed event
|
||||||
expect(nextEvent.title).toBe("1:1 with Manager")
|
expect(nextEvent!.title).toBe("1:1 with Manager")
|
||||||
expect(nextEvent.minutesUntilStart).toBe(10)
|
expect(nextEvent!.minutesUntilStart).toBe(10)
|
||||||
expect(nextEvent.location).toBeNull()
|
expect(nextEvent!.location).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes location when available", async () => {
|
test("includes location when available", async () => {
|
||||||
@@ -256,11 +255,12 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({
|
const source = new GoogleCalendarSource({
|
||||||
client: createMockClient({ primary: events }),
|
client: createMockClient({ primary: events }),
|
||||||
})
|
})
|
||||||
const entries = await source.fetchContext(createContext())
|
const result = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
const nextEvent = contextValue(result! as Context, NextEventKey)
|
||||||
expect(nextEvent.location).toBe("123 Main St")
|
expect(nextEvent).toBeDefined()
|
||||||
|
expect(nextEvent!.location).toBe("123 Main St")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips ongoing events for next-event context", async () => {
|
test("skips ongoing events for next-event context", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiCalendarEvent,
|
ApiCalendarEvent,
|
||||||
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
|
|||||||
* .register(calendarSource)
|
* .register(calendarSource)
|
||||||
*
|
*
|
||||||
* // Access next-event context in downstream sources
|
* // Access next-event context in downstream sources
|
||||||
* const next = context.get(NextEventKey)
|
* const next = contextValue(context, NextEventKey)
|
||||||
* if (next && next.minutesUntilStart < 15) {
|
* if (next && next.minutesUntilStart < 15) {
|
||||||
* // remind user
|
* // remind user
|
||||||
* }
|
* }
|
||||||
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||||
const events = await this.fetchAllEvents(context.time)
|
const events = await this.fetchAllEvents(context.time)
|
||||||
|
|
||||||
const now = context.time.getTime()
|
const now = context.time.getTime()
|
||||||
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
location: nextTimedEvent.location,
|
location: nextTimedEvent.location,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[NextEventKey, nextEvent]]
|
return { [NextEventKey]: nextEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||||
@@ -209,7 +209,7 @@ function createFeedItem(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
lookaheadMs: number,
|
lookaheadMs: number,
|
||||||
): CalendarFeedItem {
|
): CalendarFeedItem {
|
||||||
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
|
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { NextEventKey, type NextEvent } from "./calendar-context"
|
export { NextEventKey, type NextEvent } from "./calendar-context"
|
||||||
export {
|
export {
|
||||||
CalendarFeedItemType,
|
CalendarFeedItemType,
|
||||||
|
type CalendarFeedItemType as CalendarFeedItemTypeType,
|
||||||
type CalendarAllDayFeedItem,
|
type CalendarAllDayFeedItem,
|
||||||
type CalendarEventFeedItem,
|
type CalendarEventFeedItem,
|
||||||
type CalendarFeedItem,
|
type CalendarFeedItem,
|
||||||
@@ -9,6 +10,7 @@ export { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
|||||||
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
|
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
|
||||||
export {
|
export {
|
||||||
EventStatus,
|
EventStatus,
|
||||||
|
type EventStatus as EventStatusType,
|
||||||
type ApiCalendarEvent,
|
type ApiCalendarEvent,
|
||||||
type ApiEventDateTime,
|
type ApiEventDateTime,
|
||||||
type CalendarEventData,
|
type CalendarEventData,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
import type { Location } from "./types.ts"
|
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
|
||||||
|
|
||||||
import { LocationKey, LocationSource } from "./location-source.ts"
|
|
||||||
|
|
||||||
function createLocation(overrides: Partial<Location> = {}): Location {
|
function createLocation(overrides: Partial<Location> = {}): Location {
|
||||||
return {
|
return {
|
||||||
@@ -41,8 +39,8 @@ describe("LocationSource", () => {
|
|||||||
const location = createLocation()
|
const location = createLocation()
|
||||||
source.pushLocation(location)
|
source.pushLocation(location)
|
||||||
|
|
||||||
const entries = await source.fetchContext()
|
const context = await source.fetchContext()
|
||||||
expect(entries).toEqual([[LocationKey, location]])
|
expect(context).toEqual({ [LocationKey]: location })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,7 +65,7 @@ describe("LocationSource", () => {
|
|||||||
source.pushLocation(location)
|
source.pushLocation(location)
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1)
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
expect(listener).toHaveBeenCalledWith([[LocationKey, location]])
|
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core"
|
import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
import { Location, type LocationSourceOptions } from "./types.ts"
|
import { Location, type LocationSourceOptions } from "./types.ts"
|
||||||
|
|
||||||
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location")
|
export const LocationKey: ContextKey<Location> = contextKey("location")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A FeedSource that provides location context.
|
* A FeedSource that provides location context.
|
||||||
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
|
|||||||
|
|
||||||
private readonly historySize: number
|
private readonly historySize: number
|
||||||
private locations: Location[] = []
|
private locations: Location[] = []
|
||||||
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
|
private listeners = new Set<(update: Partial<Context>) => void>()
|
||||||
|
|
||||||
constructor(options: LocationSourceOptions = {}) {
|
constructor(options: LocationSourceOptions = {}) {
|
||||||
this.historySize = options.historySize ?? 1
|
this.historySize = options.historySize ?? 1
|
||||||
@@ -59,9 +59,8 @@ export class LocationSource implements FeedSource {
|
|||||||
if (this.locations.length > this.historySize) {
|
if (this.locations.length > this.historySize) {
|
||||||
this.locations.shift()
|
this.locations.shift()
|
||||||
}
|
}
|
||||||
const entries: readonly ContextEntry[] = [[LocationKey, location]]
|
|
||||||
this.listeners.forEach((listener) => {
|
this.listeners.forEach((listener) => {
|
||||||
listener(entries)
|
listener({ [LocationKey]: location })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +78,16 @@ export class LocationSource implements FeedSource {
|
|||||||
return this.locations
|
return this.locations
|
||||||
}
|
}
|
||||||
|
|
||||||
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
|
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
|
||||||
this.listeners.add(callback)
|
this.listeners.add(callback)
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners.delete(callback)
|
this.listeners.delete(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<Partial<Context> | null> {
|
||||||
if (this.lastLocation) {
|
if (this.lastLocation) {
|
||||||
return [[LocationKey, this.lastLocation]]
|
return { [LocationKey]: this.lastLocation }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
export { TflSource } from "./tfl-source.ts"
|
export { TflSource } from "./tfl-source.ts"
|
||||||
export { TflApi } from "./tfl-api.ts"
|
export { TflApi } from "./tfl-api.ts"
|
||||||
export type { TflLineId } from "./tfl-api.ts"
|
export type { TflLineId } from "./tfl-api.ts"
|
||||||
export {
|
export type {
|
||||||
TflFeedItemType,
|
ITflApi,
|
||||||
type ITflApi,
|
StationLocation,
|
||||||
type StationLocation,
|
TflAlertData,
|
||||||
type TflAlertData,
|
TflAlertFeedItem,
|
||||||
type TflAlertFeedItem,
|
TflAlertSeverity,
|
||||||
type TflAlertSeverity,
|
TflLineStatus,
|
||||||
type TflLineStatus,
|
TflSourceOptions,
|
||||||
type TflSourceOptions,
|
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Context } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { LocationKey, type Location } from "@aris/source-location"
|
import { LocationKey, type Location } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -80,9 +81,9 @@ class FixtureTflApi implements ITflApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(location?: Location): Context {
|
function createContext(location?: Location): Context {
|
||||||
const ctx = new Context(new Date("2026-01-15T12:00:00Z"))
|
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
|
||||||
if (location) {
|
if (location) {
|
||||||
ctx.set([[LocationKey, location]])
|
ctx[LocationKey] = location
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { TflApi, lineId } from "./tfl-api.ts"
|
import { TflApi, lineId } from "./tfl-api.ts"
|
||||||
import { TflFeedItemType } from "./types.ts"
|
|
||||||
|
|
||||||
const setLinesInput = lineId.array()
|
const setLinesInput = lineId.array()
|
||||||
|
|
||||||
@@ -112,7 +111,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<null> {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
this.client.fetchStations(),
|
this.client.fetchStations(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
const closestStationDistance = location
|
const closestStationDistance = location
|
||||||
@@ -151,7 +150,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
type: TflFeedItemType.Alert,
|
type: "tfl-alert",
|
||||||
timestamp: context.time,
|
timestamp: context.time,
|
||||||
data,
|
data,
|
||||||
signals,
|
signals,
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ export interface TflAlertData extends Record<string, unknown> {
|
|||||||
closestStationDistance: number | null
|
closestStationDistance: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TflFeedItemType = {
|
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||||
Alert: "tfl-alert",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
|
|
||||||
|
|
||||||
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
|
|
||||||
|
|
||||||
export interface TflSourceOptions {
|
export interface TflSourceOptions {
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
|
|||||||
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
export const WeatherFeedItemType = {
|
export const WeatherFeedItemType = {
|
||||||
Current: "weather-current",
|
current: "weather-current",
|
||||||
Hourly: "weather-hourly",
|
hourly: "weather-hourly",
|
||||||
Daily: "weather-daily",
|
daily: "weather-daily",
|
||||||
Alert: "weather-alert",
|
alert: "weather-alert",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentWeatherFeedItem extends FeedItem<
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Current,
|
typeof WeatherFeedItemType.current,
|
||||||
CurrentWeatherData
|
CurrentWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HourlyWeatherFeedItem extends FeedItem<
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Hourly,
|
typeof WeatherFeedItemType.hourly,
|
||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyWeatherFeedItem extends FeedItem<
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Daily,
|
typeof WeatherFeedItemType.daily,
|
||||||
DailyWeatherData
|
DailyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherAlertFeedItem extends FeedItem<
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
typeof WeatherFeedItemType.Alert,
|
typeof WeatherFeedItemType.alert,
|
||||||
WeatherAlertData
|
WeatherAlertData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
export { WeatherKey, type Weather } from "./weather-context"
|
export { WeatherKey, type Weather } from "./weather-context"
|
||||||
export { WeatherSource, Units, type WeatherSourceOptions } from "./weather-source"
|
export {
|
||||||
|
WeatherSource,
|
||||||
|
Units,
|
||||||
|
type Units as UnitsType,
|
||||||
|
type WeatherSourceOptions,
|
||||||
|
} from "./weather-source"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
|
type WeatherFeedItemType as WeatherFeedItemTypeType,
|
||||||
type WeatherFeedItem,
|
type WeatherFeedItem,
|
||||||
type CurrentWeatherFeedItem,
|
type CurrentWeatherFeedItem,
|
||||||
type CurrentWeatherData,
|
type CurrentWeatherData,
|
||||||
@@ -21,6 +27,11 @@ export {
|
|||||||
Certainty,
|
Certainty,
|
||||||
PrecipitationType,
|
PrecipitationType,
|
||||||
DefaultWeatherKitClient,
|
DefaultWeatherKitClient,
|
||||||
|
type ConditionCode as ConditionCodeType,
|
||||||
|
type Severity as SeverityType,
|
||||||
|
type Urgency as UrgencyType,
|
||||||
|
type Certainty as CertaintyType,
|
||||||
|
type PrecipitationType as PrecipitationTypeType,
|
||||||
type WeatherKitClient,
|
type WeatherKitClient,
|
||||||
type WeatherKitCredentials,
|
type WeatherKitCredentials,
|
||||||
type WeatherKitQueryOptions,
|
type WeatherKitQueryOptions,
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export interface Weather {
|
|||||||
daylight: boolean
|
daylight: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "weather")
|
export const WeatherKey: ContextKey<Weather> = contextKey("weather")
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { FeedSource } from "@aris/core"
|
import { contextValue, type Context } from "@aris/core"
|
||||||
|
|
||||||
import { Context } from "@aris/core"
|
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -8,7 +6,7 @@ import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
|||||||
|
|
||||||
import fixture from "../fixtures/san-francisco.json"
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
import { WeatherFeedItemType } from "./feed-items"
|
import { WeatherFeedItemType } from "./feed-items"
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey } from "./weather-context"
|
||||||
import { WeatherSource, Units } from "./weather-source"
|
import { WeatherSource, Units } from "./weather-source"
|
||||||
|
|
||||||
const mockCredentials = {
|
const mockCredentials = {
|
||||||
@@ -25,9 +23,9 @@ function createMockClient(response: WeatherKitResponse): WeatherKitClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
function createMockContext(location?: { lat: number; lng: number }): Context {
|
||||||
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
|
||||||
if (location) {
|
if (location) {
|
||||||
ctx.set([[LocationKey, { ...location, accuracy: 10, timestamp: new Date() }]])
|
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -65,19 +63,18 @@ describe("WeatherSource", () => {
|
|||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const entries = await source.fetchContext(context)
|
const result = await source.fetchContext(context)
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(entries).toHaveLength(1)
|
const weather = contextValue(result! as Context, WeatherKey)
|
||||||
|
|
||||||
const [key, weather] = entries![0]! as [typeof WeatherKey, Weather]
|
expect(weather).toBeDefined()
|
||||||
expect(key).toEqual(WeatherKey)
|
expect(typeof weather!.temperature).toBe("number")
|
||||||
expect(typeof weather.temperature).toBe("number")
|
expect(typeof weather!.temperatureApparent).toBe("number")
|
||||||
expect(typeof weather.temperatureApparent).toBe("number")
|
expect(typeof weather!.condition).toBe("string")
|
||||||
expect(typeof weather.condition).toBe("string")
|
expect(typeof weather!.humidity).toBe("number")
|
||||||
expect(typeof weather.humidity).toBe("number")
|
expect(typeof weather!.uvIndex).toBe("number")
|
||||||
expect(typeof weather.uvIndex).toBe("number")
|
expect(typeof weather!.windSpeed).toBe("number")
|
||||||
expect(typeof weather.windSpeed).toBe("number")
|
expect(typeof weather!.daylight).toBe("boolean")
|
||||||
expect(typeof weather.daylight).toBe("boolean")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("converts temperature to imperial", async () => {
|
test("converts temperature to imperial", async () => {
|
||||||
@@ -87,12 +84,12 @@ describe("WeatherSource", () => {
|
|||||||
})
|
})
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const entries = await source.fetchContext(context)
|
const result = await source.fetchContext(context)
|
||||||
expect(entries).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
|
const weather = contextValue(result! as Context, WeatherKey)
|
||||||
|
|
||||||
const [, weather] = entries![0]! as [typeof WeatherKey, Weather]
|
|
||||||
// Fixture has temperature around 10°C, imperial should be around 50°F
|
// Fixture has temperature around 10°C, imperial should be around 50°F
|
||||||
expect(weather.temperature).toBeGreaterThan(40)
|
expect(weather!.temperature).toBeGreaterThan(40)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -113,9 +110,9 @@ describe("WeatherSource", () => {
|
|||||||
const items = await source.fetchItems(context)
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
expect(items.length).toBeGreaterThan(0)
|
expect(items.length).toBeGreaterThan(0)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies hourly and daily limits", async () => {
|
test("applies hourly and daily limits", async () => {
|
||||||
@@ -128,8 +125,8 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
const items = await source.fetchItems(context)
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(3)
|
expect(hourlyItems.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(2)
|
expect(dailyItems.length).toBe(2)
|
||||||
@@ -161,7 +158,7 @@ describe("WeatherSource", () => {
|
|||||||
expect(item.signals!.timeRelevance).toBeDefined()
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
})
|
})
|
||||||
@@ -180,12 +177,12 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
describe("no reactive methods", () => {
|
describe("no reactive methods", () => {
|
||||||
test("does not implement onContextUpdate", () => {
|
test("does not implement onContextUpdate", () => {
|
||||||
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
|
const source = new WeatherSource({ credentials: mockCredentials })
|
||||||
expect(source.onContextUpdate).toBeUndefined()
|
expect(source.onContextUpdate).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not implement onItemsUpdate", () => {
|
test("does not implement onItemsUpdate", () => {
|
||||||
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
|
const source = new WeatherSource({ credentials: mockCredentials })
|
||||||
expect(source.onItemsUpdate).toBeUndefined()
|
expect(source.onItemsUpdate).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||||
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Access weather context in downstream sources
|
* // Access weather context in downstream sources
|
||||||
* const weather = context.get(WeatherKey)
|
* const weather = contextValue(context, WeatherKey)
|
||||||
* if (weather?.condition === "Rain") {
|
* if (weather?.condition === "Rain") {
|
||||||
* // suggest umbrella
|
* // suggest umbrella
|
||||||
* }
|
* }
|
||||||
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
daylight: response.currentWeather.daylight,
|
daylight: response.currentWeather.daylight,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[WeatherKey, weather]]
|
return { [WeatherKey]: weather }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
||||||
const location = context.get(LocationKey)
|
const location = contextValue(context, LocationKey)
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.current,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -325,7 +325,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.hourly,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -359,7 +359,7 @@ function createDailyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.daily,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -386,7 +386,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.alert,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user