forked from GitHubMirrors/silverbullet-icalendar
Compare commits
1 Commits
main
...
819f6aa19b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819f6aa19b |
26
Makefile
26
Makefile
@@ -1,30 +1,10 @@
|
||||
# Makefile for iCalendar Plug
|
||||
|
||||
.PHONY: build test bump release check-versions
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
deno task test
|
||||
|
||||
# Increment patch version in deno.json
|
||||
bump:
|
||||
deno task bump-version
|
||||
|
||||
# Sync version from deno.json to all other files
|
||||
sync-version:
|
||||
deno task sync-version
|
||||
|
||||
# Check version consistency
|
||||
check-versions:
|
||||
./check_versions.sh
|
||||
|
||||
# Build the plug using local Deno
|
||||
build: sync-version
|
||||
deno task build
|
||||
|
||||
# Bump version and build
|
||||
release: bump build
|
||||
@echo "Release built successfully."
|
||||
# Build the plug using a Docker container with Deno
|
||||
build: check-versions
|
||||
docker run --rm -v /home/sstent/Projects/silverbullet-icalendar:/app -w /app denoland/deno:latest task build
|
||||
|
||||
# Helper to build and copy to a local test space (if needed)
|
||||
deploy-test: build
|
||||
|
||||
2
PLUG.md
2
PLUG.md
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Library/sstent/icalendar
|
||||
version: "0.4.8"
|
||||
version: "0.4.1"
|
||||
tags: meta/library
|
||||
files:
|
||||
- icalendar.plug.js
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Extract versions
|
||||
DENO_VERSION=$(grep '"version":' deno.json | cut -d'"' -f4)
|
||||
TS_VERSION=$(grep "const VERSION =" icalendar.ts | cut -d'"' -f2)
|
||||
YAML_VERSION=$(grep "version:" icalendar.plug.yaml | head -n 1 | awk '{print $2}')
|
||||
PLUG_MD_VERSION=$(grep "version:" PLUG.md | head -n 1 | awk '{print $2}' | tr -d '"')
|
||||
PLUG_MD_VERSION=$(grep "version:" PLUG.md | head -n 1 | awk '{print $2}')
|
||||
|
||||
echo "Checking versions..."
|
||||
echo "deno.json: $DENO_VERSION"
|
||||
echo "icalendar.ts: $TS_VERSION"
|
||||
echo "icalendar.ts: $TS_VERSION"
|
||||
echo "icalendar.plug.yaml: $YAML_VERSION"
|
||||
echo "PLUG.md: $PLUG_MD_VERSION"
|
||||
echo "PLUG.md: $PLUG_MD_VERSION"
|
||||
|
||||
if [ "$DENO_VERSION" == "$TS_VERSION" ] && [ "$TS_VERSION" == "$YAML_VERSION" ] && [ "$YAML_VERSION" == "$PLUG_MD_VERSION" ]; then
|
||||
if [ "$TS_VERSION" == "$YAML_VERSION" ] && [ "$YAML_VERSION" == "$PLUG_MD_VERSION" ]; then
|
||||
echo "✅ All versions match."
|
||||
exit 0
|
||||
else
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
{
|
||||
"name": "icalendar-plug",
|
||||
"version": "0.4.8",
|
||||
"version": "0.4.1",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"sync-version": "deno run -A scripts/sync-version.ts",
|
||||
"bump-version": "deno run -A scripts/bump-version.ts",
|
||||
"build": "deno task sync-version && deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/2.4.1/plug-compile.js -c deno.json icalendar.plug.yaml",
|
||||
"watch": "deno task build --watch",
|
||||
"debug": "deno run -A https://raw.githubusercontent.com/silverbulletmd/silverbullet/v2.4.1/plug-compile.js -c deno.json icalendar.plug.yaml --debug",
|
||||
"test": "deno test -A icalendar_test.ts tests/integration_test.ts tests/reach_variations_test.ts timezones_test.ts"
|
||||
"debug": "deno run -A https://raw.githubusercontent.com/silverbulletmd/silverbullet/v2.4.1/plug-compile.js -c deno.json icalendar.plug.yaml --debug"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
|
||||
36
docker-compose.test.yml
Normal file
36
docker-compose.test.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
silverbullet-test:
|
||||
image: zefhemel/silverbullet:2.4.0
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./test_space_e2e:/space
|
||||
environment:
|
||||
- SB_LOG_PUSH=true
|
||||
- SB_DEBUG=true
|
||||
- SB_SPACE_LUA_TRUSTED=true
|
||||
|
||||
mock-ics-server:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./test_data:/usr/share/nginx/html:ro
|
||||
- ./test_data/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
network_mode: "service:silverbullet-test"
|
||||
volumes:
|
||||
- .:/work
|
||||
- /work/node_modules
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
working_dir: /work
|
||||
environment:
|
||||
- CI=true
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
- SB_URL=http://localhost:3000
|
||||
command: sh -c "npm install && npx playwright test"
|
||||
|
||||
volumes:
|
||||
sb-test-space:
|
||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
silverbullet:
|
||||
image: zefhemel/silverbullet:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./test_space:/space
|
||||
environment:
|
||||
- SB_USER=admin:admin
|
||||
- SB_LOG_PUSH=true
|
||||
- SB_DEBUG=true
|
||||
- SB_SPACE_LUA_TRUSTED=true
|
||||
|
||||
mock-ics:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./mock_calendar.ics:/usr/share/nginx/html/calendar.ics:ro
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
name: icalendar
|
||||
version: 0.4.8
|
||||
version: 0.4.1
|
||||
author: sstent
|
||||
index: icalendar.ts
|
||||
# Legacy SilverBullet permission name
|
||||
|
||||
181
icalendar.ts
181
icalendar.ts
@@ -1,9 +1,9 @@
|
||||
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import ICAL from "ical.js";
|
||||
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||
import { RRule, RRuleSet } from "rrule";
|
||||
import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts";
|
||||
|
||||
const VERSION = "0.4.8";
|
||||
const VERSION = "0.4.1";
|
||||
const CACHE_KEY = "icalendar:lastSync";
|
||||
|
||||
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
||||
@@ -65,44 +65,16 @@ async function sha256Hash(str: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts UTC Date to a specific timezone string
|
||||
* Uses toLocaleString for better compatibility
|
||||
* Converts Date to local time string (browser's timezone)
|
||||
*/
|
||||
export function dateToTimezoneString(date: Date, timezone: string = "America/Los_Angeles"): string {
|
||||
try {
|
||||
// Use toLocaleString which has better worker support
|
||||
const localeString = date.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
console.log(`[iCalendar] Converting ${date.toISOString()} to ${timezone}: ${localeString}`);
|
||||
|
||||
// Parse the result: "MM/DD/YYYY, HH:MM:SS"
|
||||
const match = localeString.match(/(\d{2})\/(\d{2})\/(\d{4}),\s*(\d{2}):(\d{2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [_, month, day, year, hour, minute, second] = match;
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
throw new Error("Failed to parse toLocaleString result");
|
||||
} catch (err) {
|
||||
console.error(`[iCalendar] Error converting to timezone ${timezone}:`, err);
|
||||
// Fallback to UTC
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return date.getUTCFullYear() + "-" +
|
||||
pad(date.getUTCMonth() + 1) + "-" +
|
||||
pad(date.getUTCDate()) + "T" +
|
||||
pad(date.getUTCHours()) + ":" +
|
||||
pad(date.getUTCMinutes()) + ":" +
|
||||
pad(date.getUTCSeconds());
|
||||
}
|
||||
export function localDateString(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return date.getFullYear() + "-" +
|
||||
pad(date.getMonth() + 1) + "-" +
|
||||
pad(date.getDate()) + "T" +
|
||||
pad(date.getHours()) + ":" +
|
||||
pad(date.getMinutes()) + ":" +
|
||||
pad(date.getSeconds());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,15 +122,13 @@ function convertDatesToStrings<T>(obj: T): any {
|
||||
// Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
async function getSources(): Promise<{ sources: any[], syncWindowDays: number, displayTimezone: string }> {
|
||||
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
|
||||
try {
|
||||
const rawConfig = await config.get("icalendar", { sources: [] }) as any;
|
||||
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
||||
|
||||
let sources = rawConfig.sources || [];
|
||||
const syncWindowDays = rawConfig.syncWindowDays || 365;
|
||||
// Get user's display timezone, default to America/Los_Angeles (PST)
|
||||
const displayTimezone = rawConfig.displayTimezone || "America/Los_Angeles";
|
||||
|
||||
if (sources && typeof sources === "object" && !Array.isArray(sources)) {
|
||||
const sourceArray = [];
|
||||
@@ -170,10 +140,10 @@ async function getSources(): Promise<{ sources: any[], syncWindowDays: number, d
|
||||
sources = sourceArray;
|
||||
}
|
||||
|
||||
return { sources, syncWindowDays, displayTimezone };
|
||||
return { sources, syncWindowDays };
|
||||
} catch (e) {
|
||||
console.error("[iCalendar] Error in getSources:", e);
|
||||
return { sources: [], syncWindowDays: 365, displayTimezone: "America/Los_Angeles" };
|
||||
return { sources: [], syncWindowDays: 365 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +213,7 @@ async function resolveEventEnd(icsEvent: any): Promise<Date | null> {
|
||||
/**
|
||||
* Expands recurring events into individual occurrences.
|
||||
*/
|
||||
export function expandRecurrences(icsEvent: any, windowDays = 365, displayTimezone = "America/Los_Angeles", now = new Date()): any[] {
|
||||
export function expandRecurrences(icsEvent: any, windowDays = 365, now = new Date()): any[] {
|
||||
const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
|
||||
if (!rruleStr) return [icsEvent];
|
||||
|
||||
@@ -298,9 +268,9 @@ export function expandRecurrences(icsEvent: any, windowDays = 365, displayTimezo
|
||||
return {
|
||||
...icsEvent,
|
||||
start: occurrenceDate.toISOString(),
|
||||
startLocal: dateToTimezoneString(occurrenceDate, displayTimezone),
|
||||
startLocal: localDateString(occurrenceDate),
|
||||
end: endDate ? endDate.toISOString() : undefined,
|
||||
endLocal: endDate ? dateToTimezoneString(endDate, displayTimezone) : undefined,
|
||||
endLocal: endDate ? localDateString(endDate) : undefined,
|
||||
recurrent: true,
|
||||
rrule: undefined,
|
||||
};
|
||||
@@ -313,7 +283,7 @@ export function expandRecurrences(icsEvent: any, windowDays = 365, displayTimezo
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndParseCalendar(source: any, windowDays = 365, displayTimezone = "America/Los_Angeles"): Promise<any[]> {
|
||||
async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> {
|
||||
try {
|
||||
const response = await fetch(source.url);
|
||||
if (!response.ok) {
|
||||
@@ -321,111 +291,47 @@ async function fetchAndParseCalendar(source: any, windowDays = 365, displayTimez
|
||||
return [];
|
||||
}
|
||||
const text = await response.text();
|
||||
const jcalData = ICAL.parse(text);
|
||||
const vcalendar = new ICAL.Component(jcalData);
|
||||
const vevents = vcalendar.getAllSubcomponents("vevent");
|
||||
|
||||
// First pass: map of UID -> Set of ISO strings for RECURRENCE-ID exceptions
|
||||
const overrides = new Map<string, Set<string>>();
|
||||
for (const vevent of vevents) {
|
||||
const recId = vevent.getFirstPropertyValue("recurrence-id") as any | null;
|
||||
const uid = vevent.getFirstPropertyValue("uid") as string | null;
|
||||
if (recId && uid) {
|
||||
if (!overrides.has(uid)) overrides.set(uid, new Set());
|
||||
overrides.get(uid)!.add(recId.toJSDate().toISOString());
|
||||
}
|
||||
const calendar = convertIcsCalendar(undefined, text);
|
||||
if (!calendar || !calendar.events) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const events: any[] = [];
|
||||
for (const vevent of vevents) {
|
||||
const icsEvent = new ICAL.Event(vevent);
|
||||
const status = vevent.getFirstPropertyValue("status") as string | null;
|
||||
const summary = icsEvent.summary || "";
|
||||
for (const icsEvent of calendar.events) {
|
||||
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
|
||||
|
||||
// Resolve start time (returns UTC Date)
|
||||
const startDateUTC = await resolveEventStart(icsEvent);
|
||||
if (!startDateUTC) continue;
|
||||
|
||||
// 1. Skip explicitly cancelled events
|
||||
if (status?.toUpperCase() === "CANCELLED") continue;
|
||||
if (summary.toLowerCase().startsWith("canceled:") || summary.toLowerCase().startsWith("cancelled:")) continue;
|
||||
|
||||
// 2. Skip declined events (look for PARTSTAT=DECLINED in attendees)
|
||||
const attendees = vevent.getAllProperties("attendee");
|
||||
let declined = false;
|
||||
for (const attendee of attendees) {
|
||||
const partstat = attendee.getParameter("partstat");
|
||||
if (partstat?.toUpperCase() === "DECLINED") {
|
||||
// Note: In a multi-user environment, we'd need to check if this is *the* user's status.
|
||||
// For a personal plug, we assume any DECLINED attendee means the user declined or the event is out.
|
||||
declined = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (declined) continue;
|
||||
|
||||
// Extract raw properties for recurrence expansion
|
||||
const uid = icsEvent.uid;
|
||||
const description = icsEvent.description;
|
||||
const location = icsEvent.location;
|
||||
const rrule = vevent.getFirstPropertyValue("rrule");
|
||||
const exdates = vevent.getAllProperties("exdate").map((p: any) => p.getFirstValue().toJSDate().toISOString());
|
||||
|
||||
// Add recurrence-id overrides to exdates so the Master doesn't expand them
|
||||
if (uid && overrides.has(uid) && !vevent.getFirstPropertyValue("recurrence-id")) {
|
||||
for (const overDate of overrides.get(uid)!) {
|
||||
exdates.push(overDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve start/end times
|
||||
const startDateUTC = icsEvent.startDate.toJSDate();
|
||||
const endDateUTC = icsEvent.endDate ? icsEvent.endDate.toJSDate() : null;
|
||||
// Resolve end time (returns UTC Date)
|
||||
const endDateUTC = await resolveEventEnd(icsEvent);
|
||||
|
||||
const rawTz = icsEvent.startDate.timezone || "UTC";
|
||||
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
|
||||
|
||||
const baseEvent = {
|
||||
uid,
|
||||
summary,
|
||||
name: summary || "Untitled Event",
|
||||
description,
|
||||
location,
|
||||
...icsEvent,
|
||||
name: icsEvent.summary || "Untitled Event",
|
||||
// Store both UTC (for sorting/comparison) and local (for display)
|
||||
start: startDateUTC.toISOString(),
|
||||
startLocal: dateToTimezoneString(startDateUTC, displayTimezone),
|
||||
startLocal: localDateString(startDateUTC),
|
||||
end: endDateUTC ? endDateUTC.toISOString() : undefined,
|
||||
endLocal: endDateUTC ? dateToTimezoneString(endDateUTC, displayTimezone) : undefined,
|
||||
endLocal: endDateUTC ? localDateString(endDateUTC) : undefined,
|
||||
tag: "ical-event",
|
||||
sourceName: source.name,
|
||||
timezone: rawTz,
|
||||
rrule: rrule ? rrule.toString() : undefined,
|
||||
exdate: exdates.length > 0 ? exdates : undefined
|
||||
timezone: rawTz
|
||||
};
|
||||
|
||||
if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) {
|
||||
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
|
||||
}
|
||||
|
||||
const expanded = expandRecurrences(baseEvent, windowDays, displayTimezone);
|
||||
const expanded = expandRecurrences(baseEvent, windowDays);
|
||||
for (const occurrence of expanded) {
|
||||
// Use summary in key to avoid collisions
|
||||
const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`;
|
||||
occurrence.ref = await sha256Hash(uniqueKey);
|
||||
|
||||
// Save our correctly formatted time strings
|
||||
const savedTimes = {
|
||||
start: occurrence.start,
|
||||
startLocal: occurrence.startLocal,
|
||||
end: occurrence.end,
|
||||
endLocal: occurrence.endLocal
|
||||
};
|
||||
|
||||
// Convert any remaining Date objects in other fields
|
||||
const converted = convertDatesToStrings(occurrence);
|
||||
|
||||
// Restore our time strings (don't let them get reconverted)
|
||||
converted.start = savedTimes.start;
|
||||
converted.startLocal = savedTimes.startLocal;
|
||||
converted.end = savedTimes.end;
|
||||
converted.endLocal = savedTimes.endLocal;
|
||||
|
||||
events.push(converted);
|
||||
events.push(convertDatesToStrings(occurrence));
|
||||
}
|
||||
}
|
||||
return events;
|
||||
@@ -437,20 +343,13 @@ async function fetchAndParseCalendar(source: any, windowDays = 365, displayTimez
|
||||
|
||||
export async function syncCalendars() {
|
||||
try {
|
||||
const { sources, syncWindowDays, displayTimezone } = await getSources();
|
||||
const { sources, syncWindowDays } = await getSources();
|
||||
if (sources.length === 0) return;
|
||||
|
||||
console.log(`[iCalendar] Using display timezone: ${displayTimezone}`);
|
||||
|
||||
// Test timezone conversion
|
||||
const testDate = new Date("2026-02-21T14:00:00.000Z"); // 14:00 UTC
|
||||
const converted = dateToTimezoneString(testDate, displayTimezone);
|
||||
console.log(`[iCalendar] Timezone test: ${testDate.toISOString()} → ${converted} (should be 06:00 PST)`);
|
||||
|
||||
await editor.flashNotification("Syncing calendars...", "info");
|
||||
const allEvents: any[] = [];
|
||||
for (const source of sources) {
|
||||
const events = await fetchAndParseCalendar(source, syncWindowDays, displayTimezone);
|
||||
const events = await fetchAndParseCalendar(source, syncWindowDays);
|
||||
allEvents.push(...events);
|
||||
}
|
||||
await index.indexObjects("$icalendar", allEvents);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assertEquals, assert } from "jsr:@std/assert";
|
||||
import { resolveEventStart, expandRecurrences, dateToTimezoneString } from "./icalendar.ts";
|
||||
import { resolveEventStart, expandRecurrences, localDateString } from "./icalendar.ts";
|
||||
|
||||
Deno.test("resolveEventStart - local date with timezone", async () => {
|
||||
const icsEvent = {
|
||||
@@ -47,7 +47,7 @@ Deno.test("resolveEventStart - UTC event", async () => {
|
||||
Deno.test("expandRecurrences - weekly event", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 14 * 86400000); // Started 2 weeks ago
|
||||
const startStr = dateToTimezoneString(start, "UTC");
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Weekly Meeting",
|
||||
@@ -68,21 +68,21 @@ Deno.test("expandRecurrences - EXDATE exclusion", () => {
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
const tomorrow = new Date(now.getTime() + 86400000);
|
||||
|
||||
const startStr = dateToTimezoneString(yesterday, "UTC");
|
||||
const tomorrowStr = dateToTimezoneString(tomorrow, "UTC");
|
||||
const startStr = localDateString(yesterday);
|
||||
const tomorrowStr = localDateString(tomorrow);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Daily Meeting EXDATE",
|
||||
start: yesterday.toISOString(),
|
||||
start: startStr,
|
||||
rrule: "FREQ=DAILY;COUNT=3",
|
||||
exdate: [tomorrow.toISOString()]
|
||||
exdate: [tomorrowStr]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30, "UTC", now);
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Yesterday (in window), Today (in window), Tomorrow (Excluded)
|
||||
// Should have 2 occurrences
|
||||
assertEquals(results.length, 2);
|
||||
assertEquals(results[0].startLocal, dateToTimezoneString(yesterday, "UTC"));
|
||||
assertEquals(results[0].start, startStr);
|
||||
});
|
||||
|
||||
Deno.test("fetchAndParseCalendar - filter cancelled events", async () => {
|
||||
@@ -107,15 +107,15 @@ Deno.test("resolveEventStart - ignore tzShift", async () => {
|
||||
|
||||
Deno.test("expandRecurrences - custom windowDays", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 7 * 86400000); // 7 days ago
|
||||
const startStr = localDateString(now);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Daily Meeting Window",
|
||||
start: start.toISOString(),
|
||||
start: startStr,
|
||||
rrule: "FREQ=DAILY"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 2, "UTC", now);
|
||||
const results = expandRecurrences(icsEvent, 2);
|
||||
// Today (in window), Tomorrow (in window), Day after tomorrow (in window)
|
||||
// set.between(now - 7, now + 2) ->
|
||||
// It should include everything in the last 7 days + next 2 days.
|
||||
@@ -124,7 +124,7 @@ Deno.test("expandRecurrences - custom windowDays", () => {
|
||||
});
|
||||
Deno.test("expandRecurrences - non-string rrule (Reproduction)", () => {
|
||||
const now = new Date();
|
||||
const startStr = dateToTimezoneString(now, "UTC");
|
||||
const startStr = localDateString(now);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Bug Reproduction Event",
|
||||
@@ -158,7 +158,7 @@ Deno.test("expandRecurrences - non-string rrule (Reproduction)", () => {
|
||||
Deno.test("expandRecurrences - validation of visibility logic", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 100 * 86400000); // Started 100 days ago
|
||||
const startStr = dateToTimezoneString(start, "UTC");
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Validation Weekly Meeting",
|
||||
@@ -177,7 +177,7 @@ Deno.test("expandRecurrences - validation of visibility logic", () => {
|
||||
Deno.test("expandRecurrences - object rrule (Reproduction of missing events)", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 100 * 86400000);
|
||||
const startStr = dateToTimezoneString(start, "UTC");
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Object RRULE Event",
|
||||
@@ -210,7 +210,7 @@ Deno.test("expandRecurrences - object rrule (Reproduction of missing events)", (
|
||||
Deno.test("expandRecurrences - object rrule with until", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 10 * 86400000);
|
||||
const startStr = dateToTimezoneString(start, "UTC");
|
||||
const startStr = localDateString(start);
|
||||
const untilDate = new Date(now.getTime() + 10 * 86400000);
|
||||
|
||||
const icsEvent = {
|
||||
@@ -229,7 +229,7 @@ Deno.test("expandRecurrences - object rrule with until", () => {
|
||||
Deno.test("expandRecurrences - object rrule with byday", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 10 * 86400000);
|
||||
const startStr = dateToTimezoneString(start, "UTC");
|
||||
const startStr = localDateString(start);
|
||||
|
||||
const icsEvent = {
|
||||
summary: "Object RRULE BYDAY Event",
|
||||
@@ -247,7 +247,7 @@ Deno.test("expandRecurrences - object rrule with byday", () => {
|
||||
Deno.test("expandRecurrences - composite object rrule", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 10 * 86400000);
|
||||
const startStr = dateToTimezoneString(start, "UTC");
|
||||
const startStr = localDateString(start);
|
||||
const untilDate = new Date(now.getTime() + 10 * 86400000);
|
||||
|
||||
const icsEvent = {
|
||||
|
||||
18
mock_calendar.ics
Normal file
18
mock_calendar.ics
Normal file
@@ -0,0 +1,18 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Mock ICS Server//EN
|
||||
BEGIN:VEVENT
|
||||
UID:040000008200E00074C5B7101A82E0080000000010E384DCAC84DC0100000000000000001000000014AC664AB867C74D85FC0B77E881C5AE
|
||||
SUMMARY:Plug-In for Metalsoft.io inside HPE Morpheus Enterprise
|
||||
DTSTART;TZID=W. Europe Standard Time:20260217T160000
|
||||
DTEND;TZID=W. Europe Standard Time:20260217T170000
|
||||
DTSTAMP:20260217T160818Z
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:040000008200E00074C5B7101A82E0080000000010405401AC8EDC010000000000000000100000000CD9E3DB97A71984FB54AC0DAD0FE9137
|
||||
SUMMARY:MetalSoft & Morpheus plugin catch up
|
||||
DTSTART;TZID=GMT Standard Time:20260217T130000
|
||||
DTEND;TZID=GMT Standard Time:20260217T133000
|
||||
DTSTAMP:20260216T192619Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
69
package-lock.json
generated
Normal file
69
package-lock.json
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "work",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.2",
|
||||
"@playwright/test": "^1.58.2"
|
||||
}
|
||||
}
|
||||
|
||||
30
playwright.config.ts
Normal file
30
playwright.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 180000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: process.env.SB_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
headless: false,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
launchOptions: {
|
||||
firefoxUserPrefs: {
|
||||
'dom.securecontext.whitelist': 'http://localhost:3000,http://silverbullet-test:3000,http://mock-ics-server',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
BIN
repro_check.png
Normal file
BIN
repro_check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -1,19 +0,0 @@
|
||||
// scripts/bump-version.ts
|
||||
const denoConfigPath = "deno.json";
|
||||
const denoConfig = JSON.parse(await Deno.readTextFile(denoConfigPath));
|
||||
const currentVersion = denoConfig.version;
|
||||
|
||||
const parts = currentVersion.split(".").map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
console.error(`Invalid version format in deno.json: ${currentVersion}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
// Increment patch version
|
||||
parts[2]++;
|
||||
const newVersion = parts.join(".");
|
||||
|
||||
denoConfig.version = newVersion;
|
||||
await Deno.writeTextFile(denoConfigPath, JSON.stringify(denoConfig, null, 2) + "\n");
|
||||
|
||||
console.log(`Bumped version: ${currentVersion} -> ${newVersion}`);
|
||||
18
test_data/nginx.conf
Normal file
18
test_data/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
test_space_e2e/CONFIG.md
Normal file
14
test_space_e2e/CONFIG.md
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
# Configuration
|
||||
|
||||
```space-lua
|
||||
config.set("icalendar", {
|
||||
sources = {
|
||||
{
|
||||
url = "http://172.22.0.3/reachcalendar.ics",
|
||||
name = "TestCalendar"
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
10
test_space_e2e/_plug/icalendar.plug.js
Normal file
10
test_space_e2e/_plug/icalendar.plug.js
Normal file
File diff suppressed because one or more lines are too long
23
test_space_e2e/index.md
Normal file
23
test_space_e2e/index.md
Normal file
@@ -0,0 +1,23 @@
|
||||
.iCalendar: Sync
|
||||
# Meetings for Jan 20th, 2026.iCalendar: Sync
|
||||
.
|
||||
|
||||
.iCalendar: Sync
|
||||
.iCalendar: Sync
|
||||
|
||||
|
||||
${template.each(query[[
|
||||
from e = index.tag "ical-event"
|
||||
where e.start:startsWith("2026-01-20")
|
||||
order by e.start
|
||||
]], function(e)
|
||||
return string.format("* %s to %s: %s (TZ: %s)\n",
|
||||
e.start:sub(12, 16),
|
||||
e["end"]:sub(12, 16),
|
||||
e.summary,
|
||||
e.timezone or "UTC")
|
||||
end)}
|
||||
|
||||
# Welcome 👋
|
||||
|
||||
Welcome to the wondrous world of SilverBullet.
|
||||
80
tests/e2e/sync.spec.ts
Normal file
80
tests/e2e/sync.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('iCalendar Sync E2E', () => {
|
||||
test('should verify iCalendar sync activity', async ({ page }) => {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on('console', msg => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error') errors.push(text);
|
||||
if (text.includes('[iCalendar]')) {
|
||||
logs.push(text);
|
||||
console.log('Detected SB Log:', text);
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Load Editor
|
||||
console.log('Navigating to /');
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
console.log('Page reached, waiting for boot sequence...');
|
||||
|
||||
// 2. Persistent Monitoring for Sync Activity
|
||||
let syncDetected = false;
|
||||
let eventsSynced = 0;
|
||||
const timeoutMs = 120000; // 2 minutes
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`Starting monitoring loop for ${timeoutMs/1000}s...`);
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
// Check for notifications
|
||||
const notification = page.locator('.sb-notification:has-text("Synced")');
|
||||
if (await notification.count() > 0) {
|
||||
const text = await notification.innerText();
|
||||
console.log('Detected Sync Notification:', text);
|
||||
const match = text.match(/Synced (\d+) events/);
|
||||
if (match) {
|
||||
eventsSynced = parseInt(match[1], 10);
|
||||
if (eventsSynced > 0) {
|
||||
syncDetected = true;
|
||||
console.log(`SUCCESS: ${eventsSynced} events synced!`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every 30 seconds, try to "poke" it with a keyboard shortcut if not started
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 30000 && elapsed < 35000 && !syncDetected) {
|
||||
console.log('Auto-sync not detected yet, trying manual trigger shortcut...');
|
||||
await page.keyboard.press('.');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.type('iCalendar: Sync');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// 3. Final verification
|
||||
console.log('Final accumulated [iCalendar] logs:', logs);
|
||||
|
||||
// Check if the query rendered meetings in the UI
|
||||
const meetingItems = page.locator('li:has-text("to"):has-text(":")');
|
||||
const meetingCount = await meetingItems.count();
|
||||
console.log(`Meetings found in UI: ${meetingCount}`);
|
||||
|
||||
// Filter out expected noise
|
||||
const relevantErrors = errors.filter(e => !e.includes('401') && !e.includes('favicon'));
|
||||
expect(relevantErrors, `Found unexpected errors: ${relevantErrors[0]}`).toHaveLength(0);
|
||||
expect(syncDetected, 'iCalendar sync failed or synced 0 events').toBe(true);
|
||||
expect(eventsSynced).toBeGreaterThan(0);
|
||||
|
||||
// Verify query rendering
|
||||
expect(meetingCount).toBeGreaterThanOrEqual(12);
|
||||
|
||||
console.log('Test Passed.');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { assertEquals, assert } from "jsr:@std/assert";
|
||||
import ICAL from "ical.js";
|
||||
import { expandRecurrences, resolveEventStart, dateToTimezoneString } from "../icalendar.ts";
|
||||
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||
import { expandRecurrences, resolveEventStart, localDateString } from "../icalendar.ts";
|
||||
|
||||
Deno.test("Integration - parse and expand real-world ICS samples", async () => {
|
||||
const testDataDir = "./test_data";
|
||||
@@ -39,39 +39,30 @@ Deno.test("Integration - parse and expand real-world ICS samples", async () => {
|
||||
for (const file of files) {
|
||||
console.log(` Testing file: ${file}`);
|
||||
const text = await Deno.readTextFile(file);
|
||||
const jcalData = ICAL.parse(text);
|
||||
const vcalendar = new ICAL.Component(jcalData);
|
||||
const vevents = vcalendar.getAllSubcomponents("vevent");
|
||||
const calendar = convertIcsCalendar(undefined, text);
|
||||
|
||||
assert(vevents.length > 0, `Failed to parse ${file} or no events found`);
|
||||
assert(calendar && calendar.events, `Failed to parse ${file}`);
|
||||
|
||||
for (const vevent of vevents) {
|
||||
const icsEvent = new ICAL.Event(vevent);
|
||||
const status = vevent.getFirstPropertyValue("status") as string | null;
|
||||
if (status?.toUpperCase() === "CANCELLED") continue;
|
||||
for (const icsEvent of calendar.events) {
|
||||
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
|
||||
|
||||
const startDateUTC = icsEvent.startDate.toJSDate();
|
||||
const summary = icsEvent.summary;
|
||||
const rrule = vevent.getFirstPropertyValue("rrule");
|
||||
const exdates = vevent.getAllProperties("exdate").map((p: any) => p.getFirstValue().toJSDate().toISOString());
|
||||
const finalDate = await resolveEventStart(icsEvent);
|
||||
if (!finalDate) continue;
|
||||
|
||||
const localIso = dateToTimezoneString(startDateUTC, "UTC");
|
||||
const localIso = localDateString(finalDate);
|
||||
const baseEvent = {
|
||||
summary,
|
||||
name: summary || "Untitled Event",
|
||||
start: startDateUTC.toISOString(),
|
||||
startLocal: localIso,
|
||||
...icsEvent,
|
||||
name: icsEvent.summary || "Untitled Event",
|
||||
start: localIso,
|
||||
tag: "ical-event",
|
||||
sourceName: "IntegrationTest",
|
||||
rrule: rrule ? rrule.toString() : undefined,
|
||||
exdate: exdates.length > 0 ? exdates : undefined
|
||||
sourceName: "IntegrationTest"
|
||||
};
|
||||
|
||||
try {
|
||||
const expanded = expandRecurrences(baseEvent, 30);
|
||||
assert(expanded.length >= 1, `Expected at least 1 occurrence for event "${summary}" in ${file}`);
|
||||
assert(expanded.length >= 1, `Expected at least 1 occurrence for event "${icsEvent.summary}" in ${file}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Error expanding recurrence for event "${summary}" in ${file}:`, err);
|
||||
console.error(`❌ Error expanding recurrence for event "${icsEvent.summary}" in ${file}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assertEquals, assert } from "jsr:@std/assert";
|
||||
import { resolveEventStart, expandRecurrences, dateToTimezoneString } from "../icalendar.ts";
|
||||
import { resolveEventStart, expandRecurrences, localDateString } from "../icalendar.ts";
|
||||
|
||||
const TEST_NOW = new Date("2026-01-20T12:00:00Z");
|
||||
|
||||
@@ -42,26 +42,24 @@ Deno.test("Variation: Transparent Meeting (Free)", async () => {
|
||||
});
|
||||
|
||||
Deno.test("Variation: Recurring Weekly (Multi-day: MO,TU,WE,TH,FR)", () => {
|
||||
const start = new Date("2026-01-16T13:00:00");
|
||||
const icsEvent = {
|
||||
summary: "BUSY Weekly",
|
||||
start: start.toISOString(),
|
||||
start: "2026-01-16T13:00:00",
|
||||
rrule: "FREQ=WEEKLY;UNTIL=20260814T170000Z;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;WKST=SU"
|
||||
};
|
||||
|
||||
// Use TEST_NOW to ensure the window matches
|
||||
const results = expandRecurrences(icsEvent, 7, "UTC", TEST_NOW);
|
||||
const results = expandRecurrences(icsEvent, 7, TEST_NOW);
|
||||
// Should have multiple occurrences per week
|
||||
assert(results.length > 1);
|
||||
assert(results.some(r => r.startLocal.includes("2026-01-19"))); // Monday
|
||||
assert(results.some(r => r.startLocal.includes("2026-01-20"))); // Tuesday
|
||||
assert(results.some(r => r.start.includes("2026-01-19"))); // Monday
|
||||
assert(results.some(r => r.start.includes("2026-01-20"))); // Tuesday
|
||||
});
|
||||
|
||||
Deno.test("Variation: Recurring with WORKWEEKSTART (Outlook style)", () => {
|
||||
const start = new Date("2026-01-20T08:30:00");
|
||||
const icsEvent = {
|
||||
summary: "Outlook Style Meeting",
|
||||
start: start.toISOString(),
|
||||
start: "2026-01-20T08:30:00",
|
||||
rrule: {
|
||||
frequency: "WEEKLY",
|
||||
interval: 1,
|
||||
@@ -70,42 +68,40 @@ Deno.test("Variation: Recurring with WORKWEEKSTART (Outlook style)", () => {
|
||||
}
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30, "UTC", TEST_NOW);
|
||||
const results = expandRecurrences(icsEvent, 30, TEST_NOW);
|
||||
assert(results.length > 0);
|
||||
assert(results[0].startLocal.includes("2026-01-20"));
|
||||
assert(results[0].start.includes("2026-01-20"));
|
||||
});
|
||||
|
||||
Deno.test("Variation: Recurring with EXDATE (Exclusion)", () => {
|
||||
const start = new Date("2026-01-20T08:30:00");
|
||||
const icsEvent = {
|
||||
summary: "HPE-Veeam check-in",
|
||||
start: start.toISOString(),
|
||||
start: "2026-01-20T08:30:00",
|
||||
rrule: "FREQ=WEEKLY;UNTIL=20260324T143000Z;INTERVAL=1;BYDAY=TU;WKST=SU",
|
||||
exdate: ["2026-02-03T08:30:00"]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 60, "UTC", TEST_NOW);
|
||||
const dates = results.map(r => r.startLocal);
|
||||
assert(dates.some(d => d.includes("2026-01-20T")), "Should find first occurrence");
|
||||
assert(dates.some(d => d.includes("2026-01-27T")), "Should find second occurrence");
|
||||
assert(!dates.some(d => d.includes("2026-02-03T")), "EXDATE 2026-02-03 should be excluded");
|
||||
assert(dates.some(d => d.includes("2026-02-10T")), "Should find fourth occurrence");
|
||||
const results = expandRecurrences(icsEvent, 60, TEST_NOW);
|
||||
const dates = results.map(r => r.start);
|
||||
assert(dates.includes("2026-01-20T08:30:00"));
|
||||
assert(dates.includes("2026-01-27T08:30:00"));
|
||||
assert(!dates.includes("2026-02-03T08:30:00"), "EXDATE should be excluded");
|
||||
assert(dates.includes("2026-02-10T08:30:00"));
|
||||
});
|
||||
|
||||
Deno.test("Variation: Monthly Recurring (Last Friday)", () => {
|
||||
const start = new Date("2026-01-30T10:00:00");
|
||||
const icsEvent = {
|
||||
summary: "Monthly Planning",
|
||||
start: start.toISOString(), // This is the last Friday of Jan 2026
|
||||
start: "2026-01-30T10:00:00", // This is the last Friday of Jan 2026
|
||||
rrule: "FREQ=MONTHLY;UNTIL=20260731T170000Z;INTERVAL=1;BYDAY=-1FR"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 100, "UTC", TEST_NOW);
|
||||
const dates = results.map(r => r.startLocal);
|
||||
const results = expandRecurrences(icsEvent, 100, TEST_NOW);
|
||||
const dates = results.map(r => r.start);
|
||||
|
||||
assert(dates.some(d => d.includes("2026-01-30")), "Should find Jan occurrence");
|
||||
assert(dates.some(d => d.includes("2026-02-27")), "Should find Feb occurrence");
|
||||
assert(dates.some(d => d.includes("2026-03-27")), "Should find Mar occurrence");
|
||||
assert(dates.includes("2026-01-30T10:00:00"));
|
||||
assert(dates.includes("2026-02-27T10:00:00")); // Last Friday of Feb 2026
|
||||
assert(dates.includes("2026-03-27T10:00:00")); // Last Friday of Mar 2026
|
||||
});
|
||||
|
||||
Deno.test("Variation: Tentative Meeting", async () => {
|
||||
@@ -138,13 +134,13 @@ Deno.test("Feature: Unlimited lookback window", () => {
|
||||
const start = new Date(TEST_NOW.getTime() - 500 * 86400000); // 500 days ago
|
||||
const icsEvent = {
|
||||
summary: "Event from 500 days ago",
|
||||
start: start.toISOString(),
|
||||
start: localDateString(start),
|
||||
rrule: "FREQ=DAILY;COUNT=1000"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30, "UTC", TEST_NOW);
|
||||
const results = expandRecurrences(icsEvent, 30, TEST_NOW);
|
||||
// Should include events from 500 days ago because there is now no limit
|
||||
assert(results.some(r => r.startLocal === dateToTimezoneString(start, "UTC")), "Should find occurrence from 500 days ago");
|
||||
assert(results.some(r => r.start === localDateString(start)), "Should find occurrence from 500 days ago");
|
||||
});
|
||||
|
||||
Deno.test("Feature: Hash Collision Prevention (Same UID/Start, Different Summary)", async () => {
|
||||
|
||||
BIN
url-nextcloud.png
Normal file
BIN
url-nextcloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
35
verify_test_results.cjs
Normal file
35
verify_test_results.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('console', msg => {
|
||||
console.log(`[BROWSER] ${msg.text()}`);
|
||||
});
|
||||
|
||||
console.log('Logging in...');
|
||||
await page.goto('http://localhost:3000/.auth');
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'admin');
|
||||
await page.click('button:has-text("Login")');
|
||||
await page.waitForNavigation();
|
||||
|
||||
console.log('Waiting 30s for indexing and plug activation...');
|
||||
await page.waitForTimeout(30000);
|
||||
|
||||
// Check command palette
|
||||
console.log('Opening command palette to check for iCalendar commands...');
|
||||
await page.keyboard.press('Control+/');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.keyboard.type('iCalendar:');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const body = await page.innerText('body');
|
||||
console.log('--- Body Output ---');
|
||||
console.log(body);
|
||||
|
||||
await page.screenshot({ path: 'repro_check.png', fullPage: true });
|
||||
await browser.close();
|
||||
})();
|
||||
Reference in New Issue
Block a user