Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 56 additions & 100 deletions app/lib/class/Agent.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
/**
* Extended Agent — adds project-specific properties on top of @solid/object's Agent.
*
* The base Agent from @solid/object already provides:
* vcardFn, foafName, name, email, hasEmail, phone, hasTelephone,
* organization, role, title, website, vcardHasUrl, foafHomepage,
* photoUrl, storageUrls, pimStorage, solidStorage, oidcIssuer, knows
*
* We extend only for properties not yet in the shared library.
*/
Comment on lines +1 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove all documentation comments that are actually explanatory comments.


Comment on lines +1 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove all comments that hinder readability and maintainability by redundantly explaining what the code does instead of why it does it.

import { Agent as BaseAgent } from "@solid/object";
import {
TermWrapper,
LiteralAs,
NamedNodeAs,
NamedNodeFrom,
TermAs,
TermFrom,
} from "@rdfjs/wrapper";
import { FOAF, PIM, SOLID, VCARD, SCHEMA } from "@/app/lib/class/Vocabulary";
import { VCARD, SOLID, SCHEMA } from "@/app/lib/class/Vocabulary";

// ---------------------------------------------------------------------------
// Helper wrappers for structured vCard nodes
// ---------------------------------------------------------------------------
Comment on lines +22 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove all of these 'structural' comments.


