diff --git a/package.json b/package.json index d24e608..d7bb8de 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ ], "license": "MIT", "dependencies": { + "@solid/object": "^0.4.0", "rdfjs-wrapper": "^0.15.0" }, "devDependencies": { diff --git a/src/mod.ts b/src/mod.ts index a8678e4..6230957 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,3 +1,4 @@ export * from "./acp/mod.js" export * from "./solid/mod.js" export * from "./webid/mod.js" + diff --git a/src/solid/Meeting.ts b/src/solid/Meeting.ts new file mode 100644 index 0000000..4a56be1 --- /dev/null +++ b/src/solid/Meeting.ts @@ -0,0 +1,44 @@ +import { TermMappings, ValueMappings, TermWrapper, DatasetWrapper } from "rdfjs-wrapper" +import { ICAL } from "../vocabulary/mod.js" + +export class Meeting extends TermWrapper { + get summary(): string | undefined { + return this.singularNullable(ICAL.summary, ValueMappings.literalToString) + } + + set summary(value: string | undefined) { + this.overwriteNullable(ICAL.summary, value, TermMappings.stringToLiteral) + } + + get location(): string | undefined { + return this.singularNullable(ICAL.location, ValueMappings.literalToString) + } + + set location(value: string | undefined) { + this.overwriteNullable(ICAL.location, value, TermMappings.stringToLiteral) + } + + get comment(): string | undefined { + return this.singularNullable(ICAL.comment, ValueMappings.literalToString) + } + + set comment(value: string | undefined) { + this.overwriteNullable(ICAL.comment, value, TermMappings.stringToLiteral) + } + + get startDate(): Date | undefined { + return this.singularNullable(ICAL.dtstart, ValueMappings.literalToDate) + } + + set startDate(value: Date | undefined) { + this.overwriteNullable(ICAL.dtstart, value, TermMappings.dateToLiteral) + } + + get endDate(): Date | undefined { + return this.singularNullable(ICAL.dtend, ValueMappings.literalToDate) + } + + set endDate(value: Date | undefined) { + this.overwriteNullable(ICAL.dtend, value, TermMappings.dateToLiteral) + } +} diff --git a/src/solid/MeetingDataset.ts b/src/solid/MeetingDataset.ts new file mode 100644 index 0000000..dedccdb --- /dev/null +++ b/src/solid/MeetingDataset.ts @@ -0,0 +1,9 @@ +import { DatasetWrapper } from "rdfjs-wrapper" +import { ICAL } from "../vocabulary/mod.js" +import { Meeting } from "./Meeting.js" + +export class MeetingDataset extends DatasetWrapper { + get meeting(): Iterable { + return this.instancesOf(ICAL.Vevent, Meeting) + } +} diff --git a/src/solid/mod.ts b/src/solid/mod.ts index fb84c5f..26f4dad 100644 --- a/src/solid/mod.ts +++ b/src/solid/mod.ts @@ -1,3 +1,5 @@ export * from "./Container.js" export * from "./ContainerDataset.js" export * from "./Resource.js" +export * from "./Meeting.js" +export * from "./MeetingDataset.js" diff --git a/src/vocabulary/ical.ts b/src/vocabulary/ical.ts index d17c669..75f5cfa 100644 --- a/src/vocabulary/ical.ts +++ b/src/vocabulary/ical.ts @@ -4,4 +4,5 @@ export const ICAL = { dtstart: "http://www.w3.org/2002/12/cal/ical#dtstart", location: "http://www.w3.org/2002/12/cal/ical#location", summary: "http://www.w3.org/2002/12/cal/ical#summary", + Vevent: "http://www.w3.org/2002/12/cal/ical#Vevent" } as const; diff --git a/src/vocabulary/mod.ts b/src/vocabulary/mod.ts index 412818c..4afb431 100644 --- a/src/vocabulary/mod.ts +++ b/src/vocabulary/mod.ts @@ -8,3 +8,4 @@ export * from "./rdf.js" export * from "./rdfs.js" export * from "./solid.js" export * from "./vcard.js" +export * from "./ical.js" diff --git a/test/unit/meeting.test.ts b/test/unit/meeting.test.ts new file mode 100644 index 0000000..3ce84f2 --- /dev/null +++ b/test/unit/meeting.test.ts @@ -0,0 +1,229 @@ +import { DataFactory, Parser, Store } from "n3" +import assert from "node:assert" +import { describe, it } from "node:test" +import { MeetingDataset } from "@solid/object"; + +describe("MeetingDataset / Meeting tests", () => { + const sampleRDF = ` +@prefix cal: . +@prefix xsd: . + + a cal:Vevent ; + + cal:summary "Team Sync" ; + cal:location "Zoom Room 123" ; + cal:comment "Discuss project updates" ; + cal:dtstart "2026-02-09T10:00:00Z"^^xsd:dateTime ; + cal:dtend "2026-02-09T11:00:00Z"^^xsd:dateTime . +`; + + it("should parse and retrieve meeting properties", () => { + const store = new Store(); + store.addQuads(new Parser().parse(sampleRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meetings = Array.from(dataset.meeting); + + const meeting = meetings[0]; + assert.ok(meeting, "No meeting found") + + // Check property types and values + assert.equal(meeting.summary, "Team Sync"); + assert.equal(meeting.location, "Zoom Room 123"); + assert.equal(meeting.comment, "Discuss project updates"); + + assert.ok(meeting.startDate instanceof Date); + assert.ok(meeting.endDate instanceof Date); + + assert.equal(meeting.startDate?.toISOString(), "2026-02-09T10:00:00.000Z"); + assert.equal(meeting.endDate?.toISOString(), "2026-02-09T11:00:00.000Z"); + }); + + it("should allow setting of meeting properties", () => { + const store = new Store(); + store.addQuads(new Parser().parse(sampleRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meetings = Array.from(dataset.meeting); + + assert.ok(meetings.length > 0, "No meetings found"); + + const meeting = Array.from(dataset.meeting)[0]!; + + // Set new values + meeting.summary = "Updated Meeting"; + meeting.location = "Conference Room A"; + meeting.comment = "New agenda"; + const newStart = new Date("2026-02-09T12:00:00Z"); + const newEnd = new Date("2026-02-09T13:00:00Z"); + meeting.startDate = newStart; + meeting.endDate = newEnd; + + // Retrieve again + assert.equal(meeting.summary, "Updated Meeting"); + assert.equal(meeting.location, "Conference Room A"); + assert.equal(meeting.comment, "New agenda"); + assert.equal(meeting.startDate.toISOString(), newStart.toISOString()); + assert.equal(meeting.endDate.toISOString(), newEnd.toISOString()); + }); + + it("should ensure all properties are correct type", () => { + const store = new Store(); + store.addQuads(new Parser().parse(sampleRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meeting = Array.from(dataset.meeting)[0]; + + assert.ok(meeting, "No meeting found") + + // Check property types + assert.equal(typeof meeting.summary, "string"); + assert.equal(typeof meeting.location, "string"); + assert.equal(typeof meeting.comment, "string"); + + assert.ok(meeting.startDate instanceof Date, "startDate should be a Date"); + assert.ok(meeting.endDate instanceof Date, "endDate should be a Date"); + }); + + it("should ensure all properties are unique text or date values", () => { + const duplicateRDF = ` +@prefix cal: . +@prefix xsd: . + + a cal:Vevent ; + cal:summary "Team Sync" ; + cal:summary "Duplicate Summary" ; + cal:location "Zoom Room 123" ; + cal:location "Duplicate Location" ; + cal:comment "Discuss project updates" ; + cal:comment "Duplicate Comment" ; + cal:dtstart "2026-02-09T10:00:00Z"^^xsd:dateTime ; + cal:dtstart "2026-02-09T09:00:00Z"^^xsd:dateTime ; + cal:dtend "2026-02-09T11:00:00Z"^^xsd:dateTime ; + cal:dtend "2026-02-09T12:00:00Z"^^xsd:dateTime . +`; + + const store = new Store(); + store.addQuads(new Parser().parse(duplicateRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meeting = Array.from(dataset.meeting)[0]; + + assert.ok(meeting, "No meeting found"); + + // Ensure exposed values are single (unique) and correct type + assert.equal(typeof meeting.summary, "string"); + assert.equal(typeof meeting.location, "string"); + assert.equal(typeof meeting.comment, "string"); + + assert.ok(meeting.startDate instanceof Date); + assert.ok(meeting.endDate instanceof Date); + + // Ensure no arrays are returned + assert.ok(!Array.isArray(meeting.summary)); + assert.ok(!Array.isArray(meeting.location)); + assert.ok(!Array.isArray(meeting.comment)); + assert.ok(!Array.isArray(meeting.startDate)); + assert.ok(!Array.isArray(meeting.endDate)); + }); + + + + // RFC 5545 requires DTSTART and UID - test the required date behaviour + + it("should parse a minimal RFC5545-style VEVENT", () => { + const minimalRDF = ` + @prefix cal: . + @prefix xsd: . + + + a cal:Vevent ; + cal:dtstart "2026-06-01T09:00:00Z"^^xsd:dateTime . + `; + + const store = new Store(); + store.addQuads(new Parser().parse(minimalRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meeting = Array.from(dataset.meeting)[0]; + + assert.ok(meeting); + assert.ok(meeting.startDate instanceof Date); + assert.equal(meeting.startDate?.toISOString(), "2026-06-01T09:00:00.000Z"); + + // Optional fields should be undefined + assert.equal(meeting.summary, undefined); + assert.equal(meeting.location, undefined); + assert.equal(meeting.comment, undefined); + assert.equal(meeting.endDate, undefined); + }); + + + // With ref to common VEVENT examples in RFC 5545 3.6.1. + + it("should parse an RFC-style VEVENT with summary, description, and location", () => { + const rfcStyleRDF = ` + @prefix cal: . + @prefix xsd: . + + + a cal:Vevent ; + cal:summary "Meeting" ; + cal:comment "Discuss events" ; + cal:location "Conference Room 1" ; + cal:dtstart "2026-07-10T13:00:00Z"^^xsd:dateTime ; + cal:dtend "2026-07-10T15:30:00Z"^^xsd:dateTime . + `; + + const store = new Store(); + store.addQuads(new Parser().parse(rfcStyleRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meeting = Array.from(dataset.meeting)[0]; + + assert.ok(meeting); + + assert.equal(meeting.summary, "Meeting"); + assert.equal(meeting.comment, "Discuss events"); + assert.equal(meeting.location, "Conference Room 1"); + + assert.equal(meeting.startDate?.toISOString(), "2026-07-10T13:00:00.000Z"); + assert.equal(meeting.endDate?.toISOString(), "2026-07-10T15:30:00.000Z"); + }); + + + // RFC 5545 allows DATE values for all-day events. This tests literalToDate handling. + + it("should parse an all-day RFC-style event (DATE value)", () => { + const allDayRDF = ` + @prefix cal: . + @prefix xsd: . + + + a cal:Vevent ; + cal:summary "Company Holiday" ; + cal:dtstart "2026-12-25"^^xsd:date ; + cal:dtend "2026-12-26"^^xsd:date . + `; + + const store = new Store(); + store.addQuads(new Parser().parse(allDayRDF)); + + const dataset = new MeetingDataset(store, DataFactory); + const meeting = Array.from(dataset.meeting)[0]; + + assert.ok(meeting); + assert.ok(meeting.startDate instanceof Date); + assert.ok(meeting.endDate instanceof Date); + + // Ensure correct calendar date + assert.equal(meeting.startDate?.getUTCFullYear(), 2026); + assert.equal(meeting.startDate?.getUTCMonth(), 11); // December (0-based) + assert.equal(meeting.startDate?.getUTCDate(), 25); + }); + + + + + +});