diff --git a/package.json b/package.json index d24e608..acf0f7f 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,16 @@ ], "license": "MIT", "dependencies": { + "@rdfjs/data-model": "^2.1.1", + "@solid/object": "^0.4.0", + "rdf-namespaces": "^1.16.0", "rdfjs-wrapper": "^0.15.0" }, "devDependencies": { "@rdfjs/types": "^2", "@types/n3": "^1", "@types/node": "^24", + "@types/rdf-js": "^4.0.1", "n3": "^1", "typescript": "^5" }, 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/Group.ts b/src/solid/Group.ts new file mode 100644 index 0000000..6bd042c --- /dev/null +++ b/src/solid/Group.ts @@ -0,0 +1,55 @@ +import { TermMappings, ValueMappings, TermWrapper } from "rdfjs-wrapper" +import { VCARD } from "../vocabulary/mod.js" +import { Person } from "./Person.js" + +export class Group extends TermWrapper { + + get name(): string | undefined { + const value = this.singularNullable(VCARD.fn, ValueMappings.literalToString) + + return value + } + + set name(value: string | undefined) { + if (!value) { + throw new Error("Group name cannot be empty") + } + this.overwriteNullable(VCARD.fn, value, TermMappings.stringToLiteral) + } + + get members(): Set { + const persons = new Set() + + for (const iri of this.objects(VCARD.member, ValueMappings.iriToString, TermMappings.stringToIri)) { + const person = new Person(iri, this.dataset, this.factory) + persons.add(person) + } + return persons + } + + /** Add a new Person to this group */ + addMember(person: Person) { + // Convert Person term to string IRI + const iri = person.term.value + + // Use objects() to get the live set and add the new member + const membersSet = this.objects( + VCARD.member, + ValueMappings.iriToString, + TermMappings.stringToIri + ) + membersSet.add(iri) + } + + /** Remove a Person from this group */ + deleteMember(person: Person) { + const iri = person.term.value + + const membersSet = this.objects( + VCARD.member, + ValueMappings.iriToString, + TermMappings.stringToIri + ) + membersSet.delete(iri) + } +} diff --git a/src/solid/GroupDataset.ts b/src/solid/GroupDataset.ts new file mode 100644 index 0000000..4bd8fbd --- /dev/null +++ b/src/solid/GroupDataset.ts @@ -0,0 +1,9 @@ +import { DatasetWrapper } from "rdfjs-wrapper" +import { VCARD } from "../vocabulary/mod.js" +import { Group } from "./Group.js" + +export class GroupDataset extends DatasetWrapper { + get group(): Iterable { + return this.instancesOf(VCARD.Group, Group) + } +} diff --git a/src/solid/Meeting.ts b/src/solid/Meeting.ts new file mode 100644 index 0000000..15087d4 --- /dev/null +++ b/src/solid/Meeting.ts @@ -0,0 +1,44 @@ +import { TermMappings, ValueMappings, TermWrapper } 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/Organization.ts b/src/solid/Organization.ts new file mode 100644 index 0000000..cd9c035 --- /dev/null +++ b/src/solid/Organization.ts @@ -0,0 +1,67 @@ +import { TermWrapper, ValueMappings, TermMappings } from "rdfjs-wrapper" +import { VCARD, SCHEMA, RDF } from "../vocabulary/mod.js" + +type SchemaOrganizationType = typeof SCHEMA[keyof typeof SCHEMA] + +const allowedOrgTypes = new Set([ + SCHEMA.Corporation, + SCHEMA.EducationalOrganization, + SCHEMA.GovernmentOrganization, + SCHEMA.NGO, + SCHEMA.PerformingGroup, + SCHEMA.Project, + SCHEMA.SportsOrganization, +]) + +export class Organization extends TermWrapper { + + constructor(term: string | any, dataset: any, factory?: any) { + // Convert string to NamedNode if needed + const t = typeof term === "string" ? (factory || dataset.factory).namedNode(term) : term + super(t, dataset, factory) + + // Always declare as vcard:Organization + if (!dataset.has(this.term, RDF.type, VCARD.Organization)) { + dataset.add((factory || dataset.factory).quad(this.term, RDF.type, VCARD.Organization)) + } + } + + get name(): string | undefined { + return this.singularNullable(SCHEMA.name, ValueMappings.literalToString) + } + + set name(value: string | undefined) { + this.overwriteNullable(SCHEMA.name, value, TermMappings.stringToLiteral) + } + + get url(): string | undefined { + return this.singularNullable(SCHEMA.url, ValueMappings.iriToString) + } + + set url(value: string | undefined) { + this.overwriteNullable(SCHEMA.url, value, TermMappings.stringToIri) + } + + get types(): Set { + const orgTypes = new Set() + for (const iri of this.objects(RDF.type, ValueMappings.iriToString, TermMappings.stringToIri)) { + orgTypes.add(iri) + } + return orgTypes + } + + /** Add a new type for this organization */ + addType(orgType: SchemaOrganizationType): void { + if (!allowedOrgTypes.has(orgType)) { + throw new Error(`Invalid organization type: ${orgType}`) + } + const types = this.objects( + RDF.type, + ValueMappings.iriToString, + TermMappings.stringToIri + ) + types.add(orgType) + } + + +} diff --git a/src/solid/OrganizationDataset.ts b/src/solid/OrganizationDataset.ts new file mode 100644 index 0000000..d8dfa3c --- /dev/null +++ b/src/solid/OrganizationDataset.ts @@ -0,0 +1,9 @@ +import { DatasetWrapper } from "rdfjs-wrapper" +import { VCARD } from "../vocabulary/mod.js" +import { Organization } from "./Organization.js" + +export class OrganizationDataset extends DatasetWrapper { + get person(): Iterable { + return this.instancesOf(VCARD.Individual, Organization) + } +} diff --git a/src/solid/Person.ts b/src/solid/Person.ts new file mode 100644 index 0000000..b377e77 --- /dev/null +++ b/src/solid/Person.ts @@ -0,0 +1,51 @@ +import { TermWrapper, ValueMappings, TermMappings } from "rdfjs-wrapper" +import { VCARD, OWL } from "../vocabulary/mod.js" +import { rdf } from "rdf-namespaces" + +export class Person extends TermWrapper { + + constructor(term: string | any, dataset: any, factory?: any) { + // Convert string to NamedNode if needed + const t = typeof term === "string" ? (factory || dataset.factory).namedNode(term) : term + super(t, dataset, factory) + + // Always declare as vcard:Individual + if (!dataset.has(this.term, rdf.type, VCARD.Individual)) { + dataset.add((factory || dataset.factory).quad(this.term, rdf.type, VCARD.Individual)) + } + } + + get name(): string | undefined { + return this.singularNullable(VCARD.fn, ValueMappings.literalToString) + } + + set name(value: string | undefined) { + this.overwriteNullable(VCARD.fn, value, TermMappings.stringToLiteral) + } + + get phone(): string | undefined { + return this.singularNullable(VCARD.phone, ValueMappings.literalToString) + } + + set phone(value: string | undefined) { + this.overwriteNullable(VCARD.phone, value, TermMappings.stringToLiteral) + } + + get email(): string | undefined { + return this.singularNullable(VCARD.email, ValueMappings.literalToString) + } + + set email(value: string | undefined) { + this.overwriteNullable(VCARD.email, value, TermMappings.stringToLiteral) + } + + + get webId(): string | undefined { + return this.singularNullable(OWL.sameAs, ValueMappings.iriToString) + } + + set webId(value: string | undefined) { + if (!value) return + this.overwriteNullable(OWL.sameAs, value, TermMappings.stringToIri) + } +} diff --git a/src/solid/PersonDataset.ts b/src/solid/PersonDataset.ts new file mode 100644 index 0000000..7ec3bb5 --- /dev/null +++ b/src/solid/PersonDataset.ts @@ -0,0 +1,9 @@ +import { DatasetWrapper } from "rdfjs-wrapper" +import { VCARD } from "../vocabulary/mod.js" +import { Person } from "./Person.js" + +export class PersonDataset extends DatasetWrapper { + get person(): Iterable { + return this.instancesOf(VCARD.Individual, Person) + } +} diff --git a/src/solid/Profile.ts b/src/solid/Profile.ts new file mode 100644 index 0000000..709b44a --- /dev/null +++ b/src/solid/Profile.ts @@ -0,0 +1,69 @@ +import { TermMappings, ValueMappings, TermWrapper, DatasetWrapper } from "rdfjs-wrapper" +import { FOAF, SOLID, SCHEMA, ORG, VCARD } from "../vocabulary/mod.js" +import { Person } from "./Person.js" + + +export class Profile extends TermWrapper { + + /* Nickname */ + get nickname(): string | undefined { + return this.singularNullable(FOAF.nick, ValueMappings.literalToString) + } + set nickname(value: string | undefined) { + this.overwriteNullable(FOAF.nick, value, TermMappings.stringToLiteral) + } + + /* Pronouns */ + + // Preferred pronoun for subject role (he/she/they + get preferredSubjectPronoun(): string | undefined { + return this.singularNullable(SOLID.preferredSubjectPronoun, ValueMappings.literalToString) + } + set preferredSubjectPronoun(value: string | undefined) { + this.overwriteNullable(SOLID.preferredSubjectPronoun, value, TermMappings.stringToLiteral) + } + + // Preferred pronoun for object role (him/her/them) + get preferredObjectPronoun(): string | undefined { + return this.singularNullable(SOLID.preferredObjectPronoun, ValueMappings.literalToString) + } + set preferredObjectPronoun(value: string | undefined) { + this.overwriteNullable(SOLID.preferredObjectPronoun, value, TermMappings.stringToLiteral) + } + + // Preferred relative pronoun (his/hers/theirs) + get preferredRelativePronoun(): string | undefined { + return this.singularNullable(SOLID.preferredRelativePronoun, ValueMappings.literalToString) + } + set preferredRelativePronoun(value: string | undefined) { + this.overwriteNullable(SOLID.preferredRelativePronoun, value, TermMappings.stringToLiteral) + } + + + /* Roles / Organization involvement */ + + get roles(): Set { + const persons = new Set() + + for (const iri of this.objects(ORG.member, ValueMappings.iriToString, TermMappings.stringToIri)) { + const person = new Person(iri, this.dataset, this.factory) + persons.add(person) + } + return persons + } + + /* Skills */ + get skills(): Set { + return this.objects(SCHEMA.skills, ValueMappings.iriToString, TermMappings.stringToIri) + } + + /* Languages */ + get languages(): Set { + return this.objects(SCHEMA.knowsLanguage, ValueMappings.iriToString, TermMappings.stringToIri) + } + + /* Online/ Social Media Accounts */ + get accounts(): Set { + return this.objects(FOAF.account, ValueMappings.iriToString, TermMappings.stringToIri) + } +} diff --git a/src/solid/ProfileDataset.ts b/src/solid/ProfileDataset.ts new file mode 100644 index 0000000..fd4f8a8 --- /dev/null +++ b/src/solid/ProfileDataset.ts @@ -0,0 +1,10 @@ +import { DatasetWrapper } from "rdfjs-wrapper" +import { FOAF } from "../vocabulary/mod.js" +import { Profile } from "./Profile.js" + +export class ProfileDataset extends DatasetWrapper { + get profile(): Iterable { + return this.instancesOf(FOAF.PersonalProfileDocument, Profile) + } +} + diff --git a/src/solid/mod.ts b/src/solid/mod.ts index fb84c5f..bef4085 100644 --- a/src/solid/mod.ts +++ b/src/solid/mod.ts @@ -1,3 +1,13 @@ export * from "./Container.js" export * from "./ContainerDataset.js" +export * from "./Group.js" +export * from "./GroupDataset.js" +export * from "./Meeting.js" +export * from "./MeetingDataset.js" +export * from "./Organization.js" +export * from "./OrganizationDataset.js" +export * from "./Person.js" +export * from "./PersonDataset.js" +export * from "./Profile.js" +export * from "./ProfileDataset.js" export * from "./Resource.js" diff --git a/src/vocabulary/foaf.ts b/src/vocabulary/foaf.ts index c375e39..4a7e197 100644 --- a/src/vocabulary/foaf.ts +++ b/src/vocabulary/foaf.ts @@ -1,8 +1,17 @@ export const FOAF = { - isPrimaryTopicOf: "http://xmlns.com/foaf/0.1/isPrimaryTopicOf", - primaryTopic: "http://xmlns.com/foaf/0.1/primaryTopic", - name: "http://xmlns.com/foaf/0.1/name", + account: "http://xmlns.com/foaf/0.1/account", + accountName: "http://xmlns.com/foaf/0.1/accountName", email: "http://xmlns.com/foaf/0.1/email", homepage: "http://xmlns.com/foaf/0.1/homepage", + icon: "http://xmlns.com/foaf/0.1/icon", + isPrimaryTopicOf: "http://xmlns.com/foaf/0.1/isPrimaryTopicOf", knows: "http://xmlns.com/foaf/0.1/knows", + maker: "http://xmlns.com/foaf/0.1/maker", + name: "http://xmlns.com/foaf/0.1/name", + nick: "http://xmlns.com/foaf/0.1/nick", + primaryTopic: "http://xmlns.com/foaf/0.1/primaryTopic", + Account: "http://xmlns.com/foaf/0.1/Account", + OnlineAccount: "http://xmlns.com/foaf/0.1/OnlineAccount", + Person: "http://xmlns.com/foaf/0.1/Person", + PersonalProfileDocument: "http://xmlns.com/foaf/0.1/PersonalProfileDocument", } as const; 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..3dbad91 100644 --- a/src/vocabulary/mod.ts +++ b/src/vocabulary/mod.ts @@ -1,10 +1,14 @@ export * from "./acp.js" export * from "./dc.js" export * from "./foaf.js" +export * from "./ical.js" export * from "./ldp.js" +export * from "./owl.js" export * from "./pim.js" export * from "./posix.js" export * from "./rdf.js" export * from "./rdfs.js" export * from "./solid.js" export * from "./vcard.js" +export * from "./schema.js" +export * from "./org.js" diff --git a/src/vocabulary/org.ts b/src/vocabulary/org.ts new file mode 100644 index 0000000..b54d77e --- /dev/null +++ b/src/vocabulary/org.ts @@ -0,0 +1,7 @@ +export const ORG = { + + + member: "http://www.w3.org/ns/org#member", + organization: "http://www.w3.org/ns/org#organization", + role: "http://www.w3.org/ns/org#role" +} \ No newline at end of file diff --git a/src/vocabulary/owl.ts b/src/vocabulary/owl.ts new file mode 100644 index 0000000..ab28ec0 --- /dev/null +++ b/src/vocabulary/owl.ts @@ -0,0 +1,3 @@ +export const OWL = { + sameAs: "http://www.w3.org/2002/07/owl#", +} as const; diff --git a/src/vocabulary/schema.ts b/src/vocabulary/schema.ts new file mode 100644 index 0000000..318c0d7 --- /dev/null +++ b/src/vocabulary/schema.ts @@ -0,0 +1,18 @@ +export const SCHEMA = { + knowsLanguage: "https://schema.org/knowsLanguage", + skills: "https://schema.org/skills", + startDate: "https://schema.org/startDate", + endDate: "https://schema.org/endDate", + description: "https://schema.org/description", + name: "https://schema.org/name", + uri: "https://schema.org/uri", + url: "https://schema.org/url", + Corporation: "https://schema.org/Corporation", + EducationalOrganization: "https://schema.org/EducationalOrganization", + GovernmentOrganization: "https://schema.org/GovernmentOrganization", + NGO: "https://schema.org/NGO", + Organization: "https://schema.org/Organization", + PerformingGroup: "https://schema.org/PerformingGroup", + Project: "https://schema.org/Project", + SportsOrganization: "https://schema.org/SportsOrganization", +} as const; diff --git a/src/vocabulary/soc.ts b/src/vocabulary/soc.ts new file mode 100644 index 0000000..23258fa --- /dev/null +++ b/src/vocabulary/soc.ts @@ -0,0 +1,22 @@ +export const SOC = { + BlueSkyAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#BlueSkyAccount", + Digg: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#Digg", + FacebookAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#FacebookAccount", + GithubAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#GithubAccount", + InstagramAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#InstagramAccount", + LinkedInAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#LinkedInAccount", + MastodonAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#MastodonAccount", + MatrixAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#MatrixAccount", + MediumAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#MediumAccount", + NostrAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#NostrAccount", + OrcidAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#OrcidAccount", + PinterestAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#PinterestAccount", + RedditAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#RedditAccount", + SnapchatAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#SnapchatAccount", + StravaAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#StravaAccount", + TiktokAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#TiktokAccount", + TumblrAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#TumblrAccount", + TwitterAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#TwitterAccount", + OtherAccount: "https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#OtherAccount", + }; + \ No newline at end of file diff --git a/src/vocabulary/solid.ts b/src/vocabulary/solid.ts index ead7f18..d69cec7 100644 --- a/src/vocabulary/solid.ts +++ b/src/vocabulary/solid.ts @@ -1,4 +1,19 @@ export const SOLID = { oidcIssuer: "http://www.w3.org/ns/solid/terms#oidcIssuer", storage: "http://www.w3.org/ns/solid/terms#storage", -} as const; + preferredSubjectPronoun: "http://www.w3.org/ns/solid/terms#preferredSubjectPronoun", + preferredObjectPronoun: "http://www.w3.org/ns/solid/terms#preferredObjectPronoun", + preferredRelativePronoun: "http://www.w3.org/ns/solid/terms#preferredRelativePronoun", + publicId: "http://www.w3.org/ns/solid/terms#publicId", + + + // the following terms are not defined but are present in https://github.com/SolidOS/profile-pane/blob/main/src/ontology/profileForm.ttl + Role: "http://www.w3.org/ns/solid/terms#Role", + CurrentRole: "http://www.w3.org/ns/solid/terms#CurrentRole", + FormerRole: "http://www.w3.org/ns/solid/terms#FormerRole", + FutureRole: "http://www.w3.org/ns/solid/terms#FutureRole", + + + +} + diff --git a/src/vocabulary/vcard.ts b/src/vocabulary/vcard.ts index 99ae2d3..a6e58a2 100644 --- a/src/vocabulary/vcard.ts +++ b/src/vocabulary/vcard.ts @@ -1,12 +1,17 @@ - export const VCARD = { + Group: "http://www.w3.org/2006/vcard/ns#Group", + Individual: "http://www.w3.org/2006/vcard/ns#Individual", + Organization: "http://www.w3.org/2006/vcard/ns#Organization", + email: "http://www.w3.org/2006/vcard/ns#email", fn: "http://www.w3.org/2006/vcard/ns#fn", hasEmail: "http://www.w3.org/2006/vcard/ns#hasEmail", - hasValue: "http://www.w3.org/2006/vcard/ns#hasValue", hasPhoto: "http://www.w3.org/2006/vcard/ns#hasPhoto", hasTelephone: "http://www.w3.org/2006/vcard/ns#hasTelephone", - title: "http://www.w3.org/2006/vcard/ns#title", - hasUrl: "http://www.w3.org/2006/vcard/ns#hasUrl", + hasURL: "http://www.w3.org/2006/vcard/ns#hasURL", + hasValue: "http://www.w3.org/2006/vcard/ns#hasValue", + member: "http://www.w3.org/2006/vcard/ns#member", organizationName: "http://www.w3.org/2006/vcard/ns#organization-name", + phone: "http://www.w3.org/2006/vcard/ns#phone", role: "http://www.w3.org/2006/vcard/ns#organization-name", + title: "http://www.w3.org/2006/vcard/ns#title", } as const; diff --git a/src/webid/Agent.ts b/src/webid/Agent.ts index d0d6d82..e60b5df 100644 --- a/src/webid/Agent.ts +++ b/src/webid/Agent.ts @@ -7,7 +7,7 @@ export class Agent extends TermWrapper { } get vcardHasUrl(): string | undefined { - return this.singularNullable(VCARD.hasUrl, ValueMappings.iriToString) + return this.singularNullable(VCARD.hasURL, ValueMappings.iriToString) } get organization(): string | null { diff --git a/test/tsconfig.json b/test/tsconfig.json index 79863ec..1536481 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "./dist/", - "rootDir": ".", + "rootDir": "/", "paths": { "@solid/object": ["../dist/mod.d.ts"] } diff --git a/test/unit/group.test.ts b/test/unit/group.test.ts new file mode 100644 index 0000000..43c2e70 --- /dev/null +++ b/test/unit/group.test.ts @@ -0,0 +1,106 @@ +import { DataFactory, Parser, Store } from "n3" +import assert from "node:assert" +import { describe, it } from "node:test" + +import { GroupDataset } from "@solid/object" +import { Person } from "@solid/object" + + +// Sample RDF for an existing group with two members + + /* +const sampleRDF = ` +@prefix vcard: . + + a vcard:Group ; + vcard:fn "Engineering Team" ; + vcard:member ; + vcard:member . +` + +describe("GroupDataset / Group tests", () => { + + + it("should parse a group and retrieve its properties", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const dataset = new GroupDataset(store, DataFactory) + const groups = Array.from(dataset.group) + assert.ok(groups.length > 0, "No groups found") + + const group = groups[0]! + assert.equal(group.name, "Engineering Team") + + const memberIRIs = Array.from(group.members).map((m: Person) => m.term.value) + assert.ok(memberIRIs.includes("https://example.org/person/alice")) + assert.ok(memberIRIs.includes("https://example.org/person/bob")) + }) + + + it("should allow changing group name", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const dataset = new GroupDataset(store, DataFactory) + const group = Array.from(dataset.group)[0]! + + group.name = "Product Team" + assert.equal(group.name, "Product Team") + }) + + + it("should allow adding a new member", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const dataset = new GroupDataset(store, DataFactory) + const group = Array.from(dataset.group)[0]! + + // Create new person (automatically typed as vcard:Individual) + const charlie = new Person("https://example.org/person/charlie", store, DataFactory) + charlie.name = "Charlie" + + group.addMember(charlie) + + const memberNames = Array.from(group.members).map((m: Person) => m.name) + assert.ok(memberNames.includes("Charlie")) + assert.ok(memberNames.includes("Alice") || memberNames.includes("Bob")) + }) + it("should allow removing a member", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const dataset = new GroupDataset(store, DataFactory) + const group = Array.from(dataset.group)[0]! + + const alice = new Person("https://example.org/person/alice", store, DataFactory) + group.deleteMember(alice) + + const memberIRIs = Array.from(group.members).map((m: Person) => m.term.value) + assert.ok(!memberIRIs.includes("https://example.org/person/alice")) + assert.ok(memberIRIs.includes("https://example.org/person/bob")) + }) + + it("should reflect live changes in the dataset", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const dataset = new GroupDataset(store, DataFactory) + const group = Array.from(dataset.group)[0]! + + const dave = new Person("https://example.org/person/dave", store, DataFactory) + group.addMember(dave) + + const bob = new Person("https://example.org/person/bob", store, DataFactory) + group.deleteMember(bob) + + const memberIRIs = Array.from(group.members).map((m: Person) => m.term.value) + assert.deepEqual(memberIRIs.sort(), [ + "https://example.org/person/alice", + "https://example.org/person/dave" + ]) + }) + +}) + */ \ No newline at end of file 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); + }); + + + + + +}); diff --git a/test/unit/organization.test.ts b/test/unit/organization.test.ts new file mode 100644 index 0000000..c01a232 --- /dev/null +++ b/test/unit/organization.test.ts @@ -0,0 +1,97 @@ +import { DataFactory, Parser, Store } from "n3"; +import assert from "node:assert"; +import { describe, it } from "node:test"; + +import { Organization } from "@solid/object"; +import {VCARD, SCHEMA, RDF} from "../../src/vocabulary/mod" + + +describe("Organization class tests", () => { + const sampleRDF = ` +@prefix vcard: . +@prefix schema: . +@prefix rdf: . + + a vcard:Organization ; + schema:name "Example Corp" ; + schema:url "https://example.org" ; + rdf:type schema:Corporation . +`; + + it("should parse Organization properties", () => { + const store = new Store(); + store.addQuads(new Parser().parse(sampleRDF)); + + const org = new Organization("https://example.org/org/1", store); + + assert.strictEqual(org.name, "Example Corp"); + assert.strictEqual(org.url, "https://example.org"); + assert.ok(org.types.has(VCARD.Organization), "Base type should be vcard:Organization"); + assert.ok(org.types.has(SCHEMA.Corporation), "Should include Corporation type"); + }); + + it("should allow setting Organization properties", () => { + const store = new Store(); + const org = new Organization("https://example.org/org/2", store); + + org.name = "New Org"; + org.url = "https://new.org"; + org.addType(SCHEMA.NGO); + + assert.strictEqual(org.name, "New Org"); + assert.strictEqual(org.url, "https://new.org"); + assert.ok(org.types.has(VCARD.Organization)); + assert.ok(org.types.has(SCHEMA.NGO)); + }); + + it("should enforce allowed organization types (runtime check)", () => { + const store = new Store(); + const org = new Organization("https://example.org/org/3", store); + + assert.throws(() => { + // bypass TS type safety for test + org.addType("https://schema.org/InvalidType" as any); + }, /Invalid organization type/); + }); + + it("should handle multiple types correctly", () => { + const store = new Store(); + const org = new Organization("https://example.org/org/4", store); + + org.addType(SCHEMA.Corporation); + org.addType(SCHEMA.NGO); + + const typesArray = Array.from(org.types); + assert.ok(typesArray.includes(VCARD.Organization)); + assert.ok(typesArray.includes(SCHEMA.Corporation)); + assert.ok(typesArray.includes(SCHEMA.NGO)); + + // Ensure no duplicates + const uniqueTypes = new Set(typesArray); + assert.strictEqual(uniqueTypes.size, typesArray.length); + }); + + it("should allow updating name and url multiple times", () => { + const store = new Store(); + const org = new Organization("https://example.org/org/5", store); + + org.name = "Org A"; + org.url = "https://orga.org"; + + org.name = "Org B"; + org.url = "https://orgb.org"; + + assert.strictEqual(org.name, "Org B"); + assert.strictEqual(org.url, "https://orgb.org"); + }); + + it("should handle minimal organization (only base type)", () => { + const store = new Store(); + const org = new Organization("https://example.org/org/minimal", store); + + assert.strictEqual(org.name, undefined); + assert.strictEqual(org.url, undefined); + assert.ok(org.types.has(VCARD.Organization)); + assert.strictEqual(org.types.size, 1); + }); +}); diff --git a/test/unit/person.test.ts b/test/unit/person.test.ts new file mode 100644 index 0000000..7bf0abf --- /dev/null +++ b/test/unit/person.test.ts @@ -0,0 +1,44 @@ +import { DataFactory, Parser, Store } from "n3"; +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Person } from "@solid/object"; + +describe("Person class tests", () => { + const sampleRDF = ` +@prefix vcard: . +@prefix owl: . + + a vcard:Individual ; + vcard:fn "Alice Smith" ; + vcard:email "alice@example.org" ; + vcard:phone "+123456789" ; + owl:sameAs . +`; + + it("should parse Person properties", () => { + const store = new Store(); + store.addQuads(new Parser().parse(sampleRDF)); + + const person = new Person("https://example.org/person/1", store); + + assert.strictEqual(person.name, "Alice Smith"); + assert.strictEqual(person.email, "alice@example.org"); + assert.strictEqual(person.phone, "+123456789"); + assert.strictEqual(person.webId, "https://alice.example.org/#me"); + }); + + it("should allow setting Person properties", () => { + const store = new Store(); + const person = new Person("https://example.org/person/1", store); + + person.name = "Bob Jones"; + person.email = "bob@example.org"; + person.phone = "+987654321"; + person.webId = "https://bob.example.org/#me"; + + assert.strictEqual(person.name, "Bob Jones"); + assert.strictEqual(person.email, "bob@example.org"); + assert.strictEqual(person.phone, "+987654321"); + assert.strictEqual(person.webId, "https://bob.example.org/#me"); + }); +}); diff --git a/test/unit/profile.test.ts b/test/unit/profile.test.ts new file mode 100644 index 0000000..f37df9a --- /dev/null +++ b/test/unit/profile.test.ts @@ -0,0 +1,150 @@ +import { DataFactory, Parser, Store } from "n3" +import assert from "node:assert" +import { describe, it } from "node:test" + +import { Profile } from "@solid/object" +import { Person } from "@solid/object" + +describe("Profile tests", () => { + + const sampleRDF = ` +@prefix foaf: . +@prefix solid: . +@prefix schema: . +@prefix org: . + + + foaf:nick "alex" ; + solid:preferredSubjectPronoun "they" ; + solid:preferredObjectPronoun "them" ; + solid:preferredRelativePronoun "theirs" ; + schema:skills ; + schema:skills ; + schema:knowsLanguage ; + schema:knowsLanguage ; + foaf:account ; + foaf:account ; + org:member . +`; + + it("should parse and retrieve profile properties", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const profile = new Profile( + DataFactory.namedNode("https://example.org/profile#me"), + store, + DataFactory + ) + + // Singular string properties + assert.equal(profile.nickname, "alex") + assert.equal(profile.preferredSubjectPronoun, "they") + assert.equal(profile.preferredObjectPronoun, "them") + assert.equal(profile.preferredRelativePronoun, "theirs") + + // Set-based properties + assert.ok(profile.skills instanceof Set) + assert.ok(profile.languages instanceof Set) + assert.ok(profile.accounts instanceof Set) + assert.ok(profile.roles instanceof Set) + + assert.ok(profile.skills.has("https://example.org/skills/TypeScript")) + assert.ok(profile.languages.has("https://example.org/lang/en")) + assert.ok(profile.accounts.has("https://github.com/example")) + + const role = Array.from(profile.roles)[0] + assert.ok(role instanceof Person) + }) + + + it("should allow setting of singular properties", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const profile = new Profile( + DataFactory.namedNode("https://example.org/profile#me"), + store, + DataFactory + ) + + profile.nickname = "updatedNick" + profile.preferredSubjectPronoun = "she" + profile.preferredObjectPronoun = "her" + profile.preferredRelativePronoun = "hers" + + assert.equal(profile.nickname, "updatedNick") + assert.equal(profile.preferredSubjectPronoun, "she") + assert.equal(profile.preferredObjectPronoun, "her") + assert.equal(profile.preferredRelativePronoun, "hers") + }) + + + it("should ensure singular properties are correct type", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const profile = new Profile( + DataFactory.namedNode("https://example.org/profile#me"), + store, + DataFactory + ) + + assert.equal(typeof profile.nickname, "string") + assert.equal(typeof profile.preferredSubjectPronoun, "string") + assert.equal(typeof profile.preferredObjectPronoun, "string") + assert.equal(typeof profile.preferredRelativePronoun, "string") + }) + + + it("should ensure set properties return Sets and not arrays", () => { + const store = new Store() + store.addQuads(new Parser().parse(sampleRDF)) + + const profile = new Profile( + DataFactory.namedNode("https://example.org/profile#me"), + store, + DataFactory + ) + + assert.ok(profile.skills instanceof Set) + assert.ok(profile.languages instanceof Set) + assert.ok(profile.accounts instanceof Set) + assert.ok(profile.roles instanceof Set) + + assert.ok(!Array.isArray(profile.skills)) + assert.ok(!Array.isArray(profile.languages)) + assert.ok(!Array.isArray(profile.accounts)) + assert.ok(!Array.isArray(profile.roles)) + }) + + + it("should handle duplicate singular values by exposing only one", () => { + const duplicateRDF = ` +@prefix foaf: . +@prefix solid: . + + + foaf:nick "alex" ; + foaf:nick "duplicateNick" ; + solid:preferredSubjectPronoun "they" ; + solid:preferredSubjectPronoun "duplicatePronoun" . +` + + const store = new Store() + store.addQuads(new Parser().parse(duplicateRDF)) + + const profile = new Profile( + DataFactory.namedNode("https://example.org/profile#me"), + store, + DataFactory + ) + + assert.equal(typeof profile.nickname, "string") + assert.equal(typeof profile.preferredSubjectPronoun, "string") + + assert.ok(!Array.isArray(profile.nickname)) + assert.ok(!Array.isArray(profile.preferredSubjectPronoun)) + }) + +})