/**
* Wraps a vCard node that has vcard:value (e.g. email, telephone, url).
* We use VCARD.value (...#value), not hasValue (...#hasValue), to match the W3C vCard ontology.
* Renamed to actualValue because TermWrapper now exposes `value` directly.
* Workaround for a bug in @solid/object@0.5.0's internal HasValue class.
*
* The library's HasValue overrides the `.value` getter (which is part of the
* RDF/JS Term interface). When N3's `termToId()` calls `.value` on a HasValue
* instance during `store.match()`, it triggers the custom getter, which calls
* `singularNullable` → `match(this, ...)` → `termToId(this)` → `.value` again,
* causing infinite recursion (Maximum call stack size exceeded) - this breaks the App.
*
* This class does the same job (resolving the actual value from a vCard email/phone
* node) but exposes it as `.hasValue` instead of overriding `.value`, so N3 can
* still call `.value` safely to get the node's IRI.
*
* It also tries both vcard:value and vcard:hasValue because some Solid Pods
* (e.g. solidcommunity.net) use vcard:value while the library expects vcard:hasValue.
*/
class HasValue extends TermWrapper {
get actualValue(): string {
return this.singularNullable(VCARD.value, LiteralAs.string) ?? this.value;
class VCardValueNode extends TermWrapper {
get hasValue(): string | null {
return this.singularNullable(VCARD.value, NamedNodeAs.string)
?? this.singularNullable(VCARD.hasValue, NamedNodeAs.string)
?? null;
}
}

Expand All @@ -32,101 +60,29 @@ class Address extends TermWrapper {
}
}

export class Agent extends TermWrapper {
get vcardFn(): string | undefined {
return this.singularNullable(VCARD.fn, LiteralAs.string);
}

get vcardHasUrl(): string | undefined {
return this.singularNullable(VCARD.hasUrl, NamedNodeAs.string);
}

get organization(): string | null {
return (
this.singularNullable(VCARD.organizationName, LiteralAs.string) ??
null
);
}

get role(): string | null {
return (
this.singularNullable(VCARD.role, LiteralAs.string) ?? null
);
}

get title(): string | null {
return this.singularNullable(VCARD.title, LiteralAs.string) ?? null;
}

get phone(): string | null {
const first = this.hasTelephone?.actualValue ?? null;
if (first != null) return first;
const all = this.telephones;
if (all.size === 0) return null;
return [...all][0]?.actualValue ?? null;
}

/** All telephone nodes (use .actualValue on each for the number). */
get telephones(): Set<HasValue> {
return this.objects(VCARD.hasTelephone, TermAs.instance(HasValue), TermFrom.instance);
}

get hasTelephone(): HasValue | undefined {
return this.singularNullable(VCARD.hasTelephone, TermAs.instance(HasValue));
}

get foafName(): string | undefined {
return this.singularNullable(FOAF.fname, LiteralAs.string);
}

get name(): string | null {
return (
this.vcardFn ??
this.foafName ??
this.value.split("/").pop()?.split("#")[0] ??
null
);
}

get storageUrls(): Set<string> {
return new Set([...this.pimStorage, ...this.solidStorage]);
}

get foafHomepage(): string | undefined {
return this.singularNullable(FOAF.homepage, NamedNodeAs.string);
}

/** vcard:url can point to a node with vcard:value (e.g. WebID URL). */
get hasUrlValue(): HasValue | undefined {
return this.singularNullable(VCARD.hasUrl, TermAs.instance(HasValue));
}

get website(): string | null {
return this.hasUrlValue?.actualValue ?? this.vcardHasUrl ?? this.foafHomepage ?? null;
}

get photoUrl(): string | null {
return this.singularNullable(VCARD.hasPhoto, LiteralAs.string) ?? null;
}

get pimStorage(): Set<string> {
return this.objects(PIM.storage, NamedNodeAs.string, NamedNodeFrom.string);
}

get solidStorage(): Set<string> {
return this.objects(SOLID.storage, NamedNodeAs.string, NamedNodeFrom.string);
}
// ---------------------------------------------------------------------------
// Extended Agent
// ---------------------------------------------------------------------------

export class Agent extends BaseAgent {
/**
* Override email to avoid @solid/object's HasValue class which has a
* .value getter that conflicts with N3's termToId (causes infinite recursion).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See solid/object#30 instead.

* We resolve vcard:hasEmail → vcard:hasValue manually.
*/
get email(): string | null {
return this.hasEmail?.actualValue ?? null;
const emailNode = this.singularNullable(VCARD.hasEmail, TermAs.instance(VCardValueNode));
if (!emailNode) return null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a good readon to return null? Otherwise let them all be undefined if they are optional.

return emailNode.hasValue;
}

get hasEmail(): HasValue | undefined {
return this.singularNullable(VCARD.hasEmail, TermAs.instance(HasValue));
}

get knows(): Set<string> {
return this.objects(FOAF.knows, NamedNodeAs.string, NamedNodeFrom.string);
/**
* Override phone for the same reason as email above.
*/
get phone(): string | null {
const telNode = this.singularNullable(VCARD.hasTelephone, TermAs.instance(VCardValueNode));
if (!telNode) return null;
return telNode.hasValue;
}

get bday(): string | null {
Expand All @@ -143,7 +99,7 @@ export class Agent extends TermWrapper {

get location(): string | null {
const addr = this.hasAddress?.formatted;
return (addr != null && addr !== "") ? addr : null;
return addr != null && addr !== "" ? addr : null;
Comment on lines -146 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change does not belong in this PR.

}

get preferredSubjectPronoun(): string | null {
Expand Down
68 changes: 33 additions & 35 deletions app/lib/class/Vocabulary.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
/** RDF vocabulary IRIs used for WebID profile (Agent) parsing. */
/**
* Vocabulary constants for RDF predicates and types.
*
* Standard vocabularies used by the base Agent class (FOAF, PIM, etc.) are
* handled internally by @solid/object and do not need to be defined here.
* We only define constants needed by our own extended classes and helpers.
*/

// ---------------------------------------------------------------------------
// Standard vocabularies — only IRIs needed beyond @solid/object's Agent
// ---------------------------------------------------------------------------

export const FOAF = {
fname: "http://xmlns.com/foaf/0.1/name",
email: "http://xmlns.com/foaf/0.1/email",
homepage: "http://xmlns.com/foaf/0.1/homepage",
knows: "http://xmlns.com/foaf/0.1/knows",
export const SOLID = {
preferredSubjectPronoun: "http://www.w3.org/ns/solid/terms#preferredSubjectPronoun",
Comment on lines +13 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please minimize the diff.

} as const;

export const PIM = {
storage: "http://www.w3.org/ns/pim/space#storage",
export const RDFS = {
label: "http://www.w3.org/2000/01/rdf-schema#label",
} as const;

export const SOLID = {
oidcIssuer: "http://www.w3.org/ns/solid/terms#oidcIssuer",
storage: "http://www.w3.org/ns/solid/terms#storage",
preferredSubjectPronoun: "http://www.w3.org/ns/solid/terms#preferredSubjectPronoun",
/** VCARD predicates not covered by @solid/object's Agent. */
export const VCARD = {
bday: "http://www.w3.org/2006/vcard/ns#bday",
note: "http://www.w3.org/2006/vcard/ns#note",
hasAddress: "http://www.w3.org/2006/vcard/ns#hasAddress",
region: "http://www.w3.org/2006/vcard/ns#region",
countryName: "http://www.w3.org/2006/vcard/ns#country-name",
hasEmail: "http://www.w3.org/2006/vcard/ns#hasEmail",
hasTelephone: "http://www.w3.org/2006/vcard/ns#hasTelephone",
value: "http://www.w3.org/2006/vcard/ns#value",
// The library uses hasValue internally but doesn't export it yet.
// TODO: Remove this once @solid/object exports VCARD from its public API.
hasValue: "http://www.w3.org/2006/vcard/ns#hasValue",
} as const;

/** schema.org - sameAs often used for social profile URLs */
// ---------------------------------------------------------------------------
// Project-specific vocabularies
// ---------------------------------------------------------------------------

/** schema.org — sameAs often used for social profile URLs. */
export const SCHEMA = {
sameAs: "https://schema.org/sameAs",
} as const;
Expand Down Expand Up @@ -52,25 +72,3 @@ export const GEO = {
lat: "http://www.w3.org/2003/01/geo/wgs84_pos#lat",
long: "http://www.w3.org/2003/01/geo/wgs84_pos#long",
} as const;

export const RDFS = {
label: "http://www.w3.org/2000/01/rdf-schema#label",
} as const;

export const VCARD = {
fn: "http://www.w3.org/2006/vcard/ns#fn",
hasEmail: "http://www.w3.org/2006/vcard/ns#hasEmail",
/** Standard predicate for the value of an email/telephone/url node (W3C vCard ...#value). */
value: "http://www.w3.org/2006/vcard/ns#value",
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",
organizationName: "http://www.w3.org/2006/vcard/ns#organization-name",
role: "http://www.w3.org/2006/vcard/ns#role",
bday: "http://www.w3.org/2006/vcard/ns#bday",
note: "http://www.w3.org/2006/vcard/ns#note",
hasAddress: "http://www.w3.org/2006/vcard/ns#hasAddress",
region: "http://www.w3.org/2006/vcard/ns#region",
countryName: "http://www.w3.org/2006/vcard/ns#country-name",
} as const;
10 changes: 0 additions & 10 deletions app/lib/class/VolunteerProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
TermAs,
TermFrom,
} from "@rdfjs/wrapper";
import type { DatasetCore, DataFactory } from "@rdfjs/types";
import { VP, GEO, RDFS } from "@/app/lib/class/Vocabulary";

/**
Expand Down Expand Up @@ -91,12 +90,3 @@ export class VolunteerProfile extends TermWrapper {
);
}
}

export function wrapVolunteerProfile(
subjectIri: string,
dataset: DatasetCore,
factory: DataFactory,
): VolunteerProfile {
const subject = factory.namedNode(subjectIri);
return new VolunteerProfile(subject, dataset, factory);
}
12 changes: 0 additions & 12 deletions app/lib/class/WebIdDataset.ts

This file was deleted.

36 changes: 14 additions & 22 deletions app/lib/helpers/profileUtils.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,29 @@
import { Parser, Store, DataFactory } from "n3";
import { WebIdDataset } from "@/app/lib/class/WebIdDataset";
import type { Agent } from "@/app/lib/class/Agent";
import { Agent } from "@/app/lib/class/Agent";

/**
* Fetches the WebID profile document, parses it as Turtle, and returns the
* main Agent via @rdfjs/wrapper. Returns null if the fetch or parse fails.
* Agent wrapping that WebID subject.
*
* Pure async — caching and dedup are handled by React Query (useAgent hook).
* Throws if the fetch or parse fails — the WebID is required for the app.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use JSDoc constructs like @throws where appropriate.

* Caching and dedup are handled by React Query (useAgent hook).
*/
export async function fetchAndParseProfile(
webId: string,
fetchFn: typeof fetch = fetch,
): Promise<Agent | null> {
const docUrl = webId.split("#")[0];
): Promise<Agent> {
const res = await fetchFn(webId, {
method: "GET",
headers: { Accept: "text/turtle, application/turtle, text/n3, application/n3" },
});

let content: string;
try {
const res = await fetchFn(docUrl, {
method: "GET",
headers: { Accept: "text/turtle, application/turtle, text/n3, application/n3" },
});
if (!res.ok) return null;
content = await res.text();
} catch {
return null;
if (!res.ok) {
throw new Error(`Failed to fetch WebID profile: ${res.status}`);
}

const content = await res.text();
const store = new Store();
try {
store.addQuads(new Parser({ baseIRI: docUrl }).parse(content));
} catch {
return null;
}
store.addQuads(new Parser({ baseIRI: webId }).parse(content));

return new WebIdDataset(store, DataFactory).mainSubject ?? null;
return new Agent(DataFactory.namedNode(webId), store, DataFactory);
}
8 changes: 4 additions & 4 deletions app/lib/helpers/volunteerProfileSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { Parser, Writer, Store, DataFactory } from "n3";
import type { BlankNode } from "n3";
import { wrapVolunteerProfile } from "@/app/lib/class/VolunteerProfile";
import { VolunteerProfile } from "@/app/lib/class/VolunteerProfile";
import { VP, GEO, RDFS, VOLUNTEERING_NS } from "@/app/lib/class/Vocabulary";
import type { SavedLocation } from "@/app/components/volunteer-info/PreferredLocations";

Expand Down Expand Up @@ -90,7 +90,7 @@ async function readPropertyFromPod(
const store = parseTurtle(text, docUrl);
if (!store) return [];

const profile = wrapVolunteerProfile(subjectIri, store, DataFactory);
const profile = new VolunteerProfile(DataFactory.namedNode(subjectIri), store, DataFactory);
return [...profile[property]];
}

Expand Down Expand Up @@ -121,7 +121,7 @@ async function writePropertyToPod(
store.addQuad(subjectNode, DataFactory.namedNode(RDF_TYPE), DataFactory.namedNode(VP.VolunteerProfile));
}

const profile = wrapVolunteerProfile(subjectIri, store, DataFactory);
const profile = new VolunteerProfile(DataFactory.namedNode(subjectIri), store, DataFactory);
const set: Set<string> = profile[property];
set.clear();
for (const uri of uris) {
Expand Down Expand Up @@ -209,7 +209,7 @@ export async function readLocationsFromPod(
const store = parseTurtle(text, docUrl);
if (!store) return [];

const profile = wrapVolunteerProfile(subjectIri, store, DataFactory);
const profile = new VolunteerProfile(DataFactory.namedNode(subjectIri), store, DataFactory);
const locations: SavedLocation[] = [];
for (const locNode of profile.locationNodes) {
const pt = locNode.point;
Expand Down
Loading